🚀 React Native iOS CI/CD with GitHub Actions + Fastlane — Build & Deploy to TestFlight Automatically

11 min read
#React Native#GitHub Actions#Fastlane#iOS#CI/CD#TestFlight#Mobile Development#DevOps#Automation

Tired of fragile xcodebuild shell scripts in CI? In this updated guide, learn how to automate React Native iOS builds with GitHub Actions and Fastlane — split into a build job and an upload job, with code signing, App Store Connect API auth, and automatic TestFlight deployment, end to end.

In this post, I’ll walk you through how I built an automated iOS TestFlight pipeline using GitHub Actions for my React Native apps — the same setup I use for production builds.

You’ll see the final Fastfile, the two-job GitHub Actions workflow, and the debugging story — code signing, App Store Connect API auth, and the sharp edges nobody warns you about.

If you’ve read my Android CI/CD article, this is the iOS companion piece.


🧩 What You’ll Need Before You Start

  • ✅ Apple Developer account with:
    • Apple Distribution Certificate (.p12)
    • App Store provisioning profile (.mobileprovision)
  • App Store Connect API key (.p8) for uploads — the modern replacement for Apple-ID + app-specific passwords
  • ✅ Your project already builds locally in Xcode
  • Ruby + Bundler + Fastlane (we’ll add a Gemfile)
  • ✅ Four files in your repo:
    • /.github/workflows/ios-build-testflight.yml
    • /ios/fastlane/Fastfile
    • /ios/fastlane/Appfile
    • /Gemfile

🔐 Required GitHub Secrets

Signing

  • APPLE_CERT_P12 — Base64 of your .p12 certificate
  • APPLE_CERT_PASSWORD — Password used when exporting the .p12
  • APPLE_MATCH_PROVISIONING_PROFILE — Base64 of your App Store .mobileprovision

App Store Connect API

  • APPLE_APP_STORE_CONNECT_API_KEYBase64 of your .p8 key file
  • APPLE_APP_STORE_CONNECT_API_KEY_ID — Key ID (plain text — the XXXXXXXX in AuthKey_XXXXXXXX.p8)
  • APPLE_APP_STORE_CONNECT_API_ISSUER_ID — Issuer UUID (plain text, from the top of the API Keys page)

⚠️ Only the three files (.p12, .mobileprovision, .p8) are Base64-encoded. The Key ID and Issuer ID are plain text. Mixing this up is the #1 cause of upload auth failures (more on that later).

Encode a file → .txt you can copy from:

base64 -i certificate.p12                 -o APPLE_CERT_P12.txt
base64 -i ios_distribution_provision.mobileprovision -o APPLE_MATCH_PROVISIONING_PROFILE.txt
base64 -i AuthKey_XXXXXXXX.p8              -o APPLE_APP_STORE_CONNECT_API_KEY.txt

📌 The .p8 downloads from App Store Connect only once. Lose it and you must revoke the key and generate a new one.


⚙️ Step 1 – Why iOS CI/CD is “special” (and why I moved to Fastlane)

Unlike Android, iOS forces you through code signing, provisioning profiles, and Xcode-version compatibility. Automatic signing — the thing that “just works” in Xcode locally — routinely breaks on CI, because the runner has no logged-in Apple ID and no signing identity in its keychain.

My first version did everything by hand: create a temporary keychain, security import the cert, drop .mobileprovision files into ~/Library/MobileDevice/Provisioning Profiles/, xcodebuild archive, xcodebuild -exportArchive, then xcrun altool --upload-app. It worked, but every step was a bespoke shell incantation, and a one-line change could silently break signing.

Fastlane collapses all of that into a few well-tested actions:

  • setup_ci — creates and unlocks a temporary keychain automatically
  • import_certificate — imports the .p12
  • build_app (gym) — archive + export in one action, with inline export options
  • app_store_connect_api_key + upload_to_testflight (pilot) — auth + upload with the API key

Same result, a fraction of the surface area.


📦 Step 2 – The Gemfile

Fastlane runs through Bundler, so pin it in a Gemfile at the repo root:

source 'https://rubygems.org'

ruby ">= 2.6.10"

