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