Flutter CI/CD - Automating Tests, Builds, and Deploys with GitHub Actions
Shipping a Flutter app by hand—running tests locally, building APKs or IPAs on your machine, then uploading them to stores—works for a weekend prototype. It falls apart the moment you have teammates, release cadence, or more than one platform. CI/CD (Continuous Integration / Continuous Delivery) turns that ritual into a repeatable pipeline
Evan Emran
Mobile Developer & Tech Blogger

Shipping a Flutter app by hand—running tests locally, building APKs or IPAs on your machine, then uploading them to stores—works for a weekend prototype. It falls apart the moment you have teammates, release cadence, or more than one platform. CI/CD (Continuous Integration / Continuous Delivery) turns that ritual into a repeatable pipeline: every push can lint, test, build, and optionally deploy without anyone babysitting the process.
This guide walks through a practical Flutter CI/CD setup on GitHub Actions—from a minimal “run tests on PR” workflow to multi-platform builds and store deploys—with concrete YAML you can adapt.
Why CI/CD matters for Flutter
Flutter projects look simple at first (flutter test, flutter build apk), but production reality adds layers:
- Multiple targets: Android, iOS, web, desktop
- Flavor/environment configs (dev, staging, prod)
- Code signing (keystore, certificates, provisioning profiles)
- Store uploads (Play Console, App Store Connect)
- Consistency: “works on my machine” is not a release strategy
A good pipeline gives you:
| Benefit | What it means in practice |
|---|---|
| Fast feedback | Broken tests/builds fail on the PR before merge |
| Reproducible builds | Same Flutter SDK, same Java/Xcode versions every time |
| Safer releases | Signed artifacts produced in a controlled environment |
| Less toil | No more “who has the release Mac today?” |
GitHub Actions fits Flutter well because the repo already lives on GitHub, workflows are YAML next to your code, and macOS runners are available when you need iOS builds.
Prerequisites
Before wiring Actions:
- A Flutter app in a GitHub repository
- Local green baseline:
flutter analyze,flutter test, and at least one successful platform build - For Android release builds: a keystore and Play Console (or Firebase App Distribution) access
- For iOS: an Apple Developer account, certificates/profiles, and preferably a Mac-friendly signing approach (Fastlane Match, App Store Connect API key, etc.)
- Secrets stored in GitHub — never commit keystores, passwords, or API keys
Pipeline shape: what to automate
A sensible Flutter pipeline usually looks like this:
PR / push
→ Setup Flutter + caches
→ Analyze + format check
→ Unit / widget / integration tests
→ (optional) Build debug artifacts for smoke checks
→ On main / tags: release builds
→ Sign + upload (Play / TestFlight / hosting)
Split concerns early:
- CI on every PR: analyze + test (cheap, fast)
- Build on main or release tags: compile signed artifacts
- Deploy: only after green builds, often gated by tags or manual approval
Project layout for Actions
Create workflows under .github/workflows/:
.github/
workflows/
ci.yml # analyze + test on PRs
build-android.yml # Android release / AAB
build-ios.yml # iOS IPA (macOS runner)
deploy.yml # optional: store upload
You can start with one file and split later. For clarity, this article uses focused workflows.
Step 1 — Cache and pin your Flutter version
Unpinned Flutter versions make “yesterday’s green build” fail tomorrow when the runner picks a newer channel. Pin explicitly.
Use the official-ish community action many teams rely on: subosito/flutter-action.
# .github/workflows/ci.yml
name: Flutter CI
on:
pull_request:
branches: [main, develop]
push:
branches: [main, develop]
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
analyze-and-test:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: "3.24.0" # pin to your project SDK
channel: stable
cache: true
- name: Install dependencies
run: flutter pub get
- name: Verify formatting
run: dart format --set-exit-if-changed .
- name: Analyze
run: flutter analyze --fatal-infos
- name: Run tests
run: flutter test --coverage
Why these steps matter
concurrency: cancels outdated runs when you push again to the same PR—saves minutes and queue time.- Java 17: modern Android Gradle Plugin / Flutter Android tooling expects a recent JDK.
cache: true: speeds up pub and Flutter SDK reuse across runs.--fatal-infos: treats analyzer infos as failures so style/API issues do not quietly pile up.- Coverage: optional, but useful if you later upload to Codecov or similar.
If your repo uses Melos / multiple packages, run melos bootstrap (or equivalent) before analyze/test.
Step 2 — Integration tests in CI
flutter test covers unit and widget tests. Integration tests need a device or emulator.
Android emulator job (slower, but valuable)
integration-android:
runs-on: ubuntu-latest
timeout-minutes: 60
needs: analyze-and-test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- uses: subosito/flutter-action@v2
with:
flutter-version: "3.24.0"
channel: stable
cache: true
- 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
- name: Run Android integration tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 33
arch: x86_64
profile: pixel_6
script: flutter test integration_test
Integration jobs are expensive. A common pattern:
- Run them on
mainand nightly, or - Run them only when
integration_test/**or critical paths change, or - Keep a smaller “smoke” suite in CI and a fuller suite overnight
Step 3 — Android release builds (APK / App Bundle)
Secrets you will need
In GitHub → Settings → Secrets and variables → Actions, add something like:
| Secret | Purpose |
|---|---|
ANDROID_KEYSTORE_BASE64 | Base64-encoded .jks / .keystore |
ANDROID_KEYSTORE_PASSWORD | Keystore password |
ANDROID_KEY_ALIAS | Key alias |
ANDROID_KEY_PASSWORD | Key password |
PLAY_SERVICE_ACCOUNT_JSON | For Play upload (optional) |
Encode the keystore locally (do this on a secure machine):
# macOS / Linux
base64 -i upload-keystore.jks | pbcopy
# Windows (PowerShell)
[Convert]::ToBase64String([IO.File]::ReadAllBytes("upload-keystore.jks")) | Set-Clipboard
Workflow: build a signed App Bundle
# .github/workflows/build-android.yml
name: Build Android
on:
push:
tags:
- "v*"
workflow_dispatch:
jobs:
build-aab:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- uses: subosito/flutter-action@v2
with:
flutter-version: "3.24.0"
channel: stable
cache: true
- name: Decode keystore
run: |
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode > android/app/upload-keystore.jks
- name: Create key.properties
run: |
cat > android/key.properties << EOF
storePassword=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
keyPassword=${{ secrets.ANDROID_KEY_PASSWORD }}
keyAlias=${{ secrets.ANDROID_KEY_ALIAS }}
storeFile=upload-keystore.jks
EOF
- name: Build App Bundle
run: flutter build appbundle --release
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: app-release-aab
path: build/app/outputs/bundle/release/app-release.aab
if-no-files-found: error
Make sure android/app/build.gradle (or .kts) reads key.properties for the release signing config—the same pattern Flutter’s docs recommend for local release signing.
Flavors and dart-defines
Production apps almost always need environment injection:
- name: Build staging
run: >
flutter build appbundle
--flavor staging
--release
--dart-define=API_BASE_URL=https://staging.api.example.com
--dart-define=ENV=staging
Keep secrets for API keys in GitHub Secrets and pass them as --dart-define or generate a config file in a step—never hardcode them in the repo.
Step 4 — iOS builds on macOS runners
iOS CI requires macos-latest (or a specific image), Xcode, and signing material. This is the hardest part of Flutter CI/CD.
Practical signing approaches
- App Store Connect API key + automatic signing (simpler for many apps)
- Fastlane Match (best for teams sharing certs/profiles via a private repo)
- Manual import of
.p12+ provisioning profile (works, more brittle)
Example: build IPA with Flutter (high-level)
# .github/workflows/build-ios.yml
name: Build iOS
on:
push:
tags:
- "v*"
workflow_dispatch:
jobs:
build-ipa:
runs-on: macos-14
timeout-minutes: 75
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: "3.24.0"
channel: stable
cache: true
- name: Install CocoaPods
run: |
cd ios
pod install
- name: Build iOS (no codesign — adjust for your signing setup)
run: flutter build ipa --release --no-codesign
# Prefer Fastlane or xcodebuild with proper signing for store uploads
- name: Upload build
uses: actions/upload-artifact@v4
with:
name: ios-build
path: build/ios/ipa/*.ipa
--no-codesign is useful for validating the compile path. For TestFlight, you need real signing. A common production pattern is:
Flutter build → Fastlane gym / upload_to_testflight
Sketch of a Fastlane lane (in ios/fastlane/Fastfile):
lane :beta do
setup_ci
match(type: "appstore", readonly: true)
build_app(
workspace: "Runner.xcworkspace",
scheme: "Runner",
export_method: "app-store"
)
upload_to_testflight(skip_waiting_for_build_processing: true)
end
Then from Actions:
- name: Deploy to TestFlight
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.ASC_KEY_ID }}
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY_KEY: ${{ secrets.ASC_KEY_P8 }}
run: |
cd ios
bundle exec fastlane beta
Treat Apple credentials as highly sensitive. Prefer App Store Connect API keys over storing Apple ID passwords.
Step 5 — Deploying Android to Play Console
Once you have a signed .aab, upload with Google Play Publisher or Fastlane supply.
- name: Upload to Play (internal track)
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
packageName: com.example.myapp
releaseFiles: build/app/outputs/bundle/release/app-release.aab
track: internal
status: completed
Recommended progression:
- Internal testing track on every release tag
- Closed / open testing after QA
- Production via manual promotion or a separate protected workflow
For internal dogfooding without store review friction, also consider Firebase App Distribution or GitHub Release assets.
Step 6 — Web and desktop (optional but easy wins)
Flutter web to GitHub Pages / Firebase Hosting
- name: Build web
run: flutter build web --release --base-href "/your-repo/"
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: build/web
Windows / macOS / Linux desktop
Desktop builds are mostly “install deps → flutter build <platform> → upload artifact.” Use the matching runner OS (windows-latest, macos-latest, ubuntu-latest).
A full “release” workflow pattern
Many teams use tags as the release signal:
git tag v1.4.0
git push origin v1.4.0
That tag triggers:
- CI re-run (analyze + tests)
- Android AAB + Play internal upload
- iOS IPA + TestFlight
- GitHub Release with changelog notes and artifacts
Example skeleton:
name: Release
on:
push:
tags:
- "v*"
jobs:
quality:
uses: ./.github/workflows/ci.yml
# or inline the same analyze/test job
android:
needs: quality
# build + upload AAB
ios:
needs: quality
# build + TestFlight
github-release:
needs: [android, ios]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: softprops/action-gh-release@v2
with:
files: |
*.aab
*.ipa
generate_release_notes: true
If reusable workflows feel heavy early on, keep one release.yml with clear jobs and needs: dependencies.
Versioning and build numbers
Stores reject duplicate version codes / build numbers. Automate them from the tag or GITHUB_RUN_NUMBER:
- name: Set build name/number
run: |
VERSION=${GITHUB_REF_NAME#v} # v1.4.0 -> 1.4.0
BUILD=${{ github.run_number }}
flutter build appbundle --release \
--build-name="$VERSION" \
--build-number="$BUILD"
For iOS, the same flags work with flutter build ipa.
Keep pubspec.yaml version in sync with tags, or treat CI as the source of truth for --build-name / --build-number on release builds.
Caching strategies that actually save time
| Cache | Typical win |
|---|---|
Flutter SDK (flutter-action cache) | Large |
Pub (PUB_CACHE) | Medium |
Gradle (~/.gradle) | Large on Android |
CocoaPods (Pods/ / cache) | Medium on iOS |
Example Gradle cache:
- uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
gradle-${{ runner.os }}-
Do not cache build outputs as a substitute for clean release builds—cache dependencies, not signed artifacts.
Matrix builds: multiple Flutter versions or channels
Useful when maintaining a package or validating upgrades:
strategy:
fail-fast: false
matrix:
flutter: ["3.22.0", "3.24.0"]
steps:
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ matrix.flutter }}
For apps, prefer one pinned SDK. For libraries, matrices earn their keep.
Security checklist
- No keystores,
.p12, or provisioning profiles in git - Secrets only in GitHub Secrets / Environments
- Use Environments (
production) with required reviewers for store deploys - Least-privilege service accounts for Play / ASC
- Rotate signing materials when people leave the team
- Restrict
workflow_dispatchand tag creation if releases are sensitive - Prefer OIDC where providers support it (reduces long-lived JSON keys)
Example environment protection:
deploy-play:
needs: build-aab
runs-on: ubuntu-latest
environment: production
steps:
# upload only after approval
Common failures and how to fix them
| Symptom | Likely cause | Fix |
|---|---|---|
sdk constraint errors | Flutter version mismatch | Pin flutter-version to match pubspec SDK constraint |
| Android signing fails | Wrong path in key.properties | Ensure storeFile path matches where CI wrote the .jks |
| Gradle / JDK errors | Java 8/11 on modern AGP | Use Java 17 |
| iOS CocoaPods errors | Stale Pods / Ruby env | pod repo update or commit Podfile.lock, use Bundler |
| Emulator tests flake | Cold boot / timing | Increase timeouts, retry once, shrink suite |
| Out of disk on macOS | Xcode + derived data | Clean DerivedData; prefer larger runners if needed |
| Play upload rejected | Duplicate versionCode | Drive --build-number from CI run/tag |
Always download Action logs’ “Build” and “Upload” steps first—Flutter’s verbose toolchain errors are usually near the end of the failing step.
Minimal starter checklist
If you want the smallest useful pipeline this week:
- Add
ci.ymlwith format + analyze +flutter test - Make it required in branch protection for
main - Add Android signed AAB on
v*tags + artifact upload - Add Play internal track upload
- Add iOS later with Fastlane Match + TestFlight
- Protect production deploys with a GitHub Environment
That sequence delivers value without boiling the ocean.
Sample ci.yml you can copy
name: Flutter CI
on:
pull_request:
push:
branches: [main]
concurrency:
group: flutter-ci-${{ github.ref }}
cancel-in-progress: true
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- uses: subosito/flutter-action@v2
with:
flutter-version: "3.24.0"
channel: stable
cache: true
- run: flutter pub get
- run: dart format --set-exit-if-changed .
- run: flutter analyze --fatal-infos
- run: flutter test --coverage
- name: Upload coverage
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/lcov.info
Replace 3.24.0 with whatever your project actually uses (flutter --version locally).
Closing thoughts
Flutter CI/CD on GitHub Actions is less about clever YAML and more about discipline: pin toolchains, fail fast on PRs, sign only in CI, and promote builds through tracks instead of building from laptops.
Start with analyze + test on every pull request. Add Android release artifacts next. Introduce iOS when you have signing sorted. Automate store uploads only after the build path is boring and reliable.
When the pipeline is boring, you can finally spend release day on product quality—not on “which machine still has the keystore.”