For two years, every release of our React Native app followed a checklist in the wiki: update version numbers in three files, open Xcode, archive, upload, wait for processing, build the Android bundle, sign it, upload to the Play Console, copy release notes, notify QA. It took the most experienced mobile developer about half a day, and it went wrong in small ways regularly: a forgotten build number, a debug flag left on, a missing iOS extension profile.
We replaced it with a fastlane setup that CI runs when someone pushes a release tag. This post describes the layout and lanes, and the decisions that made it reliable.
What fastlane does here
fastlane is a Ruby tool that wraps the build and store tooling for iOS and Android in "actions" you combine into "lanes". For a React Native app, the JavaScript bundle is built by the native build itself (Gradle and Xcode build phases call the React Native bundler), so fastlane only needs to drive the native builds and the store uploads.
Project layout
app/
android/
fastlane/
Fastfile
Appfile
ios/
fastlane/
Fastfile
Appfile
Matchfile
Gemfile # pins fastlane and plugins
package.json
One fastlane directory per platform keeps each Fastfile simple. The Gemfile at the root pins the fastlane
version, and every invocation runs through bundle exec so CI and laptops use the same version.
Versioning
Two numbers matter for the stores: the user-visible version (1.42.0) and a build number that must
increase for every upload (versionCode on Android, CFBundleVersion on iOS). We derive both in CI:
- the version from the git tag (
v1.42.0); - the build number from the CI run number, which is unique and increasing.
# shared helper in both Fastfiles
def release_version
ENV.fetch("GITHUB_REF_NAME").delete_prefix("v") # "1.42.0"
end
def build_number
ENV.fetch("GITHUB_RUN_NUMBER").to_i + 10_000 # offset keeps numbers above old manual builds
end
Nothing writes version numbers back to the repository; the build sets them on the fly. That removed the "bump version" commits and their merge conflicts.
iOS lane
# ios/fastlane/Fastfile
default_platform(:ios)
platform :ios do
lane :beta do
setup_ci # temporary keychain on CI machines
app_store_connect_api_key(
key_id: ENV["ASC_KEY_ID"], issuer_id: ENV["ASC_ISSUER_ID"],
key_content: ENV["ASC_KEY_P8"], is_key_content_base64: true
)
match(type: "appstore", readonly: true,
app_identifier: ["com.example.shop", "com.example.shop.NotificationService"])
increment_version_number(version_number: release_version, xcodeproj: "Shop.xcodeproj")
increment_build_number(build_number: build_number, xcodeproj: "Shop.xcodeproj")
build_app(workspace: "Shop.xcworkspace", scheme: "Shop", configuration: "Release",
export_method: "app-store", clean: true)
upload_to_testflight(skip_waiting_for_build_processing: true,
changelog: File.read("../../RELEASE_NOTES.md"))
end
end
Key choices:
- App Store Connect API key instead of an Apple ID with two-factor authentication. API keys work non-interactively and can be scoped to the App Manager role.
matchin read-only mode on CI: certificates and profiles come from an encrypted repository, and CI never creates or revokes anything.setup_cicreates a temporary keychain for the job; without it, codesigning on CI can hang waiting for a keychain prompt.
Android lane
# android/fastlane/Fastfile
default_platform(:android)
platform :android do
lane :beta do
gradle(
task: "bundle", build_type: "Release",
properties: {
"android.injected.version.name" => release_version,
"android.injected.version.code" => build_number,
"android.injected.signing.store.file" => ENV["ANDROID_KEYSTORE_PATH"],
"android.injected.signing.store.password" => ENV["ANDROID_KEYSTORE_PASSWORD"],
"android.injected.signing.key.alias" => ENV["ANDROID_KEY_ALIAS"],
"android.injected.signing.key.password" => ENV["ANDROID_KEY_PASSWORD"],
}
)
upload_to_play_store(track: "internal", aab: lane_context[SharedValues::GRADLE_AAB_OUTPUT_PATH],
json_key_data: ENV["PLAY_SERVICE_ACCOUNT_JSON"],
release_status: "completed")
end
end
We build an Android App Bundle (.aab), which Google Play requires for new apps, and upload to the
internal testing track. With Play App Signing, the key in CI is the upload key; Google holds the app
signing key, so a leaked upload key can be reset without losing the ability to update the app.
CI workflow
name: mobile-release
on:
push:
tags: ["v*"]
jobs:
ios:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with: { bundler-cache: true }
- uses: actions/setup-node@v4
with: { node-version-file: .nvmrc, cache: npm }
- run: npm ci
- run: cd ios && bundle exec pod install
- run: cd ios && bundle exec fastlane beta
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_AUTH }}
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }}
android:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: "17" }
- uses: ruby/setup-ruby@v1
with: { bundler-cache: true }
- uses: actions/setup-node@v4
with: { node-version-file: .nvmrc, cache: npm }
- run: npm ci
- run: echo "$KEYSTORE_B64" | base64 -d > "$RUNNER_TEMP/upload.keystore"
env: { KEYSTORE_B64: "${{ secrets.ANDROID_KEYSTORE_B64 }}" }
- run: cd android && bundle exec fastlane beta
env:
ANDROID_KEYSTORE_PATH: ${{ runner.temp }}/upload.keystore
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: upload
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
The two platforms build in parallel. Secrets live in a GitHub environment with required reviewers, so pushing a tag alone does not release; a maintainer approves the environment.
What still needs a human
- Promotion to production. CI uploads to TestFlight and the Play internal track. QA tests, then a
release manager promotes in the consoles, or runs a separate
promotelane, after checking crash rates in the beta. - App Store review notes and screenshots when they change.
- Staged rollout decisions on Google Play (for example 10%, then 50%, then 100%).
Common mistakes
| Mistake | Effect | Do this instead |
|---|---|---|
| Version numbers committed by hand | Conflicts, duplicates, forgotten bumps | Derive from tag and CI run number |
| Apple ID with 2FA on CI | Sessions expire, prompts block builds | App Store Connect API key |
| CI allowed to create certificates | Accidental revocations and new profiles | match read-only on CI |
| App signing key stored in CI | A leak means losing the app identity | Play App Signing with an upload key |
| Unpinned fastlane | Different behaviour between laptop and CI | Gemfile.lock and bundle exec |
Key takeaways
- fastlane lanes per platform, run from CI on a tag, replace a manual release checklist.
- Derive version and build numbers from the tag and CI, never by hand.
- Use App Store Connect API keys and read-only
matchfor iOS signing on CI. - Use Play App Signing so CI only holds a resettable upload key.
- Automate uploads to beta channels; keep production promotion a deliberate human step.
References
- fastlane, Getting started with React Native: https://docs.fastlane.tools/getting-started/cross-platform/react-native/
- fastlane, match: https://docs.fastlane.tools/actions/match/
- App Store Connect API keys: https://developer.apple.com/documentation/appstoreconnectapi/creating_api_keys_for_app_store_connect_api
- Google Play, Use Play App Signing: https://support.google.com/googleplay/android-developer/answer/9842756
- fastlane, upload_to_play_store (supply): https://docs.fastlane.tools/actions/upload_to_play_store/
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.