Skip to content
/CI/CD for React Native with Fastlane — From Commit to TestFlight & Play
Mar 2026·8 min read

CI/CD for React Native with Fastlane — From Commit to TestFlight & Play

How to wire Fastlane + GitHub Actions so every merge to main builds, signs, and ships a release candidate to TestFlight Internal and Play Internal Testing. Includes match for cert sync, the credentials JSON Play wants, and the cache that cuts build time by ~40%.

React NativeCI/CDFastlaneGitHub ActionsDeployment
Text-to-Speech unsupported
Text Size

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

Install + init
# 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 + lane
# 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
end

Step 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.

android/fastlane/Fastfile
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
end

Step 4 — GitHub Actions workflow

.github/workflows/release.yml (abridged)
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 internal

Step 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.

Share X LinkedIn
NY
Neelesh Yadav
React Native Developer with 3 yr 6 mos years building production mobile apps. Writes about performance, architecture, and mobile engineering.
Follow ↗

Comments

Leave a comment

Comments are reviewed before they appear.

More Articles

Boosting React Native Performance: New Architectures Deep Dive
8 min read
Building Real-Time Chat with WebSocket in React Native
6 min read
AR Try-On: Three.js + MediaPipe in React Native
10 min read