Cutting Xcode Build Times on GitHub Actions

Xcode jobs run long on GitHub Actions because DerivedData and SwiftPM start cold. Measure by phase, restore both caches, then size the macOS runner.

Last verified:

Xcode builds run long on GitHub Actions because every job starts on a fresh machine where DerivedData, the SwiftPM checkout directory, and the module cache are empty, so the compiler redoes work it finished on the previous run. The fix is to measure the job by phase, restore the two caches that matter, pass xcodebuild flags that stop redundant resolution and indexing, and size the macOS runner against the phase that dominates.

Diagnosis

An Xcode job on a hosted runner is four phases with different bottlenecks. Treating it as one number hides which one you are paying for.

Package resolution and checkout. xcodebuild resolves the Swift Package Manager graph, clones every dependency, and writes them under SourcePackages inside DerivedData unless you redirect that path. On a fresh runner this is network work and repository clone work on every single run, even when Package.resolved has not moved in three weeks. Private dependencies over SSH add authentication round trips to the same phase.

Module compilation. This is where most of the minutes go. Swift compiles per module, and a cold DerivedData means every module in the graph, including every package dependency, compiles from source. Type-checking cost is uneven across a codebase: a handful of expressions with heavy overload resolution can hold a module open long after the rest of it finished.

Linking and debug symbols. The linker is largely serial, and it runs after compilation completes. The expensive part is often dsymutil rather than the link step itself, because the default DEBUG_INFORMATION_FORMAT of dwarf-with-dsym triggers symbol generation on every configuration. A test build has no use for a dSYM bundle.

Archive and export. xcodebuild archive builds Release configuration, which usually means whole-module optimization, then -exportArchive signs the app and every embedded framework and writes the IPA. Codesigning walks the bundle file by file, so a large asset catalog or many embedded frameworks stretches this phase independently of compiler speed.

The step-level measurement path

Get a number per phase before you change a flag.

  1. Split the job so each phase is its own step. The GitHub Actions log prints a duration next to every step name, which gives you four numbers per run at no cost.
  2. Add -showBuildTimingSummary to the xcodebuild invocation. It prints an aggregate per task type at the end of the build, so you can see compile time separated from link time inside a single step.
  3. Pull raw timestamps for a specific run with gh run view <run-id> --log. Step durations round; the raw log does not.
  4. Find slow type-checking with OTHER_SWIFT_FLAGS="-Xfrontend -warn-long-function-bodies=300 -Xfrontend -warn-long-expression-type-checking=300". The compiler then warns on each function body and expression above 300 milliseconds, with a file and line.
  5. Confirm the change held across runs rather than on one lucky build. The WarpBuild reports page aggregates Duration P75 and P90 per repository, workflow, and job name, alongside queue time P75 and P90, with CSV export. CI observability adds CPU and memory P75 and P90 per job, which is the number that tells you whether a job was compute bound or waiting on a serial phase.

Here is the shape the measurement usually produces. Cold means no cache restored, warm means both caches restored, on warp-macos-15-arm64-6x at 6 vCPU and 22GB. Substitute your own measured numbers; the arithmetic later in this page carries whatever you put here.

PhaseCold minutesWarm minutesWhat the warm column depends on
Package resolution and checkout2.50.3Restored SourcePackages, resolution pinned to Package.resolved
Module compilation12.05.0Restored DerivedData, index store off
Linking and debug symbols2.51.5dwarf instead of dwarf-with-dsym on test builds
Archive and export5.04.2Fewer embedded frameworks to sign
Total22.011.0

The archive phase barely moves, which is the point of measuring. Cache work pays off in compilation, and nothing else you do to the cache configuration will speed up codesigning.

Fix

Six changes, in the order that returns the most minutes per hour of effort.

Redirect the two cache paths, then cache them. Pass -derivedDataPath and -clonedSourcePackagesDirPath explicitly so both live inside the workspace where actions/cache can reach them. Key the SwiftPM cache on Package.resolved alone, because it changes rarely. Key DerivedData on the toolchain plus the commit SHA with a prefix restore-keys, so a pull request job that misses on its own SHA still restores the last main build and compiles incrementally from there.

