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.
  • match in read-only mode on CI: certificates and profiles come from an encrypted repository, and CI never creates or revokes anything.
  • setup_ci creates 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 promote lane, 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 match for 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