Lesofi
← Writing

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.appcom.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
Don't cache on self-hosted Macs

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

  1. Decision 1 (signing from secrets) and decision 4 (the runner boundary) — these two are a pair, and decide your security posture.
  2. Decision 2 and decision 6 — build numbering. Cheap to get right now, painful to change once the stores have seen your numbers.
  3. 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.

WorkflowTriggerRunnerProducesLands
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."

Trade-off, stated honestly

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-on never 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_dispatch with an explicit version input, or a v* tag. Merging to main cannot 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

  1. 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.
  2. 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 run flutter doctor when something breaks.
  3. Register the runner: Settings → Actions → Runners → New self-hosted runner, macOS/arm64. Give each machine a unique --name and accept the default labels. The defaults (self-hosted, macOS, ARM64) match a runs-on: [self-hosted, macOS, arm64] selector case-insensitively — no --labels flag needed.
  4. 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 .path file — not just your interactive shell's. This catches almost everyone once: it works when you test it by hand and fails as a service.
  5. 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.
  6. For a second machine, repeat with a different --name and 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.

SecretUsed byWhat it is
ANDROID_KEYSTORE_BASE64staging + prodbase64 of the upload keystore .jks
ANDROID_KEY_PROPERTIESstaging + prodfull contents of key.properties
GOOGLE_SERVICES_JSON_STAGINGstagingFirebase Android config, staging flavor
GOOGLE_SERVICES_JSON_PRODprodFirebase Android config, production flavor
GOOGLE_SERVICE_INFO_PLIST_STAGINGstagingFirebase iOS config plist, staging
GOOGLE_SERVICE_INFO_PLIST_PRODprodFirebase iOS config plist, production
FIREBASE_FAD_SERVICE_ACCOUNT_JSONstagingservice account with App Distribution Admin
FIREBASE_ANDROID_APP_ID_STAGINGstagingApp Distribution app ID, 1:NNN:android:XXXX
IOS_CERT_P12_BASE64staging + prodbase64 Apple Distribution certificate
IOS_CERT_PASSWORDstaging + prodthe .p12 export password
IOS_PROVISION_PROFILE_BASE64_STAGINGstagingbase64 .mobileprovision, UAT
IOS_PROVISION_PROFILE_BASE64_PRODprodbase64 .mobileprovision, production
APP_STORE_CONNECT_API_KEY_P8staging + prodcontents of the ASC API key .p8
APP_STORE_API_KEY_IDstaging + prodASC API Key ID
APP_STORE_ISSUER_IDstaging + prodASC issuer UUID
APP_STORE_APPLE_ID_UATstagingnumeric Apple ID of the UAT app record
APP_STORE_APPLE_IDprodnumeric Apple ID of the production app record
PLAY_STORE_SERVICE_ACCOUNT_JSONprodGCP 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.

SymptomCauseFix
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.

Changelog

First published. Includes a Flutter quick-start covering flavor-to-build-configuration mapping, the exact build invocations, and the composite action in full.

Standing this up, or stuck halfway?

hello@lesofi.co