Save DerivedData from main only. Use actions/cache/restore and actions/cache/save as separate steps, with the save step guarded on github.ref == 'refs/heads/main'. Every pull request writing its own DerivedData entry fills the repository cache allowance and evicts the entry everything else restores from. Check the current per-repository allowance and eviction behavior in GitHub's documentation, and see GitHub Actions cache size limits for the workarounds when a DerivedData tree does not fit.

Put the toolchain in every cache key. Xcode invalidates module output across versions, and a restored DerivedData from a different Xcode is worse than no cache at all, because the compiler discovers the mismatch after paying to read it. xcodebuild -version in a key generation step costs a second and prevents the whole class of problem.

Stop the redundant work with flags. -onlyUsePackageVersionsFromResolvedFile and -disableAutomaticPackageResolution keep the build from re-resolving a graph you already pinned. -skipPackagePluginValidation and -skipMacroValidation remove interactive trust prompts that turn into wasted time on a headless machine. COMPILER_INDEX_STORE_ENABLE=NO stops index-while-building, which exists for the editor and has no consumer in a workflow. DEBUG_INFORMATION_FORMAT=dwarf and CODE_SIGNING_ALLOWED=NO on test builds skip dSYM generation and signing. ONLY_ACTIVE_ARCH=YES builds one slice for the simulator destination.

Split build from test. xcodebuild build-for-testing produces the test bundle, and test-without-building runs it. Splitting them lets the build step cache cleanly and lets a simulator matrix reuse one compiled bundle across destinations rather than compiling once per leg. The simulator side of that split is covered in running iOS simulator tests on GitHub Actions.

Change the runner label. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners that register with GitHub as self-hosted runners carrying warp- labels, so the migration for a job is the value of runs-on. The macOS catalog offers multiple sizes and configurations per chip, so the size decision is a label choice rather than a support ticket. The list price arithmetic is direct: warp-macos-latest-arm64-6x at 6 vCPU and 22GB costs $0.08 per minute against $0.102 per minute for the largest GitHub-hosted macOS ARM64 runner at 5 vCPU and 14GB, which is 22 percent lower list price, GitHub list price checked on 2026-08-13.

Two supporting facts matter during a migration. Run as many jobs as your workflows need, since generally available Linux and Windows runners do not have plan-level concurrency caps, which is what keeps a simulator matrix from queueing behind itself once the build step is fast.

On Xcode versions, the macOS 26 image ships the Xcode 27.0 SDKs and simulator runtimes (build 27A5194q, with iOS, tvOS, watchOS, and visionOS 27.0 runtimes) while GitHub's upstream macOS 27 runner image is in beta, and a dedicated macOS 27 image follows once that image is released. A maintenance branch that needs an older toolchain stays on warp-macos-14-arm64-6x at the same $0.08 per minute.

Configuration

The build job on the 6 vCPU macOS runner, with both caches and the flags from above.

name: ios-build

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

concurrency:
  group: ios-build-${{ github.ref }}
  cancel-in-progress: true

env:
  DERIVED_DATA: ${{ github.workspace }}/.derived-data
  SPM_CHECKOUTS: ${{ github.workspace }}/.spm-checkouts

jobs:
  build:
    runs-on: warp-macos-15-arm64-6x
    steps:
      - uses: actions/checkout@v4

      - name: Record the toolchain in the cache key
        id: toolchain
        run: |
          echo "id=$(xcodebuild -version | tr '\n' '-' | tr -d ' ')" >> "$GITHUB_OUTPUT"

      - name: Restore SwiftPM checkouts
        uses: actions/cache@v4
        with:
          path: ${{ env.SPM_CHECKOUTS }}
          key: spm-${{ steps.toolchain.outputs.id }}-${{ hashFiles('**/Package.resolved') }}

      - name: Restore DerivedData
        uses: actions/cache/restore@v4
        with:
          path: ${{ env.DERIVED_DATA }}
          key: dd-${{ steps.toolchain.outputs.id }}-${{ github.sha }}
          restore-keys: |
            dd-${{ steps.toolchain.outputs.id }}-

      - name: Resolve packages from the lock file only
        run: |
          xcodebuild -resolvePackageDependencies \
            -scheme App \
            -clonedSourcePackagesDirPath "$SPM_CHECKOUTS" \
            -onlyUsePackageVersionsFromResolvedFile

      - name: Build for testing
        run: |
          xcodebuild build-for-testing \
            -scheme App \
            -destination "platform=iOS Simulator,name=iPhone 16" \
            -derivedDataPath "$DERIVED_DATA" \
            -clonedSourcePackagesDirPath "$SPM_CHECKOUTS" \
            -disableAutomaticPackageResolution \
            -skipPackagePluginValidation \
            -skipMacroValidation \
            -showBuildTimingSummary \
            -quiet \
            COMPILER_INDEX_STORE_ENABLE=NO \
            DEBUG_INFORMATION_FORMAT=dwarf \
            ONLY_ACTIVE_ARCH=YES \
            CODE_SIGNING_ALLOWED=NO

      - name: Save DerivedData from main only
        if: github.ref == 'refs/heads/main'
        uses: actions/cache/save@v4
        with:
          path: ${{ env.DERIVED_DATA }}
          key: dd-${{ steps.toolchain.outputs.id }}-${{ github.sha }}

