CI/CD for Flutter: Automating Builds and Store Releases
Shipping a Flutter release by hand is a ritual: bump the version, run whichever tests you remember to run, build twice — once per platform — fiddle with signing, upload to two different consoles, fill in release notes, repeat anything that failed. It takes an hour when nothing goes wrong, and something eventually goes wrong: a stale build cache, a forgotten version bump, an expired certificate discovered at the worst moment. Worse than the lost hour is what the friction does to your release cadence — when shipping hurts, you ship less often, changes pile up, and each release gets riskier precisely because it's bigger.
Automating this isn't a luxury for big teams. It's how solo developers ship weekly without dreading it — I speak from experience on both sides of that line.
Automate in stages, not all at once
The mistake is trying to build the full pipeline in a weekend, getting stuck on iOS signing, and abandoning the whole thing. Each stage pays for itself independently, so add them in order and stop wherever the returns stop:
- CI on every pull request — analyze, test, build.
- Automated release builds — signed artifacts produced by the pipeline, not your laptop.
- Automated store delivery — artifacts uploaded to TestFlight and Play internal testing.
- Promotion to production — still a human decision, just a one-click one.
Stage 1 alone eliminates "it worked on my machine" and the embarrassment of a broken main branch. Most apps — including mine — are well served stopping at stage 3 and promoting manually.
Stage 1: the quality gate
Every push should run the checks you'd otherwise run from memory, in a clean environment that has no memory to be stale:
- run: flutter pub get
- run: flutter analyze
- run: dart format --set-exit-if-changed .
- run: flutter test
Any general CI service (GitHub Actions with a Flutter setup action is the common default) can do this; specialized services like Codemagic ship with Flutter preinstalled and understand the platform quirks out of the box. What matters more than the vendor is that the gate is mandatory — a failing check blocks the merge, no exceptions, or the gate stops being a gate within a month. Pin the exact Flutter version in your config too, so CI can't silently drift from your local toolchain; "CI is on a newer Flutter than me" is a genre of confusing failure you can simply opt out of.
Two habits keep the gate healthy: cache your pub dependencies so runs stay fast (slow CI gets ignored), and treat a flaky test as a bug to fix this week — the first time someone re-runs a red build without reading it, the suite has started dying.
The iOS caveat: building for iOS requires macOS runners, which are the expensive, slow part of every mobile pipeline. A sensible economy is to run analysis and tests on cheap Linux runners for every PR, and only pay for macOS builds on release branches or tags.
Signing: the hard 20%
Signing is where every mobile pipeline earns its scars, because signing material is secret, secrets can't live in the repo, and both platforms handle it differently.
Android is the gentler case: base64-encode your keystore into a CI secret, decode it to a file during the build, and feed the passwords in via environment variables referenced from key.properties. Guard the keystore carefully and keep an offline backup — and enroll in Play App Signing, where Google holds the final signing key and your uploaded key can be reset if lost. That turns "lost keystore" from an existential app-identity crisis into a support ticket.
iOS means certificates and provisioning profiles, which expire, multiply, and live partly in your Apple developer account and partly in someone's keychain. Doing this by hand in CI is misery; use tooling. Fastlane's match stores your signing assets in a private encrypted repo and syncs them onto any machine, including CI runners, with one command. Set it up once, and "new laptop," "new team member," and "new CI provider" all stop being signing crises. When a certificate expires — they always expire, always at a bad time — match regenerates and re-syncs instead of you re-living the Apple developer portal.
General secret hygiene applies throughout: CI secret stores only, nothing echoed to logs, and the same rules as any other credential.
Stages 2–3: build and deliver
Tag-triggered releases work well: pushing v1.4.0 kicks off the release workflow, and the tag doubles as the permanent record of what shipped. Derive the build number from the CI run number or commit count so it always increments without a human remembering — the stores reject reused build numbers, and this is the single most common automated-release failure. Bake the git SHA into the build too; "which commit is this crash from?" deserves an exact answer.
For delivery, Fastlane again does the boring parts: upload_to_play_store for Android (pointed at an internal track first), upload_to_testflight for iOS. Keep release notes in a file in the repo (CHANGELOG.md or fastlane's metadata folders) so they version with the code and the pipeline can submit them — future-you, writing the store listing update, will thank present-you.
The payoff of stage 3 is bigger than saved minutes: every merge to main can produce an installable build for testers automatically. Feedback arrives days earlier, from builds produced by a clean, repeatable process rather than whatever state your laptop was in. Half the value of CI/CD is speed; the other half is that the artifact is trustworthy — same environment, same steps, every time, with an audit trail.
Keep the human where the risk is
Full auto-promotion to production is possible and usually wrong for mobile apps. Store releases are hard to un-ship — there's no instant rollback like a server deploy; a bad binary rolls out to real users while you scramble to submit a fix through review. So let the robot do everything up to the store's testing track, then promote deliberately: use staged rollouts on Play (start at a small percentage, watch crash reports, expand) and phased release on the App Store. Check your crash reporting before widening the rollout — the whole point of the staged release is that you look.
The pipeline's job is to make the release candidate cheap, frequent, and trustworthy; the release decision stays yours. That division of labor is what lets you ship weekly without shipping recklessly.
The takeaway
CI/CD for Flutter is one honest weekend for the quality gate, one annoying weekend for signing, and an afternoon for delivery — after which releases stop being events and become routine. Test on every PR with a mandatory gate, sign with match and CI secrets, auto-increment build numbers, deliver every merge to the test tracks, and promote to production by hand with staged rollouts. Boring, repeatable releases are what let you ship often enough to matter — and shipping often is the closest thing to a superpower a small app team has.
Further reading
- Fastlane docs —
match,upload_to_testflight,upload_to_play_store, and the rest of the delivery toolbox. - Codemagic — Flutter-native CI/CD if you'd rather not assemble your own.
- Play App Signing — why losing your Android key no longer has to be fatal.
- Getting through app store review — the human process waiting at the end of the pipeline.