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

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

Tired of manually archiving and uploading builds to TestFlight? In this detailed guide, learn how to automate your React Native iOS builds with GitHub Actions. We’ll cover code signing, provisioning profiles, Xcode setup, 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 version of the YAML, all the errors I faced, and how I finally got a working pipeline that builds, signs, and uploads to TestFlight — with manual signing for Release.

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 profiles (.mobileprovision)
  • ✅ Access to App Store Connect (API key for uploads)
  • ✅ Your project already builds locally in Xcode
  • ✅ Two files ready:
    • /.github/workflows/ios-build-testflight.yml
    • /ios/exportOptions.plist

🔐 Required GitHub Secrets

  • APPLE_CERT_P12 — Base64 of your .p12 certificate
  • APPLE_CERT_PASSWORD — Password used when exporting
  • APPLE_MATCH_PROVISIONING_PROFILE — Base64 of main .mobileprovision
  • APPLE_EXTENSION_PROVISION_PROFILE — Base64 of widget/extension profile(if you have one)
  • APPLE_APP_STORE_CONNECT_API_KEY — Base64 of .p8 key
  • APPLE_APP_STORE_CONNECT_API_KEY_ID — Key ID
  • APPLE_APP_STORE_CONNECT_API_ISSUER_ID — Issuer UUID

Encode via:

base64 -i certificate.p12 | pbcopy


⚙️ Step 1 – Why iOS CI/CD is “special”

Unlike Android, iOS requires code signing, provisioning profiles, and Xcode version compatibility.

Local Xcode magic (automatic signing) often breaks on CI.

Tools like xcodeproj don’t always support new project versions right away (objectVersion issues).

Getting an .ipa reliably uploaded to TestFlight is a multi-step dance.

I started with the same mindset as Android: build → test → deploy. But iOS quickly forced me to slow down and debug each layer.


🪜 Step 2 – What I started with (Android style optimism)

On Android, I could:

gradlew assembleRelease

So I thought iOS would be “just swap commands.” Here’s my naïve first try:

jobs:
  ios:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 18 }
      - run: npm install
      - run: cd ios && pod install
      - run: |
          cd ios
          xcodebuild -workspace MyApp.xcworkspace \
            -scheme MyApp \
            -configuration Release \
            -archivePath $PWD/build/MyApp.xcarchive \
            archive

Result

Pods installed successfully.
Build failed with:
Code signing is required for product type Application because I didn’t handle signing or provisioning yet.


🧱 Step 3 – Add Certificates & Temporary Keychain

We import the Apple Distribution certificate (.p12) and unlock a temporary keychain:

- name: Create temporary keychain
  run: |
    security create-keychain -p "${{ secrets.APPLE_CERT_PASSWORD }}" build.keychain
    security default-keychain -s build.keychain
    security unlock-keychain -p "${{ secrets.APPLE_CERT_PASSWORD }}" build.keychain
    security set-keychain-settings -t 3600 -u build.keychain

- name: Import certificate
  run: |
    echo "${{ secrets.APPLE_CERT_P12 }}" | base64 --decode > certificate.p12
    security import certificate.p12 -k build.keychain -P "${{ secrets.APPLE_CERT_PASSWORD }}" -T /usr/bin/codesign
    security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "${{ secrets.APPLE_CERT_PASSWORD }}" build.keychain
    rm certificate.p12

💡 If you skip the set-key-partition-list line, codesign will fail later with No signing certificate "Apple Distribution" found.


🪪 Step 4 – Install Provisioning Profiles

I realized CI needs to see .mobileprovision files in ~/Library/MobileDevice/Provisioning Profiles/. So I added:

- name: Install provisioning profiles
  run: |
    mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
    echo "$PROVISION_APP_BASE64" | base64 --decode > ~/Library/MobileDevice/Provisioning\ Profiles/app_dist.mobileprovision
    echo "$PROVISION_EXT_BASE64" | base64 --decode > ~/Library/MobileDevice/Provisioning\ Profiles/ext_dist.mobileprovision

