
Cache the DerivedData directory between CI runs keyed on {runner-os}-xcode-{version}-{hash(Package.resolved + Podfile.lock)}, exclude Index.noindex, and only save the cache on the default branch after a successful build. A persistent self-hosted Mac makes this cheap because restore and save are local disk copies instead of network transfers; the Figment Managed Build Mac at $249/month is the matching configuration (one M-series Mac, CI setup, backups, monitoring, 30 minutes monthly support). An M-series developer Mac from $95/month is available on request; the exact chip, RAM and disk are confirmed on your configuration quote, not a fixed spec.
Every time an ephemeral CI runner starts fresh, Xcode recompiles every source file from scratch. Swift files that took 8 minutes locally, where Xcode reused the previous session's modules, can take 18 minutes in CI. Persisting DerivedData with a correct key fixes that.
Xcode's incremental build system checks file content hashes against the module dependency graph recorded in DerivedData. On an ephemeral runner there is no previous DerivedData, so every module is rebuilt. Even on a persistent runner, git checkout rewrites the working tree each run; combined with a missing module cache, Xcode cannot prove modules are up to date and recompiles them.
Restoring DerivedData addresses this in two ways:
.swiftmodule, .o, static archives) are present, so only files whose content hash changed recompile.A cache produced by a different Xcode version is not safe to restore. The module binary format changes between major versions and can produce module-load errors. The Xcode version must be part of the cache key.
DerivedData lives at ~/Library/Developer/Xcode/DerivedData/ by default. For a project MyApp:
DerivedData/
MyApp-<hash>/
Build/
Intermediates.noindex/ # .o files, .d dependency files, .swiftmodule
Products/ # built .app, .framework, .xctest
Index.noindex/ # SourceKit index (skip; large, unused by xcodebuild)
Logs/
info.plist
Cache Build/Intermediates.noindex/ and Build/Products/. Skip Index.noindex/: it can reach several gigabytes on a large app and xcodebuild does not consume it.
Pin DerivedData to a project-relative path so the cache is predictable:
xcodebuild build \
-scheme MyApp \
-destination 'generic/platform=iOS' \
-derivedDataPath ./DerivedData \
-showBuildTimingSummary
-showBuildTimingSummary prints the slowest build phases at the end of the log, which is how you tell whether DerivedData caching or linker tuning will help more.
A good key invalidates exactly when cached products are no longer valid:
{runner-os}-xcode-{xcode-version}-{hash(Package.resolved + Podfile.lock)}
| Component | Why |
|---|---|
runner-os | macOS version affects SDK headers and linker behavior |
xcode-version | Module binary format differs between Xcode versions |
hash(Package.resolved) | SwiftPM dependency tree changed |
hash(Podfile.lock) | CocoaPods dependency tree changed |
Do not put github.sha in the primary key, because that mints a new cache per commit and defeats the point. Use github.sha only as a restore-key suffix fallback.
The GitHub Actions caching documentation confirms a 512-character key limit and a default 10 GB cache quota per repository. The actions/cache repository documents split restore/save steps and the cache-hit output used below.
# .github/workflows/ios-build.yml
name: iOS Build
on: [push, pull_request]
env:
XCODE_VERSION: '16.2'
DERIVED_DATA_PATH: ./DerivedData
jobs:
build:
runs-on: [self-hosted, macOS]
steps:
- uses: actions/checkout@v4
- name: Select Xcode
run: sudo xcode-select -s /Applications/Xcode_${{ env.XCODE_VERSION }}.app
- name: Restore DerivedData cache
id: cache-dd
uses: actions/cache/restore@v4
with:
path: ${{ env.DERIVED_DATA_PATH }}
key: ${{ runner.os }}-xcode-${{ env.XCODE_VERSION }}-${{ hashFiles('**/Package.resolved', '**/Podfile.lock') }}
restore-keys: |
${{ runner.os }}-xcode-${{ env.XCODE_VERSION }}-
- name: Build
run: |
set -euo pipefail
xcodebuild build \
-scheme MyApp \
-destination 'generic/platform=iOS' \
-derivedDataPath "${{ env.DERIVED_DATA_PATH }}" \
-showBuildTimingSummary \
| tee build.log
- name: Save DerivedData cache
if: success() && github.ref == 'refs/heads/main' && steps.cache-dd.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
with:
path: ${{ env.DERIVED_DATA_PATH }}
key: ${{ runner.os }}-xcode-${{ env.XCODE_VERSION }}-${{ hashFiles('**/Package.resolved', '**/Podfile.lock') }}
Why this shape:
restore/save lets you gate save on success() and on github.ref so a failed or PR build never overwrites a good cache.restore-keys prefix warm-starts the next build after a minor lock-file change.cache-hit != 'true' avoids rewriting an identical cache.Buildkite artifacts move files between steps in one build, not across builds. On a persistent self-hosted Mac, keep the cache on local disk under a key derived from the lockfiles and Xcode version:
#!/usr/bin/env bash
set -euo pipefail
XCODE_VERSION=$(xcodebuild -version | awk 'NR==1{print $2}')
LOCKFILES=$(ls Package.resolved Podfile.lock 2>/dev/null || true)
LOCKFILE_HASH=$(cat $LOCKFILES 2>/dev/null | shasum -a 256 | awk '{print $1}')
CACHE_DIR="$HOME/.xcode-cache/$XCODE_VERSION/$LOCKFILE_HASH"
DERIVED_DATA="./DerivedData"
mkdir -p "$DERIVED_DATA"
if [[ -d "$CACHE_DIR" ]]; then
rsync -a "$CACHE_DIR/" "$DERIVED_DATA/"
fi
xcodebuild build \
-scheme MyApp \
-destination 'generic/platform=iOS' \
-derivedDataPath "$DERIVED_DATA" \
-showBuildTimingSummary
# Only refresh the cache if the build succeeded (set -e handles failure).
rsync -a --delete \
--exclude 'Index.noindex' \
"$DERIVED_DATA/" "$CACHE_DIR/"
Add a weekly cron that prunes ~/.xcode-cache entries older than N days to keep disk usage bounded.
build:
tags: [mac]
variables:
DERIVED_DATA: "$CI_PROJECT_DIR/DerivedData"
cache:
key:
files:
- Package.resolved
- Podfile.lock
prefix: "xcode-16.2"
paths:
- DerivedData/Build/Intermediates.noindex/
- DerivedData/Build/Products/
policy: pull-push
script:
- set -euo pipefail
- xcodebuild build -scheme MyApp -destination 'generic/platform=iOS' -derivedDataPath "$DERIVED_DATA" -showBuildTimingSummary
Fill this in with your own numbers; code is an example, not a benchmark.
| Measurement | How to capture | Your value |
|---|---|---|
| Cold build wall time (T_cold) | time xcodebuild build ... on a clean DerivedData | fill in |
| Warm build wall time (T_warm) | Same command after one successful cache save | fill in |
| Cache restore overhead (T_r) | CI step timestamp delta for the restore step | fill in |
| Cache save overhead (T_s) | Same for the save step | fill in |
| Builds per workday (N) | PR + main runs in a typical day | fill in |
| Net daily time saved | N * (T_cold - T_warm - T_r) | calculate |
Worked cost example at Figment list pricing: one Managed Build Mac is $249/month; one M-series Mac is $95/month. If the Managed Build Mac saves one engineer 30 minutes per day across cache-driven rebuilds (10 workdays per billing month equals 5 saved engineering hours), each saved hour costs $249 ÷ 5 = $49.80 in hosting. Compare that against your blended engineering cost per hour to decide whether the managed tier or a bare M-series developer Mac is the right fit; confirm the exact chip and RAM in the quote before you commit.
XCODE_VERSION; the old cache key becomes unreachable.OTHER_SWIFT_FLAGS, optimization level or -enable-experimental-feature flips invalidate modules. Encode the relevant flag or accept one stale-cache rebuild.v2-xcode-...) to drop orphaned .swiftmodule files that would otherwise sit in the cache forever.| Runner type | Cache benefit | Notes |
|---|---|---|
| Persistent self-hosted Mac | Highest. Restore and save are local disk copies | Prune policy required to bound disk use |
| Ephemeral hosted runner | Moderate. Cache is uploaded and downloaded over the network each run | Network throughput and the 10 GB per-repo quota become the limits |
| Shared pool of Macs | Lower. Hit rate depends on routing the same repo to the same host | Consider a shared remote cache keyed the same way |
For an iOS CI job on a Figment Managed Build Mac, the cache and the toolchain both live on the same disk, so the path with the largest payoff is the Buildkite-style local rsync pattern above. See Automate tvOS App Builds in a CI Pipeline and Automate watchOS App Builds in a CI Pipeline for sibling platform notes, and Remote Xcode from Windows if you also need a desktop endpoint on the same Mac.