10.7 — GitHub Actions iOS Full Walkthrough
Opening scenario
You join a team that runs CI on Bitrise. The bill is $700/month for two iOS apps and three Android apps. Your boss asks if you can cut it. You move iOS to GitHub Actions on macos-15 runners, keep Android on the free ubuntu-latest, and the next month’s bill is $180. You also gain PR-reviewable YAML, native integration with everything in the GitHub ecosystem, and the same runners as the open-source projects you depend on.
GitHub Actions on macOS is the default professional choice when you need a CI that does more than just iOS.
Context taxonomy
| Item | Value (2026) |
|---|---|
| Default macOS runner | macos-15 (Apple Silicon, Sequoia, Xcode 16.x) |
| Larger macOS runner | macos-15-xlarge (M2, 12 cores, ~3× faster) |
| Free macOS minutes | 200/mo on personal Free, 0 for orgs — macos multiplier 10× |
| Standard macOS pricing | $0.08/min standard, $0.32/min xlarge |
| Concurrency limit | 5 concurrent macOS jobs (Free), 50 (Team), 180 (Enterprise) |
| Job timeout default | 6 hours |
| Cache storage | 10 GB per repo (LRU) |
Concept → Why → How → Code
Concept. A .github/workflows/*.yml file declares jobs that run on hosted runners. Each job is a fresh VM; you script every step (dependencies, build, test, sign, upload).
Why. Total control + tight GitHub integration + the same runner spec as millions of OSS projects (huge knowledge base).
How — a complete annotated deploy workflow.
# .github/workflows/deploy.yml
name: iOS Deploy
on:
push:
branches: [main]
tags: ['v*.*.*']
pull_request:
branches: [main]
workflow_dispatch: # manual "Run workflow" button
# Cancel superseded runs on the same ref
concurrency:
group: ios-${{ github.ref }}
cancel-in-progress: true
env:
XCODE_VERSION: "16.0"
SCHEME: "Acme"
WORKSPACE: "Acme.xcworkspace"
jobs:
test:
name: Tests
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Select Xcode
run: sudo xcode-select -switch /Applications/Xcode_${{ env.XCODE_VERSION }}.app
- name: Cache SPM
uses: actions/cache@v4
with:
path: |
~/Library/Developer/Xcode/DerivedData/**/SourcePackages
~/.swiftpm
key: spm-${{ runner.os }}-${{ env.XCODE_VERSION }}-${{ hashFiles('**/Package.resolved') }}
restore-keys: |
spm-${{ runner.os }}-${{ env.XCODE_VERSION }}-
- name: Resolve packages
run: xcodebuild -resolvePackageDependencies -workspace ${{ env.WORKSPACE }} -scheme ${{ env.SCHEME }}
- name: Run tests
run: |
set -o pipefail
xcodebuild test \
-workspace ${{ env.WORKSPACE }} \
-scheme ${{ env.SCHEME }} \
-destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=18.0' \
-enableCodeCoverage YES \
-resultBundlePath build/result.xcresult \
| xcpretty --report junit --output build/test-results.xml
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: |
build/test-results.xml
build/result.xcresult
archive:
name: Archive & TestFlight
needs: test
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Select Xcode
run: sudo xcode-select -switch /Applications/Xcode_${{ env.XCODE_VERSION }}.app
- name: Install fastlane
run: |
gem install fastlane -NV --no-document
- name: Decode App Store Connect API key
env:
ASC_KEY_BASE64: ${{ secrets.ASC_KEY_BASE64 }}
run: |
mkdir -p fastlane
echo "$ASC_KEY_BASE64" | base64 --decode > fastlane/AuthKey.p8
- name: Sync certs via match
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_TOKEN_B64 }}
run: fastlane certs
- name: Build & upload to TestFlight
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
run: fastlane beta
- name: Notify Slack
if: always()
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
run: |
STATUS=${{ job.status }}
EMOJI=$([ "$STATUS" = "success" ] && echo "✅" || echo "❌")
curl -X POST -H 'Content-Type: application/json' \
--data "{\"text\":\"$EMOJI iOS deploy $STATUS — ${{ github.sha }}\"}" \
"$SLACK_WEBHOOK"
Key patterns in this workflow.
- Concurrency group + cancel-in-progress: stops wasting minutes when you push three commits in a row.
- Pinned Xcode version:
macos-15ships multiple Xcode versions side-by-side;xcode-selectmakes the choice explicit. - SPM cache: cuts resolution from ~2 min to ~10 sec on hot cache.
- xcpretty + JUnit: makes test results render in GitHub UI and PR checks.
- Jobs split: tests run on every PR; archive runs only on main and tags. Saves 80% of macOS minutes.
- Base64-encoded API key as secret: a
.p8file goes in as one secret, decoded at runtime.
Manually trigger a workflow from the CLI:
gh workflow run "iOS Deploy" --ref main -f environment=production
In the wild
- Pretty much every OSS Swift package on GitHub uses GitHub Actions for CI — it’s the path of least resistance.
- The Composable Architecture by Point-Free runs a complex matrix workflow across Xcode versions and platforms.
- Cash App’s iOS team publicly uses GitHub Actions + Fastlane + match for their main release pipeline.
- Microsoft (Outlook iOS) moved much of their iOS CI to GitHub Actions after the Microsoft acquisition.
Common misconceptions
- “macOS minutes are 10× UNIX minutes.” Correct — but only for billed minutes. Free-tier minute allotments are also multiplied (200 macOS-equivalent free minutes on a personal Free plan = 20 actual macOS minutes).
- “You can run iOS builds on Linux.” No. iOS toolchain requires macOS (Xcode is macOS-only). Some build steps (test result parsing, fastlane metadata) can run on Linux to save money.
- “Caching is free.” Cache use is free; cache storage counts against a 10 GB per-repo limit. Old entries are evicted LRU; warm caches occasionally rebuild.
- “
macos-latestis fine.” It moves whenever GitHub bumps the default — your build will break the day after a major Xcode release. Always pin. - “GitHub Actions can sign builds without certificates.” No. You import certs at runtime via match or via base64-decoded
.p12.
Seasoned engineer’s take
The pattern that wins on GitHub Actions for iOS is split jobs, pinned versions, aggressive caching, and matrix where it helps.
TIP. Run
xcodebuild -showsdksandxcodebuild -showdestinationsin a one-off workflow to confirm what’s pre-installed onmacos-15. Apple bumps simulator runtimes silently; what worked last month may have moved.
WARNING. Never put a
.p12,.p8, or.mobileprovisionin a workflow log viaecho. Evenset -xcan leak. Useadd-maskor rely on GitHub’s auto-redaction of secrets — and nevercatdecoded files.
The non-obvious cost saver: use a free ubuntu-latest job to do metadata sync (fastlane deliver --skip_binary_upload) and test result analysis. Only spin up macos-15 for the actual xcodebuild steps.
Interview corner
Junior — “What does runs-on: macos-15 give you?” A fresh macOS Sequoia VM with the current Xcode pre-installed, simulator runtimes, common tools (git, brew, fastlane installable). Each job gets a clean VM.
Mid — “How do you handle code signing in a GitHub Actions iOS workflow?” Two approaches: (1) fastlane match against an encrypted private certs repo, with MATCH_PASSWORD and a Git access token as secrets; (2) base64-encode the .p12 and .mobileprovision, store as secrets, decode at runtime, and import into a temp keychain via security import.
Senior — “Design a cost-optimized CI for a 20-engineer iOS team.” Tests on PR via macos-15 only when iOS code changes (paths: filter). Linting and metadata validation on ubuntu-latest. Archive only on main push and tag. Use concurrency groups to cancel stale runs. Cache SPM, DerivedData (carefully). Move screenshots to a nightly schedule. Set monthly budget alerts in GitHub billing. Expect ~3–4k macOS minutes/month for that team size, ~$300/mo.
Red flag — “We run the full build matrix on every PR including UI screenshots across 8 devices.” You’ll spend $5k/month doing nothing useful — PR builds need a smoke subset, not the full nightly matrix.
Lab preview
Lab 10.3 builds exactly this workflow file end-to-end, including the matching Fastfile from chapter 5, plus a tag-triggered release lane that promotes to App Store.