After this, I got the infamous:

ReactAppDependencyProvider does not support provisioning profiles

Because I had earlier set manual provisioning globally (in project.pbxproj) — that broke pods and dependencies.


🧩 Step 5 – Switch release build to manual, keep dependencies automatic

On my local machine I changed in Xcode:

For the Release configuration under the app target → Manual signing (selecting my distribution profile)

For all pods, React targets, extensions → Automatic signing

With that change, CI no longer tried to manually sign pods.

In the pipeline step I also added explicit flags:

  xcodebuild … 
  CODE_SIGN_STYLE=Manual \
  DEVELOPMENT_TEAM=MY_TEAM_ID \
  CODE_SIGN_IDENTITY="Apple Distribution"

This got me past the “does not support provisioning profiles” error.


🔧 Step 6 – Patch for Xcode 16 (objectVersion = 70)

After opening the project in a newer Xcode, my project.pbxproj had:

objectVersion = 56;

But newer Xcode (16+) bumped it to 70. Then CI or xcodeproj gem would complain:

xcodeproj: unsupported objectVersion 70

When Xcode 16 arrived, the xcodeproj gem didn’t yet know version 70.

Solution: patch the xcodeproj gem at runtime before pod install, e.g.:

- name: Patch xcodeproj for Xcode 16
  run: |
    ruby -i -pe "gsub(/LAST_KNOWN_OBJECT_VERSION = '[0-9]*'/, \"LAST_KNOWN_OBJECT_VERSION = '70'\")" \\
      $(gem which xcodeproj/project/object/native_target.rb) 2>/dev/null || true

That lets pod install proceed with objectVersion 70 recognized.

If your CI runner uses older Xcode, you might instead downgrade the objectVersion in the project file to what the runner supports.


🪄 Step 7 – Auto-update Version & Build Number

Before building, I needed to bump marketing version and build number:

- name: Update version and build number
  run: |
    cd ios
    agvtool new-marketing-version ${{ github.event.inputs.version }}
    agvtool new-version -all ${{ github.event.inputs.buildNumber }}
    cd ..

This ensures every build has consistent versioning.


🏗️ Step 8 – Archive → Export → Upload

Here’s the complete working workflow.

🟡 Remember

Replace every MyApp → your actual app name

Replace every MY_TEAM_ID → your Team ID


📄 Step 9 – exportOptions.plist

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>method</key>
    <string>app-store</string>
    <key>teamID</key>
    <string>MY_TEAM_ID</string>
    <key>signingStyle</key>
    <string>manual</string>
    <key>provisioningProfiles</key>
    <dict>
        <key>com.MyApp.app</key>
        <string>ios_distribution_provision</string>
        <!--- if you have an extension, add it here, else remove it -->
        <key>com.MyApp.app.ImageNotificationExtension</key> 
        <string>comMyAppappImageNotificationExtension</string>
    </dict>
    <key>uploadSymbols</key>
    <true/>
    <key>compileBitcode</key>
    <false/>
    <key>uploadBitcode</key>
    <false/>
</dict>
</plist>



🎯 Result

Each run now:

  1. Installs Pods

  2. Sets version & build

  3. Archives with manual signing

  4. Exports .ipa via exportOptions.plist

  5. Uploads automatically to TestFlight

  6. Saves IPA artifact

No manual Xcode work required. 🎉


🧠 Key Lessons

  1. Automatic ≠ CI-friendly — use Manual for Release only.

  2. Match your Xcode version to the runner.

  3. Patch xcodeproj when Apple bumps objectVersion.

  4. Keep signing files as Base64 secrets, never in Git.

  5. Always use a temporary keychain in Actions.

  6. Confirm provisioning profiles path is correct.


🏁 Closing Thoughts

  • This workflow went through countless errors:

  • No signing certificate found

  • Provisioning profile mismatch

  • ReactAppDependencyProvider doesn’t support profiles

  • objectVersion unsupported

…but once it works, it’s fully automated and repeatable.

Now I can push a tag, trigger a build, and minutes later see it appear 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