Skip to content

A Fastlane + GitHub Actions pipeline for iOS and Android, from zero

· 3 min read · Amrith Vengalath

  • React Native
  • Fastlane
  • GitHub Actions
  • CI/CD

For a while, releasing our app meant a senior dev sitting down for an hour with Xcode, the right certificates installed, the right Node version, and a lot of clicking. It worked until the day that person was on leave and a hotfix needed to ship. That's the day you finally automate.

This is the pipeline I settled on: a git tag triggers a build that lands on TestFlight (iOS) and the Play Store internal track (Android), with nobody touching a build tool by hand. Fastlane does the heavy lifting; GitHub Actions runs it.

Why both tools

They do different jobs and it's worth being clear about the split:

  • Fastlane knows how to build, sign, and upload a mobile app. It abstracts the genuinely awful parts - code signing, provisioning, the App Store and Play Console upload APIs - into named "lanes."
  • GitHub Actions is the trigger and the runner. It watches for a tag, spins up a machine, and runs the Fastlane lanes.

You can run Fastlane locally too, which is great for debugging - the same lane that runs in CI runs on your laptop.

The Fastlane lanes

A Fastfile with one lane per platform. The iOS lane, roughly:

platform :ios do
  lane :beta do
    setup_ci  # creates a temporary keychain on CI
    match(type: "appstore", readonly: true)  # pulls signing certs
    build_app(scheme: "MyApp", export_method: "app-store")
    upload_to_testflight(skip_waiting_for_build_processing: true)
  end
end

match is the piece that made iOS signing bearable. Instead of every developer and every CI machine juggling certificates and provisioning profiles, match stores them encrypted in a git repo and syncs them. CI pulls them read-only. Before match, code signing on CI was the single most painful part of this whole exercise.

The Android lane is simpler because Android signing is simpler:

platform :android do
  lane :beta do
    gradle(task: "bundleRelease")
    upload_to_play_store(track: "internal", aab: "app/build/outputs/bundle/release/app-release.aab")
  end
end

The GitHub Actions workflow

Trigger on a version tag, build each platform on the right runner. iOS needs macOS; Android is happy on Linux (and Linux minutes are cheaper, which matters on a budget):

on:
  push:
    tags: ["v*"]
 
jobs:
  ios:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 18, cache: npm }
      - run: npm ci
      - run: cd ios && pod install
      - run: bundle exec fastlane ios beta
        env:
          MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
          APP_STORE_CONNECT_API_KEY: ${{ secrets.ASC_API_KEY }}
 
  android:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 18, cache: npm }
      - run: npm ci
      - run: bundle exec fastlane android beta
        env:
          PLAY_STORE_JSON_KEY: ${{ secrets.PLAY_STORE_JSON_KEY }}

Things I got wrong the first time

  • Secrets everywhere. Every credential - the match password, the App Store Connect API key, the Play Store service account JSON - lives in GitHub secrets, never in the repo. The App Store Connect API key in particular replaced an awful flow involving someone's Apple ID and two-factor codes that obviously can't work on CI.
  • npm ci, not npm install. CI should build from the lockfile exactly. install can drift; ci is reproducible.
  • Caching matters for cost and time. Without caching node modules and Pods, every run reinstalls everything and the macOS minutes add up fast. macOS runners are billed at a premium.
  • Skip waiting for processing. skip_waiting_for_build_processing: true on TestFlight means the job doesn't sit idle for ten minutes while Apple processes the build. The upload's done; let the runner go.

What it bought us

A hotfix now ships by pushing a tag, from anyone, without the certificate ritual or a specific person's machine. Release day stopped being an event. The first setup took a couple of days - mostly fighting iOS signing before match clicked - but it paid for itself within the first month, and it's the kind of infrastructure that quietly keeps paying every release after.