Xcode 26 compilation caching on GitHub Actions

Xcode 26 can reuse the result of any compilation whose inputs it has seen before. On your Mac that helps after a branch switch or a clean build. On a GitHub-hosted runner, which starts every job on a new virtual machine, it helps only if the cache comes from somewhere else. This guide covers what the feature is, how to turn it on, what you would have to run to share it between runners, and how Warmbuild does that.

TL;DR

  • Turn it on with COMPILATION_CACHE_ENABLE_CACHING=YES (the “Enable Compilation Caching” build setting). Each cacheable task then prints Cache hit or Cache miss.
  • The cache lives on disk, by default inside DerivedData. A fresh runner has none, so the setting alone saves a CI job nothing.
  • To share it, Xcode talks gRPC over a Unix socket to a cache service you run (COMPILATION_CACHE_ENABLE_PLUGIN, COMPILATION_CACHE_REMOTE_SERVICE_PATH). You also need storage, prefix maps so keys carry no machine paths, authentication and a rule for who may write.
  • Warmbuild is that service as one Action step: the default branch fills the cache, pull requests and Macs read it.

What compilation caching is

Apple introduced it in Xcode 26 as an opt-in feature. In the words of the release notes, it “caches the results of compilations that were produced for a set of source file inputs and, when it detects that the same set of source files are getting re-compiled, it speeds-up the build by providing the prior compilation results directly from the cache.” It covers Swift and the C family (C, Objective-C, C++), and it is built on LLVM’s content-addressable storage (CAS): each compile task gets a key computed from its inputs, and the outputs are stored under that key.

That is different from Xcode’s incremental build. Incremental builds decide what to redo from file timestamps and the dependency graph in DerivedData; compilation caching decides whether it has seen these exact inputs before. Delete DerivedData, switch branches and back, or touch every file, and a cached compile still hits as long as its inputs are the same.

It depends on explicitly built modules, which Xcode 26 turns on for Swift by default (WWDC25, “What’s new in Xcode 26”). Module compilations, including the SDK’s, are cache entries too, which is why a cold build of a small app already produces a few hundred of them.

Turn it on

In Xcode: the target’s or project’s Build Settings → Enable Compilation Caching → Yes. For xcodebuild, pass the setting or put it in an xcconfig:

Terminal
xcodebuild build \  -scheme App \  -destination 'generic/platform=iOS Simulator' \  COMPILATION_CACHE_ENABLE_CACHING=YES

Every cacheable task (SwiftCompile, SwiftEmitModule, CompileC, module precompiles) now prints Cache hit or Cache miss in the build log, so you can count them:

Terminal
xcodebuild build -scheme App -destination 'generic/platform=iOS Simulator' \  COMPILATION_CACHE_ENABLE_CACHING=YES 2>&1 | tee build.loggrep -c 'Cache hit' build.log; grep -c 'Cache miss' build.log

By default the cache is stored per project inside DerivedData (CompilationCache.noindex). COMPILATION_CACHE_CAS_PATH moves it, for example to a folder every checkout shares. To empty it, use Xcode’s Settings → Locations → Compilation Cache, or delete the folder.

What it does on one Mac, measured on Warmbuild’s 8-file fixture app (Xcode 27.2, with the CAS outside DerivedData): the cold build looked up 218 keys and missed all of them; after deleting DerivedData, the next build hit all 218. Touching every source file gave 22 hits and 0 misses, because only the project’s own compilations re-ran, and each was served from the cache.

Why a fresh runner gets nothing from it

Each GitHub-hosted runner is a new virtual machine. A job on macos-26 starts with no DerivedData and no CAS, so every key misses, and the job spends extra time writing a cache that is thrown away when the VM is. Turning the setting on in CI is harmless, but on its own it saves nothing. To get hits, the cache has to outlive the job: restored from somewhere, or served by something that is still running when the next job starts.

Sharing it yourself

There are two routes. Both work; both have costs that are easy to underestimate.

Route 1: save and restore the CAS with actions/cache

.github/workflows/build.yml
jobs:  build:    runs-on: macos-26    steps:      - uses: actions/checkout@v5      - uses: actions/cache@v6        with:          path: ${{ runner.temp }}/cas          key: xcode-cas-${{ runner.os }}-${{ github.sha }}          restore-keys: xcode-cas-${{ runner.os }}-      - run: |          xcodebuild build -scheme App -destination 'generic/platform=iOS Simulator' \            COMPILATION_CACHE_ENABLE_CACHING=YES \            COMPILATION_CACHE_CAS_PATH="$RUNNER_TEMP/cas"

This gets hits, with caveats you should know before relying on it:

  • It is not what the format is for. Asked on the Swift Forums which directory to save on CI, a Swift compiler engineer working on the feature answered that copying it “is not designed for that use case” and that saving and restoring can take longer than compiling.
  • It only grows. Cache entries in actions/cache are immutable, so every save is a new full upload under a new key. The CAS from four builds of the 8-file fixture app was 287 MB; a real app’s is far bigger. The default repository limit is 10 GB, and entries unused for 7 days are evicted.
  • Branch scope. A run can restore caches from its own branch and the default branch, never from a sibling branch, so pull requests do not share with each other.
  • Paths. Keys include absolute paths unless you map them. GitHub-hosted runners check out to the same path every time, so this route mostly works between runs of one workflow. A different checkout path (another workflow’s path:, a self-hosted runner, a Mac) misses: copying the fixture to a second folder and building against the same CAS hit 196 of 218 keys; all 22 misses were the project’s own code: its Swift and C compilations and the module built from its C target.

Route 2: run a remote cache service

This is the design Xcode supports for sharing. Instead of reading files, Xcode calls a cache service over gRPC on a Unix socket:

