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
endmatch 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
endThe 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
matchpassword, 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, notnpm install. CI should build from the lockfile exactly.installcan drift;ciis 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: trueon 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.