Every React Native project I've joined had the same release process: one person, one laptop, a text file of steps, and a lot of hoping. It works until that person is on holiday or their Xcode updates. Fastlane fixes this in an afternoon, and once it's done, a release is bundle exec fastlane ios beta and bundle exec fastlane android beta. Here's the setup I use, including the parts that trip people up.
What you're setting up
Two lanes per platform:
beta— bump the build number, build a release binary, upload to TestFlight (iOS) or the Play internal track (Android).release— same, but submit to App Store review or promote to the Play production track with a staged rollout.
Plus match for iOS signing so the whole team can build, and a CI job so nobody's laptop is involved at all.
Install it properly
Use Bundler so everyone runs the same Fastlane version:
# Gemfile (repo root)
source "https://rubygems.org"
gem "fastlane"
gem "cocoapods"
bundle install
cd ios && bundle exec fastlane init # pick "manual setup"
cd ../android && bundle exec fastlane init
I keep one fastlane/ directory at the repo root rather than one per platform, so shared config lives in one place. Move the generated files up and use platform :ios do … end and platform :android do … end blocks in a single Fastfile.
iOS signing with match
Manual certificate management is where iOS releases die. match stores your certificates and provisioning profiles encrypted in a private git repo and installs them on any machine with one command.
# fastlane/Matchfile
git_url("git@github.com:yourorg/certificates.git")
storage_mode("git")
type("appstore")
app_identifier(["com.yourorg.yourapp"])
username("you@yourorg.com")
Generate once from a machine with your Apple credentials:
bundle exec fastlane match appstore
bundle exec fastlane match development
From then on, every developer and CI runner runs match with readonly: true and gets the same signing identity. If Apple invalidates a certificate, one person runs match nuke and regenerates; nobody else has to touch it.
For CI, create an App Store Connect API key (Users and Access → Integrations → App Store Connect API) so Fastlane doesn't need your Apple ID password or 2FA:
# fastlane/Appfile
app_identifier("com.yourorg.yourapp")
# In the lane
api_key = app_store_connect_api_key(
key_id: ENV["ASC_KEY_ID"],
issuer_id: ENV["ASC_ISSUER_ID"],
key_content: ENV["ASC_KEY_CONTENT"], # base64 of the .p8 file
is_key_content_base64: true
)
Android signing with Play App Signing
Enrol in Play App Signing when you create the app. Google holds the app signing key; you keep only an upload key. If you lose the upload key, it's a support ticket; if you lose a legacy signing key without Play App Signing, it's a new app listing and all your installs are gone.
Keep the upload keystore out of the repo. In CI, store it base64-encoded as a secret and decode it at build time:
echo "$ANDROID_KEYSTORE_BASE64" | base64 --decode > android/app/upload.keystore
// android/app/build.gradle
signingConfigs {
release {
storeFile file(System.getenv("ANDROID_KEYSTORE_PATH") ?: "upload.keystore")
storePassword System.getenv("ANDROID_KEYSTORE_PASSWORD")
keyAlias System.getenv("ANDROID_KEY_ALIAS")
keyPassword System.getenv("ANDROID_KEY_PASSWORD")
}
}
For uploads, create a Google Cloud service account with the "Release manager" role in Play Console and download its JSON key. Fastlane's supply uses it.
The Fastfile
Here's the whole thing, trimmed to what matters:
# fastlane/Fastfile
default_platform(:ios)
before_all do
ensure_git_status_clean unless ENV["CI"]
end
platform :ios do
desc "Build and upload to TestFlight"
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_CONTENT"], is_key_content_base64: true
)
setup_ci if ENV["CI"] # temporary keychain on CI
match(type: "appstore", readonly: true, api_key: api_key)
increment_build_number(
build_number: latest_testflight_build_number(api_key: api_key) + 1,
xcodeproj: "ios/YourApp.xcodeproj"
)
cocoapods(podfile: "ios/Podfile")
build_app(
workspace: "ios/YourApp.xcworkspace",
scheme: "YourApp",
export_method: "app-store"
)
upload_to_testflight(api_key: api_key, skip_waiting_for_build_processing: true)
end
desc "Submit the current TestFlight build for App Store review"
lane :release 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_CONTENT"], is_key_content_base64: true
)
deliver(
api_key: api_key,
submit_for_review: true,
automatic_release: false,
phased_release: true,
skip_screenshots: true,
skip_metadata: true,
precheck_include_in_app_purchases: false
)
end
end
platform :android do
desc "Build an AAB and upload to the Play internal track"
lane :beta do
version_code = google_play_track_version_codes(track: "internal").first.to_i + 1
gradle(
task: "bundle",
build_type: "Release",
project_dir: "android/",
properties: { "versionCode" => version_code }
)
upload_to_play_store(
track: "internal",
aab: lane_context[SharedValues::GRADLE_AAB_OUTPUT_PATH],
skip_upload_metadata: true,
skip_upload_images: true,
skip_upload_screenshots: true
)
end
desc "Promote internal to production with a 10% staged rollout"
lane :release do
upload_to_play_store(
track: "internal",
track_promote_to: "production",
rollout: "0.1",
skip_upload_aab: true,
skip_upload_metadata: true,
skip_upload_images: true,
skip_upload_screenshots: true
)
end
end
Two details worth noting. First, build numbers come from the stores, not from git, so parallel branches never collide. Second, release on Android promotes the already-tested internal build rather than building again — the binary that testers used is exactly the one users get.
For the Android versionCode property to work, read it in Gradle:
defaultConfig {
versionCode (project.hasProperty("versionCode") ? project.versionCode.toInteger() : 1)
versionName "1.4.0"
}
Environment variables
Everything sensitive goes in environment variables. Locally, a .env file loaded by dotenv (Fastlane loads fastlane/.env automatically); in CI, repository secrets.
ASC_KEY_ID=
ASC_ISSUER_ID=
ASC_KEY_CONTENT= # base64 -i AuthKey_XXXX.p8
MATCH_PASSWORD=
MATCH_GIT_BASIC_AUTHORIZATION= # base64 "user:token" for the certs repo
ANDROID_KEYSTORE_BASE64=
ANDROID_KEYSTORE_PASSWORD=
ANDROID_KEY_ALIAS=
ANDROID_KEY_PASSWORD=
SUPPLY_JSON_KEY_DATA= # contents of the Play service account JSON
Add fastlane/.env, *.keystore, and *.p8 to .gitignore before you do anything else.
GitHub Actions
With the lanes done, CI is short. This runs on every tag matching v*:
# .github/workflows/release.yml
name: Release
on:
push:
tags: ["v*"]
jobs:
ios:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with: { ruby-version: "3.2", bundler-cache: true }
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci
- run: bundle exec fastlane ios beta
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY_CONTENT: ${{ secrets.ASC_KEY_CONTENT }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
android:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with: { ruby-version: "3.2", bundler-cache: true }
- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: 17 }
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci
- run: echo "$ANDROID_KEYSTORE_BASE64" | base64 --decode > android/app/upload.keystore
env: { ANDROID_KEYSTORE_BASE64: "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" }
- run: bundle exec fastlane android beta
env:
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
SUPPLY_JSON_KEY_DATA: ${{ secrets.SUPPLY_JSON_KEY_DATA }}
Tag a commit, push the tag, and ten minutes later there's a build in TestFlight and one on the Play internal track. Promotion to production stays a deliberate manual step — bundle exec fastlane android release and ios release — because that's the one moment you want a human deciding.
Things that will bite you
- Xcode version on CI. Pin it with
xcodesor thexcode-selectstep; a runner upgrade can break a build that worked yesterday. - Hermes and
ENABLE_DEBUGGER. Make sure the CI scheme is Release; a Debug archive will be rejected or ship with dev tooling. .envloading. Fastlane only auto-loadsfastlane/.envandfastlane/.env.default. If you keep it elsewhere, load it explicitly.- Play "first release must be manual." For a brand-new app, the very first production release has to be done in Play Console;
supplyonly works after that. - Expo. If you're on Expo managed workflow, EAS Build and EAS Submit replace most of this. Fastlane still earns its keep for bare projects or if you want everything in your own CI.
Why it matters beyond convenience
The obvious benefit is speed. The bigger one is that a release stops being a risk. Every build is produced the same way from a tagged commit, the tested binary is the shipped binary, and anyone on the team can ship a hotfix at 2 a.m. without asking who has the certificates. That's the difference between "we can push a fix tonight" and "we'll fix it next sprint" — and users notice.
If you'd like this set up on your project, release engineering is part of what I do — see App Store submission services or get in touch. The full release checklist that surrounds this is in How to Launch Your App Successfully.