Mobile CI/CD on GitHub Actions, without fastlane
How to take mobile releases off one developer's laptop — self-hosted
Apple Silicon runners, code signing from repository secrets instead of
fastlane match, one build number across both stores, and a production lane
that always stops at a human.
This is a field guide to one pattern: shipping a Flutter app to both stores from GitHub Actions, with signing material in repository secrets and Apple builds on self-hosted hardware. It describes the shape of the system, the reasoning behind each decision, and the things that are routinely got wrong the first time. It is maintained as a living document and updated as the pattern evolves.
The worked example is Flutter, but the load-bearing parts are not Flutter-specific. If you are shipping React Native, native iOS/Android, or anything else that needs Apple hardware and signing material to produce a release artifact, the decisions in Part 2 are the transferable content. The Flutter and Xcode specifics are there to make it concrete.
Start here — if you're on Flutter
If your app is Flutter, this is not an analogy — it is your stack, and the commands below are the ones the pipeline actually runs. Four things are worth knowing before you start, because each one has a Flutter-shaped edge that costs a day if you meet it by surprise.
Flavors become Xcode build configurations, and that is where signing breaks
A Flutter flavor named staging produces Xcode build configurations named
Debug-staging, Release-staging, Profile-staging.
Each configuration carries its own signing settings. So "iOS signing is
fixed" is only ever true of the configuration that was fixed. Set manual signing on
Release-staging, ship staging successfully, and the identical failure sits
waiting on Release-production — typically discovered weeks later, during the
production phase, by someone who believes signing is a solved problem.
Fix every release configuration you intend to build, in one sitting. And check what
the flavor did to your bundle identifiers: flavors commonly suffix them
(com.example.app → com.example.app.uat), which means a separate
App Store Connect record, a separate provisioning profile, and a separate Firebase app —
each with its own secret.
The build commands CI runs
Note that --build-number and --build-name are passed
explicitly on every invocation. That is decision 6 in practice: the
values in pubspec.yaml are ignored by CI, so nothing needs committing to cut
a release.
# staging — APK for Firebase App Distribution
flutter build apk --flavor staging \
--build-number=$BUILD_NUMBER
# production — AAB for Play
flutter build appbundle --flavor production \
--build-name=$VERSION --build-number=$BUILD_NUMBER
# iOS, either lane — export options plist selects the environment
flutter build ipa --flavor staging \
--build-number=$BUILD_NUMBER \
--export-options-plist=ios/ExportOptions.plist
One export options plist per environment, both checked in — each carrying
signingStyle: manual and the exact provisioning profile
Name discussed in Part 3.2.
The composite action, in full
This is the entirety of the shared toolchain prologue from decision
3. If you use code generation — freezed, json_serializable,
riverpod, retrofit — the build_runner step is not
optional, and forgetting it produces missing-file compile errors that look nothing like
a codegen problem.
runs:
using: composite
steps:
- uses: subosito/flutter-action@v2
with:
flutter-version: 3.38.5 # exact version, never a channel
# Caching helps on ephemeral Linux and hurts on persistent Macs —
# see the note below. Don't hard-code this to true.
cache: ${{ runner.os == 'Linux' }}
- run: flutter pub get
shell: bash
- run: dart run build_runner build --delete-conflicting-outputs
shell: bash
The cache input above covers both the SDK and the pub package cache, so
it is the whole decision — there is usually no separate actions/cache step
to think about. Leave it on for GitHub-hosted Linux, where every run starts from a bare
image. Turn it off for persistent self-hosted Macs, which already hold the SDK and
~/.pub-cache on local disk: caching there packs, uploads, downloads and
unpacks a copy of something already sitting on the filesystem, which is measurably
slower than not caching at all.
What to gate PRs on
dart analyze is fast, deterministic and safe to make blocking on day one.
flutter test is the one to think about: if your suite is not currently green,
adding it as a blocking gate blocks everything, and adding it as non-blocking is a check
nobody reads. Decide which, give it an owner and a date, and see
Part 6 — it is the least satisfying compromise in the design.
Suggested reading order
- Decision 1 (signing from secrets) and decision 4 (the runner boundary) — these two are a pair, and decide your security posture.
- Decision 2 and decision 6 — build numbering. Cheap to get right now, painful to change once the stores have seen your numbers.
- Part 3 to stand it up, then Part 5 when — not if — iOS signing fails.
Part 0 — The one-laptop release
The situation this replaces is common enough to be a genre. Releases were built and signed by running a shell script on one developer's Mac. That machine held the Android keystore, the iOS distribution certificate in the login keychain, and the provisioning profiles. Build numbers were incremented by hand in the manifest and remembered, or not. Uploads went out through Xcode's Organizer and the Play Console web UI.
It works right up until it doesn't, and the failure modes are all the same failure:
- One person can release. Not "one person usually releases" — one person can. Holidays and illness become release blockers, and the bus factor is 1 for a revenue-carrying artifact.
- The build is not reproducible. Whatever was in that keychain, on that Xcode version, with those local uncommitted changes, is what shipped. Nobody can reconstruct it later.
- Silent wrong-build shipping. A forgotten flavor flag or a stale provisioning profile does not fail loudly. It produces a valid artifact pointed at the wrong backend, and you find out from users.
- Build numbers collide. Both stores reject a build number at or below the current maximum. Hand-bumping produces duplicates and a failed upload at the end of a long build.
The target state is narrow and worth stating plainly: any engineer with repo write access can cut a release from a clean checkout, and nothing reaches a user without a human deliberately clicking submit.
Part 1 — The shape
Three workflow files and one composite action. That is the whole system.
| Workflow | Trigger | Runner | Produces | Lands |
|---|---|---|---|---|
ci-checks.yml |
Pull request → main |
Linux, GitHub-hosted or self-hosted | — | Blocking static-analysis gate |
deploy-staging.yml · prepare |
Push to main, or manual |
Linux | build_number |
Output consumed by both platform jobs |
deploy-staging.yml · android |
Push to main, or manual |
Self-hosted macOS arm64 | Staging APK | Firebase App Distribution → QA group |
deploy-staging.yml · ios |
Push to main, or manual |
Self-hosted macOS arm64 | Staging IPA | TestFlight, UAT bundle ID |
deploy-prod.yml · prepare |
Manual dispatch or v* tag only |
Linux | build_number + version |
Outputs consumed by both platform jobs |
deploy-prod.yml · android |
Manual dispatch or v* tag |
Self-hosted macOS arm64 | Production AAB | Play internal track, status draft |
deploy-prod.yml · ios |
Manual dispatch or v* tag |
Self-hosted macOS arm64 | Production IPA | App Store Connect, uploaded not submitted |
.github/actions/flutter-setup |
Composite, used by every build job | — | Pinned toolchain + deps + codegen | — |
Two physical Mac minis carry the same runner labels, so GitHub routes each job to
whichever is idle. Adding the second machine required no workflow change and no new
secret — identical labels are the entire mechanism. That is worth knowing before you
buy the first one: capacity here is a hardware purchase and a
./config.sh run, not a refactor.
One caveat on that table before you copy it: the Android jobs run on the Macs, but they don't need to. Android has no Apple-hardware requirement, and the original design had it on Linux. It moved for toolchain-consistency reasons and stayed — a compromise rather than a recommendation. See Part 6.
Part 2 — Six decisions that mattered
This is the part worth stealing. The YAML is downstream of these.
1. Drop fastlane; sign from repository secrets
The first design was fastlane-based, using fastlane match for iOS
signing. It was abandoned before implementation, for two reasons.
The first is footprint. match pulls in a Ruby toolchain, a
Fastfile, a Matchfile, and a separate private certificates
repository, all of which become things the team maintains and debugs. For a pipeline
whose entire job is "produce two signed artifacts and upload them," that is a large
surface.
The second is the one that actually decided it. match encrypts
the certificates repo with a passphrase, and losing that passphrase locks you out of
your own signing material. Recovery means revoking and regenerating
certificates and every profile built against them. For a small team without a
rigorous secret-custody process, that is a real operational risk taken on in exchange
for convenience the team did not need.
What replaced it: base64-encode the .p12 certificate and the
.mobileprovision profile, store them as repository secrets, and have each
run import them into a fresh keychain it creates and destroys. Android
is the same idea — the keystore and its key.properties are base64 secrets
decoded per run.
This is more explicit YAML than match, and that is the point. Every
step is visible in the workflow file, there is no state living outside GitHub, and
rotating a certificate is "replace a secret" rather than "run a tool that mutates a
remote encrypted repo."
Signing from secrets means the certificate lives in GitHub Actions secrets, and
anyone who can push a workflow to a branch that runs on the signing runner can
exfiltrate it. That is why decision 4 exists. If you cannot enforce
that boundary, match's separate-repo model is genuinely the safer
option — pick deliberately rather than by default.
2. One workflow file per environment, not per platform
The obvious decomposition is one workflow per platform: ios.yml,
android.yml. Don't. On GitHub Actions, github.run_number is
scoped to the workflow file. Split the platforms across two files and
their counters drift independently, so the same logical release ships as build 412 on
iOS and build 389 on Android. Every later "which build is this?" conversation gets
harder, and correlating a crash report to a commit stops being mechanical.
Instead: one file per environment (deploy-staging.yml,
deploy-prod.yml), each containing a cheap prepare job on
Linux that computes the build number once and passes it to both platform jobs:
jobs:
prepare:
runs-on: ubuntu-latest
outputs:
build_number: ${{ steps.n.outputs.build_number }}
steps:
- id: n
# run_number is scoped per workflow FILE — both platform jobs
# below consume this one value, so a run is one build number.
run: echo "build_number=$(( ${{ github.run_number }} + $OFFSET ))" >> "$GITHUB_OUTPUT"
android:
needs: prepare
runs-on: [self-hosted, macOS, arm64]
# ... uses needs.prepare.outputs.build_number
ios:
needs: prepare
runs-on: [self-hosted, macOS, arm64]
# ... uses needs.prepare.outputs.build_number
The corollary is a live footgun: renaming one of these files resets its counter to 1. The next release will try to upload a build number the stores saw two years ago, and be rejected as a duplicate. Put a comment saying so at the top of the file, because the person who eventually tidies up your workflow filenames will not have read this guide.
3. Put the toolchain prologue in a composite action
Every build job needs the same four things: the SDK at a pinned version, dependencies fetched, code generation run, and caching. Four jobs across three workflows means four copies that drift.
A composite action collapses it to one file that every job references with
uses: ./.github/actions/flutter-setup. Pin the SDK to an
exact version — not a channel, not a range. A pipeline that silently
picks up a new minor SDK is a pipeline that will one day fail on a machine you cannot
reproduce.
One detail worth copying: condition the dependency cache on
runner.os == 'Linux'. Self-hosted Macs persist their package cache on disk
between runs natively, so running actions/cache there does real work
(pack, upload, download, unpack) to replace something already sitting on the local
filesystem. It is slower than no cache at all.
4. Self-hosted Macs — and the boundary that actually matters
Apple builds need Apple hardware. GitHub's hosted macOS runners exist, but they are
billed at a steep multiplier against Linux minutes, and for a team shipping staging
builds on every merge to main, the arithmetic stops working quickly. Two
Mac minis pay for themselves in months and are meaningfully faster besides — warm
caches, no per-run image provisioning.
They also introduce the one genuine security decision in this whole design.
A self-hosted runner is a persistent machine that executes whatever the workflow tells it to. On a repository that accepts pull requests, the machine holding your signing material must never execute code from an unreviewed pull request — a PR that modifies a workflow file to print a decoded certificate is a complete compromise of your Apple and Play credentials.
So: PR-triggered jobs run on disposable Linux; only push and manual-dispatch events reach the signing runners. Enforce it structurally, not by convention:
- Keep PR jobs in a separate workflow file whose
runs-onnever references the macOS labels. - Set the repository's Actions setting to require approval for all outside collaborators.
- Scope secrets to a GitHub environment with required reviewers, so even a job that reaches the runner cannot read the signing material unhindered.
- Register runners at the repository level, not the organization level, unless every repo in the org is equally trusted.
Then harden the machines for unattended operation, which is unglamorous and entirely necessary: a dedicated non-admin login user with a real login session (the iOS keychain needs one), the runner installed as a launchd service, sleep disabled, auto-restart on power loss enabled, and a decision made about FileVault — either off with auto-login, or on with the runner user added as an unlock user. A Mac mini that reboots after a power cut and sits at a login screen is a release pipeline that is down without telling anyone.
5. Production is draft-only, by construction
The production workflow uploads to both stores and then stops. Android lands on the
internal track with status: draft. iOS is uploaded to App Store Connect but
not attached to a version and not submitted for review. A person promotes it.
Two properties make this hold:
- It never triggers on push. Only
workflow_dispatchwith an explicit version input, or av*tag. Merging tomaincannot start a production release, so nobody can ship by accident. - The final step is a store-side draft state, not a workflow approval gate. If the whole pipeline runs to completion unattended — which it will, eventually, because someone will push a tag they meant to push next week — the artifact still sits in a draft nobody's users can see.
Under any compliance regime this is not caution, it is the requirement. But it is worth arguing for anywhere. Full continuous deployment to an app store is a strange goal in any case: store review latency means you are not getting a fast rollback out of it, so the automation buys you correctness and repeatability, not speed to user. Keep the human.
6. Build numbers are computed, never committed
build_number = github.run_number + OFFSET
Injected at build time via the build system's build-number flag. The value in the committed manifest is ignored entirely by CI, which means no release commit, no version-bump PR, and no merge conflicts on the version line. The manifest value continues to matter only for local development builds.
OFFSET exists because run_number starts at 1 and your
stores already know about builds numbered in the hundreds. The exact rule differs by
store, and it is worth being precise: Play requires a globally monotonic
versionCode — higher than any you have ever uploaded on any track,
forever. App Store Connect scopes the constraint to the marketing version,
so a build number only has to exceed previous builds of the same version train. Treating
both as globally monotonic is the safe superset, and is what an ever-increasing
run_number gives you for free — so the offset must lift run 1 clear of the
highest number either store has seen.
Get this wrong in the direction of "too small" and you discover it at the upload step at the end of the build. The classic version of this mistake is picking the offset from the manifest version while designing, then shipping several releases before the pipeline goes live — by which point the app has already passed it, and the offset has to be raised before the first run. Pick the offset from the store's current maximum, not from your manifest, and pad it generously — there is no cost to an offset that is too large, and a failed release to a rejected duplicate at the end of every build until someone works out why.
Part 3 — Standing it up
3.1 The macOS runner
- Create a dedicated, normal-UID login user for the runner. It needs a real login session — the iOS keychain does not work from a daemon-only context.
- Install Xcode and accept its license (
xcodebuild -license accept), the Xcode command line tools, CocoaPods, the Android SDK plus--android-licenses, and the Firebase CLI. The Flutter SDK itself does not need pre-installing — the composite action installs it per run — but install it once anyway so you can runflutter doctorwhen something breaks. - Register the runner: Settings → Actions → Runners → New self-hosted
runner, macOS/arm64. Give each machine a unique
--nameand accept the default labels. The defaults (self-hosted, macOS, ARM64) match aruns-on: [self-hosted, macOS, arm64]selector case-insensitively — no--labelsflag needed. - Install it as a service:
./svc.sh install && ./svc.sh start. Then put every tool the builds need on the service PATH — the runner's.pathfile — not just your interactive shell's. This catches almost everyone once: it works when you test it by hand and fails as a service. - Harden for unattended operation:
sudo pmset -a sleep 0 displaysleep 0 disksleep 0, enable auto-restart on power loss, and resolve FileVault as described in decision 4. - For a second machine, repeat with a different
--nameand the same labels. No workflow or secret change.
3.2 Secrets
Around eighteen repository secrets, which sounds like a lot until you see them grouped — it is four credential families times two environments.
| Secret | Used by | What it is |
|---|---|---|
ANDROID_KEYSTORE_BASE64 | staging + prod | base64 of the upload keystore .jks |
ANDROID_KEY_PROPERTIES | staging + prod | full contents of key.properties |
GOOGLE_SERVICES_JSON_STAGING | staging | Firebase Android config, staging flavor |
GOOGLE_SERVICES_JSON_PROD | prod | Firebase Android config, production flavor |
GOOGLE_SERVICE_INFO_PLIST_STAGING | staging | Firebase iOS config plist, staging |
GOOGLE_SERVICE_INFO_PLIST_PROD | prod | Firebase iOS config plist, production |
FIREBASE_FAD_SERVICE_ACCOUNT_JSON | staging | service account with App Distribution Admin |
FIREBASE_ANDROID_APP_ID_STAGING | staging | App Distribution app ID, 1:NNN:android:XXXX |
IOS_CERT_P12_BASE64 | staging + prod | base64 Apple Distribution certificate |
IOS_CERT_PASSWORD | staging + prod | the .p12 export password |
IOS_PROVISION_PROFILE_BASE64_STAGING | staging | base64 .mobileprovision, UAT |
IOS_PROVISION_PROFILE_BASE64_PROD | prod | base64 .mobileprovision, production |
APP_STORE_CONNECT_API_KEY_P8 | staging + prod | contents of the ASC API key .p8 |
APP_STORE_API_KEY_ID | staging + prod | ASC API Key ID |
APP_STORE_ISSUER_ID | staging + prod | ASC issuer UUID |
APP_STORE_APPLE_ID_UAT | staging | numeric Apple ID of the UAT app record |
APP_STORE_APPLE_ID | prod | numeric Apple ID of the production app record |
PLAY_STORE_SERVICE_ACCOUNT_JSON | prod | GCP service account with Play Release permission |
One thing deliberately not a secret: the provisioning profile's
Name. It lives in the checked-in export options plist, one per
environment, because it is not sensitive and because keeping it in the repo makes the
coupling visible. It must match the profile's embedded name exactly. Extract
it before you upload the profile as a secret:
security cms -D -i profile.mobileprovision | plutil -extract Name raw -
Every time you rotate a profile, re-run that and update the plist. This single mismatch is reliably the most expensive thing here, and it tends to recur during the production phase after being solved once in staging.
3.3 Distribution targets
Firebase App Distribution (staging Android): create the tester group before the first run and add at least one tester; create a service account with the Firebase App Distribution Admin role and take its JSON key; note the app ID from project settings.
Play Console (production Android): create a service account in your GCP project, enable the Google Play Android Developer API on that project, then in Play Console under Setup → API access link the Cloud project, invite the service account, and grant it Release permission on the app. Missing any one of those produces the same opaque HTTP 403.
App Store Connect (both iOS lanes): create an API key with Developer or App Manager access under Users and Access → Integrations; record the Key ID and Issuer ID. Confirm both app records exist and note their numeric Apple IDs. Create both distribution profiles against the Apple Distribution certificate — not the legacy iPhone Distribution one. See the gotchas table for why that sentence is in bold.
Part 4 — Running it
Staging is automatic. Merging any PR to main builds and
distributes both platforms with a shared build number. To retry one platform without
rebuilding the other:
gh workflow run deploy-staging.yml -f platform=ios
gh workflow run deploy-staging.yml -f platform=android
gh workflow run deploy-staging.yml -f platform=both
Production is deliberate. Two triggers, same result:
# Explicit version, no tag required
gh workflow run deploy-prod.yml --ref main -f version=1.3.0 -f platform=both
# Or push a tag — version is derived from it (v1.3.0 -> 1.3.0)
git tag v1.3.0 && git push origin v1.3.0
Then finish by hand: in Play Console, promote the draft from the internal track when ready; in App Store Connect, attach the uploaded build to a version and submit for review. Until someone does both, nothing has shipped.
A platform input on both workflows is worth the small amount of YAML it
costs. Store-side failures are routinely one-sided — an expired Apple profile, a Play
permission change — and re-running a full two-platform build to retry one upload gets
old immediately.
Part 5 — Gotchas
Every row here cost real debugging time. If you read only one section, read this one.
| Symptom | Cause | Fix |
|---|---|---|
| iOS build hangs waiting for an Apple ID prompt, or fails to sign | The Xcode build configuration is set to automatic signing, which needs an interactive Apple account session and cannot work headless | Switch the release configurations to manual signing in the Xcode project and set signingStyle: manual in the matching export options plist. Do this for every release configuration; fixing staging alone leaves the identical failure waiting on production. |
| "No matching provisioning profile" / codesign failure | The profile was generated against the legacy iPhone Distribution certificate while CI imports the modern Apple Distribution one — or the reverse. It bites in both directions. | Standardize on Apple Distribution and regenerate every profile against it. Verify with security cms -D -i … | plutil -extract Name raw - and match the result to the plist exactly. |
| Play upload rejected on release notes | Play caps release notes at 500 characters per language. Notes generated from git log since the last tag blow through that easily — a few weeks of commits ran to more than three times the limit. |
Truncate to ~497 characters plus an ellipsis before writing the notes file. Do it unconditionally; do not assume your commit range is short. |
| Release-notes step fails: no such file or directory | The step writes into the build output directory before anything has created it — that directory is gitignored and only appears later, when the build runs. | mkdir -p before writing. Trivial, and it only reproduces on a clean checkout, which is exactly where CI lives. |
| Play upload fails with HTTP 403 | Either the service account is not linked in Play Console with Release permission, or the Google Play Android Developer API is not enabled on the GCP project. Both produce the same error. | Check both, in that order. They are configured in different consoles, which is why one is usually missed. |
| Firebase App Distribution upload fails with HTTP 403 | The service account lacks the Firebase App Distribution Admin role, or the app ID secret is wrong | Confirm the role in the Firebase console; confirm the app ID matches project settings exactly, including the 1:NNN:android:XXXX shape. |
| Upload succeeds, testers never receive the build | The tester group did not exist when the upload ran. The upload does not fail — it just has nobody to notify. | Create the group and add a tester before the first staging run. |
| iOS job queues forever | All self-hosted Macs are offline or unhealthy. There is no fallback: the label matches nothing, so the job waits rather than failing. | Check the runner service status on the machine and confirm Idle in the repo's runner settings. Consider an alert on queue time — a silently-down runner looks identical to a slow build. |
| App Store Connect upload returns 401/403 | Wrong Key ID or Issuer ID, or the .p8 was not written to the exact path the upload tool expects (~/.appstoreconnect/private_keys/AuthKey_<ID>.p8) before the upload step ran |
Verify both IDs, and confirm the key-writing step actually ran and ordered before the upload. |
| "Duplicate build number" on upload | The computed number is not above the store's current maximum — either the offset is too small, or a workflow file was renamed and reset run_number to 1 |
Check the store's current max, raise the offset well clear of it, and confirm nobody renamed a workflow file. See decision 6. |
Part 6 — Weak points in this design
Written down because a guide that only lists wins is not useful.
- The test suite is not in the blocking gate. Static analysis blocks; tests typically do not, because the suite is rarely green at the moment the pipeline lands, and gating on a red suite means gating on nothing. That is the right call at the time and the wrong steady state — a "temporarily non-blocking" test job has a way of becoming permanent. Track it as real work with an owner, not a TODO.
- Android builds on the Mac. Android has no Apple-hardware requirement, so Linux is cheaper and correct in principle. It tends to migrate to the Mac for toolchain consistency and then stay there. It works, but it consumes scarce Mac capacity for a build that does not need it, and it is the first thing to move back if the Macs become a bottleneck.
- Runner capacity is a single point of failure until it isn't. One Mac mini means every iOS release depends on one machine, in one building, on one internet connection. A second runner belongs in the original plan rather than added after the first outage; the marginal cost is a used Mac mini and twenty minutes.
- Documentation drifts within weeks. Runbooks routinely disagree with themselves about runner hostnames and quote manifest versions that have long moved on. Anything written down that duplicates a value in the repo will go stale — prefer pointing at the file over restating its contents.
If you are starting from a one-laptop release
The order that works: composite setup action first, then the PR gate, then the staging pipeline end-to-end, and only then production. Each stage is independently useful, and staging is where you will discover every signing problem while the cost of discovering it is zero. By the time production is built, it is the same pattern with a different set of secrets and a draft flag.
Budget most of your time for iOS signing. Not the pipeline, not the uploads — the certificate, the profile, and the exact string that has to match between them.