gem 'cocoapods', '~> 1.16.2'
gem 'xcodeproj', '>= 1.27.0', '< 2.0'   # new enough for Xcode’s objectVersion 70+
gem 'activesupport', '>= 6.1.7.5', '!= 7.1.0'
gem 'concurrent-ruby', '< 1.3.4'

# Ruby 3.4 dropped these from the standard library:
gem 'bigdecimal'
gem 'logger'
gem 'benchmark'
gem 'mutex_m'
gem 'nkf'
gem 'tsort'

gem 'fastlane'

💡 A modern xcodeproj (>= 1.27) is what quietly fixed the old “unsupported objectVersion 70” pain. No more runtime-patching the gem with sed/ruby -i before pod install — just pin the version.


📇 Step 3 – The Appfile

ios/fastlane/Appfile tells Fastlane which app you’re shipping:

app_identifier "com.myapp"   # your bundle identifier

# apple_id is NOT required here — uploads authenticate via the
# App Store Connect API key. Only set it if you switch to Apple-ID auth.
# apple_id "you@example.com"

🏗️ Step 4 – The Fastfile (build + upload lanes)

Here’s the heart of it. Two real lanes — build and upload — plus a beta convenience lane that runs both (handy locally).

# ios/fastlane/Fastfile
require 'base64'

default_platform(:ios)

platform :ios do
  desc "Build the signed IPA"
  lane :build do
    setup_ci

    # 1. Decode + import the distribution certificate
    cert_path = File.join(Dir.pwd, "certificate.p12")
    File.open(cert_path, "wb") { |f| f.write(Base64.decode64(ENV["APPLE_CERT_P12"])) }

    import_certificate(
      certificate_path: cert_path,
      certificate_password: ENV["APPLE_CERT_PASSWORD"],
      keychain_name: ENV["MATCH_KEYCHAIN_NAME"] || "fastlane_tmp_keychain",
      keychain_password: ENV["MATCH_KEYCHAIN_PASSWORD"] || ""
    )

    # 2. Decode the provisioning profile and read its NAME (needed for export)
    profile_path = File.expand_path("~/Library/MobileDevice/Provisioning Profiles")
    FileUtils.mkdir_p(profile_path)
    dest = "#{profile_path}/ios_distribution_provision.mobileprovision"
    File.open(dest, "wb") { |f| f.write(Base64.decode64(ENV["APPLE_MATCH_PROVISIONING_PROFILE"])) }

    # profiles are CMS-signed — decode first, then read :Name
    decoded = "/tmp/#{SecureRandom.uuid}.plist"
    system("security cms -D -i '#{dest}' > '#{decoded}'")
    app_profile_name = `/usr/libexec/PlistBuddy -c "Print :Name" "#{decoded}"`.strip
    File.delete(decoded) if File.exist?(decoded)

    # 3. Version + build number from workflow inputs
    increment_version_number(version_number: ENV["VERSION"], xcodeproj: "MyApp.xcodeproj") if ENV["VERSION"]
    increment_build_number(build_number: ENV["BUILD_NUMBER"], xcodeproj: "MyApp.xcodeproj") if ENV["BUILD_NUMBER"]

    # 4. Build — export options are built INLINE (no exportOptions.plist needed)
    export_options = { method: "app-store", provisioningProfiles: {} }
    export_options[:provisioningProfiles]["com.myapp"] = app_profile_name if app_profile_name

    build_app(
      workspace: "MyApp.xcworkspace",
      scheme: "MyApp",
      configuration: "Release",
      clean: true,
      export_method: "app-store",
      export_options: export_options,
      xcargs: "CODE_SIGN_STYLE=Manual",
      output_directory: "./build",
      output_name: "App.ipa"
    )
  end

  desc "Upload an already-built IPA to TestFlight"
  lane :upload do
    # Anchor to the Fastfile location (ios/fastlane) so this is correct
    # regardless of Fastlane's working directory. IPA lives in ios/build.
    ipa_path = File.expand_path("../build/App.ipa", __dir__)
    ipa_path = Dir[File.expand_path("../build/*.ipa", __dir__)].first unless File.exist?(ipa_path)
    UI.user_error!("❌ IPA not found in ios/build. Build (or download the artifact) first.") unless ipa_path && File.exist?(ipa_path)

    api_key = app_store_connect_api_key(
      key_id: ENV["APPLE_APP_STORE_CONNECT_API_KEY_ID"],
      issuer_id: ENV["APPLE_APP_STORE_CONNECT_API_ISSUER_ID"],
      key_content: ENV["APPLE_APP_STORE_CONNECT_API_KEY"],
      is_key_content_base64: true          # <-- secret is Base64 of the .p8
    )

    opts = { api_key: api_key, ipa: ipa_path, skip_waiting_for_build_processing: true }
    notes = ENV["TESTFLIGHT_NOTES"]
    opts[:changelog] = notes if notes && !notes.empty?

    upload_to_testflight(opts)
  end

  desc "Build + upload (local convenience)"
  lane :beta do
    build
    upload
  end
