Mobile CI/CD (Cross-platform)
Fastlane, GitHub Actions, signing, TestFlight, Play Internal, build flavors
- 01
What does a typical mobile CI pipeline look like?
EasyA mobile pipeline is really two pipelines — a fast lane that must finish while the reviewer is still looking at the PR, and a slow lane that may take an hour — and most of the design work is deciding what belongs in which.
# .github/workflows/android.yml — the fast PR lane name: Android CI on: pull_request: { branches: [main] } push: { branches: [main] } jobs: … - 02
What is Fastlane and what does it automate?
MediumFastlane is a Ruby toolkit that turns the release ritual — build, sign, upload symbols, ship to a test track — into named lanes you run identically on a laptop and in CI.
# fastlane/Fastfile — Android default_platform(:android) platform :android do desc "Build and upload to Play internal testing" lane :internal do … - 03
What does iOS code signing actually consist of, and how does CI get the certificates and profiles?
MediumiOS signing is four moving parts that all have to agree: a certificate identifying who built the binary, a provisioning profile binding an App ID to that certificate, entitlements compiled into the app, and a bundle identifier that already exists in App Store Connect.
# One-time setup, run by a human on a Mac fastlane match init fastlane match appstore # creates or fetches cert + profile, encrypted into the repo # CI: read-only, so a runner can never revoke the team's certificates export MATCH_PASSWORD="${MATCH_PASSWORD}" … - 04
How do you sign and ship an Android app?
MediumAndroid has two signing keys and the important one is not yours: you sign with an upload key, and Play App Signing re-signs the artifact with the app signing key that devices actually verify.
// app/build.gradle.kts — release signing, entirely from environment variables android { signingConfigs { create("release") { storeFile = file(System.getenv("KEYSTORE_PATH") ?: "release.keystore") storePassword = System.getenv("KEYSTORE_PASSWORD") … - 05
What are build flavors / variants and when do you use them?
MediumFlavors let you produce multiple APKs/AABs from one codebase, differing in package name, branding, endpoints, or features.
// Android — Gradle flavors android { flavorDimensions += "environment" productFlavors { create("dev") { dimension = "environment" … - 06
How do you do a staged rollout and observe a release?
MediumStaged rollout = release to a small percent first, watch metrics, ramp.
# fastlane — staged Play rollout lane :production_5pct do upload_to_play_store( track: "production", rollout: "0.05", # 5% aab: "app/build/outputs/bundle/release/app-release.aab" … - 07
The PR pipeline takes forty minutes and the macOS runners cost more than the laptops. Where do you get the time and the money back?
HardMeasure before you optimise, because the wall clock in a mobile pipeline is usually dependency resolution, an emulator boot, or a job that never needed to run at all.
# .github/workflows/pr.yml — the levers that actually move the number name: PR on: pull_request concurrency: group: pr-${{ github.ref }} # a new push cancels the run it superseded cancel-in-progress: true … - 08
A pull request arrives from a stranger's fork. What stops it from walking away with your signing key?
HardSecrets have to be unreachable from any code a stranger can change, which mostly means fork pull requests run without them and release jobs sit behind a human approval.
# A fork PR builds with no secrets present. Keep it that way. name: CI on: pull_request: push: branches: [main] … - 09
Your team ships every two weeks, review takes days, and a bad build cannot be recalled. How do you structure branches and releases around that?
MediumMobile releases are forward-only, so the branching model exists to make the ship date independent of whether any given feature is finished.
# Cut the train on schedule; main keeps moving without you git switch main && git pull git switch -c release/8.4 git push -u origin release/8.4 # After the cut, only fixes ride the branch, and each one is an explicit cherry-pick … - 10
The first crash report from the release build is a wall of hex addresses. What should the pipeline have done at build time?
MediumA crash report is only readable if the symbol file for that exact build was uploaded before anyone needed it, and rebuilding does not recreate it.
# fastlane/Fastfile — symbols leave in the same lane that built the binary platform :ios do lane :beta do api_key = app_store_connect_api_key( key_id: ENV["ASC_KEY_ID"], issuer_id: ENV["ASC_ISSUER_ID"], … - 11
Nobody touched the code and Monday's build fails. What drifts on its own in a mobile pipeline?
MediumAlmost every unexplained CI failure comes from something nobody pinned — a runner image, a toolchain, or a dependency that resolved to a version which did not exist last week.
# Pin the image, then pin the toolchain inside it jobs: ios: runs-on: macos-15 # not macos-latest: the default Xcode moves under you steps: - uses: actions/checkout@v6 … - 12
Where do the UI tests actually run in CI, and how do you stop a flaky suite from being ignored?
MediumA UI suite is only worth its runtime if a red build reliably means a broken app, and that is as much an infrastructure decision as a test-writing one.
// Make the device a declared build input, so every run uses the same image android { testOptions { managedDevices { localDevices { create("pixel8api36") { … - 13
The upload is rejected with "the bundle version must be higher than the previously uploaded version" — who should own that number?
EasyThe build number is pipeline state, not source a human edits: CI has to derive a value that only ever goes up.
# fastlane/Fastfile — the store is the source of truth for the next number platform :ios do lane :beta do api_key = app_store_connect_api_key( key_id: ENV["ASC_KEY_ID"], issuer_id: ENV["ASC_ISSUER_ID"], key_content: ENV["ASC_KEY_P8"] ) … - 14
The release build works on every laptop in the team and fails on a clean CI checkout. What do you check first?
EasyThe runner is almost always right: something the build needs is not in the repository, or has a different name once the filesystem stops being case-insensitive.
#!/usr/bin/env bash # Reproduce the CI environment locally: a clean clone, nothing from your working copy. set -euo pipefail TMP="$(mktemp -d)" git clone --depth 1 "file://$PWD" "$TMP/app" … - 15
QA signed off on build 812 and the release job then rebuilt from the same tag. Why is that not the same build?
MediumA rebuild is a new binary — a different runner image, a dependency that resolved differently, a new build number — so what ships is not what anyone tested.
# Compile once; every later job only moves the artifact. jobs: build: runs-on: macos-15 outputs: build-number: ${{ steps.rc.outputs.number }} … - 16
codesign on the runner fails with "User interaction is not allowed", but the same command works when you run it over a screen share. What is different?
MediumThe private key's access control wants a UI confirmation that a headless session cannot display, so signing has to be given explicit non-interactive permission to use that key.
#!/usr/bin/env bash # An ephemeral signing keychain: created, used, destroyed within one job. set -euo pipefail KEYCHAIN="$RUNNER_TEMP/build.keychain-db" PASSWORD="$(uuidgen)" # nobody needs to know it; it dies with the job … - 17
Google Sign-In works in the APK your CI built and fails for everyone who installed the app from Play. What changed between the two?
MediumPlay re-signs your upload with the app signing key, so the certificate fingerprint on a user's device is Google's and not your upload key's — and every integration keyed to that fingerprint has to know both.
#!/usr/bin/env bash set -euo pipefail # What you signed with (the upload key) — NOT what users verify. keytool -list -v -alias upload -keystore upload.jks -storepass "$KEYSTORE_PASSWORD" | grep SHA256 … - 18
The upload lane went green an hour ago and the testers still see nothing in TestFlight. Where did the build go?
MediumA successful upload only means App Store Connect accepted the bytes; processing, export compliance and Beta App Review all happen afterwards, and any of them can park the build silently.
# fastlane/Fastfile — the lane is only green when a tester can actually install the build platform :ios do lane :beta do api_key = app_store_connect_api_key( key_id: ENV["ASC_KEY_ID"], issuer_id: ENV["ASC_ISSUER_ID"], key_content: ENV["ASC_KEY_P8"] ) … - 19
Someone left the company with a copy of the release keystore and the iOS distribution certificate on their laptop. What can you actually rotate?
HardOn iOS nearly everything, because Apple signs what users install; on Android it depends entirely on whether Google holds your app signing key.
#!/usr/bin/env bash # iOS — the certificate is replaceable because Apple signs what users install. bundle exec fastlane match nuke distribution # revoke certs + profiles for the whole team bundle exec fastlane match appstore # reissue, re-encrypt into the match repo # CI then picks the new material up on its next run: # bundle exec fastlane match appstore --readonly … - 20
Marketing wants the feature live on both platforms Tuesday morning; Android is approved and iOS is still in review. How do you ship?
HardSeparate shipping from launching: get both binaries to users ahead of the date with the feature dark, then turn it on from the server once both are actually out.
# Ship early and dark; launch later from the server. name: release on: workflow_dispatch: inputs: publish: { description: 'publish approved builds now', type: boolean, default: false } …