
For Swift packages that use macros, C/C++ interop, or Apple-silicon-only dependencies, run this on a Figment M-series Mac from $95/month (exact chip, RAM, and disk confirmed at quote; physical allocation is request-based until supply is confirmed). For pure Swift libraries that build on both architectures and target a Swift toolchain shipped with Xcode 26 or earlier, the virtual Intel macOS VM at $29/month (2 vCPU, 8 GiB RAM, 80 GiB disk) runs swift build and swift test as a lower-cost entry point; it is virtual Intel macOS on Linux/KVM with no GPU acceleration or audio, and access is through the browser console with outbound CI agents connecting to your service. Teams who want the runner configured for them can take Managed Build Mac at $249/month, which includes one M-series Mac, CI setup, backups, monitoring, and 30 minutes of monthly support.
If the project is a pure Swift package — no app bundle, no storyboards — swift build and swift test give a tighter feedback loop than xcodebuild. They read Package.swift directly and can emit machine-readable test output, as documented by the Swift Package Manager documentation. Running the same tests on Linux in the same workflow catches platform-specific bugs early. Here is a working pipeline for a Figment Mac runner.
Use swift build when:
.xcodeproj and no Xcode-specific build settingsxcodebuild is macOS-only)xcbuild layerUse xcodebuild when:
.ipa exportA single repository can use both: swift build for the library in CI and xcodebuild for a host app or UI tests.
Each Xcode version ships one Swift toolchain. On a hosted Mac, xcode-select chooses which installed Xcode swift resolves to:
# List installed Xcode versions
ls /Applications | grep -i '^Xcode'
# Point the toolchain at a specific installation
sudo xcode-select -s /Applications/Xcode_16.3.app
# Confirm before anything else runs
xcodebuild -version
swift --version
Make the selection a named step in the workflow so the active version appears in the log and is trivial to bump. The exact Swift version shipped with each Xcode release is listed in Apple's Xcode release notes; verify the Xcode/macOS pair against Apple's Xcode release notes before pinning. On the $29 Intel VM, keep to Xcode 26 or earlier (per Apple's porting guidance at developer.apple.com); for ARM-only toolchains or Swift macros, use an M-series host.
--parallel distributes test cases across cores. Historically, SwiftPM only wrote --xunit-output when --parallel was set for XCTest suites (swiftlang/swift-package-manager#7065); keep --parallel in the command to stay on the supported path. When the package also contains Swift Testing suites, SwiftPM writes a second XML file alongside the XCTest one, with the -swift-testing.xml suffix.
mkdir -p test-results
swift test \
--parallel \
--xunit-output test-results/results.xml
After the run, expect:
test-results/results.xml for XCTest casestest-results/results-swift-testing.xml if Swift Testing suites existUpload the directory so the summary step picks up both files.
SwiftPM keeps its incremental build products in .build. Restoring that directory between runs avoids recompiling dependencies that did not change. A good cache key hashes Package.resolved (which pins every resolved dependency), and partitions by configuration and runner OS:
- uses: actions/cache@v4
with:
path: .build
key: "${{ runner.os }}-spm-${{ matrix.configuration }}-${{ hashFiles('Package.resolved') }}"
restore-keys: |
${{ runner.os }}-spm-${{ matrix.configuration }}-
When a dependency bump changes Package.resolved, the primary key misses and the restore key falls back to the newest partial match, so unchanged modules still skip recompilation.
Some bugs only surface with -O. Add a release build as a second matrix leg:
swift build -c release
This only compiles; it does not run tests. If the package relies on @inlinable or specialization, release-mode tests (swift test -c release) may be worth the extra minutes, but debug tests plus a release build cover most library projects.
name: Swift Package CI
on:
push:
branches: [main]
pull_request:
jobs:
build-and-test:
runs-on: [self-hosted, macOS, figment]
strategy:
fail-fast: false
matrix:
configuration: [debug, release]
steps:
- uses: actions/checkout@v4
- name: Select Xcode
run: |
sudo xcode-select -s /Applications/Xcode.app
swift --version
- name: Cache .build
uses: actions/cache@v4
with:
path: .build
key: "${{ runner.os }}-spm-${{ matrix.configuration }}-${{ hashFiles('Package.resolved') }}"
restore-keys: |
${{ runner.os }}-spm-${{ matrix.configuration }}-
- name: Build (${{ matrix.configuration }})
run: swift build -c ${{ matrix.configuration }}
- name: Test (debug only)
if: matrix.configuration == 'debug'
run: |
mkdir -p test-results
swift test \
--parallel \
--xunit-output test-results/results.xml
- name: Upload test results
if: always() && matrix.configuration == 'debug'
uses: actions/upload-artifact@v4
with:
name: swift-test-results
path: test-results/
retention-days: 30
actions/upload-artifact@v4 returns a SHA-256 digest output that the download action re-verifies, per the GitHub Actions artifact documentation; treat it as the integrity check rather than rolling your own checksum step. retention-days is capped by the repository, organization, or enterprise retention limit — the default is inherited from that setting, not hard-coded here. This example is illustrative, not a benchmark: it has not been timed, and the failing steps use set -e semantics so a failed build cannot be reported as a fast run.
Swift packages often target both platforms. Add a Linux job in parallel:
test-linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Test on Linux
run: |
mkdir -p test-results
swift test \
--parallel \
--xunit-output test-results/linux-results.xml
- uses: actions/upload-artifact@v4
if: always()
with:
name: swift-test-results-linux
path: test-results/
If a test passes on macOS but fails on Linux (or vice versa), the matrix exposes it on the pull request without a second workflow file.
A worked worksheet, using only approved prices:
| Scenario | Figment plan | Monthly | Notes |
|---|---|---|---|
| Pure Swift library, Xcode 26 or earlier, no ARM-only deps | Virtual Intel macOS VM | $29 | 2 vCPU, 8 GiB RAM, 80 GiB; browser console; outbound CI agent |
| Library uses Swift macros, C/C++ interop, or ARM-only deps | M-series Mac | from $95 | Specific chip/RAM/disk confirmed at quote; request-based allocation |
| Want the runner, agent, and backups configured for you | Managed Build Mac | $249 | One M-series Mac, CI setup, backups, monitoring, 30 min monthly support |
| Two-runner team with team setup | Managed Mac Team | $999 | Two M-series Macs, two CI runners, backups, monitoring, 1 hour monthly support |
Swift macros execute as native host plugins during the build, so a macro-using package on the Intel VM will either refuse to build or build only the non-macro subset — that is the practical line between the $29 and the $95 tiers. If the package compiles cleanly on Intel today and tests pass, the VM is a legitimate option; if it does not, move to M-series.
Start a runner at /mac-hosting/start, or read the plan overview at /mac-hosting.
Related guides