Mobile CI/CD (Cross-platform)
Irbisa · cheatsheetSeptember 13, 2026

Mobile CI/CD (Cross-platform)

Fastlane, GitHub Actions, signing, TestFlight, Play Internal, build flavors

Senior Developer20 itemscompressed for a skim
  1. 01

    What does a typical mobile CI pipeline look like?

    Easy

    A 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:
    …
  2. 02

    What is Fastlane and what does it automate?

    Medium

    Fastlane 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
    …
  3. 03

    What does iOS code signing actually consist of, and how does CI get the certificates and profiles?

    Medium

    iOS 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}"
    …
  4. 04

    How do you sign and ship an Android app?

    Medium

    Android 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")
    …
  5. 05

    What are build flavors / variants and when do you use them?

    Medium

    Flavors 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"
    …
  6. 06

    How do you do a staged rollout and observe a release?

    Medium

    Staged 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"
    …
  7. 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?

    Hard

    Measure 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
    …
  8. 08

    A pull request arrives from a stranger's fork. What stops it from walking away with your signing key?

    Hard

    Secrets 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]
    …
  9. 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?

    Medium

    Mobile 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. 10

    The first crash report from the release build is a wall of hex addresses. What should the pipeline have done at build time?

    Medium

    A 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. 11

    Nobody touched the code and Monday's build fails. What drifts on its own in a mobile pipeline?

    Medium

    Almost 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. 12

    Where do the UI tests actually run in CI, and how do you stop a flaky suite from being ignored?

    Medium

    A 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. 13

    The upload is rejected with "the bundle version must be higher than the previously uploaded version" — who should own that number?

    Easy

    The 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. 14

    The release build works on every laptop in the team and fails on a clean CI checkout. What do you check first?

    Easy

    The 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. 15

    QA signed off on build 812 and the release job then rebuilt from the same tag. Why is that not the same build?

    Medium

    A 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. 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?

    Medium

    The 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. 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?

    Medium

    Play 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. 18

    The upload lane went green an hour ago and the testers still see nothing in TestFlight. Where did the build go?

    Medium

    A 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. 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?

    Hard

    On 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. 20

    Marketing wants the feature live on both platforms Tuesday morning; Android is approved and iOS is still in review. How do you ship?

    Hard

    Separate 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 }
    …