🚀 React Native iOS CI/CD with GitHub Actions + Fastlane — Build & Deploy to TestFlight Automatically
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)
- Apple Distribution Certificate (
- ✅ 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.p12certificateAPPLE_CERT_PASSWORD— Password used when exporting the.p12APPLE_MATCH_PROVISIONING_PROFILE— Base64 of your App Store.mobileprovision
App Store Connect API
APPLE_APP_STORE_CONNECT_API_KEY— Base64 of your.p8key fileAPPLE_APP_STORE_CONNECT_API_KEY_ID— Key ID (plain text — theXXXXXXXXinAuthKey_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
.p8downloads 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 automaticallyimport_certificate— imports the.p12build_app(gym) — archive + export in one action, with inline export optionsapp_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 withsed/ruby -ibeforepod 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
.p8secret wasn’t valid Base64 of the key. Withis_key_content_base64: true,APPLE_APP_STORE_CONNECT_API_KEYmust 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:
- build-ios → installs Pods, imports the cert, decodes the profile, bumps version/build, archives + exports the signed
.ipa, uploads it as an artifact. - upload-testflight → downloads that
.ipa, authenticates with the App Store Connect API key, and pushes to TestFlight (only whenrelease=true).
No manual Xcode work. And if the upload flakes, I retry the upload alone — no rebuild. 🎉
🧠 Key Lessons
- Let Fastlane own the signing dance —
setup_ci+import_certificate+build_appbeat hand-rolledsecurity/xcodebuildscripts. - Split build and upload. Separate failures, cheap upload retries.
- Base64 the files, plain-text the IDs. The
.p8/.p12/.mobileprovisionare Base64; Key ID and Issuer ID are not. is_key_content_base64: truemust match how you stored the secret — the top upload-auth footgun.- Anchor file paths to
__dir__, notDir.pwdinside a Fastfile. - “Re-run failed jobs” uses the old commit — push, then dispatch a fresh run.
- Pin
xcodeproj/cocoapodsin 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
🚀 CI/CD for React Native Android: Build, Sign, Version, and Upload to Google Play with GitHub Actions
A complete guide to React Native CI/CD with GitHub Actions: build signed APK/AAB, auto-increment versionCode, and release to Google Play automatically — no more manual Gradle commands or Play Console uploads.
Custom Fonts in React Native WebView: The Complete Fix
Struggling with custom fonts not rendering inside a React Native WebView? Learn why it happens and how to properly inject fonts using platform-specific asset paths and base64 embedding.
Building Offline-First React Native Apps: Complete Implementation Guide
Master offline-first architecture in React Native. Learn AsyncStorage, Realm, SQLite, sync strategies, and conflict resolution for seamless offline UX.