Setting up iOS CI/CD on GitHub Actions from scratch
This is the whole pipeline for an iOS app on GitHub Actions, in the order you build it. Compile and test first. Then signing. Then archive and upload. Each step is small and the YAML is copy-ready. It works on GitHub-hosted runners and on a dedicated Mac; the only line that changes is runs-on.
1. The skeleton
name: ios
on:
pull_request:
push:
branches: [main]
concurrency:
group: ios-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: macos-latest # or [self-hosted, macOS, your-label]
timeout-minutes: 30
steps:
- uses: actions/checkout@v42. Pick the Xcode
Hosted images ship several Xcode versions; select one explicitly so an image update does not change your build under you:
- run: sudo xcode-select -s /Applications/Xcode_26.6.app
- run: xcodebuild -versionOn a dedicated Mac the version is pinned once on the machine and this step goes away.
3. Dependencies
- uses: actions/cache@v4
with:
path: |
~/Library/Caches/org.swift.swiftpm
Pods
key: ${{ runner.os }}-deps-${{ hashFiles('**/Package.resolved', '**/Podfile.lock') }}
- run: pod install --repo-update # CocoaPods projects only4. Build and test on a simulator
- run: |
xcodebuild test \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 17' \
-resultBundlePath TestResults \
| xcpretty
- uses: actions/upload-artifact@v4
if: failure()
with: { name: test-results, path: TestResults }Stop here for pull requests. Everything below is release work and belongs on main only.
5. Code signing without a shared Mac login
Use an App Store Connect API key rather than an Apple ID. Store the key ID, issuer ID and the .p8 contents as repository secrets, and let xcodebuild sign with cloud-managed profiles:
- run: echo "${{ secrets.ASC_KEY_P8 }}" > AuthKey.p8
- run: |
xcodebuild archive \
-scheme MyApp -configuration Release \
-archivePath build/MyApp.xcarchive \
-allowProvisioningUpdates \
-authenticationKeyPath "$PWD/AuthKey.p8" \
-authenticationKeyID "${{ secrets.ASC_KEY_ID }}" \
-authenticationKeyIssuerID "${{ secrets.ASC_ISSUER_ID }}"6. Export and upload to TestFlight
- run: |
xcodebuild -exportArchive \
-archivePath build/MyApp.xcarchive \
-exportPath build/export \
-exportOptionsPlist ExportOptions.plist \
-allowProvisioningUpdates \
-authenticationKeyPath "$PWD/AuthKey.p8" \
-authenticationKeyID "${{ secrets.ASC_KEY_ID }}" \
-authenticationKeyIssuerID "${{ secrets.ASC_ISSUER_ID }}"
- run: |
xcrun altool --upload-app -f build/export/MyApp.ipa -t ios \
--apiKey "${{ secrets.ASC_KEY_ID }}" --apiIssuer "${{ secrets.ASC_ISSUER_ID }}"Many teams wrap steps 5 and 6 in fastlane; the underlying commands are the same.
7. Where the minutes go, and the runner choice
On a hosted runner, steps 2 and 3 repeat on every job and bill at about $0.062 to $0.102 a minute. On a dedicated Mac they happen once and the caches stay warm. That is why teams past roughly 1,920 minutes a month move the whole pipeline to a flat-price machine. They change only the runs-on line. The pipeline-on-dedicated-hardware guide covers that side.
Frequently asked questions
Do I need a Mac to run iOS CI on GitHub Actions?
+
Yes. Building and signing iOS apps requires Xcode, which only runs on macOS. So the build job needs a macOS runner, either GitHub-hosted or a Mac you control.
How do I sign an iOS app in CI without my Apple ID?
+
Create an App Store Connect API key, store its ID, issuer ID and .p8 contents as secrets, and pass them to xcodebuild with -allowProvisioningUpdates. No Apple ID or keychain password is needed on the runner.
Should tests run on every pull request?
+
Build and test on every pull request against one simulator. Keep archiving, signing and TestFlight upload on main only; they are the slow, expensive steps.
What changes when I move this pipeline to a dedicated Mac?
+
The runs-on line. Xcode selection and dependency caching become unnecessary because the machine keeps them between jobs, and there is no queue in front of the build.