How to Make iOS Builds Faster on GitHub Actions
iOS builds on GitHub Actions get faster when DerivedData and SwiftPM caches survive between runs, xcodebuild skips work CI does not need, and the compile phase runs on a Mac with more cores.
Last verified:
iOS builds on GitHub Actions get faster when DerivedData and SwiftPM caches survive between runs, xcodebuild skips work CI does not need, and the compile phase runs on a Mac with more cores, such as a WarpBuild macOS runner. Every job starts on a fresh machine, so without caches the compiler redoes all the work of the last run. Measure the build by phase first, then apply the fix that matches the slowest phase.
How to make iOS builds faster on GitHub Actions
Cache DerivedData and SwiftPM checkouts, turn off work CI does not need, and run the compile phase on a Mac with more cores. Apply the steps in this order.
- Measure the job by phase, so you know which phase takes the most time.
- Cache the SwiftPM checkout directory and DerivedData, with the Xcode version in both cache keys.
- Save DerivedData from the main branch only.
- Pass
xcodebuildflags that stop package re-resolution, indexing, dSYM generation and signing on test builds. - Split
build-for-testingfromtest-without-building. - Run the compile-heavy job on a Mac with more cores. WarpBuild's 12 vCPU runner runs on Apple M4 Pro or M5 Pro hosts (cloud runners documentation). GitHub's
macos-latest-xlargehas 5 cores (GitHub docs).
This page is about build time. For the full iOS pipeline with code signing, see iOS Builds on GitHub Actions with macOS Runners. For simulator test sharding, see Running iOS Simulator Tests on GitHub Actions.
Where an iOS build spends its time
An iOS build on a hosted runner has four phases, and each phase has a different bottleneck. If you look only at the total job time, you cannot tell which fix will help.
Package resolution and checkout. xcodebuild resolves the Swift Package Manager graph, clones every dependency, and writes them under SourcePackages inside DerivedData unless you set another path. On a fresh runner this is network and clone work on every run, even when Package.resolved has not changed. Private dependencies over SSH add authentication time to the same phase.
Module compilation. This is usually the longest phase. Swift compiles per module, and an empty DerivedData means every module in the graph, including every package dependency, compiles from source. Type-checking cost is uneven. A few expressions with heavy overload resolution can keep one module compiling long after the rest has finished.
Linking and debug symbols. The linker is largely serial and runs after compilation completes. The expensive part is often dsymutil, not the link step itself. When DEBUG_INFORMATION_FORMAT is dwarf-with-dsym, which Xcode uses by default for Release, the build generates a dSYM bundle. A test build has no use for it.
Archive and export. xcodebuild archive builds the 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 make this phase longer, whatever the compiler speed.
How to measure each phase
Get a number per phase before you change a flag.
- Split the job so each phase is its own step. The GitHub Actions log prints a duration next to every step name.
- Add
-showBuildTimingSummaryto thexcodebuildcommand. It prints a total per task type at the end of the build, so you can see compile time and link time inside one step. - Pull raw timestamps for a run with
gh run view <run-id> --log. - 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, with file and line, on each function body and expression above the threshold. The value is in milliseconds, and 300 is only an example threshold. - Check that a change held across many runs, not one lucky build. The WarpBuild Jobs report shows Duration, Queue, CPU and Memory at p75 or p90 per job, with CSV export. CPU and memory need Observability turned on. CPU tells you whether a job was limited by compute or waiting on a serial phase.
The table below is an example, not a measurement. It shows the shape a phase breakdown often takes, on a WarpBuild 6 vCPU runner. "Cold" means no cache restored and "warm" means both caches restored. Put your own measured numbers in its place. The cost model later on this page uses these example numbers.
| Phase | Example cold minutes | Example warm minutes | What the warm column depends on |
|---|---|---|---|
| Package resolution and checkout | 2.5 | 0.3 | Restored SourcePackages, resolution pinned to Package.resolved |
| Module compilation | 12.0 | 5.0 | Restored DerivedData, index store off |
| Linking and debug symbols | 2.5 | 1.5 | dwarf instead of dwarf-with-dsym on test builds |
| Archive and export | 5.0 | 4.2 | Fewer embedded frameworks to sign |
| Total | 22.0 | 11.0 |
In this example the archive phase barely moves. Caches help compilation, and no cache setting will speed up codesigning.
Which fixes cut iOS build time
Caches, xcodebuild flags, and splitting build from test cut the most time for the least effort. Apply them in this order.
Set the two cache paths, then cache them. Pass -derivedDataPath and -clonedSourcePackagesDirPath so both live inside the workspace where actions/cache can reach them. Key the SwiftPM cache on Package.resolved, because it changes rarely. Key DerivedData on the Xcode version plus the commit SHA, with a prefix restore-keys. A pull request job that misses on its own SHA then restores the last main build and compiles only what changed.
Save DerivedData from main only. Use actions/cache/restore and actions/cache/save as separate steps, and guard the save step with github.ref == 'refs/heads/main'. If every pull request writes its own DerivedData entry, the repository cache fills up and evicts the entry the other jobs restore from. Check GitHub's dependency caching reference for the current per-repository cache limit. See The GitHub Actions Cache Size Limit, Explained for workarounds when DerivedData does not fit.
Put the Xcode version in every cache key. Then a job never restores DerivedData or SwiftPM output that a different Xcode version produced. Running xcodebuild -version in a key step adds almost no time.
Stop redundant work with flags. -onlyUsePackageVersionsFromResolvedFile and -disableAutomaticPackageResolution stop the build from resolving a graph you already pinned. -skipPackagePluginValidation and -skipMacroValidation remove trust prompts that a headless machine cannot answer. COMPILER_INDEX_STORE_ENABLE=NO turns off indexing while building, which only the editor uses. On test builds, DEBUG_INFORMATION_FORMAT=dwarf and CODE_SIGNING_ALLOWED=NO skip dSYM generation and signing. ARCHS=arm64 builds only the arm64 simulator slice, which is the one Apple Silicon runners use.
Split build from test. xcodebuild build-for-testing produces the test bundle, and test-without-building runs it. The build step then caches cleanly, and a simulator matrix reuses one compiled bundle on every destination instead of compiling once per destination. The simulator side is covered in Running iOS Simulator Tests on GitHub Actions.
Does faster Mac hardware speed up iOS builds?
Yes, for the compile phase. Swift compiles independent modules in parallel, so more cores help when the target graph is wide. Linking, codesigning and IPA export run largely in series, so more cores do little for them. A Mac with more cores helps most when compilation is the biggest phase in your measurements.
The table compares the WarpBuild macOS runners with GitHub's Apple Silicon and Intel standard and larger macOS runners, priced at GitHub's per-minute rates.
| Runner | Chip | CPU | Memory | Storage | Price per minute |
|---|---|---|---|---|---|
WarpBuild warp-macos-26-arm64-12x / warp-macos-15-arm64-12x | Apple M4 Pro or M5 Pro | 12 vCPU | 28GB | 270GB SSD | $0.16 |
WarpBuild warp-macos-26-arm64-6x / warp-macos-15-arm64-6x | Apple M4 Pro or M5 Pro | 6 vCPU | 14GB | 120GB SSD | $0.08 |
GitHub macos-latest-xlarge | arm64 (M2) | 5 cores | 14GB | 14GB | $0.102 |
GitHub macos-latest-large | Intel | 12 cores | 30GB | 14GB | $0.077 |
GitHub macos-latest | arm64 (M1) | 3 cores | 7GB | 14GB | $0.062 |
Two more facts matter for sizing:
- More memory and disk. DerivedData, several simulator runtimes and an archive on one disk can outgrow a small SSD before the CPU becomes the limit.
- Check CPU before you move up. Read CPU p75 and p90 per job on the Jobs report. If a job does not use most of 6 vCPU, it will not use 12.
To see the hardware difference on your own project, run the same build on a WarpBuild label and on macos-latest-xlarge and compare the phase timings.
What the workflow looks like
The workflow has a build job on the WarpBuild 6 vCPU macOS runner, with both caches and the flags from above, and an archive job on the 12 vCPU runner. Both jobs select Xcode 27.0, so the build and the archive use the same toolchain.
name: ios-build
on:
pull_request:
branches: [main]
push:
branches: [main]
concurrency:
group: ios-build-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
DERIVED_DATA: ${{ github.workspace }}/.derived-data
SPM_CHECKOUTS: ${{ github.workspace }}/.spm-checkouts
jobs:
build:
runs-on: warp-macos-26-arm64-6x
steps:
- uses: actions/checkout@v7
- name: Select Xcode 27.0
run: sudo xcode-select -s "$XCODE_27_DEVELOPER_DIR"
- 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@v6
with:
path: ${{ env.SPM_CHECKOUTS }}
key: spm-${{ steps.toolchain.outputs.id }}-${{ hashFiles('**/Package.resolved') }}
- name: Restore DerivedData
uses: actions/cache/restore@v6
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 "generic/platform=iOS Simulator" \
-derivedDataPath "$DERIVED_DATA" \
-clonedSourcePackagesDirPath "$SPM_CHECKOUTS" \
-disableAutomaticPackageResolution \
-skipPackagePluginValidation \
-skipMacroValidation \
-showBuildTimingSummary \
COMPILER_INDEX_STORE_ENABLE=NO \
DEBUG_INFORMATION_FORMAT=dwarf \
ARCHS=arm64 \
CODE_SIGNING_ALLOWED=NO
- name: Save DerivedData from main only
if: github.ref == 'refs/heads/main'
uses: actions/cache/save@v6
with:
path: ${{ env.DERIVED_DATA }}
key: dd-${{ steps.toolchain.outputs.id }}-${{ github.sha }}The archive job is where the WarpBuild 12 vCPU runner is most likely to pay off, because a Release build compiles the full target graph with whole-module optimization. The WarpBuild macOS 26 runners ship Xcode 27.0, and XCODE_27_DEVELOPER_DIR points to it.
archive:
if: github.ref == 'refs/heads/main'
needs: build
runs-on: warp-macos-26-arm64-12x
steps:
- uses: actions/checkout@v7
- name: Select Xcode 27.0
run: sudo xcode-select -s "$XCODE_27_DEVELOPER_DIR"
- name: Restore SwiftPM checkouts
uses: actions/cache@v6
with:
path: ${{ github.workspace }}/.spm-checkouts
key: spm-archive-xcode27-${{ hashFiles('**/Package.resolved') }}
- name: Resolve packages from the lock file only
run: |
xcodebuild -resolvePackageDependencies \
-scheme App \
-clonedSourcePackagesDirPath "${{ github.workspace }}/.spm-checkouts" \
-onlyUsePackageVersionsFromResolvedFile
# Code signing for the archive is covered on the iOS solutions page: /solutions/ios-github-actions
- 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=NOFive facts from the cloud runners documentation decide whether this works:
- Use
actions/cacheon macOS. Cache DerivedData and the SwiftPM directory withactions/cache, as written above. - Runner storage is ephemeral. Every job starts on a fresh VM, so anything the next job needs must go through an artifact or a cache.
- Run container builds and Android emulators on Linux runners. WarpBuild macOS runners do not run Docker or nested virtualization.
- Aliases track the latest image.
warp-macos-latest-arm64-6xresolves towarp-macos-15-arm64-6x. Pin the explicit version label on a release branch, so an alias change does not change your Xcode during a release. - Xcode 27 is a WarpBuild addition on macOS 26. The
warp-macos-26-arm64-6xandwarp-macos-26-arm64-12xrunners add Xcode 27.0 (build 27A266a) and Xcode 27.1 on top of the upstream GitHub image. The default Xcode on those runners is 26.6. These additions are maintained only until the official macOS 27 images are released. A maintenance branch that needs an older toolchain can usewarp-macos-14-arm64-6x, also at $0.08 per minute.
How to plan macOS concurrency
Count how many macOS jobs your workflows run at the same time, then compare that number with each platform's macOS limit. WarpBuild Linux and Windows runners have unlimited concurrency for generally available features. GitHub caps concurrent macOS jobs at 5 on Free, Pro and Team and 50 on Enterprise, shared across standard and larger runners (GitHub Actions limits). If your workflows need high concurrency on macOS runners, contact [email protected] before you migrate.
After the move, check Queue p75 and p90 per job on the Jobs report to see whether jobs wait for a runner.
What faster iOS builds cost
Faster builds cost less when billing is per minute, because fewer minutes are billed. The model below uses the example phase table from this page.
When the 12 vCPU runner pays off
| Label | vCPU | Memory | Storage | Price per minute |
|---|---|---|---|---|
warp-macos-26-arm64-6x | 6 | 14GB | 120GB SSD | $0.08 |
warp-macos-26-arm64-12x | 12 | 28GB | 270GB SSD | $0.16 |
The 12 vCPU runner has twice the vCPU and memory of the 6 vCPU runner (cloud runners documentation) and costs twice as much per minute (pricing page). It costs the same per job only when it cuts the job to half its time or less. In the example warm job of 11.0 minutes ($0.88 on 6 vCPU), the 12 vCPU runner would need to finish in 5.5 minutes to cost the same.
More cores shorten module compilation and the Release build inside the archive phase. They do little for package resolution, linking and codesigning. So the larger the compile share of a job, the closer it gets to half its time on 12 vCPU. Run the same job on both labels and compare Duration p75 on the Jobs report. A job can also justify 12 vCPU for speed alone, when shorter waits matter more than the extra cost.
Example monthly model
All numbers in this model are examples. Substitute your own:
- One iOS repository with 900 jobs per month (45 per working day across 20 working days).
- Every job runs the four phases from the example table on
warp-macos-26-arm64-6x. This keeps one runner size per platform, so only the price per minute changes. - The workflow above runs archive differently. It is a separate job on
warp-macos-26-arm64-12xthat runs on main only. To model that setup, price the build job on 6 vCPU for every run and the archive job on 12 vCPU for each push to main, then add the totals. - Cold job is 22.0 minutes. Warm job with both caches restored is 11.0 minutes.
- Both platforms bill per minute of job time.
- Minutes are the same on both platforms, so only the price changes. If faster hardware shortens the job, the WarpBuild minutes drop further.
- Cache storage, artifact storage and reruns are not included.
Minute totals: 900 x 22.0 = 19,800 cold minutes, or 900 x 11.0 = 9,900 warm minutes.
| Configuration | macOS minutes | Price per minute | Monthly cost |
|---|---|---|---|
GitHub-hosted macos-latest-xlarge, cold caches | 19,800 | $0.102 | $2,019.60 |
GitHub-hosted macos-latest-xlarge, warm caches | 9,900 | $0.102 | $1,009.80 |
warp-macos-26-arm64-6x, cold caches | 19,800 | $0.08 | $1,584.00 |
warp-macos-26-arm64-6x, warm caches | 9,900 | $0.08 | $792.00 |
What the table shows:
- Caches alone on
warp-macos-26-arm64-6xsave $792.00 per month. - The label change alone saves $435.60 per month with cold caches and $217.80 with warm caches.
- Both together take the bill from $2,019.60 to $792.00, a difference of $1,227.60 per month for the same number of jobs.
Time matters as much as the bill. In this example, 11 minutes saved on 900 jobs is 9,900 minutes per month, or 165 hours that engineers no longer wait on pull requests.
GitHub's per-minute rates list arm64 larger runners as "macOS 5-core (M2 Pro)" at $0.102 per minute, and the larger runner docs list macos-latest-xlarge as arm64 (M2) with 5 cores. The model compares against it because it is GitHub's largest Apple Silicon runner. At list price, the $0.08 rate on the WarpBuild pricing page is about 22 percent less than $0.102. For macOS price arithmetic across every runner shape, see What macOS Runners Cost on GitHub Actions.
Where the model breaks
Cache hit rate. The warm column assumes DerivedData restores. If most pull requests touch a low-level module, the compiler reuses less, and the real number sits between the two columns. Log whether the restore step reported a hit, and compare Duration p75 for runs that hit and runs that missed.
Dependency churn. If your team updates packages every week, the SwiftPM cache misses every week. In the example table, that adds back 2.2 minutes (2.5 cold minus 0.3 warm) to each job until the cache is saved again.
Archive frequency. Running archive on main only removes the example archive minutes (5.0 cold, 4.2 warm) from every pull request job and changes every total in the table. Run the arithmetic again with your own job mix.
To pick a runner, start with macOS Runners for GitHub Actions: Sizes and Pricing.
FAQ
Why is my iOS build slow on GitHub Actions?
Every job starts on a fresh machine. DerivedData, the SwiftPM checkout directory and the module cache are empty, so the compiler rebuilds every module from source and package resolution goes to the network again. Measure the four phases separately before you change anything, because the slowest phase decides which fix is worth the effort.
Which caches cut iOS build time?
Two. The SwiftPM checkout directory set by -clonedSourcePackagesDirPath, keyed on Package.resolved, removes repeated clones. DerivedData set by -derivedDataPath, keyed on the Xcode version plus the commit with a prefix restore key, lets the compiler reuse module output from the last main build. Save DerivedData from main only, so pull request jobs restore it instead of writing new entries.
Does a Mac with more cores make iOS builds faster?
It speeds up the compile phase, because Swift compiles independent modules in parallel. Package resolution, linking, codesigning and export run largely in series, so extra cores do little for them. WarpBuild's warp-macos-26-arm64-12x and warp-macos-15-arm64-12x runners have 12 vCPU and 28GB of memory on Apple M4 Pro or M5 Pro hosts. GitHub's macos-latest-xlarge has 5 cores and 14GB of memory.
Should I use the 6 vCPU or the 12 vCPU WarpBuild macOS runner?
WarpBuild's 6 vCPU macOS runner is $0.08 per minute and its 12 vCPU runner is $0.16 per minute. The 12 vCPU runner costs the same per job only when it cuts the job to half its time or less. Read CPU p75 and p90 per job on the Jobs report first. A job that does not use most of 6 vCPU will not use 12.
How do I measure where iOS build time goes?
Put each phase in its own workflow step so the GitHub Actions log shows a duration for each. Pass -showBuildTimingSummary to xcodebuild to see compile and link time inside one step. Then compare Duration p75 and p90 per job on the WarpBuild Jobs report to check that a change held across many 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.