Build1 publisher3 min readPublished
Split Flutter CI from CD, or pay macOS rates on every pull request
A dev.to walkthrough gates merges on Linux in minutes and reserves signed store uploads for tag pushes. The credential handling it promises is the part the published text never reaches.
The Engineer · Build desk
Drafted by a language model from the sources cited here and checked against its claim ledger before publication. How we use AISend a correction
What happened
- In the described pipeline, GitHub Actions runs flutter analyze and tests on every pull request, and only when a version tag is pushed does it build signed iOS and Android artifacts and upload them to TestFlight and the Google Play internal track.
- The author states the single most useful decision in a Flutter CI/CD setup is separating CI from CD.
- CI runs on every PR on Linux (runs-on: ubuntu-latest) and covers format check, flutter analyze, unit and widget tests; it should finish in a couple of minutes.
- CD runs on a tag, is slow and expensive due to macOS minutes for iOS, produces signed builds and talks to the stores.
- macOS runners bill at roughly ten times the per-minute rate of Linux runners on GitHub-hosted machines.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
A practitioner writeup on dev.to lays out a Flutter pipeline in which GitHub Actions runs `flutter analyze` and tests on every pull request, and only a pushed version tag builds signed iOS and Android artifacts and delivers them to TestFlight and the Google Play internal track [1]. The author calls separating those two concerns the single most useful decision in a Flutter CI/CD setup [2], and the justification is billing arithmetic more than taste.
The CI half runs on `ubuntu-latest`: format check, analyze, unit and widget tests, finishing in a couple of minutes [3]. The CD half is slow and expensive because iOS needs macOS minutes, produces signed builds and talks to the stores [4]. According to the post, macOS runners bill at roughly ten times the per-minute rate of Linux runners on GitHub-hosted machines [5], which means a two-minute PR gate moved onto macOS costs the equivalent of about twenty Linux minutes per run [1]. Hence the instruction to keep iOS in its own job [6].
The trigger is the audit story. A tag like `v1.4.0` is the release record, and pushing to `main` ships nothing, which is how the author avoids surprise TestFlight builds at 2am [7][8].
Details worth copying wholesale. The workflow pins `flutter-version: "3.27.1"` rather than tracking latest, because a Dart SDK bump can otherwise break the build on an unrelated PR [9]. `cache: true` on `flutter-action` caches the SDK and pub cache and shaves a minute or more off most runs [10]. `dart format --output=none --set-exit-if-changed .` fails the build on unformatted code [11], and `flutter analyze --fatal-infos` turns analyzer infos into failures [12]. Integration and golden tests go behind a separate job or a label so ordinary PRs stay fast [13].
Delivery is Fastlane's job, with separate `fastlane` folders under `android/` and `ios/` [14]. Android authenticates with a Google Cloud service account JSON granted access in the Play Console and uploads via `upload_to_play_store` [15]. The one habit here that matters: the author passes the service-account JSON as raw data through `json_key_data` read from an environment variable, rather than writing a key file to the runner's filesystem [16]. Uploads land with `release_status: "draft"`, so a human still performs the promote [17]. On iOS, `upload_to_testflight` uses an App Store Connect API key with the `.p8` supplied base64-encoded through `key_content` [18], chosen over an Apple ID and app-specific password because API keys do not trip two-factor auth and do not break when Apple decides a session looks suspicious [19].
That leaves the advertised hard part unresolved. The post explicitly names getting the Android keystore and the iOS distribution certificate into CI without leaking them as the thing everyone gets wrong the first time [20], but the available text cuts off mid-way through the iOS lane and never shows that mechanism [21]. What it does show is a consistent pattern worth generalising: credentials arrive as environment variables and are never materialised as files.
Three things to watch if you copy this. Whether `release_status: "draft"` survives contact with a team in a hurry, because the draft step is what keeps the tag an honest record rather than a rubber stamp [17][7]. Whether the environment-variable-only rule holds once the keystore appears, since keystores are usually handled as decoded files. And who owns bumping the pinned Flutter version [9]; a pin that nobody updates becomes its own outage.