Flutter CI/CD with GitHub Actions and Fastlane: A Real Pipeline
A production-grade Flutter CI/CD pipeline: analyze and test every PR, then build signed iOS/Android artifacts and ship to TestFlight and Google Play on a tag — real Fastlane lanes and workflow YAML.
Flutter CI/CD stops being optional the moment you ship to two stores from one codebase and a manual release eats half a day. In this post I walk through the exact pipeline I run on production apps: GitHub Actions runs flutter analyze and tests on every pull request, then — only when I push a version tag — it builds signed iOS and Android artifacts and uploads them to TestFlight and the Google Play internal track. No clicking through Xcode Organizer, no dragging .aab files into a browser.
I'll cover the workflow YAML, the Fastlane lanes that do the store uploads, and the part everyone gets wrong the first time: getting signing material — the Android keystore and the iOS distribution certificate — into CI without leaking it.
Why split CI and CD on a tag
The single most useful decision in a Flutter CI/CD setup is separating two concerns:
- CI (every PR): fast, cheap, runs on Linux, gates merges. Format check,
flutter analyze, unit and widget tests. This should finish in a couple of minutes. - CD (on a tag): slow, expensive (macOS minutes for iOS), produces signed builds, talks to the stores. You only want this when you actually intend to release.
Tying release to a Git tag like v1.4.0 gives you a clean, auditable trigger. The tag is the release record. Pushing to main shouldn't ship anything — that's how you end up with surprise TestFlight builds at 2am.
| Trigger | Runs on | What it does | Cost |
|---|---|---|---|
| Pull request | ubuntu-latest | format, analyze, test | Low / fast |
Tag v*.*.* (Android) | ubuntu-latest | signed .aab to Play internal | Low |
Tag v*.*.* (iOS) | macos-latest | signed .ipa to TestFlight | High (macOS minutes) |
Keep iOS on its own job. macOS runners bill at roughly ten times the per-minute rate of Linux runners on GitHub-hosted machines, so you don't want every PR burning them.
The CI job: analyze and test on every PR
Here's the lint-and-test half of the workflow. It pins the Flutter version (never rely on latest — a Dart SDK bump can break your build on an unrelated PR) and caches pub dependencies so reruns are quick.
name: ci
on:
pull_request:
branches: [main]
jobs:
analyze-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: "3.27.1"
channel: stable
cache: true
- name: Install dependencies
run: flutter pub get
- name: Verify formatting
run: dart format --output=none --set-exit-if-changed .
- name: Analyze
run: flutter analyze --fatal-infos
- name: Run tests
run: flutter test --coverage
A few things I insist on:
dart format --set-exit-if-changedfails the build on unformatted code. It ends the "whitespace-only diff" wars permanently.--fatal-infostreats analyzer infos as failures. If youranalysis_options.yamlflags something, the PR should be red. Half-honored lint rules are worse than none.cache: trueonflutter-actioncaches the SDK and the pub cache between runs, which shaves a minute or more off most runs.
If you run integration tests or golden tests, gate the expensive ones behind a separate job or a label so day-to-day PRs stay fast.
Fastlane: lanes that talk to the stores
flutter build produces the artifact; Fastlane handles delivery — uploading that artifact to the right track with the right credentials. You keep two fastlane folders, one under android/ and one under ios/, each with its own Fastfile.
Android: upload to the Play Store internal track
For Android you authenticate with a Google Cloud service account JSON that has been granted access in the Play Console. The upload_to_play_store action (the supply integration) does the upload.
# android/fastlane/Fastfile
default_platform(:android)
platform :android do
desc "Upload a signed AAB to the Play Store internal track"
lane :internal do
upload_to_play_store(
track: "internal",
aab: "../build/app/outputs/bundle/release/app-release.aab",
json_key_data: ENV["PLAY_STORE_SERVICE_ACCOUNT_JSON"],
release_status: "draft",
skip_upload_apk: true,
skip_upload_metadata: true,
skip_upload_images: true,
skip_upload_screenshots: true
)
end
end
I pass the service-account JSON as raw data via json_key_data (read from an env var) rather than a file on disk, so nothing sensitive is ever written to the runner's filesystem. release_status: "draft" leaves a human to do the final promote — automate the upload, keep the publish deliberate.
iOS: upload to TestFlight
For iOS, upload_to_testflight (Pilot) talks to App Store Connect. Use an App Store Connect API key, not your Apple ID and an app-specific password — API keys don't trip two-factor auth and don't break the moment Apple decides your session looks suspicious.
# ios/fastlane/Fastfile
default_platform(:ios)
platform :ios do
desc "Upload the signed IPA to TestFlight"
lane :beta do
app_store_connect_api_key(
key_id: ENV["ASC_KEY_ID"],
issuer_id: ENV["ASC_ISSUER_ID"],
key_content: ENV["ASC_KEY_CONTENT"], # base64-encoded .p8
is_key_content_base64: true
)
upload_to_testflight(
ipa: "../build/ios/ipa/Runner.ipa",
skip_waiting_for_build_processing: true
)
end
end
app_store_connect_api_key stores the key in Fastlane's shared lane context, so upload_to_testflight picks it up automatically — no need to thread the hash through by hand. skip_waiting_for_build_processing: true returns as soon as the upload finishes instead of blocking the runner (and your billed minutes) while Apple processes the build. The trade-off: you can't auto-assign testers in the same run, which is fine — I let a later step or TestFlight's own settings handle distribution.
Secrets and signing material without leaking them
This is where Flutter CI/CD pipelines go wrong. You can't commit your keystore or .p8, and you can't flutter build a release without your signing material. The bridge is GitHub Actions encrypted secrets plus base64 encoding for binary files.
Encode the binaries locally once:
# Android upload keystore
base64 -i upload-keystore.jks | pbcopy # paste into secret KEYSTORE_BASE64
# App Store Connect API key (.p8)
base64 -i AuthKey_ABC123.p8 | pbcopy # paste into secret ASC_KEY_CONTENT
Then add these repository secrets (Settings, then Secrets and variables, then Actions):
| Secret | Used by | Notes |
|---|---|---|
KEYSTORE_BASE64 | Android | base64 of the upload keystore |
KEYSTORE_PASSWORD / KEY_PASSWORD / KEY_ALIAS | Android | signing config |
PLAY_STORE_SERVICE_ACCOUNT_JSON | Android | full JSON, pasted raw |
ASC_KEY_ID / ASC_ISSUER_ID | iOS | from App Store Connect |
ASC_KEY_CONTENT | iOS | base64 of the .p8 |
MATCH_GIT_URL / MATCH_PASSWORD | iOS | certs repo + decryption passphrase |
On the runner, decode the keystore into place and have Gradle read the signing config from environment variables, so nothing is hardcoded in build.gradle:
echo "$KEYSTORE_BASE64" | base64 --decode > android/app/upload-keystore.jks
// android/app/build.gradle
android {
signingConfigs {
release {
storeFile file(System.getenv("KEYSTORE_PATH") ?: "upload-keystore.jks")
storePassword System.getenv("KEYSTORE_PASSWORD")
keyAlias System.getenv("KEY_ALIAS")
keyPassword System.getenv("KEY_PASSWORD")
}
}
buildTypes {
release {
signingConfig signingConfigs.release
}
}
}
The file("upload-keystore.jks") path resolves relative to android/app/, which is exactly where the decode step writes it.
For iOS code signing in CI, the cleanest path is Fastlane Match, which stores your distribution certificate and provisioning profiles encrypted in a private Git repo and installs them on the runner. This matters more than it looks: a bare flutter build ipa on a fresh macOS runner has no certificate in its keychain and will fail to sign. Add a sync_certificates (Match) lane and call it before the build:
# ios/fastlane/Fastfile (add inside platform :ios)
desc "Install distribution signing assets via Match"
lane :certificates do
setup_ci # creates a temporary keychain on CI
match(
type: "appstore",
readonly: true,
api_key: app_store_connect_api_key(
key_id: ENV["ASC_KEY_ID"],
issuer_id: ENV["ASC_ISSUER_ID"],
key_content: ENV["ASC_KEY_CONTENT"],
is_key_content_base64: true
)
)
end
setup_ci spins up a throwaway keychain so the imported certificate doesn't linger, and readonly: true means CI consumes existing certs but never mints new ones (certificate creation stays a deliberate local action). If you're not on Match yet you can import a base64 .p12 into a temporary keychain by hand, but Match scales better across machines and teammates, and it's what I reach for on any app with more than one engineer.
Two rules I never break:
- Never
echoa secret to stdout. GitHub masks known secret values in logs, but a base64 round-trip or a substring can slip through. Pipe directly intobase64 --decode. - Scope credentials minimally. The Play service account only needs release-manager permission on that one app; the ASC API key can be limited in role. Least privilege contains the blast radius if a token leaks.
The release job: build signed artifacts on a tag
Now the CD half. This triggers only on version tags and runs Android and iOS as parallel jobs. Note the iOS job is the only one on macos-latest, and it installs signing assets via Match before the build.
name: release
on:
push:
tags:
- "v*.*.*"
jobs:
android:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: "3.27.1"
channel: stable
cache: true
- uses: ruby/setup-ruby@v1
with:
ruby-version: "3.3"
bundler-cache: true
working-directory: android
- name: Decode keystore
run: echo "$KEYSTORE_BASE64" | base64 --decode > android/app/upload-keystore.jks
env:
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
- name: Build AAB
run: flutter build appbundle --release
env:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
- name: Upload to Play Store
working-directory: android
run: bundle exec fastlane internal
env:
PLAY_STORE_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_STORE_SERVICE_ACCOUNT_JSON }}
ios:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: "3.27.1"
channel: stable
cache: true
- uses: ruby/setup-ruby@v1
with:
ruby-version: "3.3"
bundler-cache: true
working-directory: ios
- name: Install signing assets (Match)
working-directory: ios
run: bundle exec fastlane certificates
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY_CONTENT: ${{ secrets.ASC_KEY_CONTENT }}
MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
- name: Build IPA
run: flutter build ipa --release --export-options-plist=ios/ExportOptions.plist
- name: Upload to TestFlight
working-directory: ios
run: bundle exec fastlane beta
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY_CONTENT: ${{ secrets.ASC_KEY_CONTENT }}
A couple of notes that save real debugging time:
- Pin your version source. Pass
--build-nameand--build-numberderived from the tag (for example, strip thevand feed the rest to--build-name, and use${{ github.run_number }}for--build-number) so the binary's version matches the Git tag exactly. A TestFlight build whose version doesn't match its tag is a forensic nightmare later. - Commit a
Gemfilein bothandroid/andios/and usebundle exec fastlane— this pins the Fastlane version so a Fastlane release doesn't silently change behavior mid-flight.bundler-cache: truemakes the install cheap. --export-options-plistcontrols the iOS export method (app-storefor TestFlight). Generate it once, check it in, and don't let Flutter guess. The team identifier and signing style in that plist must line up with what Match installed.
Automate Flutter releases without losing the safety rails
The goal isn't to remove humans from releases — it's to remove toil so the human decisions are the only ones left. To automate Flutter releases responsibly:
- Keep the final publish/promote manual (
release_status: "draft", internal track first). The pipeline gets a build into testers' hands; a person decides when it goes to production. - Fail loudly. If signing fails, the whole job should go red — never let a half-uploaded release look green.
- Make tagging the only release ritual.
git tag v1.4.0 && git push origin v1.4.0should be the entire release procedure. If it isn't, you've left a manual step that someone will eventually forget. - Smoke-test the artifact where you can — install the internal-track build on a real device before promoting. CI proves it builds and signs; it doesn't prove it runs.
Once this is wired up, GitHub Actions Flutter releases get boring in the best way: open a PR, get green checks, merge, tag, and walk away while TestFlight and the Play Store internal track fill up on their own.
Wrapping up
A solid Flutter CI/CD pipeline is mostly discipline encoded as YAML: fast checks on every PR, slow signed builds only on a tag, and signing material handled so nothing sensitive ever lands on disk in plaintext. Get those three right and releasing two apps collapses into a single git push. I've stood this exact pipeline up on production iOS and Android apps more than a few times — if you'd like help wiring it into your project you can hire me, or just get in touch and we'll talk through your setup.
More writing
Keep reading
Jan 20, 2025
Automating Flutter Releases with GitHub Actions
Turning the stressful release-day ritual into a calm, repeatable pipeline that ships signed builds to both stores.
Nov 3, 2024
Building Offline-First Apps with Hive and BLoC
A pattern for caching writes locally and reconciling with the server so the app stays usable with no connection.
Jun 10, 2026
Offline-First Flutter: Syncing Local and Remote Data Reliably
A practical engineering guide to offline-first Flutter: a local source of truth, a durable outbox sync queue, conflict resolution (last-write-wins vs. merge), and real failure handling.