The archive job is the one place the wider machine can earn its rate, since Release configuration compiles with whole-module optimization across the full target graph.

  archive:
    if: github.ref == 'refs/heads/main'
    needs: build
    runs-on: warp-macos-26-arm64-12x
    steps:
      - uses: actions/checkout@v4

      - name: Select Xcode 27.0
        run: sudo xcode-select -s "$(ls -d /Applications/Xcode_27*.app | tail -1)"

      - name: Restore SwiftPM checkouts
        uses: actions/cache/restore@v4
        with:
          path: ${{ github.workspace }}/.spm-checkouts
          key: spm-archive-${{ hashFiles('**/Package.resolved') }}
          restore-keys: spm-archive-

      - name: Archive
        run: |
          xcodebuild archive \
            -scheme App \
            -configuration Release \
            -destination "generic/platform=iOS" \
            -archivePath build/App.xcarchive \
            -clonedSourcePackagesDirPath "${{ github.workspace }}/.spm-checkouts" \
            -disableAutomaticPackageResolution \
            -showBuildTimingSummary \
            SWIFT_COMPILATION_MODE=wholemodule \
            COMPILER_INDEX_STORE_ENABLE=NO

Four configuration facts decide whether this lands cleanly:

  1. The WarpBuild cache is a Linux runner feature. On macOS, use actions/cache for DerivedData and the SwiftPM directory, exactly as written above.
  2. Runner storage is ephemeral. Every job starts on a fresh VM, so anything the next job needs travels through an artifact or a cache.
  3. macOS runners cannot run Docker and do not support nested virtualization. Container builds and Android emulator work belong on Linux x64 runners.
  4. Aliases track the latest image. warp-macos-latest-arm64-6x resolves to warp-macos-15-arm64-6x. Pin the explicit version label on a release branch so the pointer moving does not change your toolchain mid-release.

Label shapes, sizes, and rates live in the cloud runners documentation.

Cost or Time Model

The size decision

LabelvCPUMemoryStorageRate per minute
warp-macos-15-arm64-6x622GB120GB SSD$0.08
warp-macos-26-arm64-12x1244GB270GB SSD$0.16

The 12 vCPU size costs exactly twice the 6 vCPU size per minute, so it breaks even on cost only when it cuts the job to under half its current wall clock. Apply that to the warm 11-minute job from the phase table: the same job on the 12 vCPU size has to finish in 5.5 minutes to cost the same $0.88.

Module compilation is the phase with real parallelism, and it scales with how wide the target graph is rather than how many cores exist. Package resolution, linking, and export run largely serial. A job whose warm profile is 5.0 minutes of compilation against 6.0 minutes of everything else cannot reach 5.5 minutes on any number of cores, so it stays on 6 vCPU. The 270GB SSD is the other reason to move up: DerivedData, several simulator runtimes, and an archive on one disk outgrow 120GB before the CPU becomes the constraint.

Read CPU P75 and P90 per job on the reports page before you decide. A job sitting at 40 percent peak CPU on 6 vCPU will not use 12.

Worked monthly model

