Swift Package Builds on GitHub Actions
Run swift build and swift test on GitHub Actions with WarpBuild macOS runners. Workflow YAML, 6x vs 12x sizing, SwiftPM cache keys, and per-minute costs.
Last verified:
A Swift package build on GitHub Actions runs swift build and swift test on a macOS or Linux runner, and the label in runs-on decides the machine, the memory, and the per-minute rate. WarpBuild runs those jobs on labels such as warp-macos-15-arm64-6x at $0.08 per minute and warp-ubuntu-latest-x64-8x at $0.016 per minute, so moving a Swift package pipeline is a one-line label change in the workflow file.
Overview
This page covers Swift Package Manager builds: libraries, command line tools, and server-side Swift services that compile with swift build and test with swift test. Application archiving, simulator runs, code signing, and fastlane lanes live on the iOS builds on GitHub Actions page, which owns the app side of the same toolchain.
Two facts shape every decision below. The Swift compiler on a macOS runner is whichever compiler the selected Xcode carries, so toolchain selection is Xcode selection. And GitHub Actions runners are ephemeral, so every run starts with an empty .build directory unless a cache step puts one back.
For Swift work, the macOS labels build and test for Apple platforms, and the Linux labels build the same package for a server-side deployment target. The runner catalog lists five macOS labels across macOS 14, macOS 15, and macOS 26, and the macOS 26 image adds Xcode 27.0 (build 27A5194q) on top of the upstream GitHub image. That image matrix is what determines which Swift toolchains a workflow can reach, which is why the coverage question and the toolchain question are the same question.
The macOS versions available on the fleet, with sizes and rates, come straight from the WarpBuild cloud runners documentation:
| Runner label | macOS | vCPU | Memory | Storage | Per-minute (USD) |
|---|---|---|---|---|---|
warp-macos-26-arm64-6x | macOS 26 | 6 | 22GB | 120GB SSD | $0.08 |
warp-macos-26-arm64-12x | macOS 26 | 12 | 44GB | 270GB SSD | $0.16 |
warp-macos-15-arm64-6x | macOS 15 | 6 | 22GB | 120GB SSD | $0.08 |
warp-macos-15-arm64-12x | macOS 15 | 12 | 44GB | 270GB SSD | $0.16 |
warp-macos-14-arm64-6x | macOS 14 | 6 | 22GB | 120GB SSD | $0.08 |
The warp-macos-latest-arm64-6x and warp-macos-latest-arm64-12x aliases point at the macOS 15 labels. macOS 14 ships a 6 vCPU size only, so a package that must build against the older Xcode series has one size available and no larger option to escalate to.
For the Linux half of a Swift matrix, the relevant rows are the mid-sized Ubuntu labels:
| Runner label | OS | vCPU | Memory | Storage | Per-minute (USD) |
|---|---|---|---|---|---|
warp-ubuntu-latest-x64-8x | Ubuntu 24.04 | 8 | 32GB | 150GB SSD | $0.016 |
warp-ubuntu-latest-x64-16x | Ubuntu 24.04 | 16 | 64GB | 150GB SSD | $0.032 |
warp-ubuntu-latest-arm64-8x | Ubuntu 24.04 | 8 | 32GB | 150GB SSD | $0.012 |
warp-ubuntu-latest-arm64-16x | Ubuntu 24.04 | 16 | 64GB | 150GB SSD | $0.024 |
Server-side Swift images that must be produced inside your own cloud account have a second path: BYOC runs on AWS, GCP, and Azure, with the runners registering the same way. The macOS fleet stays on WarpBuild-hosted machines.
Configuration
The macOS workflow below builds and tests a Swift package, records the toolchain in the log, and caches both the package build directory and the shared SwiftPM dependency cache.
name: swift-package
on:
push:
branches: [main]
pull_request:
jobs:
macos:
runs-on: warp-macos-15-arm64-6x
timeout-minutes: 30
env:
DEVELOPER_DIR: ${{ vars.XCODE_DEVELOPER_DIR }}
steps:
- uses: actions/checkout@v4
- name: Select and record the toolchain
run: |
ls /Applications | grep -i '^Xcode'
sudo xcode-select -s "$DEVELOPER_DIR"
xcodebuild -version
swift --version
- name: Restore SwiftPM caches
uses: actions/cache@v4
with:
path: |
.build
~/Library/Caches/org.swift.swiftpm
key: spm-macos15-${{ hashFiles('Package.resolved') }}
restore-keys: |
spm-macos15-
- name: Resolve dependencies
run: |
swift package resolve
git diff --exit-code Package.resolved
- name: Build
run: swift build --build-tests
- name: Test
run: swift test --skip-build --parallelSet the XCODE_DEVELOPER_DIR repository variable to a path the image actually carries, such as a bundle listed in the macOS image readme linked from the preinstalled software documentation. The ls /Applications line prints the bundles present on the image, so a stale value fails in the first ten seconds of the job instead of ten minutes into a build. Leave DEVELOPER_DIR unset and the image default applies; on macOS 14 that default is Xcode 15.4, matching the GitHub-hosted image.
swift package resolve runs as its own step so resolution time shows up as its own line in the GitHub Actions timing view. The git diff --exit-code Package.resolved check fails the job when resolution moved a dependency that the committed lockfile did not expect, which is the difference between a reproducible build and a build that silently picked up a new minor version.
Splitting swift build --build-tests from swift test --skip-build --parallel keeps compile time and test time separate in the log. Without the split, a slow test suite and a slow compile look identical from the outside.
The macOS job uses actions/cache@v4 because the runner catalog scopes the WarpBuild cache to Linux runners. On Linux labels, WarpBuilds/cache@v1 is documented as a drop-in replacement for actions/cache@v4, so the same path, key, and restore-keys inputs apply.
Server-side Swift builds belong on Linux labels, since macOS runners do not support nested virtualization and cannot run Docker. A matrix over both Linux architectures catches architecture-specific failures before a deployment does:
name: swift-linux
on:
pull_request:
jobs:
linux:
strategy:
fail-fast: false
matrix:
runner:
- warp-ubuntu-latest-x64-8x
- warp-ubuntu-latest-arm64-8x
runs-on: ${{ matrix.runner }}
container: swift:6.1
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- name: Restore SwiftPM caches
uses: WarpBuilds/cache@v1
with:
path: |
.build
/root/.cache/org.swift.swiftpm
key: spm-${{ matrix.runner }}-${{ hashFiles('Package.resolved') }}
restore-keys: |
spm-${{ matrix.runner }}-
- run: swift --version
- run: swift build --build-tests
- run: swift test --skip-build --parallelThe cache path differs by platform. SwiftPM keeps its shared dependency cache under ~/Library/Caches/org.swift.swiftpm on macOS and ~/.cache/org.swift.swiftpm on Linux, and inside a container job HOME is /root, so the Linux path is written out in full.
The runner label belongs in the cache key. Ubuntu 24.04 ARM64 runners set the work directory to /runner/_work, while GitHub uses /home/runner/work/ for the same instance, and SwiftPM records absolute paths in its build database. A cache shared between two labels with different work directories restores files that the next build ignores, and you pay the storage for the privilege.
Migrating an existing Swift workflow is a label edit. Replace macos-latest with warp-macos-15-arm64-6x and ubuntu-latest with warp-ubuntu-latest-x64-8x, leave every step alone, and the runner images carry the same tooling as their GitHub-hosted counterparts.
Sizing
macOS runners come in multiple sizes and configurations per chip. For Swift package work the choice is between the 6 vCPU label at $0.08 per minute with 22GB of memory and 120GB of storage, and the 12 vCPU label at $0.16 per minute with 44GB of memory and 270GB of storage.
Start on the 6x label. A Swift package build parallelizes across compilation units, so more cores help when the dependency graph is wide, and help much less when one large module sits on the critical path. Memory is the other trigger: a release build with whole-module optimization on a large module holds the entire module in memory at once, and test suites that spin up in-process servers add to it. Watch for the job hitting the 22GB ceiling before spending on cores you cannot use.
The arithmetic sets a clean rule. A run that takes 11 minutes on the 6x label costs 11 x $0.08 = $0.88. The same run on the 12x label costs $0.16 per minute, so it reaches $0.88 at 5.5 minutes. The 12x label lowers the bill only when it brings the run under 5.5 minutes, and above that it buys wall clock at a premium.
That premium is often worth paying. If the 12x label finishes the same run in 7 minutes, each run costs 7 x $0.16 = $1.12, which is $0.24 more than the 6x run. Across 600 runs per month that is $144.00 more, and it returns 4 minutes per run, or 2,400 minutes of wall clock every month. Pull request feedback time and engineer waiting time are the budget that spend comes out of.
Move to the 12x label for the merge queue and release jobs, where wall clock gates a deploy, and keep pull request jobs on the 6x label, where a queue of parallel jobs matters more than any single job finishing early. Storage is the third trigger: 120GB on the 6x label is generous for a package build, and a repository with large binary fixtures or many cached toolchains can want the 270GB on the 12x label.
Concurrency behaves differently on macOS than on Linux and Windows. If a Swift pipeline needs high macOS concurrency, raise it before the release crunch rather than during it.
Cost model against GitHub public list prices
GitHub publishes per-minute rates for its hosted runners. These rows come from the GitHub Actions minute multipliers reference, checked on 2026-08-13:
| GitHub-hosted tier | Per-minute (USD) |
|---|---|
| macOS 3-core or 4-core | $0.062 |
| macOS 5-core | $0.102 |
| macOS 12-core | $0.077 |
| Linux 8-core x64 | $0.022 |
| Linux 8-core arm64 | $0.014 |
Take a Swift package repository with 600 workflow runs per month and an 11 minute macOS job, which is 6,600 macOS minutes.
warp-macos-15-arm64-6xat $0.08 per minute: 6,600 x $0.08 = $528.00 per month.- GitHub-hosted macOS 5-core tier at $0.102 per minute: 6,600 x $0.102 = $673.20 per month.
- Difference: $145.20 per month, or $1,742.40 over twelve months.
Stated as list-price arithmetic: 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 warp-macos-15-arm64-6x label used above is the same machine behind that alias.
The model holds minutes constant across both runners so the arithmetic stays about rate. Confirm the image and architecture behind any GitHub label on GitHub's own runner list before treating a tier as a substitute for an arm64 Swift build, since the 12-core row is a different machine shape from the smaller macOS tiers.
The Linux half of the same pipeline: a 9 minute server-side Swift build, 600 times per month, is 5,400 minutes. On warp-ubuntu-latest-x64-8x at $0.016 per minute that is $86.40, and on warp-ubuntu-latest-arm64-8x at $0.012 per minute it is $64.80, a $21.60 difference for running the same package on the ARM64 label. Against GitHub's own ARM64 row: warp-ubuntu-latest-arm64-8x (8 vCPU, 32 GB) costs $0.012 per minute against $0.014 per minute for the 8-core Linux ARM64 larger runner (8 vCPU, 32 GB): 14 percent lower list price. GitHub list price checked on 2026-08-13.
Cache is billed separately and stays small for a package build. Cache storage is $0.20 per GB-month, so a 3GB .build cache costs $0.60 per month, and cache operations are $0.0001 each, so two operations per run across 600 runs cost $0.12. Current rates for every label are on the WarpBuild pricing page.
Bottlenecks
Four things dominate Swift build time on GitHub Actions, and each has a specific fix.
Whole-module optimization on release builds. In release configuration the compiler treats a module as a single unit, which produces better code and destroys file-level incrementality. Changing one file recompiles the module. Keep pull request jobs in the default debug configuration where the compiler works file by file, and reserve swift build -c release for tagged builds and nightly jobs. Teams that need release-configuration testing on every pull request usually get further by splitting the largest module than by adding cores, because whole-module work on one module does not spread across 12 vCPUs.
Cold SwiftPM resolution. A package with a deep dependency graph clones every dependency on a cold runner before a single line compiles. Cache .build (which holds checkouts and the build database) together with the shared SwiftPM cache directory, and key on hashFiles('Package.resolved') so the entry is invalidated exactly when the dependency set changes. The restore-keys prefix keeps a near-miss useful: a new dependency invalidates the exact key, and the older entry still saves the other clones. Running swift package resolve as its own step makes the cost visible, so you can tell a slow resolve from a slow compile without guessing.
Toolchain selection against the image's Xcode versions. Each macOS image carries a set of Xcode bundles and a default. The macOS 26 image ships Xcode 27.0 alongside the versions in the upstream GitHub image, macOS 14 defaults to Xcode 15.4, and the exact list per image lives in the readmes linked from the preinstalled software documentation. Pin DEVELOPER_DIR at the job level rather than depending on the default, and print swift --version in the log. Image updates are published in the WarpBuild changelog, and a pinned DEVELOPER_DIR turns a toolchain change into a deliberate pull request rather than a Monday morning surprise. When Xcode 27.0 is a strict requirement, the macOS 26 labels are the ones that carry it, which in practice means five labels, three macOS versions, and an Xcode version added on top of the upstream GitHub image.
Incremental state lost between runs. Runners are ephemeral, so nothing survives a job except what a cache step writes. A restored .build directory is a dependency cache first and an incremental build second, because SwiftPM invalidates incremental state when the compiler version or the absolute paths change. Put the toolchain identifier in the cache key alongside the Package.resolved hash so a new Xcode does not silently reuse artifacts built by the old one. Expect the first run after a toolchain bump to be a full build.
Two features help when a Swift job behaves differently on the runner than on a laptop. CI observability reports system metrics from the runner agent next to the GitHub Actions job logs, which is how you separate a memory-bound link step from a CPU-bound compile. The Action Debugger pauses a workflow and opens an SSH session on the runner, so a failing swift test can be reproduced on the machine that failed rather than reconstructed from a log.
Proof
WarpBuild runners register with GitHub the same way any self-hosted runner does, so the evidence a team should ask for is operational rather than architectural.
Public OSS repositories running warp- labels are citable evidence for the macOS half. manaflow-ai/cmux shards its app-host XCTest suite across warp-macos-15-arm64-6x, the same label the workflow above uses (checked on 2026-08-13).
The cheapest way to test a Swift pipeline is to run it. Point one branch's workflow at a WarpBuild macOS label, leave the rest on the existing runners, and compare the timing view for the same commit.
Continue with the WarpBuild macOS runner catalog for the full label list, the Xcode versions on each macOS image when a specific Xcode is a hard requirement, and the macOS runner cost guide to model a full month against your own run counts.
FAQ
Which runner label should a Swift package build use?
Start on warp-macos-15-arm64-6x for Apple platform packages and on warp-ubuntu-latest-x64-8x or warp-ubuntu-latest-arm64-8x for server-side Swift. Move to warp-macos-15-arm64-12x only when a job needs more than 22GB of memory or when the run finishes in under half the minutes the 6x label takes.
How do I pin the Swift toolchain on a macOS runner?
The Swift compiler on a macOS runner comes from the selected Xcode, so set DEVELOPER_DIR at the job level and run sudo xcode-select -s on it. Print swift --version and xcodebuild -version in the first step so the log records the toolchain that produced the build.
Should I cache .build between GitHub Actions runs?
Cache .build and the shared SwiftPM cache directory, keyed on a hash of Package.resolved plus the runner label and the toolchain version. Treat the restore as a dependency cache rather than a working incremental build, because SwiftPM invalidates incremental state when paths or compiler versions change.
Can macOS runners build Docker images for a server-side Swift service?
No. macOS runners do not support nested virtualization and cannot run Docker, so container builds belong on Linux labels such as warp-ubuntu-latest-x64-8x. Keep the macOS job for the Apple platform build and the Linux job for the container image.
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.