Manually archiving in Xcode and uploading AABs through Play Console gets old by the third release. On the EC Infosolutions React Native team we automated the entire iOS + Android release pipeline so a tagged commit ships a release candidate to TestFlight Internal and Play Internal Testing within ~18 minutes — and the on-call engineer's only job is approving the production promotion. Here's the exact setup.
The shape of the pipeline
GitHub Actions on push of a v* tag → spin up macOS + Ubuntu runners → each runs Fastlane → iOS lane archives, signs, uploads to TestFlight; Android lane bundles, signs, uploads to Play Internal Testing. Build numbers auto-increment via the latest_testflight_build_number / google_play_track_version_codes APIs so engineers never edit Info.plist or build.gradle for releases. Slack notifies #releases on success or failure.
Step 1 — Fastlane setup
# Run from project root bundle init echo "gem 'fastlane'" >> Gemfile bundle install # Init Fastlane for iOS and Android cd ios && bundle exec fastlane init && cd .. cd android && bundle exec fastlane init && cd .. # This creates ios/fastlane/Fastfile, android/fastlane/Fastfile, and Appfile # Commit Fastfile + Appfile; .gitignore the auth artifacts they suggest
Step 2 — Signing without losing your mind: match for iOS
fastlane match stores your iOS certificates and provisioning profiles in a private Git repo, encrypted with a passphrase. Every CI runner — and every team member's laptop — runs match development / match appstore once and gets the same certs. No more emailing .p12 files or hoping someone remembers the keychain password.
# ios/fastlane/Matchfile
git_url("git@github.com:your-org/ios-certificates-private.git")
storage_mode("git")
type("appstore")
app_identifier(["com.yourcompany.yourapp"])
username("ci@yourcompany.com")
# ios/fastlane/Fastfile
platform :ios do
lane :beta do
setup_ci if ENV['CI'] # creates a temp keychain on CI
match(type: 'appstore', readonly: true)
increment_build_number(
build_number: latest_testflight_build_number(
app_identifier: 'com.yourcompany.yourapp'
) + 1,
xcodeproj: 'YourApp.xcodeproj'
)
build_app(
workspace: 'YourApp.xcworkspace',
scheme: 'YourApp',
export_method: 'app-store',
include_bitcode: false,
)
upload_to_testflight(
skip_waiting_for_build_processing: true,
changelog: ENV['CHANGELOG'] || 'CI release'
)
end
endStep 3 — Android signing + Play Console API access
Android signing on CI needs your upload keystore + passwords loaded from secrets. For Play Console uploads, create a Service Account in Google Cloud → grant it Release Manager access in Play Console → download the JSON credentials file. Fastlane uses this to authenticate; without it you'd be uploading AABs manually forever.
platform :android do
lane :internal do
gradle(
task: 'bundle',
build_type: 'Release',
project_dir: '.',
properties: {
'android.injected.signing.store.file' => ENV['KEYSTORE_PATH'],
'android.injected.signing.store.password' => ENV['KEYSTORE_PASSWORD'],
'android.injected.signing.key.alias' => ENV['KEY_ALIAS'],
'android.injected.signing.key.password' => ENV['KEY_PASSWORD'],
}
)
upload_to_play_store(
track: 'internal',
aab: 'app/build/outputs/bundle/release/app-release.aab',
json_key_data: ENV['PLAY_SERVICE_ACCOUNT_JSON'],
release_status: 'completed',
skip_upload_metadata: true,
skip_upload_changelogs: true,
skip_upload_images: true,
skip_upload_screenshots: true,
)
end
endStep 4 — GitHub Actions workflow
name: Release
on:
push:
tags: ['v*']
jobs:
ios:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: 'yarn' }
- uses: ruby/setup-ruby@v1
with: { ruby-version: 3.2, bundler-cache: true }
- run: yarn install --frozen-lockfile
- run: cd ios && pod install
- name: Fastlane beta
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_AUTH }}
APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.ASC_KEY_ID }}
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY_KEY: ${{ secrets.ASC_KEY_P8 }}
run: cd ios && bundle exec fastlane beta
android:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: 'yarn' }
- uses: actions/setup-java@v4
with: { distribution: 'temurin', java-version: 17 }
- uses: ruby/setup-ruby@v1
with: { ruby-version: 3.2, bundler-cache: true }
- run: yarn install --frozen-lockfile
- name: Restore keystore
run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > android/app/release.keystore
- name: Fastlane internal
env:
KEYSTORE_PATH: ${{ github.workspace }}/android/app/release.keystore
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
run: cd android && bundle exec fastlane internalStep 5 — Cache aggressively or watch your bills
macOS minutes on GitHub Actions are 10× the price of Linux. Three caches cut our iOS build from 22 minutes to about 13: yarn cache (handled by setup-node), CocoaPods cache (cache ios/Pods + Podfile.lock), and DerivedData (cache ~/Library/Developer/Xcode/DerivedData keyed by the iOS project hash). On Android the Gradle cache + node_modules cache are the equivalent wins. Don't cache the .xcarchive — it's specific to each build and caching invalid archives is worse than re-building.
App Store Connect API keys, not passwords
Stop using Apple ID + password (or app-specific passwords) in CI — they break with 2FA and are deprecated for upload. Generate an App Store Connect API key (Users and Access → Integrations → App Store Connect API), grant it Admin or App Manager access, download the .p8 file, and pass APP_STORE_CONNECT_API_KEY_KEY_ID, _ISSUER_ID, and _KEY contents as env vars. Fastlane picks them up automatically.
Wins from automation, in order: humans never touch a keystore again (lower loss risk), build numbers can't drift (no more "forgot to bump CFBundleVersion" rejections), TestFlight gets a build per PR if you want it, and rollbacks are a re-tag away. Start with TestFlight Internal + Play Internal Testing only — promotion to Production is the one place humans still belong.