iOS Development

GitHub Actions for iOS CI/CD: Build, Test, Archive, Upload to TestFlight

May 2026 · 12 min read

iOS CI/CD on GitHub Actions costs $0 for public repos and works fine for private ones inside the free tier for most apps (2,000 minutes per month). The setup is finicky the first time — code signing trips everyone up — but stable once it's running. Here's a complete workflow from build to TestFlight in 8-12 minutes per run.

Step 1: macOS runner

iOS builds require macOS runners. macos-15 is current (June 2026). Free for public repos. For private repos, the macOS multiplier is 10x, so 200 build-minutes consumed per build-hour. Plan accordingly.

Step 2: Basic build workflow

.github/workflows/ios-build.yml:

name: iOS Build
on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

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

      - name: Select Xcode
        run: sudo xcode-select -switch /Applications/Xcode_16.app

      - name: Install XcodeGen
        run: brew install xcodegen

      - name: Generate Xcode project
        run: xcodegen generate

      - name: Build
        run: |
          xcodebuild build \
            -project MyApp.xcodeproj \
            -scheme MyApp \
            -destination 'platform=iOS Simulator,name=iPhone 16 Pro' \
            -derivedDataPath build/ \
            CODE_SIGNING_ALLOWED=NO

      - name: Test
        run: |
          xcodebuild test \
            -project MyApp.xcodeproj \
            -scheme MyApp \
            -destination 'platform=iOS Simulator,name=iPhone 16 Pro' \
            -derivedDataPath build/

Step 3: Code signing with Fastlane Match

Match stores signing certificates and provisioning profiles in a private Git repo, encrypted with a password. The CI checks them out, decrypts, and installs them in the runner's keychain.

Initial setup (on your local Mac):

brew install fastlane
fastlane match init  # configure repo URL, app identifier
fastlane match appstore  # generates and uploads signing materials

In GitHub Actions, add secrets: MATCH_PASSWORD, MATCH_GIT_BASIC_AUTHORIZATION (base64 of username:personal_access_token), APP_STORE_CONNECT_API_KEY_ID, APP_STORE_CONNECT_API_ISSUER_ID, APP_STORE_CONNECT_API_KEY (full .p8 file contents).

Step 4: Archive and upload workflow

name: TestFlight Upload
on:
  push:
    tags: ['v*']
  workflow_dispatch:

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

      - name: Setup Xcode
        run: sudo xcode-select -switch /Applications/Xcode_16.app

      - name: Install dependencies
        run: |
          brew install xcodegen
          gem install fastlane

      - name: Generate project
        run: xcodegen generate

      - name: Sync signing
        env:
          MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
          MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
        run: fastlane match appstore --readonly

      - name: Archive
        run: |
          xcodebuild archive \
            -project MyApp.xcodeproj \
            -scheme MyApp \
            -destination 'generic/platform=iOS' \
            -archivePath build/MyApp.xcarchive \
            CODE_SIGN_STYLE=Manual \
            DEVELOPMENT_TEAM=ABC123XYZ \
            PROVISIONING_PROFILE_SPECIFIER="match AppStore com.example.myapp"

      - name: Export IPA
        run: |
          xcodebuild -exportArchive \
            -archivePath build/MyApp.xcarchive \
            -exportPath build/ \
            -exportOptionsPlist ExportOptions.plist

      - name: Upload to TestFlight
        uses: apple-actions/upload-testflight-build@v1
        with:
          app-path: build/MyApp.ipa
          issuer-id: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
          api-key-id: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
          api-private-key: ${{ secrets.APP_STORE_CONNECT_API_KEY }}

Step 5: ExportOptions.plist

Required by xcodebuild -exportArchive. Commit this to the repo:

<?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-connect</string>
  <key>teamID</key>
  <string>ABC123XYZ</string>
  <key>signingStyle</key>
  <string>manual</string>
  <key>provisioningProfiles</key>
  <dict>
    <key>com.example.myapp</key>
    <string>match AppStore com.example.myapp</string>
  </dict>
</dict>
</plist>

Step 6: Build number auto-increment

Each TestFlight upload needs a unique CFBundleVersion. Easiest: derive from the Actions run number or git commit count.

- name: Set build number
  run: |
    BUILD_NUMBER=$(git rev-list --count HEAD)
    /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" \
      Sources/Info.plist

Or use Fastlane's increment_build_number.

Step 7: Caching for speed

Cache Homebrew, ruby gems, Swift Package Manager packages, and DerivedData when possible. Saves 2-4 minutes per build.

- name: Cache SwiftPM
  uses: actions/cache@v4
  with:
    path: ~/Library/Developer/Xcode/DerivedData/**/SourcePackages
    key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }}

Common errors

  • "No profile matching the bundle identifier": Match wasn't run, or the bundle ID in xcodeproj doesn't match what Match registered.
  • "Code signing is required for product type 'Application'": missing DEVELOPMENT_TEAM or signing style mismatch.
  • "unauthorized" on TestFlight upload: API key permissions are App Manager, not Developer. Re-create with App Manager role.
  • "Build number already exists": increment failed or duplicate from a previous run. Use git rev-list trick above.

Comparison

Step Typical duration Optimization
Checkout + setup 30 sec Cache Homebrew
XcodeGen generate 5 sec Skip if .xcodeproj committed
SPM resolve 60 sec Cache ~/Library/.../SourcePackages
Build 3-5 min Avoid clean unless needed
Test 2-3 min Parallel tests if possible
Archive 3-4 min Release config only on tag
Upload to TestFlight 1-2 min Async to App Store Connect
Total 8-12 min Use macos-15 over macos-14

FAQ

How much do macOS runners cost on private repos?
Free tier: 2,000 minutes per month including 10x macOS multiplier = 200 macOS minutes. Beyond that, $0.08 per minute on Pro plans.

Should I use Fastlane or just xcodebuild?
Fastlane for signing (Match) and TestFlight upload. xcodebuild for build/test/archive. The hybrid approach is leaner than full-Fastlane and easier to debug.

Can I sign with App Store Connect API key without Match?
Yes — automatic signing works in CI if you configure App Store Connect API key access. Match still recommended because it gives reproducible builds across machines.

What about Xcode Cloud?
Apple's hosted CI. Free tier 25 hours/month. Simpler than GitHub Actions for pure iOS but less flexible (no custom Linux jobs, no cross-platform pipelines).

Need iOS CI/CD set up correctly the first time?

We've configured GitHub Actions iOS pipelines for 12 App Store apps. Fixed-price 1-day setup.

Book a discovery call

Related Posts

Docker for Development AWS Lambda Python
← All blog posts

Stop manually archiving iOS builds

8-minute pipeline from git tag to TestFlight. Fixed-price 1-day setup.

Book a discovery call