end

🟡 Replace every MyApp → your app / scheme name, and com.myapp → your bundle identifier.

Notice there’s no exportOptions.plist anymore — build_app takes the export options as an inline hash, and the provisioning-profile name is read at runtime straight from the decoded profile. One less file to keep in sync.


🔀 Step 5 – The workflow: two jobs, not one

I split the pipeline into build-ios (produces the IPA, uploads it as an artifact) and upload-testflight (downloads the artifact and ships it). Why bother?

  • Separation of concerns — a signing/build failure is clearly distinct from an upload/auth failure.
  • Cheap retries — if only the upload fails (expired token, App Store Connect hiccup), you don’t pay for a ~15-minute rebuild to try again.
  • Conditional release — the upload job is gated on an input, so you can build-only when you just want an artifact.
name: iOS TestFlight Deployment

on:
  workflow_dispatch:
    inputs:
      release:
        description: "Trigger release to TestFlight"
        required: true
        default: "true"
        type: choice
        options: [ "true", "false" ]
      version:
        description: "App version (e.g. 1.0.0)"
        default: "1.0.0"
        required: true
      buildNumber:
        description: "Build number (e.g. 1)"
        default: "1"
        required: true
      notes:
        description: "Release notes for TestFlight (optional)"
        required: false
        default: ""

