+1 (415) 480-3939

Flutter + GitHub Actions: A Complete Mobile CI/CD Pipeline

A Flutter app needs a CI/CD pipeline that does four things reliably: analyze and test every pull request, build real iOS and Android binaries, run integration tests on an emulator, and deliver builds to TestFlight and the Play internal track without a human touching signing keys. This is the GitHub Actions setup we deploy for clients, trimmed to the essentials. It uses subosito/flutter-action for SDK setup and Fastlane for store delivery.

Pipeline shape

pull request  -> analyze + unit/widget tests (Linux, fast)
              -> Android build + integration test on emulator (Linux)
              -> iOS build, no codesign (macOS)
tag v*        -> signed iOS -> TestFlight
              -> signed Android -> Play internal track

Pull requests never touch signing secrets. Only tag builds on the protected default branch do.

Pin the Flutter version

Commit the SDK version so CI and laptops agree. With FVM, .fvmrc already holds it; otherwise add an environment entry in pubspec.yaml and read it in the workflow. The examples below read .fvmrc:

{ "flutter": "3.44.0" }

Workflow 1: pull request checks

# .github/workflows/pr.yml
name: PR checks
on:
  pull_request:

concurrency:
  group: pr-${{ github.ref }}
  cancel-in-progress: true

jobs:
  analyze-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Read Flutter version
        id: fvm
        run: echo "version=$(jq -r .flutter .fvmrc)" >> "$GITHUB_OUTPUT"
      - uses: subosito/flutter-action@v2
        with:
          flutter-version: ${{ steps.fvm.outputs.version }}
          cache: true
      - run: flutter pub get
      - run: dart format --output=none --set-exit-if-changed .
      - run: flutter analyze --fatal-infos
      - run: flutter test --coverage --reporter=github
      - uses: actions/upload-artifact@v4
        with:
          name: coverage
          path: coverage/lcov.info

  android-build:
    runs-on: ubuntu-latest
    needs: analyze-test
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: '17'
      - id: fvm
        run: echo "version=$(jq -r .flutter .fvmrc)" >> "$GITHUB_OUTPUT"
      - uses: subosito/flutter-action@v2
        with:
          flutter-version: ${{ steps.fvm.outputs.version }}
          cache: true
      - run: flutter build apk --debug

  ios-build:
    runs-on: macos-latest
    needs: analyze-test
    steps:
      - uses: actions/checkout@v4
      - id: fvm
        run: echo "version=$(jq -r .flutter .fvmrc)" >> "$GITHUB_OUTPUT"
      - uses: subosito/flutter-action@v2
        with:
          flutter-version: ${{ steps.fvm.outputs.version }}
          cache: true
      - run: flutter build ios --debug --no-codesign

