GitHub Actions for building Swift packages on Linux with proper incremental compilation caching.
SwiftPM's build engine (llbuild) uses file modification times to detect changes. actions/checkout sets all timestamps to the current time, making every CI run look like a full source change — complete rebuild even with a cached .build directory.
This action normalizes file mtimes to deterministic content-based hashes. Same content → same mtime → llbuild skips unchanged modules → true incremental builds.
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: ltetzlaff/swift@v1That's it. Installs Swift, resolves dependencies, normalizes timestamps, and builds in release mode.
- uses: actions/checkout@v5
- uses: ltetzlaff/swift@v1
- run: swift test- uses: actions/checkout@v5
- uses: ltetzlaff/swift@v1
with:
product: MyServer- uses: actions/checkout@v5
- uses: ltetzlaff/swift@v1
with:
ssh-key: ${{ secrets.DEPLOY_SSH_KEY }}- uses: actions/checkout@v5
- uses: ltetzlaff/swift@v1
id: build
with:
product: MyServer
static-swift-stdlib: "true"
- run: cp "${{ steps.build.outputs.bin-path }}/MyServer" .
- uses: docker/setup-buildx-action@v3
- uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ vars.AWS_REGION }}
- uses: aws-actions/amazon-ecr-login@v2
id: ecr
- uses: docker/build-push-action@v6
with:
push: true
context: .
tags: ${{ steps.ecr.outputs.registry }}/my-server:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max| Input | Default | Description |
|---|---|---|
swift-version |
6.4.1 |
Swift toolchain version |
path |
. |
Path to the Swift package |
ssh-key |
(empty) | SSH private key for private dependency repos |
configuration |
release |
debug or release |
product |
(empty) | Specific product to build (all by default) |
static-swift-stdlib |
true |
Statically link the Swift standard library |
extra-swift-flags |
(empty) | Additional flags passed to swift build |
cache-key-prefix |
swiftpm |
Cache key prefix for the .build directory |
cache-suffix |
(empty) | Change to abandon existing caches and build cold |
jemalloc |
false |
Install and link jemalloc |
build |
true |
Set to false to skip build (e.g. for testing) |
fresh-root-package |
false |
Recompile the root package's own targets cold; dependencies stay cached |
locked-dependencies |
false |
Fail unless dependencies match the committed Package.resolved |
| Output | Description |
|---|---|
cache-hit |
Whether the .build cache was an exact match |
cache-recovered |
Whether a stale .build was discarded and rebuilt cold |
bin-path |
Absolute path to the build output directory |
-
Swift toolchain — cached by version + OS + arch
-
SwiftPM
.builddirectory — cached by Swift version +Package.resolvedhash + commit SHA. A cache from another toolchain is never restored: its modules cannot be loaded, and from Swift 6.4 SwiftPM builds with Swift Build into a different layout (.build/out). An earlier commit's cache is reused only when thePackage.resolvedhash matches: a.buildfrom a different dependency graph would link stale objects against freshly compiled dependencies, so a changed graph always builds cold. -
Mtime normalization — file timestamps are set to deterministic content-based hashes so llbuild sees unchanged files as unchanged. Stamps always land in the past (a fixed 40-year window before 2020) so that no source ever appears newer than the outputs built from it.
-
Stale-cache recovery — within one dependency graph the cache is still reused across commits, so a restored
.buildholds first-party objects older than the checkout. When a first-party module's ABI moves, llbuild does not reliably recompile every dependent, and the build dies at link time on a symbol that no longer exists. On that signature the action discards the cache, rebuilds cold once, and reportscache-recovered: true.This axis cannot be fixed by narrowing the cache key the way the dependency graph was. First-party sources change on nearly every commit, so keying on them would mean a cold build every time — no cache at all. Recovery keeps builds incremental and makes the rare stale link self-healing instead of fatal.
Recovery also matters because a failed run saves no cache. Without it, one stale entry stays the newest for its key and every later build restores it and fails identically, until someone deletes it through the API.
-
Fresh root package (opt-in) — recovery only catches stale objects that fail to link. A stale object can also link fine: when a type changes size or layout and the incremental build does not recompile a module that only stores or copies it, the binary builds green and misbehaves at runtime. With
fresh-root-package: "true"the action deletes the root package's own outputs from the restored.buildbefore building (in both SwiftPM's native layout and Swift Build's.build/out), so every first-party module compiles from scratch against the current sources. Dependency outputs are pinned byPackage.resolvedand stay cached, so this costs only the compile time of your own targets. Turn it on for builds you ship.
Only link-time and module-format failures are retried:
undefined reference to/Undefined symbols for architectureduplicate symbolerror: link command failedmodule file was created by a different version/is not a valid Swift module
Ordinary compile errors are never retried — they come from the source, so a second full build would just fail again and double the wait. A build that started cold is never retried either: there is no cache to blame.
The toolchain is only installed if its signature verifies against a Swift
release key vendored in keys/ and pinned by fingerprint.
All third-party actions are pinned to commit SHAs. See SECURITY.md
for what the action guarantees and how to report a vulnerability.
The reusable workflows refer to this action with uses: $/, so they always run
the action from the same commit as the workflow. On self-hosted runners that
needs runner 2.336.0 or newer.
MIT