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

ItemValue (2026)
Default macOS runnermacos-15 (Apple Silicon, Sequoia, Xcode 16.x)
Larger macOS runnermacos-15-xlarge (M2, 12 cores, ~3× faster)
Free macOS minutes200/mo on personal Free, 0 for orgs — macos multiplier 10×
Standard macOS pricing$0.08/min standard, $0.32/min xlarge
Concurrency limit5 concurrent macOS jobs (Free), 50 (Team), 180 (Enterprise)
Job timeout default6 hours
Cache storage10 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-15 ships multiple Xcode versions side-by-side; xcode-select makes 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 .p8 file 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

  1. “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).
  2. “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.
  3. “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.
  4. macos-latest is fine.” It moves whenever GitHub bumps the default — your build will break the day after a major Xcode release. Always pin.
  5. “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 -showsdks and xcodebuild -showdestinations in a one-off workflow to confirm what’s pre-installed on macos-15. Apple bumps simulator runtimes silently; what worked last month may have moved.

WARNING. Never put a .p12, .p8, or .mobileprovision in a workflow log via echo. Even set -x can leak. Use add-mask or rely on GitHub’s auto-redaction of secrets — and never cat decoded 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.


Next: 10.8 — CI Secrets, Certs & Code Signing