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_numberworks until you move repositories; a date-based number (yyyymmddHH) survives that. - CocoaPods and Xcode drift. Pin the Xcode version with
maxim-lobanov/setup-xcodeif amacos-latestimage 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/retryand 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.