cache: true on the Flutter action caches the SDK; Gradle and CocoaPods caching add another large saving (actions/cache keyed on **/*.gradle* and ios/Podfile.lock). The concurrency block cancels superseded runs when someone pushes twice.

Sharding tests

Once a suite takes more than a few minutes, shard it. flutter test has built-in support:

  unit-tests:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        shard: [0, 1, 2, 3]
    steps:
      # ... checkout + flutter setup as above ...
      - run: flutter test --total-shards 4 --shard-index ${{ matrix.shard }}

Four shards on four runners finish in roughly a quarter of the time, at the same cost in runner minutes.

Workflow 2: integration tests on an Android emulator

Flutter's integration tests need a device. On Linux runners with KVM enabled, an emulator is practical:

  integration-android:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Enable KVM
        run: |
          echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
            | sudo tee /etc/udev/rules.d/99-kvm4all.rules
          sudo udevadm control --reload-rules && sudo udevadm trigger --name-match=kvm
      - uses: actions/setup-java@v4
        with: { distribution: temurin, java-version: '17' }
      - id: fvm
        run: echo "version=$(jq -r .flutter .fvmrc)" >> "$GITHUB_OUTPUT"
      - uses: subosito/flutter-action@v2
        with: { flutter-version: "${{ steps.fvm.outputs.version }}", cache: true }
      - uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: 34
          arch: x86_64
          profile: pixel_6
          script: flutter test integration_test --dart-define=CI=true

For iOS integration tests, macos-latest runners ship with Xcode simulators; boot one with xcrun simctl boot "iPhone 16" and run the same flutter test integration_test command. Run iOS integration tests nightly rather than per-PR — macOS minutes are the expensive part of any mobile pipeline.

Signing secrets done right

Never commit keystores or certificates. Store them base64-encoded in GitHub Environment secrets (an environment named release, with required reviewers if you want a manual gate) and decode them at build time.

Android needs three things: the keystore file, its passwords, and a key.properties file that android/app/build.gradle reads:

      - name: Restore Android signing
        env:
          KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }}
        run: |
          echo "$KEYSTORE_B64" | base64 --decode > android/app/upload-keystore.jks
          cat > android/key.properties <<EOT
          storeFile=upload-keystore.jks
          storePassword=${{ secrets.ANDROID_STORE_PASSWORD }}
          keyPassword=${{ secrets.ANDROID_KEY_PASSWORD }}
          keyAlias=upload
          EOT

For iOS, use Fastlane match with a private git repository or cloud bucket holding encrypted certificates and provisioning profiles. The runner needs MATCH_PASSWORD, a deploy key for the match repo, and an App Store Connect API key (issuer id, key id, and the .p8 contents) so nothing depends on a human's Apple ID.

Workflow 3: release on tag

# .github/workflows/release.yml
name: Release
on:
  push:
    tags: ['v*']

jobs:
  android-release:
    runs-on: ubuntu-latest
    environment: release
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { distribution: temurin, java-version: '17' }
      - id: fvm
        run: echo "version=$(jq -r .flutter .fvmrc)" >> "$GITHUB_OUTPUT"
      - uses: subosito/flutter-action@v2
        with: { flutter-version: "${{ steps.fvm.outputs.version }}", cache: true }
      - name: Restore Android signing
        run: ... # as above
      - run: flutter build appbundle --release --build-number=${{ github.run_number }}
      - uses: ruby/setup-ruby@v1
        with: { ruby-version: '3.3', bundler-cache: true, working-directory: android }
      - name: Upload to Play internal track
        working-directory: android
        env:
          SUPPLY_JSON_KEY_DATA: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
        run: bundle exec fastlane internal

  ios-release:
    runs-on: macos-latest
    environment: release
    steps:
      - uses: actions/checkout@v4
      - id: fvm
        run: echo "version=$(jq -r .flutter .fvmrc)" >> "$GITHUB_OUTPUT"
      - uses: subosito/flutter-action@v2
        with: { flutter-version: "${{ steps.fvm.outputs.version }}", cache: true }
      - uses: ruby/setup-ruby@v1
        with: { ruby-version: '3.3', bundler-cache: true, working-directory: ios }
      - run: flutter build ipa --release --build-number=${{ github.run_number }} --export-options-plist=ios/ExportOptions.plist
        env:
          MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
          MATCH_GIT_PRIVATE_KEY: ${{ secrets.MATCH_DEPLOY_KEY }}
      - name: Upload to TestFlight
        working-directory: ios
        env:
          APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
          APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.ASC_KEY_ID }}
          APP_STORE_CONNECT_API_KEY_KEY: ${{ secrets.ASC_KEY_P8 }}
        run: bundle exec fastlane beta

The matching Fastlane lanes are short:

# android/fastlane/Fastfile
platform :android do
  lane :internal do
    upload_to_play_store(track: "internal", aab: "../build/app/outputs/bundle/release/app-release.aab")
  end
end

# ios/fastlane/Fastfile
platform :ios do
  lane :beta do
    app_store_connect_api_key
    match(type: "appstore", readonly: true)
    upload_to_testflight(ipa: "../build/ios/ipa/MyApp.ipa", skip_waiting_for_build_processing: true)
  end
end

skip_waiting_for_build_processing saves ten or more macOS minutes per release.

Things that bite

  • Build numbers must increase. github.run_number works until you move repositories; a date-based number (yyyymmddHH) survives that.
  • CocoaPods and Xcode drift. Pin the Xcode version with maxim-lobanov/setup-xcode if a macos-latest image update ever breaks your build mid-release.
  • Secrets in forks. Pull-request workflows from forks do not receive secrets; design PR checks to need none.
  • Flaky integration tests. Retry once with nick-fields/retry and treat a second failure as real.

If you are migrating off a CI provider that no longer exists — as many Flutter teams had to after Cirrus CI stopped running jobs in June 2026 — our 2026 CI/CD playbook covers the provider comparison, and we are happy to set up the pipeline with your team.