← Back to all articles
FlutterAug 6, 2026 · 12 min read

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

E

Evan Emran

Mobile Developer & Tech Blogger

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: 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:

BenefitWhat it means in practice
Fast feedbackBroken tests/builds fail on the PR before merge
Reproducible buildsSame Flutter SDK, same Java/Xcode versions every time
Safer releasesSigned artifacts produced in a controlled environment
Less toilNo 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:

  1. A Flutter app in a GitHub repository
  2. Local green baseline: flutter analyze, flutter test, and at least one successful platform build
  3. For Android release builds: a keystore and Play Console (or Firebase App Distribution) access
  4. For iOS: an Apple Developer account, certificates/profiles, and preferably a Mac-friendly signing approach (Fastlane Match, App Store Connect API key, etc.)
  5. 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 main and 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:

SecretPurpose
ANDROID_KEYSTORE_BASE64Base64-encoded .jks / .keystore
ANDROID_KEYSTORE_PASSWORDKeystore password
ANDROID_KEY_ALIASKey alias
ANDROID_KEY_PASSWORDKey password
PLAY_SERVICE_ACCOUNT_JSONFor 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

  1. App Store Connect API key + automatic signing (simpler for many apps)
  2. Fastlane Match (best for teams sharing certs/profiles via a private repo)
  3. 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:

  1. Internal testing track on every release tag
  2. Closed / open testing after QA
  3. 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:

  1. CI re-run (analyze + tests)
  2. Android AAB + Play internal upload
  3. iOS IPA + TestFlight
  4. 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

CacheTypical 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_dispatch and 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

SymptomLikely causeFix
sdk constraint errorsFlutter version mismatchPin flutter-version to match pubspec SDK constraint
Android signing failsWrong path in key.propertiesEnsure storeFile path matches where CI wrote the .jks
Gradle / JDK errorsJava 8/11 on modern AGPUse Java 17
iOS CocoaPods errorsStale Pods / Ruby envpod repo update or commit Podfile.lock, use Bundler
Emulator tests flakeCold boot / timingIncrease timeouts, retry once, shrink suite
Out of disk on macOSXcode + derived dataClean DerivedData; prefer larger runners if needed
Play upload rejectedDuplicate versionCodeDrive --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:

  1. Add ci.yml with format + analyze + flutter test
  2. Make it required in branch protection for main
  3. Add Android signed AAB on v* tags + artifact upload
  4. Add Play internal track upload
  5. Add iOS later with Fastlane Match + TestFlight
  6. 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.”


Further reading