shared.xcconfig
COMPILATION_CACHE_ENABLE_CACHING = YESCOMPILATION_CACHE_ENABLE_PLUGIN = YESCOMPILATION_CACHE_REMOTE_SERVICE_PATH = /path/to/cache-service.sock// SDK and toolchain paths to canonical namesSWIFT_ENABLE_PREFIX_MAPPING = YESCLANG_ENABLE_PREFIX_MAPPING = YES

The protocol is open: compilation_caching_cas.proto (CASDBService: Get, Put, Load, Save) and compilation_caching_kv.proto (KeyValueDB: GetValue, PutValue) in Apple’s fork of LLVM, under llvm/lib/RemoteCachingService. The same repository has llvm-remote-cache-test, a server that implements the protocol over an on-disk CAS “for testing purposes”. It shows the protocol working; it does not share anything between machines.

What you would run and maintain yourself:

  1. A process on every runner and Mac that serves the socket before xcodebuild starts, answers thousands of lookups per build quickly (a local tier helps), and batches the rest over the network.
  2. Shared storage for blobs and keys, with expiry, close enough to the runners that a hit is faster than a compile.
  3. Authentication from CI without long-lived secrets (GitHub’s OIDC token works), and from developers’ Macs.
  4. A write policy. Whoever can write can poison everyone’s builds, so in practice only trusted default-branch builds should write; pull requests, especially from forks, only read.
  5. Prefix maps for the checkout and DerivedData, so keys match across machines. Xcode’s *_ENABLE_PREFIX_MAPPING settings cover the SDK and toolchain; your own paths go in SWIFT_OTHER_PREFIX_MAPPINGS and CLANG_OTHER_PREFIX_MAPPINGS. Some generated files still embed absolute paths (the Swift bridging header issue, swiftlang/swift#84088).
  6. Failure handling. When the service is slow or down, the build must still pass, as a plain build.

In September 2025 the same engineer wrote that distributed caching would be the next focus for Swift’s caching build. Check the current toolchain’s release notes; until the toolchain ships it, the service is yours to build.

How Warmbuild does it

Warmbuild is Route 2, hosted. One step before xcodebuild starts the cache service on the runner and exports XCODE_XCCONFIG_FILE with the settings above, so your build command stays as it is:

.github/workflows/build.yml
permissions:  id-token: write   # the Action signs in with GitHub's OIDC token  contents: readjobs:  build:    runs-on: macos-26    steps:      - uses: actions/checkout@v5      - uses: warmbuild/action@v1      - run: xcodebuild build -scheme App -destination 'generic/platform=iOS Simulator'
  • Pushes to the default branch write; everything else only reads. You can change that under Write rules on the dashboard.
  • The checkout and DerivedData are mapped to fixed paths, so a pull request, another runner and a developer’s Mac compute the same keys as the default branch.
  • If the service cannot be reached, the build runs without the cache. Hits, misses and bytes are in the job summary.
  • On a Mac, curl -fsSL https://warmbuild.dev/install.sh | sh in the repository sets up the same cache, read-only, for Xcode and xcodebuild.

One measured case, from the docs: on one pull request of billet, an iOS app with about 100k lines of Swift built with Xcode 27.2, the xcodebuild build step went from 521 s without the cache to 212 s with it, at 97% hits. That is one app on hosted runners, which vary from run to run; your result depends on how much of each build is compilation.

Set it up

Sign in, install the GitHub App, add the step. The GitHub Action · The Mac CLI · What Warmbuild does

When it misses, whoever runs the service

Keys match only for the same Xcode version, SDK, configuration, destination, architecture and build settings, so a pull request built for generic/platform=iOS Simulator reads nothing that a device build wrote. Some settings turn caching off for a target; Xcode says so in the log:

Messages that mean a target compiled without the cache
Xcode saysFix
swift compiler caching requires explicit module buildSWIFT_ENABLE_EXPLICIT_MODULES = YES (some generated projects set NO on every target)
swift compiler caching requires integrated driverSWIFT_USE_INTEGRATED_DRIVER = YES
Compile caching is not supported with implicit modulesCLANG_ENABLE_EXPLICIT_MODULES = YES
Explicit modules is not supported with C_COMPILER_LAUNCHERRemove the launcher (for example ccache)
passing incompatible option '…' with '-fcas-include-tree'Remove that flag from the target’s C flags

Messages as printed by Xcode 27.2. A task that prints neither Cache hit nor Cache miss ran without the cache.

Also watch for Run Script phases that regenerate sources on every build (new content, new keys) and for paths: Xcode 26 puts the DerivedData path into Swift keys despite prefix maps, so Swift compilations hit only when DerivedData is at the same path as where the cache was filled. Clang modules hit anywhere, and Xcode 27 does not have the limitation.

Sources

  1. Apple, Xcode 26 release notes, “Compilation caching” (149700201).
  2. Apple, What’s new in Xcode 26 (WWDC25 session 247), Builds chapter: explicitly built modules for Swift.
  3. Swift Forums, About Swift shared cache across machines (Aug–Sep 2025): local-only setting, prefix mapping, copying the cache on CI, distributed caching plans.
  4. swiftlang/llvm-project, llvm/lib/RemoteCachingService (protocol) and llvm-remote-cache-test.
  5. swiftlang/swift, issue #84088: absolute paths in the bridging header.
  6. GitHub Docs, Dependency caching reference: 10 GB per repository, 7-day eviction, branch scope, immutable entries. Checked 15 November 2026.
  7. GitHub Docs, GitHub-hosted runners: each runner is a new VM. Checked 15 November 2026.
  8. Measurements: Warmbuild’s fixtures/ios app (8 Swift files and one local package), Xcode 27.2 on an Apple-silicon Mac, iOS Simulator destination, 15 November 2026.