Launch9 min read

Article

Fastlane for React Native: One-Command Releases

A working Fastlane setup for React Native — Fastfile lanes for iOS and Android, match for certificates, Play App Signing, version bumping, and a GitHub Actions job that ships to TestFlight and Play internal testing.

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 xcodes or the xcode-select step; 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.
  • .env loading. Fastlane only auto-loads fastlane/.env and fastlane/.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; supply only 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.

Keep Reading

More articles, and where this thinking shows up in shipped work.

Relevant service