Assumptions, all stated so you can substitute your own:

  • One iOS repository, 900 Xcode jobs per month, which is 45 per working day across 20 working days.
  • Every job runs the four phases from the table above. Teams that skip archive on pull requests should model two job shapes and add the totals.
  • Cold job: 22.0 minutes. Warm job with both caches restored: 11.0 minutes.
  • Billing is per minute of job time on both platforms.
  • Minutes are held identical across both platforms so the runner label is the only variable, which makes the comparison conservative.
  • Cache storage, artifact storage, and reruns are excluded from both columns.

Minute totals: 900 x 22.0 = 19,800 cold macOS minutes, or 900 x 11.0 = 9,900 warm macOS minutes.

ConfigurationmacOS minutesRate per minuteMonthly cost
GitHub-hosted macos-latest-xlarge, cold caches19,800$0.102$2,019.60
GitHub-hosted macos-latest-xlarge, warm caches9,900$0.102$1,009.80
warp-macos-15-arm64-6x, cold caches19,800$0.08$1,584.00
warp-macos-15-arm64-6x, warm caches9,900$0.08$792.00

The arithmetic behind each row:

  • 19,800 x $0.102 = $2,019.60 and 9,900 x $0.102 = $1,009.80.
  • 19,800 x $0.08 = $1,584.00 and 9,900 x $0.08 = $792.00.
  • Cache work alone at the WarpBuild rate returns $792.00 per month.
  • The label change alone returns $435.60 per month at cold volume and $217.80 at warm volume.
  • Both together separate $2,019.60 from $792.00, a $1,227.60 monthly difference at the same job count.

The wall clock number matters as much as the invoice. Eleven minutes returned on 900 jobs is 9,900 macOS minutes per month, or 165 hours of engineers waiting on a pull request that they now get back.

GitHub rates read from GitHub Actions per-minute rates, checked on 2026-08-13. macos-latest-xlarge is the matched shape because it is GitHub's ARM64 macOS runner; the 12-core macos-latest-large runner is Intel hardware and the 3-core macos-latest runner is a smaller machine. WarpBuild rates come from the pricing page, verified the same day. For the full macOS price arithmetic across every shape, including the non-Xcode job split, see what macOS runners cost on GitHub Actions.

Where the model breaks

Cache hit rate. The warm column assumes DerivedData restores. A repository where every pull request touches a low-level module gets partial incremental value at best, and the honest number sits between the two columns. Track it: log whether the restore step reported a hit, and compare Duration P75 across the runs that hit and the runs that missed.

Dependency churn. A team bumping packages weekly invalidates the SwiftPM cache weekly, which puts 2.2 minutes back into every job for the rest of that day.

Archive frequency. Moving archive to main only removes 5.0 minutes per pull request job from the model and changes every total in the table. Rerun the arithmetic with your own job mix rather than trusting these rows.

Start with the runner catalog on WarpBuild macOS runners for GitHub Actions, then take the pipeline shape from iOS pipelines on GitHub Actions.

FAQ

Why is my Xcode build slow on GitHub Actions?

Every job starts on a fresh machine, so DerivedData, the SwiftPM checkout directory, and the module cache are empty and the compiler rebuilds every module from source. Package resolution also hits the network on each run unless the resolved file is treated as authoritative. Measure the four phases separately before you change anything, because the phase that dominates decides which fix is worth the effort.

Which caches actually cut Xcode build time?

Two. The SwiftPM clone directory set by -clonedSourcePackagesDirPath, keyed on Package.resolved, which removes repeated clones. And DerivedData set by -derivedDataPath, keyed on the toolchain plus the commit with a prefix restore-key, which lets the compiler reuse module output from the last main build. Save DerivedData from main only so pull request jobs restore instead of writing.

Should I use the 6 vCPU or the 12 vCPU macOS runner?

warp-macos-15-arm64-6x is $0.08 per minute and warp-macos-26-arm64-12x is $0.16 per minute, so the wider machine has to cut the job below half its current wall clock to cost the same. Module compilation parallelizes across a wide target graph. Package resolution, linking, and export are largely serial, so a job dominated by those phases stays on 6 vCPU.

How do I measure where the Xcode build time goes?

Pass -showBuildTimingSummary to xcodebuild for per-task aggregates, split the job into one step per phase so the GitHub Actions log carries a duration for each, and pull the raw log with gh run view --log for timestamps. Then read Duration P75 and P90 per job on the WarpBuild reports page to see whether a change held across runs.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.