Skip to content
compiler.dev

Speed Up Xcode Builds in CI

Xcode builds on CI are slow for predictable reasons: cold package downloads, full recompiles, simulator boot time and tests running on a small VM. Because Mac minutes are expensive, each of these costs real money. This guide covers what helps and what does not.

Pin the environment

Start by making runs comparable. Pin the runner image and the Xcode version:

runs-on: macos-15
steps:
  - uses: actions/checkout@v4
  - run: sudo xcode-select -s /Applications/Xcode_16.4.app
  - run: xcodebuild -version

Check which Xcode versions are installed in the runner-images repository readme for your image; the available paths change as images update. Using macos-latest means your build can change under you when the default moves.

Cache Swift Package Manager dependencies

Resolving and cloning packages can take a minute or more on a cold runner. Tell xcodebuild where to keep the clones, then cache that directory:

- uses: actions/cache@v4
  with:
    path: .spm
    key: spm-${{ runner.os }}-${{ hashFiles('**/Package.resolved') }}
    restore-keys: spm-${{ runner.os }}-
- run: |
    xcodebuild -resolvePackageDependencies \
      -scheme MyApp \
      -clonedSourcePackagesDirPath .spm

Pass the same -clonedSourcePackagesDirPath .spm to later build and test commands. Commit Package.resolved so the key is stable and builds are reproducible.

For CocoaPods, cache the Pods directory keyed on Podfile.lock, or better, cache ~/.cocoapods and run pod install so the lockfile stays authoritative. Carthage users should cache Carthage/Build and use XCFrameworks with --use-xcframeworks.

DerivedData: be careful

Caching ~/Library/Developer/Xcode/DerivedData is the tempting fix for slow compiles, and it often disappoints. Xcode's incremental build decisions depend on file modification times and absolute paths. A fresh git checkout gives every source file a new modification time, so Xcode sees everything as changed and rebuilds, after you have paid to restore a multi-gigabyte cache.

What does work:

  • Cache per-dependency build products for dependencies that do not change, for example prebuilt XCFrameworks produced for your pods or packages, keyed on their version.
  • Use a compiler cache such as ccache for C, C++ and Objective-C parts, or a build-system cache if you use Bazel or Tuist cache features.
  • Avoid a shared DerivedData path across jobs unless you test it: set -derivedDataPath build so the location is explicit and the same every run.

If you try a DerivedData cache, compare a cold run with a warm run in the build log. If the warm run still compiles most files, drop the cache; it is using storage from your 10 GB repository limit for nothing.

Build once, test in parallel

Compile once, then run tests many ways:

- run: |
    xcodebuild build-for-testing \
      -scheme MyApp \
      -destination 'platform=iOS Simulator,name=iPhone 16' \
      -derivedDataPath build \
      -clonedSourcePackagesDirPath .spm
- run: |
    xcodebuild test-without-building \
      -scheme MyApp \
      -destination 'platform=iOS Simulator,name=iPhone 16' \
      -derivedDataPath build \
      -parallel-testing-enabled YES

You can also upload the built products as an artifact and fan out test-without-building to several jobs with -only-testing: filters. Each extra job bills a minimum of a minute at the Mac rate, so do this only for suites that take many minutes.

Make tests cheaper

  • Pick one simulator for PR builds, such as the latest iPhone, and run the wider device matrix nightly. Each destination multiplies run time.
  • Boot the simulator early so it warms up while you resolve packages: xcrun simctl boot "iPhone 16" as a background step. Check available devices with xcrun simctl list devices available.
  • Parallel testing (-parallel-testing-enabled YES) clones simulators and runs test classes at once. It needs tests that do not share global state, and it uses more memory, which matters on the 7 GB standard runner.
  • Skip UI tests on every push. UI tests are slow and flaky; run them on merge or on label.
  • Disable code coverage and unneeded result bundles on PR runs: -enableCodeCoverage NO.

Reduce compile work

  • Use -skipMacroValidation and -skipPackagePluginValidation only when you trust the macros and plugins in your dependencies; otherwise CI stops waiting for approval. Understand the security trade-off first.
  • Set COMPILER_INDEX_STORE_ENABLE=NO in CI builds. Indexing serves the editor, not the build.
  • Build the Debug configuration for tests. Release builds with whole-module optimization are much slower and are only needed for archives.
  • Break big targets into modules so changes recompile less. This also helps developer machines.
  • Specify ARCHS=arm64 (or ONLY_ACTIVE_ARCH=YES) for simulator builds so you do not compile for architectures the runner will not use.

Cut the bill, not only the clock

Mac minutes are expensive. Combine speed work with the cost rules in macOS runners for iOS CI: put linting on Linux, cancel superseded runs, set timeout-minutes, and measure cost per run before moving to a larger Mac. Plug your numbers into the cost calculator to see the monthly effect.

A reasonable order

  1. Pin Xcode and the runner image.
  2. Cache SPM or Pods.
  3. Split build from test with build-for-testing.
  4. Cut destinations and UI tests from PR runs.
  5. Try a larger runner and compare cost per run.

FAQ

Should I cache DerivedData?

Test it before you commit to it. Checkout resets file modification times, which often defeats Xcode's incremental logic. Dependency caches are a safer first step.

Why are my simulator tests slower in CI than locally?

CI Macs have fewer cores and less memory than a developer laptop, and the simulator starts cold. Boot it early and reduce parallel workers if you see memory pressure.

Does a larger runner help Xcode?

Usually, since Xcode compiles in parallel. The M2 Pro 5-core runner costs $0.102 a minute against $0.062 for the standard one, so it needs to be at least 1.65 times faster to cost less.

How do I find slow targets?

Add -showBuildTimingSummary to xcodebuild and read the summary at the end of the log, or enable Swift's -Xfrontend -warn-long-function-bodies=500 to find slow-to-type-check functions.

Compare on your own project

Build times depend heavily on your project. compiler.dev's comparison mode lets you run one macOS workflow on its early-access M4 Pro runners and compare run time and cost against your current runner, without changing the rest of your pipeline.

Made by compiler.dev. Free tools · Pricing