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:
#!/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-changedchecks formatting without modifying files and fails if any file would change.flutter analyzeruns the analyzer with youranalysis_options.yamllints.--no-fatal-infoslets "info"-level lints through while warnings and errors still fail; tighten it later.flutter test --coverageruns every test undertest/and writescoverage/lcov.info; theawkline 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¶
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-filereading theenvironment.flutterconstraint, 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-goldensand 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,
constevaluation of defines, platform build config). Android builds need Java and the Android SDK on the runner; iOS builds needmacos-*runners.
Beyond the basics¶
- Branch protection: require the
checkjob 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
pushto main, so problems are found after merge. Run on pull requests.
Exercise¶
- Add
dart run custom_lintor extra lints (prefer_const_constructors,require_trailing_commas) toanalysis_options.yamland fix what they find. Which ones are worth enforcing for your team? - Make
ci.shfail if coverage oflib/domain/drops below a threshold you choose. - Add a job that builds an Android App Bundle on
ubuntu-latestwith Java set up viaactions/setup-java. - Configure
avoid_printto be ignored only undertest/(hint: a secondanalysis_options.yamlintest/that includes the root one).