iOS Builds on GitHub Actions with macOS Runners
Run iOS builds on GitHub Actions macOS runners with xcodebuild, DerivedData and SPM caching, and code signing, sized across WarpBuild 6 and 12 vCPU labels.
Last verified:
An iOS build on GitHub Actions has to run on a macOS runner, because xcodebuild, the iOS simulator runtimes, and the code signing toolchain only exist on macOS. WarpBuild provides macOS runners on Apple Silicon in two configurations, 6 vCPU with 22GB of memory at $0.08 per minute and 12 vCPU with 44GB at $0.16 per minute, selected by putting a warp- label in runs-on.
This page covers the workflow YAML for a test job and a signed archive job, how to choose between the two macOS configurations, the four bottlenecks that dominate iOS wall clock time on GitHub Actions, and the arithmetic against GitHub-hosted macOS list prices.
Overview
A typical iOS repository ends up with three job shapes on GitHub Actions.
The first is a fast feedback job on every pull request push: SwiftLint, unit tests, and a small set of UI tests against one simulator destination. The second is a wider matrix job on merge to the main branch: more simulator destinations, more test plans, sometimes a build for a physical device. The third is the release job: a signed xcodebuild archive, an -exportArchive step that produces an IPA, and an upload to App Store Connect or TestFlight.
All three need macOS. Only the first two can skip code signing, which is why they are usually split from the release job.
WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. The macOS fleet runs on Apple Silicon with ARM64 architecture, and WarpBuild offers multiple sizes and configurations per chip, so a test job and an archive job can sit on different labels inside the same workflow file.
macOS runner catalog
| Runner label | macOS | vCPU | Memory | Storage | Price | Alias |
|---|---|---|---|---|---|---|
warp-macos-26-arm64-6x | macOS 26 | 6 | 22GB | 120GB SSD | $0.08/minute | |
warp-macos-26-arm64-12x | macOS 26 | 12 | 44GB | 270GB SSD | $0.16/minute | |
warp-macos-15-arm64-6x | macOS 15 | 6 | 22GB | 120GB SSD | $0.08/minute | warp-macos-latest-arm64-6x |
warp-macos-15-arm64-12x | macOS 15 | 12 | 44GB | 270GB SSD | $0.16/minute | warp-macos-latest-arm64-12x |
warp-macos-14-arm64-6x | macOS 14 | 6 | 22GB | 120GB SSD | $0.08/minute |
Source: WarpBuild cloud runners documentation, verified 2026-08-13. The latest aliases track macOS 15, in sync with GitHub's macos-latest tag. macOS 13 runners were removed on June 8, 2026, so a workflow still pinned to a macOS 13 label needs to move to macOS 14, macOS 15, or macOS 26.
The warp-macos-26-arm64-6x and warp-macos-26-arm64-12x images ship Xcode 27.0 (build 27A5194q) on top of the Xcode versions in the upstream GitHub macOS 26 image, together with iOS, tvOS, watchOS, and visionOS 27.0 simulator runtimes. The Xcode 27.0 addition is maintained until the official macOS 27 images land. Everything else on the image matches the GitHub-hosted equivalent, which is documented in the preinstalled software reference and in the upstream actions/runner-images readme for each macOS version.
Two limits shape how an iOS pipeline is laid out. macOS runners do not support nested virtualization and cannot run Docker, so any containerized step belongs on a Linux label in a separate job. And the WarpBuild cache is enabled by default on Linux runners, so macOS jobs use actions/cache against the GitHub Actions cache backend.
Configuration
Point runs-on at a warp- macOS label. The rest of the workflow is standard GitHub Actions, because the images carry the same tooling as GitHub-hosted macOS runners.
The workflow below splits the two jobs that matter. The test job runs on every push against 6 vCPU. The archive job runs only on the main branch, on 12 vCPU, and does the code signing work in a throwaway keychain.
name: ios
on:
pull_request:
push:
branches: [main]
concurrency:
group: ios-${{ github.ref }}
cancel-in-progress: true
env:
DEVELOPER_DIR: /Applications/Xcode_${{ vars.XCODE_VERSION }}.app/Contents/Developer
SCHEME: Kitchen
SIMULATOR: iPhone 17
jobs:
test:
runs-on: warp-macos-15-arm64-6x
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Report the selected toolchain
run: |
xcodebuild -version
xcrun simctl list runtimes
- name: Cache resolved Swift packages
uses: actions/cache@v4
with:
path: SourcePackages
key: spm-${{ runner.os }}-${{ hashFiles('**/Package.resolved') }}
restore-keys: spm-${{ runner.os }}-
- name: Cache DerivedData build products
uses: actions/cache@v4
with:
path: DerivedData/Build
key: dd-test-${{ runner.os }}-${{ hashFiles('**/Package.resolved', '**/*.xcodeproj/project.pbxproj') }}
restore-keys: dd-test-${{ runner.os }}-
- name: Boot the simulator
run: |
xcrun simctl boot "$SIMULATOR" || true
xcrun simctl bootstatus "$SIMULATOR" -b
- name: Unit and UI tests
run: |
xcodebuild test \
-scheme "$SCHEME" \
-destination "platform=iOS Simulator,name=$SIMULATOR" \
-derivedDataPath DerivedData \
-clonedSourcePackagesDirPath SourcePackages \
-resultBundlePath TestResults.xcresult \
-skipPackagePluginValidation \
CODE_SIGNING_ALLOWED=NO
- uses: actions/upload-artifact@v4
if: always()
with:
name: xcresult
path: TestResults.xcresult
archive:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: warp-macos-26-arm64-12x
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- name: Import the signing certificate and profile
env:
CERT_P12_BASE64: ${{ secrets.IOS_DIST_CERT_P12 }}
CERT_PASSWORD: ${{ secrets.IOS_DIST_CERT_PASSWORD }}
PROFILE_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILE }}
KEYCHAIN_PASSWORD: ${{ secrets.IOS_KEYCHAIN_PASSWORD }}
run: |
KEYCHAIN="$RUNNER_TEMP/signing.keychain-db"
echo "$CERT_P12_BASE64" | base64 --decode > "$RUNNER_TEMP/dist.p12"
echo "$PROFILE_BASE64" | base64 --decode > "$RUNNER_TEMP/dist.mobileprovision"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security set-keychain-settings -lut 3600 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security import "$RUNNER_TEMP/dist.p12" -k "$KEYCHAIN" -P "$CERT_PASSWORD" \
-T /usr/bin/codesign -T /usr/bin/security
security set-key-partition-list -S apple-tool:,apple: \
-s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security list-keychains -d user -s "$KEYCHAIN" login.keychain-db
mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles"
cp "$RUNNER_TEMP/dist.mobileprovision" \
"$HOME/Library/MobileDevice/Provisioning Profiles/"
- name: Resolve Swift packages
run: |
xcodebuild -resolvePackageDependencies \
-scheme "$SCHEME" \
-clonedSourcePackagesDirPath SourcePackages
- name: Archive
run: |
xcodebuild archive \
-scheme "$SCHEME" \
-configuration Release \
-destination 'generic/platform=iOS' \
-archivePath build/Kitchen.xcarchive \
-clonedSourcePackagesDirPath SourcePackages \
-derivedDataPath DerivedData
- name: Export the IPA
run: |
xcodebuild -exportArchive \
-archivePath build/Kitchen.xcarchive \
-exportPath build/export \
-exportOptionsPlist ci/ExportOptions.plist
- name: Upload to TestFlight
env:
API_KEY: ${{ secrets.APP_STORE_CONNECT_KEY }}
API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
API_ISSUER: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
run: |
mkdir -p "$HOME/.appstoreconnect/private_keys"
echo "$API_KEY" | base64 --decode \
> "$HOME/.appstoreconnect/private_keys/AuthKey_$API_KEY_ID.p8"
xcrun altool --upload-app -f build/export/Kitchen.ipa -t ios \
--apiKey "$API_KEY_ID" --apiIssuer "$API_ISSUER"
- name: Remove the signing keychain
if: always()
run: security delete-keychain "$RUNNER_TEMP/signing.keychain-db"Four details in that file are worth calling out.
DEVELOPER_DIR pins the toolchain for every step in the job without a separate xcode-select call. Set the repository variable XCODE_VERSION to a version the image actually ships; the per-image list lives on the Xcode images page.
-clonedSourcePackagesDirPath SourcePackages moves the resolved Swift Package Manager checkouts to a fixed path in the workspace, which is what makes them cacheable. Without it the checkouts land inside DerivedData under a hashed directory name and the cache key stops being stable.
The DerivedData cache stores DerivedData/Build rather than the whole DerivedData tree. The index store and the module cache are large, rebuild quickly, and inflate both the upload and the restore.
The keychain is created in $RUNNER_TEMP, unlocked, given a partition list, and deleted in an always() step. The security set-key-partition-list call is the one people skip, and skipping it makes codesign block on a UI prompt that never appears on a headless runner, so the job hangs until the timeout fires.
Nothing else changes when moving an existing workflow across. Swapping macos-latest for warp-macos-15-arm64-6x is the whole migration for most repositories.
Sizing
The two macOS configurations differ in three dimensions that matter to Xcode.
| Configuration | vCPU | Memory | Storage | Price | Fits |
|---|---|---|---|---|---|
6x (warp-macos-15-arm64-6x, warp-macos-26-arm64-6x, warp-macos-14-arm64-6x) | 6 | 22GB | 120GB SSD | $0.08/minute | Unit tests, one or two simulator destinations, incremental debug builds, lint |
12x (warp-macos-15-arm64-12x, warp-macos-26-arm64-12x) | 12 | 44GB | 270GB SSD | $0.16/minute | Release archives, whole-module optimization, wide simulator matrices, large DerivedData |
Start every job on 6x. The Swift compiler parallelizes across files within a module, so a test job on a medium app rarely saturates 12 vCPU, and a debug build with an incremental DerivedData cache spends much of its time on linking and simulator work that extra cores do not shorten.
Move the archive job to 12x first. Release configuration turns on whole-module optimization, which raises both peak memory and the amount of work available to parallelize, and the archive step also produces dSYMs and runs -exportArchive on top of the compile.
Storage decides more of this than people expect. A 120GB SSD holds Xcode, the bundled simulator runtimes, the repository, DerivedData, and the resolved packages. A large app with several simulator runtimes downloaded at job time, a fat DerivedData tree, and an .xcarchive plus IPA in the same job can run that disk close to full. The 12x configuration carries 270GB, which removes the cleanup steps that a tight disk forces into the workflow.
The pricing arithmetic is simple because both rates are flat. At $0.16 per minute against $0.08 per minute, the 12 vCPU configuration lowers the cost of a job only when it cuts that job's wall clock time by more than half. For a release archive that runs once per merge, wall clock usually matters more than the per-job cost, so the larger label is the right default there even when it does not pay for itself in minutes. For a test job that runs on every push, the per-job cost dominates and 6x is usually the better trade.
The CI observability view in the WarpBuild dashboard reports runner right-sizing recommendations from measured CPU and memory utilization per repository, workflow, job, and instance type, which is a faster way to settle the question than guessing from a single run.
Worked cost model
Take an eight-engineer iOS team on a 22 weekday month.
Test job: 45 pull request pushes per weekday, 11 minutes each, on warp-macos-15-arm64-6x.
- 45 pushes x 22 days = 990 test jobs
- 990 jobs x 11 minutes = 10,890 minutes
- 10,890 minutes x $0.08 = $871.20
Archive job: 6 merges to the main branch per weekday, 16 minutes each, on warp-macos-26-arm64-12x.
- 6 merges x 22 days = 132 archive jobs
- 132 jobs x 16 minutes = 2,112 minutes
- 2,112 minutes x $0.16 = $337.92
Monthly total: $871.20 + $337.92 = $1,209.12.
That total is the whole bill for runner time.
Against GitHub-hosted macOS list prices
GitHub publishes its per-minute rates in the GitHub Actions minute multipliers reference and its plan pricing at github.com/pricing. The macOS rows below were checked on 2026-08-13.
| Runner | Architecture | Cores | Per-minute rate |
|---|---|---|---|
GitHub-hosted macOS standard (actions_macos) | arm64 or x64 | 3 or 4 | $0.062 |
GitHub-hosted macOS larger, arm64 (macos_xl) | arm64 | 5 | $0.102 |
GitHub-hosted macOS larger, x64 (macos_l) | x64 | 12 | $0.077 |
warp-macos-15-arm64-6x | arm64 | 6 vCPU, 22GB | $0.080 |
warp-macos-15-arm64-12x | arm64 | 12 vCPU, 44GB | $0.160 |
The like-for-like row is the arm64 larger runner: warp-macos-latest-arm64-6x (6 vCPU, 22 GB) costs $0.08 per minute against $0.102 per minute for the largest GitHub-hosted macOS ARM64 runner (5 vCPU, 14 GB): 22 percent lower list price. GitHub list price checked on 2026-08-13. The difference is $0.102 - $0.080 = $0.022 per minute in WarpBuild's favor, on more cores and more memory.
Applied to the test job in the model above: 10,890 minutes x $0.102 = $1,110.78 on the GitHub-hosted arm64 larger runner, against $871.20 on warp-macos-15-arm64-6x. The gap is $239.58 per month on that job alone.
Two honest caveats on the other rows. GitHub's standard macOS runner is cheaper per minute at $0.062, and it is a 3-core or 4-core machine, so the comparison is between different amounts of hardware rather than between the same machine at two prices. GitHub's 12-core macOS larger runner at $0.077 is x64, so it is not an Apple Silicon comparison at all. Also note that GitHub rounds the minutes and partial minutes each job uses up to the nearest whole minute, which matters when a repository runs many short macOS jobs.
Bottlenecks
Four things dominate iOS wall clock time on GitHub Actions, and none of them are the Swift compiler alone.
Simulator boot
xcodebuild test boots the simulator implicitly on first use, and the boot includes launching SpringBoard and the runtime services before the test host installs. Booting explicitly with xcrun simctl boot followed by xcrun simctl bootstatus -b moves that cost into a named step, which makes it visible in the job timeline instead of hiding inside the test step.
Reuse one destination across test plans in the same job. Each additional simulator name in -destination boots another device. A matrix over four device names inside a single job pays four boots serially; a matrix over four jobs pays them in parallel, at the cost of four cold runners.
If a project targets a simulator runtime that the image does not bundle, Xcode downloads it at job time. That download is measured in gigabytes and runs on every job, because the runner is ephemeral. Check the runtimes shipped on each image on the Xcode images page and target one of those where the test matrix allows it.
DerivedData cold start
Every job starts on a fresh ephemeral VM with an empty DerivedData directory, so the first build compiles every module from scratch, including all Swift package dependencies. This is the single largest difference between a laptop build and a GitHub Actions build.
Cache DerivedData/Build and the cloned package checkouts separately. The package checkouts key cleanly on Package.resolved and change rarely. The build products key on Package.resolved plus project.pbxproj and change often, so give that entry a restore-keys prefix and accept a partial hit.
Watch the restore time. A DerivedData archive for a large app can reach several gigabytes, and past a certain size the download and decompress cost more than recompiling the modules it saves. Measure both before keeping the cache.
Code signing and provisioning
Code signing fails in ways that look like build failures. The common causes, in the order they show up:
- The keychain is created in one step and used in another without
security unlock-keychain, socodesigncannot read the private key. security set-key-partition-listis skipped, andcodesignwaits on an interactive authorization prompt on a headless machine until the job times out.- The provisioning profile is decoded but never copied into
~/Library/MobileDevice/Provisioning Profiles, so-exportArchivereports that no profile matches the bundle identifier. - The profile or the distribution certificate expired, which surfaces late, during export, after the full archive has already been compiled.
- The keychain outlives the job on a persistent machine. On ephemeral runners the VM is destroyed anyway, and the explicit
security delete-keychainstep keeps the workflow correct if it is ever run elsewhere.
Put the whole signing sequence in one step, as in the workflow above, so the keychain state cannot drift between steps.
Xcode version drift
The runner image ships several Xcode versions with one of them selected as default, and the default moves when the image is updated. A project that requires a specific Swift version or a specific SDK will fail on a Monday for no visible reason if the workflow relies on the image default.
Pin DEVELOPER_DIR in the workflow env block and print xcodebuild -version in the first step so the log records what actually ran. When the pinned version is retired from the image, the failure is loud and immediate at the first step rather than subtle and late.
macOS 26 images ship Xcode 27.0 (build 27A5194q) alongside the upstream versions, so a project moving to Xcode 27 can pin it on warp-macos-26-arm64-6x or warp-macos-26-arm64-12x while the rest of the fleet stays on macOS 15.
When a job fails only on the runner and not on a laptop, the Action Debugger pauses the workflow and opens an SSH session on the runner machine, which is the shortest path to inspecting a keychain, a provisioning profile, or a simulator log in place.
Proof
The migration cost is a label. The macOS images carry the same tooling as GitHub-hosted macOS runners, so xcodebuild, xcrun, simctl, security, Fastlane, and CocoaPods behave the same way, and existing actions from the marketplace keep working. The catalog and the tooling references are in the cloud runners documentation and the preinstalled software reference.
Beyond the runners themselves, CI observability and the Action Debugger are the two parts of the WarpBuild product surface that iOS teams use most: the first to right-size the macOS labels from measured utilization, the second to open an SSH session on a runner when a signing or simulator failure will not reproduce locally.
Rates, sizes, and labels for every platform are on the WarpBuild pricing page, and the full macOS lineup with its Xcode coverage is on the macOS runners hub. To model a specific repository's bill before switching, work through the macOS runner cost guide. For package-level Swift work without an Xcode project, see Swift builds on GitHub Actions.
FAQ
Which macOS runner label should an iOS test job use?
Start on warp-macos-15-arm64-6x (6 vCPU, 22GB, 120GB SSD, $0.08 per minute) for xcodebuild test against one or two simulator destinations. Move to warp-macos-26-arm64-12x (12 vCPU, 44GB, 270GB SSD, $0.16 per minute) when a release archive or a wide simulator matrix runs long or fills the disk.
Can a macOS runner run Docker as part of an iOS build?
No. WarpBuild macOS runners do not support nested virtualization and cannot run Docker. Put containerized steps such as backend integration fixtures on a Linux runner label in a separate job and pass artifacts between the jobs.
How do I cache DerivedData and Swift Package Manager dependencies?
Use actions/cache@v4 with two entries, one for the resolved package checkout directory keyed on Package.resolved and one for the DerivedData build products keyed on Package.resolved plus project.pbxproj. The WarpBuild cache is enabled on Linux runners, so macOS jobs use the GitHub Actions cache backend.
What does a macOS minute cost compared with GitHub-hosted macOS runners?
The WarpBuild 6 vCPU macOS runner with 22GB of memory is $0.08 per minute. GitHub lists its arm64 macOS larger runner at $0.102 per minute and its standard 3-core or 4-core macOS runner at $0.062 per minute (checked on 2026-08-13). On the like-for-like Apple Silicon larger runner the difference is $0.022 per minute.
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.