jobs:
  build-ios:
    runs-on: macos-15
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Select Xcode
        uses: maxim-lobanov/setup-xcode@v1
        with:
          xcode-version: '26.2.0'

      - run: npm install --force

      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.2'
          bundler-cache: true            # runs 'bundle install' + caches gems

      - name: Cache CocoaPods
        uses: actions/cache@v4
        with:
          path: ios/Pods
          key: ${{ runner.os }}-pods-${{ hashFiles('ios/Podfile.lock') }}
          restore-keys: ${{ runner.os }}-pods-

      - name: Install CocoaPods
        run: cd ios && pod install --repo-update

      - name: Build with Fastlane
        env:
          APPLE_CERT_P12: ${{ secrets.APPLE_CERT_P12 }}
          APPLE_CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }}
          APPLE_MATCH_PROVISIONING_PROFILE: ${{ secrets.APPLE_MATCH_PROVISIONING_PROFILE }}
          VERSION: ${{ github.event.inputs.version }}
          BUILD_NUMBER: ${{ github.event.inputs.buildNumber }}
        run: cd ios && bundle exec fastlane build

      - name: Upload IPA as artifact
        uses: actions/upload-artifact@v4
        with:
          name: ios-app-${{ github.event.inputs.version }}-${{ github.event.inputs.buildNumber }}
          path: ios/build/*.ipa
          retention-days: 30

  upload-testflight:
    needs: build-ios
    if: ${{ github.event.inputs.release == 'true' }}
    runs-on: macos-15
    steps:
      - uses: actions/checkout@v4

      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.2'
          bundler-cache: true

      - name: Download IPA artifact
        uses: actions/download-artifact@v4
        with:
          name: ios-app-${{ github.event.inputs.version }}-${{ github.event.inputs.buildNumber }}
          path: ios/build

      - name: Upload with Fastlane
        env:
          APPLE_APP_STORE_CONNECT_API_KEY: ${{ secrets.APPLE_APP_STORE_CONNECT_API_KEY }}
          APPLE_APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APPLE_APP_STORE_CONNECT_API_KEY_ID }}
          APPLE_APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APPLE_APP_STORE_CONNECT_API_ISSUER_ID }}
          TESTFLIGHT_NOTES: ${{ github.event.inputs.notes }}
        run: cd ios && bundle exec fastlane upload

The two jobs hand off through an artifact whose name — ios-app-<version>-<buildNumber> — is identical in both, so the download matches the upload. The build job’s secrets are signing-only; the upload job’s secrets are App-Store-Connect-only.


🐛 Step 6 – The errors I actually hit (and the fixes)

The happy-path YAML looks tidy. Getting there was not. These are the real ones:

1. Authentication credentials are missing or invalid

The build succeeded, produced a valid IPA, then upload_to_testflight died at the API-key step. This is not a permissions error (that would say Forbidden) — Apple rejected the JWT itself. Causes, in order of likelihood:

  • The .p8 secret wasn’t valid Base64 of the key. With is_key_content_base64: true, APPLE_APP_STORE_CONNECT_API_KEY must be the Base64 of the .p8. Paste the raw PEM by mistake and the token is garbage.
  • Key ID / Issuer ID swapped or wrong.
  • It’s an Individual key (no Issuer ID) instead of a Team key.

Quick local sanity check — the first line must be the PEM header:

echo "$APPLE_APP_STORE_CONNECT_API_KEY" | base64 --decode | head -1
# -> -----BEGIN PRIVATE KEY-----   ✅   (garbage = re-encode the .p8)

2. IPA not found at .../ios/fastlane/build/App.ipa

When I first split the lanes, the upload lane built its path from Dir.pwd. But Fastlane’s working directory is the fastlane/ folder, so Dir.pwd + "build/App.ipa" resolved to ios/fastlane/build/... — a directory that doesn’t exist. The build job writes to ios/build, and download-artifact restores to ios/build.

Fix: anchor to the Fastfile’s own location instead of the CWD:

ipa_path = File.expand_path("../build/App.ipa", __dir__)  # __dir__ = ios/fastlane → ios/build

3. “I re-ran the failed job and it still failed with old code”

This one’s a GitHub Actions gotcha, not a Fastlane one. “Re-run failed jobs” pins to the original run’s commit SHA. Even after you push a fix, re-running that old run keeps executing the old code — and it can’t see a fix that landed later. You have to trigger a fresh workflow_dispatch run on the updated branch. (And because the upload job reads the artifact from its own run, a fresh run does rebuild.)

4. objectVersion / xcodeproj

The old post patched the xcodeproj gem at runtime to accept Xcode’s objectVersion = 70. With a modern xcodeproj (>= 1.27) pinned in the Gemfile and a recent Xcode selected on the runner, this just… goes away.


🎯 Result

Each dispatch now:

  1. build-ios → installs Pods, imports the cert, decodes the profile, bumps version/build, archives + exports the signed .ipa, uploads it as an artifact.
  2. upload-testflight → downloads that .ipa, authenticates with the App Store Connect API key, and pushes to TestFlight (only when release=true).

No manual Xcode work. And if the upload flakes, I retry the upload alone — no rebuild. 🎉


🧠 Key Lessons

  1. Let Fastlane own the signing dancesetup_ci + import_certificate + build_app beat hand-rolled security/xcodebuild scripts.
  2. Split build and upload. Separate failures, cheap upload retries.
  3. Base64 the files, plain-text the IDs. The .p8/.p12/.mobileprovision are Base64; Key ID and Issuer ID are not.
  4. is_key_content_base64: true must match how you stored the secret — the top upload-auth footgun.
  5. Anchor file paths to __dir__, not Dir.pwd inside a Fastfile.
  6. “Re-run failed jobs” uses the old commit — push, then dispatch a fresh run.
  7. Pin xcodeproj / cocoapods in a Gemfile instead of patching gems at runtime.

🏁 Closing Thoughts

The raw-xcodebuild version taught me why each step exists. The Fastlane version is what I actually want to maintain: fewer moving parts, clearer failures, and a clean build → upload handoff. Push a branch, dispatch the workflow, and minutes later the build shows up in TestFlight.


Written by @iamhusnain

— if you found this helpful, check out the Android version:

React Native Android CI/CD with GitHub Actions → Google Play .

Let's bring your app idea to life

I specialize in mobile and backend development.

Share this article

Related Articles