Skip to content

05 · CI for Flutter

Continuous integration runs your checks on every push and pull request, on a clean machine, so "works on my laptop" stops being the standard. For Flutter, the core gates are the same everywhere: format, analyze, test, build. This lesson writes them as a script you can run locally, runs it against this course's own example project (with an honest first result), and wraps the same steps in a GitHub Actions workflow. General Git and Actions concepts are covered in the GitHub course; here the focus is what's specific to Flutter.

Step 1: a script that is the CI

Put the checks in one script, so CI and developers run exactly the same thing:

tool/ci.sh
#!/usr/bin/env bash
# The same checks CI runs, runnable locally before pushing.
set -euo pipefail

echo "== pub get";   flutter pub get > /dev/null
echo "== format";    dart format --output=none --set-exit-if-changed lib test
echo "== analyze";   flutter analyze --no-fatal-infos
echo "== test";      flutter test --coverage --reporter=compact
echo "== coverage";  awk -F: '/^LF:/{lf+=$2} /^LH:/{lh+=$2} END{printf "lines covered: %d/%d (%.1f%%)\n", lh, lf, 100*lh/lf}' coverage/lcov.info
  • dart format --output=none --set-exit-if-changed checks formatting without modifying files and fails if any file would change.
  • flutter analyze runs the analyzer with your analysis_options.yaml lints. --no-fatal-infos lets "info"-level lints through while warnings and errors still fail; tighten it later.
  • flutter test --coverage runs every test under test/ and writes coverage/lcov.info; the awk line totals it.

Step 2: run it — and read what fails

The first run against the example project behind Levels 2–4 of this course (about 60 Dart files written while preparing the lessons) stopped at the second gate:

$ ./tool/ci.sh
== pub get
== format
Changed test/a/anim_test.dart
Changed test/a/bloc_test.dart
...
Changed test/t/strength_golden_test.dart
Formatted 64 files (57 changed) in 0.11 seconds.

set -e stopped the script there with a non-zero exit code, exactly as CI would. The code had been written for readability on lesson pages with long lines, not to dart format's style (80-column default). The fix is mechanical — run the formatter and commit:

$ dart format lib test
Formatted 64 files (57 changed) in 0.11 seconds.

$ ./tool/ci.sh
== pub get
== format
Formatted 64 files (0 changed) in 0.10 seconds.
== analyze
165 issues found. (ran in 4.7s)
== test
00:14 +99: All tests passed!
== coverage
lines covered: 1241/1302 (95.3%)

The 165 analyzer findings were all info level, so --no-fatal-infos let the run continue. Grouped by rule:

$ flutter analyze | grep '•' | awk -F'•' '{print $1, $NF}' | sort | uniq -c | sort -rn
 148    info   avoid_print
  11    info   curly_braces_in_flow_control_structures
   4    info   prefer_initializing_formals
   1    info   use_null_aware_elements
   1    info   camel_case_types

avoid_print is expected in this project's tests, which print on purpose to show output in the lessons; a real project would use expect instead, or disable the rule for test/ only. The curly_braces findings appeared because of formatting: once the formatter moved a long if (...) statement; onto two lines, the lint asks for braces. That's typical — tools interact, which is why CI runs all of them together. On this machine the whole script took about 24 seconds of wall time.

The 95 % coverage figure needs a caveat: coverage counts lines of lib/ that tests executed, here mostly small demo files each written alongside its own test. Real apps rarely look like that, and high coverage doesn't mean good assertions. Use coverage to find untested logic, not as a score.

Step 3: the GitHub Actions workflow

.github/workflows/ci.yml
name: CI
on:
  push:
    branches: [main]
  pull_request:

jobs:
  check:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2   # community action that installs Flutter
        with:
          channel: stable
          flutter-version-file: pubspec.yaml   # or pin with flutter-version: x.y.z
          cache: true
      - run: ./tool/ci.sh
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: golden-failures
          path: test/**/failures/

  build-web:
    needs: check
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
        with: { channel: stable, cache: true }
      - run: flutter build web --release --dart-define-from-file=config/prod.json
      - uses: actions/upload-artifact@v4
        with: { name: web, path: build/web }

Not executed on GitHub for this lesson

The script above was run locally (output shown); the workflow file was not run on GitHub Actions for this course. Action names and major versions (actions/checkout@v4, subosito/flutter-action@v2) change over time — check each action's page for its current version and inputs, and prefer pinning to a commit SHA for third-party actions in security-sensitive repos.

Design choices:

  • Pin the Flutter version (via flutter-version-file reading the environment.flutter constraint, or an explicit version) so CI doesn't break the day a new stable ships. Upgrade deliberately (lesson 09).
  • Cache the Flutter SDK and pub packages; it saves minutes per run.
  • Upload golden failure images when tests fail, so reviewers can see the diff without reproducing it.
  • Goldens on Linux CI: golden PNGs generated on macOS may not match Linux pixel-for-pixel. Generate them on the CI platform (e.g. a workflow that runs flutter test --update-goldens and uploads the results), or run golden tests on one OS only.
  • Build jobs prove the app compiles in release mode, which catches issues tests don't (tree-shaking, const evaluation of defines, platform build config). Android builds need Java and the Android SDK on the runner; iOS builds need macos-* runners.

Beyond the basics

  • Branch protection: require the check job to pass before merging.
  • Integration tests on an emulator nightly (Level 3 · 09).
  • Release pipelines that build signed artifacts and upload to the stores on tags (lesson 06).
  • Dependency updates with Dependabot or Renovate (both understand pub), which open PRs that CI then validates.
  • A dedicated course on pipelines, test gates and deployment strategies is on the Mastery Path roadmap; until then, the GitHub course covers Actions in depth.

How It Actually Works

A CI runner is a fresh virtual machine per job. subosito/flutter-action downloads the requested Flutter SDK archive (or restores it from the cache keyed by version), puts flutter and dart on PATH, and optionally caches ~/.pub-cache. From then on, the job is exactly your script: each command's exit code decides whether the step passes, and set -euo pipefail makes the script stop at the first failing command, including failures inside pipes. flutter test runs test files in parallel across CPU cores (each file in its own test process, controlled by --concurrency), which is why the per-test output interleaves. Nothing persists between runs except caches and uploaded artifacts — which is precisely what makes CI results trustworthy.

Common mistakes

  • CI and local checks diverge — a YAML file full of inline commands nobody runs locally. Use one script.
  • Unpinned SDK, so builds break on someone else's schedule.
  • Flaky tests tolerated with automatic retries; fix or quarantine them, or nobody trusts red builds.
  • Secrets in the repo instead of encrypted CI secrets.
  • Running only on push to main, so problems are found after merge. Run on pull requests.

Exercise

  1. Add dart run custom_lint or extra lints (prefer_const_constructors, require_trailing_commas) to analysis_options.yaml and fix what they find. Which ones are worth enforcing for your team?
  2. Make ci.sh fail if coverage of lib/domain/ drops below a threshold you choose.
  3. Add a job that builds an Android App Bundle on ubuntu-latest with Java set up via actions/setup-java.
  4. Configure avoid_print to be ignored only under test/ (hint: a second analysis_options.yaml in test/ that includes the root one).