How do I test Swift packages on Linux in CI?
To build and test a Swift package on Linux in CI, run swift build and swift test in the official swift container image with a pinned tag. Include every local package that the dependency graph needs. Run the XCTest and Swift Testing tests, and keep macOS build products out of the Linux build. This setup fits server-side Swift. It also fits the shared logic of an iOS app when that logic is in its own Swift package. Code that imports UIKit, SwiftUI, or other Apple SDKs needs Xcode on macOS, and so does the iOS app itself. The Dagger swift module meets the same requirements in one place. It builds and tests every Swift Package Manager (SwiftPM) package in the repository, and it runs the same way on a laptop and in CI.
What builds on Linux and what needs macOS
A Linux job builds only code that uses the open-source Swift toolchain and its libraries. Apple describes Xcode as the tools “to develop, test, and distribute apps for Apple platforms”. SwiftUI builds apps “across all Apple platforms”. The swift image does not have these SDKs, so a package that imports UIKit fails to build in it:
/src/Packages/Signals/Sources/Signals/Signals.swift:2:8: error: no such module 'UIKit'
1 | import Units
2 | import UIKit
| `- error: no such module 'UIKit'
The Linux build finds problems that a Mac build does not find. The Swift server testing guide says that on macOS, “you may end up using APIs that are not available on Linux”. It also says that “it is critical to test on Linux”.
The requirements
A good Linux build for Swift packages meets these requirements:
- Pin the Swift toolchain. The official
swiftimage has a tag for each release and Linux distribution, such as6.4.1-nobleand6.0-jammy. Thelatesttag points to the newest release. A pinned tag keeps the toolchain the same for each run. - Build every package and its local dependencies. A repository can hold several packages. A
.package(path: "../Units")dependency resolves only when the sibling directory is in the build. A package that needs Apple SDKs, such as the package of the iOS app itself, stays out of the Linux build. - Run the XCTest and Swift Testing tests.
swift testruns both. The Swift Testing README says that XCTest tests run “side-by-side” with Swift Testing tests, and it lists Linux as a supported platform. - Run one test or a few without the whole suite.
swift test --filtertakes a regular expression in the format<test-target>.<test-case>/<test>.swift test listprints the identifiers that the filter matches. - Keep macOS build products out of the Linux build. SwiftPM writes build products to
.buildby default, as its--scratch-pathoption says. A.builddirectory from a Mac is not usable on Linux.
Meet the requirements by hand
A common way is a CI job that runs in the swift image. GitHub Actions runs a job in a container when the job sets jobs.<job_id>.container. GitHub’s Swift guide uses the same swift build and swift test commands that you use locally. This workflow meets the first three requirements for one package:
jobs:
linux:
runs-on: ubuntu-latest
container: swift:6.4.1-noble
steps:
- uses: actions/checkout@v7
- run: swift build
working-directory: Packages/Signals
- run: swift test
working-directory: Packages/Signals
The checkout contains the whole repository, so ../Units resolves. Each additional package needs its own steps or a matrix entry.
To run the same build on a laptop, the Swift server guide mounts the source into the swift image with docker run. Mount the repository root, so that the local dependencies are present. In the container, add --scratch-path so that SwiftPM writes its Linux products outside the Mac’s .build directory. To run one test, list the identifiers, then filter by one of them:
swift test list --scratch-path /tmp/build
swift test --scratch-path /tmp/build --filter SignalsTests.averageOfThree
SignalsTests.AverageXCTests/testAverageOfEmptyIsNil
SignalsTests.averageOfThree()
The first identifier is an XCTest method, and the second is a Swift Testing function.
New packages and Swift releases need manual edits
The hand-built version works, but some parts get hard to keep correct as the repository grows:
- Pin the Swift toolchain. The image tag is in the workflow, in each local
docker runcommand, and in any scripts. All of them must change together. - Build every package and its local dependencies. A new package needs a new step or matrix entry. Nothing tells you when one is missing.
- Run one test or a few.
--filteris a regular expression that matches part of an identifier. A short pattern can also select other tests whose names contain it, unless you escape and anchor it. - Keep macOS build products out. Each local command needs the
--scratch-pathflag. The CI checkout has no.builddirectory, so the local and CI runs differ.
Meet the requirements with the Dagger swift module
The swift module is a Dagger module: a package of functions that build and test a project. It runs SwiftPM in Linux containers. Its source and README are at github.com/dagger/apple. Its builds and tests are checks, which are functions that pass or fail, and dagger check runs them.
From the repository root, install it:
dagger module install dagger.io/apple/swift
Created workspace config in /work
Installed module "swift" in /work/dagger.toml
The command creates dagger.toml, the workspace configuration file, which records the module and its settings. It also creates dagger.lock, which pins the Git ref of the module. Commit both files.
Pin the Swift toolchain
The baseImageAddress setting is the swift image that runs each build and test. Its default is swift:6.0-noble. Set it to the tag that your team uses:
dagger module settings swift baseImageAddress swift:6.4.1-noble
This is the same official image as the hand-built job. The tag is only in dagger.toml, so local runs and CI runs use the same toolchain. If a package needs more system libraries, give the base setting a prepared container with Swift installed. base overrides baseImageAddress.
Build every package and its local dependencies
The module finds each directory that contains a Package.swift file. Each package becomes an item in a collection, a set of items that the module finds at run time and that dagger check selects by key. List the packages:
dagger list swift-packages -a
MODULE SWIFT-PACKAGE DESCRIPTION
swift App/SignalsApp The package at a workspace-relative root.
swift Packages/Signals The package at a workspace-relative root.
swift Packages/Units The package at a workspace-relative root.
Each package gets a build check, which runs swift build. A new package gets its check with no change to the configuration. Discovery skips gitignored paths, and also .build, node_modules, and DerivedData directories.
App/SignalsApp is the package of the iOS app, and it imports UIKit. Its build check fails on Linux with error: no such module 'UIKit'. The packageExclude setting removes packages from discovery. It takes package directories or globs, from the workspace root:
dagger module settings swift packageExclude 'App/**'
dagger list swift-packages -a
MODULE SWIFT-PACKAGE DESCRIPTION
swift Packages/Signals The package at a workspace-relative root.
swift Packages/Units The package at a workspace-relative root.
The packageInclude setting works the other way: it keeps only the packages that match. With packageInclude set to Packages/**, the module finds the same two packages. If you set both, an exclusion wins over an inclusion. The two settings need version v0.1.1 or newer of the module. Keep the packages that need Apple SDKs in a macOS build with Xcode.
Each package runs in its own container with its own directory. A package with a local dependency, such as Packages/Signals, also needs its sibling package. The includeExtraFiles setting takes patterns, from the workspace root, for local dependencies and test fixtures. The module mounts these files at their original paths, so .package(path: "../Units") resolves as it does on your machine:
dagger module settings swift includeExtraFiles 'Packages/Units/**'
Include every sibling package in the dependency graph. To add more patterns, edit the list in dagger.toml.
Run the XCTest and Swift Testing tests
The module runs swift test list in each package. It adds one test check for each test function, from XCTest and from Swift Testing. List every check:
dagger check -l --all
MODULE SWIFT-PACKAGE SWIFT-TEST CHECK DESCRIPTION
swift Packages/Signals build Compile this package for Linux with swift build.
swift Packages/Units build Compile this package for Linux with swift build.
swift Packages/Signals SignalsTests.AverageXCTests/testAverageOfEmptyIsNil test Run this test, or all cases of this parameterized test function.
swift Packages/Signals SignalsTests.averageOfThree() test Run this test, or all cases of this parameterized test function.
swift Packages/Units UnitsTests.roundsToTwoPlaces() test Run this test, or all cases of this parameterized test function.
The test identifiers are the same ones that swift test list prints. Run every check:
dagger check
CHECKS ✔ 3 passed
✔ dag://?check=swift/packages/build 7.0s OK
✔ dag://?check=test&swift-package=Packages/Signals 2.6s OK
✔ dag://?check=test&swift-package=Packages/Units 2.4s OK
The module runs the selected tests of a package together in one swift test command. When a check fails, the report shows the last 30 lines of output. The full output is in the Dagger trace.
The testFlags setting adds arguments to swift test. Put -- before a value that starts with a dash:
dagger module settings swift testFlags -- --parallel
The buildFlags setting adds arguments to swift build in the same way. The two settings are independent. For a release configuration in both, set --configuration release in each one.
Run one test or a few
Select a package with --swift-package. Select a test by its SwiftPM identifier with --swift-test:
dagger check --swift-package=Packages/Signals --swift-test='SignalsTests.averageOfThree()'
CHECKS ✔ 1 passed
✔ dag://?check=test&swift-package=Packages/Signals&swift-test=SignalsTests.averageOfThree() 4.2s OK
Repeat --swift-test to run several tests. The module escapes and anchors each identifier, so a test with a similar name does not also run. A selection of tests replaces a --filter in testFlags, but a --skip still applies. To run all the tests of one package, use dagger check --swift-package=Packages/Signals --check test. To run only the builds, use dagger check --check build.
Keep macOS build products out of the Linux build
The module does not upload the .build directory from your machine, so macOS products never get into the Linux build. It also leaves out .git, xcuserdata, and the files that .gitignore matches. You do not need a --scratch-path flag in any command.
Run the same checks locally and in CI
dagger check runs the same checks on a laptop and in CI. In this guide’s project, the first dagger check took 8.1 seconds, and a rerun with no changes took 1.5 seconds. The rerun used the cached results of the first run. These numbers come from one run on one machine.
For CI, use Cloud Checks. Cloud Checks run the workspace’s checks on Dagger’s Cloud Engines after each push and pull request, with no CI workflow file. The repository must be on GitHub. From the repository root, turn them on:
dagger cloud checks on
Commit dagger.toml and dagger.lock, and push. Each push then runs the same build and test checks that dagger check runs locally.
Bottom line: Build and test Swift packages on Linux with swift build and swift test in a pinned swift image. Include the local dependencies, and keep the Mac’s .build directory out. The Dagger swift module does this for every package in the repository, with one check per test, and runs the same checks locally and in Cloud Checks. Code that imports UIKit, SwiftUI, or other Apple SDKs, and the iOS app itself, still need Xcode on macOS. Leave those packages out of the Linux checks with packageExclude.
Related guides
- How do I set up CI for an Android app?: the same approach for an Android app, with the Android module.
- How do I cache Rust builds in CI?: how caching works for another compiled language in CI.
- How do I migrate from GitHub Actions to Dagger?: how to move workflow jobs, such as a Swift container job, to Dagger.