Skip to content
FlutterCI/CDDevOps

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.

By Bimal Khatri·10 min read·Jun 15, 2026·Updated Jun 21, 2026

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.

TriggerRuns onWhat it doesCost
Pull requestubuntu-latestformat, analyze, testLow / fast
Tag v*.*.* (Android)ubuntu-latestsigned .aab to Play internalLow
Tag v*.*.* (iOS)macos-latestsigned .ipa to TestFlightHigh (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-changed fails the build on unformatted code. It ends the "whitespace-only diff" wars permanently.
  • --fatal-infos treats analyzer infos as failures. If your analysis_options.yaml flags something, the PR should be red. Half-honored lint rules are worse than none.
  • cache: true on flutter-action caches 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):

SecretUsed byNotes
KEYSTORE_BASE64Androidbase64 of the upload keystore
KEYSTORE_PASSWORD / KEY_PASSWORD / KEY_ALIASAndroidsigning config
PLAY_STORE_SERVICE_ACCOUNT_JSONAndroidfull JSON, pasted raw
ASC_KEY_ID / ASC_ISSUER_IDiOSfrom App Store Connect
ASC_KEY_CONTENTiOSbase64 of the .p8
MATCH_GIT_URL / MATCH_PASSWORDiOScerts 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 echo a secret to stdout. GitHub masks known secret values in logs, but a base64 round-trip or a substring can slip through. Pipe directly into base64 --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-name and --build-number derived from the tag (for example, strip the v and 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 Gemfile in both android/ and ios/ and use bundle exec fastlane — this pins the Fastlane version so a Fastlane release doesn't silently change behavior mid-flight. bundler-cache: true makes the install cheap.
  • --export-options-plist controls the iOS export method (app-store for 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.0 should 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