⚙️ Guide · Intermediate

A Flutter CI pipeline that catches real problems

Every Flutter repository eventually grows a .github/workflows/ci.yml containing flutter test. It passes, everyone feels covered, and then a release build fails on the build machine for a reason nobody’s laptop could have surfaced.

CI earns its keep by catching the failures that local development structurally cannot: stale generated code, a dependency that only resolves because of your local pub cache, a formatting change nobody ran, a golden that drifted. This is a pipeline built around those.

The shape of it

Four jobs, in two waves:

JobRunsCatches
analyzeevery push and PRformat drift, lint regressions, unused code
testevery push and PRunit, widget, and golden failures
buildPRs to main and tagscompile failures that only occur on a clean machine
releasetags onlysigning, artifact upload

analyze and test run in parallel and are fast. build is slow and gated. That split matters: a pipeline where every push waits eight minutes for an Android build is a pipeline people learn to ignore.

Analyze, with teeth

name: CI

on:
  push:
    branches: [main]
  pull_request:

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

jobs:
  analyze:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
        with:
          flutter-version-file: pubspec.yaml
          cache: true

      - run: flutter pub get
      - run: dart format --output=none --set-exit-if-changed .
      - run: flutter analyze --fatal-infos
      - run: flutter pub outdated --exit-code-on-outdated-transitive || true

Four details are doing the work.

concurrency with cancel-in-progress. Push three commits to a PR and you get one run, not three. On a busy repo this is the single largest saving available.

flutter-version-file: pubspec.yaml reads the SDK constraint from the repo rather than pinning a version in the workflow. One fewer place to update, and no drift between what CI builds and what the project declares.

dart format --set-exit-if-changed fails the build on unformatted code instead of quietly rewriting it. The --output=none stops it from writing files in CI, which would otherwise make the diff confusing.

--fatal-infos promotes info-level lints to failures. This is stricter than most teams start with, and it is the setting that keeps a lint baseline from decaying — infos that never fail anything accumulate until nobody reads the analyzer output at all.

Test, including goldens

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
        with:
          flutter-version-file: pubspec.yaml
          cache: true

      - run: flutter pub get
      - run: flutter test --coverage --reporter github

      - name: Upload failed goldens
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: golden-failures
          path: '**/failures/**'

--reporter github annotates failures directly on the diff, so a failing test shows up on the line it belongs to rather than buried in a log.

The artifact upload is the part people skip and then regret. When a golden test fails in CI, the framework writes the expected, actual, and diff images into a failures/ directory. Without uploading them you are reduced to guessing; with them, you download three PNGs and see the problem in ten seconds.

Goldens are also font-dependent and platform-dependent. Generate them on Linux in CI, or accept that a Mac-generated golden will fail on an Ubuntu runner. If you generate locally, flutter test --update-goldens on the same OS the CI uses is the only reliable path.

Guarding generated code

If the project uses build_runner, CI must verify the committed output matches the sources:

      - name: Verify generated code is current
        run: |
          dart run build_runner build --delete-conflicting-outputs
          if ! git diff --quiet; then
            echo "Generated code is stale. Run build_runner and commit."
            git diff --stat
            exit 1
          fi

This catches the single most common “works on my machine” failure in code-generation projects: someone edited a model, forgot to regenerate, and the committed .g.dart no longer matches.

Build, on a matrix

  build:
    needs: [analyze, test]
    strategy:
      fail-fast: false
      matrix:
        include:
          - os: ubuntu-latest
            target: apk
            cmd: flutter build apk --release --split-per-abi
          - os: macos-latest
            target: ios
            cmd: flutter build ios --release --no-codesign
          - os: ubuntu-latest
            target: web
            cmd: flutter build web --release
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
        with:
          flutter-version-file: pubspec.yaml
          cache: true
      - run: flutter pub get
      - run: ${{ matrix.cmd }}

fail-fast: false is deliberate — when the web build breaks you want to know whether iOS also broke, not have it cancelled.

--no-codesign for iOS lets you verify compilation on every PR without managing certificates. Signing belongs in the release job, where the secrets are.

Signing, without leaking

The release job is where secrets appear, and where care is warranted:

  release:
    if: startsWith(github.ref, 'refs/tags/v')
    needs: [build]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
        with:
          flutter-version-file: pubspec.yaml
          cache: true

      - name: Restore keystore
        env:
          KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
        run: echo "$KEYSTORE_B64" | base64 --decode > android/app/upload-keystore.jks

      - name: Write signing config
        env:
          STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
          KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
          KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
        run: |
          cat > android/key.properties <<EOF
          storePassword=$STORE_PASSWORD
          keyPassword=$KEY_PASSWORD
          keyAlias=$KEY_ALIAS
          storeFile=upload-keystore.jks
          EOF

      - run: flutter build appbundle --release

Three rules that are not optional:

  1. Secrets go through env:, never inline in a run: string. An inline ${{ secrets.X }} inside a shell command can end up in a trace or an error message.
  2. Never echo a secret. GitHub masks known secret values in logs, but only exact matches — a base64 fragment or a transformed value is not masked.
  3. Gate the job on the tag. if: startsWith(github.ref, 'refs/tags/v') means a PR from a fork never reaches the step that touches signing material.

If you can use OIDC and short-lived credentials for your distribution target, prefer that over long-lived stored secrets entirely.

What makes it fast

The cache: true on subosito/flutter-action caches the SDK itself. Add the pub cache and the Gradle cache for the Android job:

      - uses: actions/cache@v4
        with:
          path: |
            ~/.pub-cache
            ~/.gradle/caches
          key: ${{ runner.os }}-deps-${{ hashFiles('**/pubspec.lock', '**/*.gradle*') }}
          restore-keys: ${{ runner.os }}-deps-

Keying on pubspec.lock rather than pubspec.yaml matters: the lock file is what actually determines resolved versions, so a cache keyed on it is never stale in the way that counts. The restore-keys prefix gives you a partial hit when the lock changes, which is far better than a cold cache.

FAQ

Should CI run integration tests?

Only if you have a real device farm or a reliable emulator setup, and only on a schedule or on main. Integration tests on every PR are the fastest way to teach a team to ignore a red build.

Why does flutter analyze pass locally and fail in CI?

Usually a stale local analysis server, or generated files present locally but not committed. The build_runner check above catches the second; restarting your analyzer catches the first.

Is --fatal-infos too strict?

For a new project, no. For an existing one with hundreds of infos, adopt it after a cleanup pass, or you will just disable the job.

How do I keep the workflow from running on documentation-only changes?

paths-ignore on the trigger. Be careful: if a required status check never runs, the PR cannot merge. Use a separate always-passing job with the same name, or make the check non-required.

Should the pipeline publish to the stores automatically?

Building and uploading to an internal test track automatically is reasonable. Promoting to production automatically is a policy decision that should involve a human, and I would not wire it up by default.


The GitHub Actions features, Flutter build commands, dart format flags and signing setup described here are documented in the references linked above. The four-job split, the --fatal-infos recommendation, the generated-code guard, and the secret-handling rules are my own judgement from maintaining pipelines of this shape. Action versions and runner images change — pin what you depend on and re-check the versions before copying this wholesale.

#Flutter#CI/CD#GitHub Actions#Testing#DevOps