How Do I Cache Swift Package Manager Dependencies?

Cache .build and the shared SwiftPM directory with a key from hashFiles on Package.resolved, on the same macOS job that runs swift build. YAML and costs.

Cache the resolved package directory, .build, together with the shared SwiftPM cache at ~/Library/Caches/org.swift.swiftpm, and key both on hashFiles('Package.resolved') so the entry is invalidated exactly when the pinned dependency set changes. Keep that cache step on the same macOS job that runs swift build, because a cache entry written on macOS cannot be restored on a Linux runner.

Answer

Every GitHub Actions runner is ephemeral, so .build starts empty on each run and Swift Package Manager re-clones every dependency before the first line compiles. A cache step puts the resolved graph back.

Three paths carry the state worth restoring, and the WarpBuild caching documentation covers the action inputs that move them:

PathWhat it holdsCache it
.build/checkoutsWorking copies of each source dependency at the revision pinned in Package.resolvedYes
.build/artifactsBinary target archives, such as xcframeworks pulled during resolutionYes
.build/debug, .build/releaseCompiled objects, module files, and the SwiftPM build databaseOptional
~/Library/Caches/org.swift.swiftpmSwiftPM's shared repository clones and parsed manifests, reused across packages on the machineYes
~/Library/Developer/Xcode/DerivedData/*/SourcePackagesPackage checkouts when xcodebuild drives resolution rather than swift buildYes for xcodebuild

Caching .build wholesale is the simplest correct answer for a package built with swift build. The compiled subdirectories are the part to treat with suspicion: SwiftPM records absolute paths in its build database and invalidates incremental state when the compiler version changes, so a restored .build behaves as a dependency cache first and an incremental build second.

The key is built from the lockfile:

key: spm-macos15-${{ steps.toolchain.outputs.id }}-${{ hashFiles('Package.resolved') }}
restore-keys: |
  spm-macos15-${{ steps.toolchain.outputs.id }}-

hashFiles is a GitHub Actions expression function that hashes the matched files and returns a single digest (GitHub expressions reference). Package.resolved changes when a dependency is added, removed, or moved to a new revision, which is the moment the cache should turn over. The restore-keys prefix keeps the previous entry useful when one dependency changes and the other forty do not.

One boundary decides which action goes in the workflow. The WarpBuild cache is enabled by default on the Linux runners. A Swift job on a macOS label therefore uses actions/cache@v4 for this path, and the Linux half of a cross-platform matrix can use WarpBuilds/cache@v1, which accepts the same path, key, and restore-keys inputs. Toolchain-level caching on Linux is also covered by the cache-enabled forks listed in the setup actions documentation.

Detail

Where Package.resolved lives

For a standalone package the file sits at the package root, next to Package.swift. When an Xcode project or workspace owns the dependency graph, the resolved file moves inside the container: MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved for a project, and MyApp.xcworkspace/xcshareddata/swiftpm/Package.resolved for a workspace. Point hashFiles at the path that actually exists in the repository, because a glob that matches nothing returns an empty hash and produces one key that never turns over.

A workflow with restore, build, and save

name: swift-package

on:
  push:
    branches: [main]
  pull_request:

jobs:
  build:
    runs-on: warp-macos-15-arm64-6x
    steps:
      - uses: actions/checkout@v5

      - name: Record toolchain
        id: toolchain
        run: |
          swift --version
          echo "id=$(swift --version | head -n 1 | shasum | cut -c1-8)" >> "$GITHUB_OUTPUT"

      - name: Restore SwiftPM cache
        id: spm
        uses: actions/cache/restore@v4
        with:
          path: |
            .build
            ~/Library/Caches/org.swift.swiftpm
          key: spm-macos15-${{ steps.toolchain.outputs.id }}-${{ hashFiles('Package.resolved') }}
          restore-keys: |
            spm-macos15-${{ steps.toolchain.outputs.id }}-

      - name: Resolve dependencies
        run: swift package resolve

      - name: Fail on lockfile drift
        run: git diff --exit-code Package.resolved

      - name: Build
        run: swift build --build-tests

      - name: Save SwiftPM cache
        if: steps.spm.outputs.cache-hit != 'true'
        uses: actions/cache/save@v4
        with:
          path: |
            .build
            ~/Library/Caches/org.swift.swiftpm
          key: ${{ steps.spm.outputs.cache-primary-key }}

      - name: Test
        run: swift test --skip-build --parallel

The order matters in three places. The restore runs before swift package resolve, so resolution finds the checkouts already on disk and exits in seconds instead of cloning. The save runs after the build and before the tests, so a red test suite still leaves a warm entry for the next run. And the save is guarded on cache-hit != 'true', because cache entries are immutable: writing to a key that already exists fails with a warning rather than replacing the entry.

swift package resolve is a separate step so resolution time appears as its own line in the timing view, which is how you tell a slow restore from a slow clone. The git diff --exit-code Package.resolved check fails the job when resolution moved a dependency the committed lockfile did not expect.

What the key should carry

Three components belong in the key, and each prevents a specific wrong restore.

The Package.resolved hash handles dependency changes. The runner label or macOS version handles machine shape: SwiftPM records absolute paths, and the path layout differs between images. The toolchain fingerprint handles Xcode upgrades, since the Swift compiler on a macOS runner is whichever compiler the selected Xcode carries, and artifacts built by one compiler are rejected by the next. Without the third component, the first run after an image update restores objects the new compiler discards, and you pay the download for nothing.

What the cache costs

Cache is billed separately from runner minutes, at $0.20 per GB-month for storage and $0.0001 per operation, both from the WarpBuild pricing page, checked on 2026-08-13. A worked month for a package with 600 runs, a 3 GB entry, and two cache operations per run:

LineAmount
Cache storage, 3 GB at $0.20 per GB-month$0.60
Cache operations, 1,200 at $0.0001$0.12
Monthly cache bill$0.72
Minutes of resolution and dependency compilation skipped per warm run3
Runner minutes those 600 runs no longer bill at $0.08 per minute$144.00

The $0.08 rate is the warp-macos-15-arm64-6x label. The 12 vCPU label is $0.16 per minute, where the same 1,800 minutes bill $288.00. Restoring a 3 GB entry takes wall clock time of its own, so measure the restore step against the resolution step it replaces before caching the compiled subdirectories alongside the checkouts.

The full macOS label list with sizes and rates is on the WarpBuild macOS runners page.

Limits inherited from GitHub cache

On the GitHub cache backend, three rules shape the hit rate. All caches in a repository share a 10 GB ceiling by default and entries are evicted least recently used first; any entry unused for 7 days is deleted regardless of the total; and a run can restore entries from its own branch or its base branch, so sibling feature branches cannot read each other's copies (GitHub dependency caching reference). A 3 GB SwiftPM entry per active branch reaches the ceiling quickly, which is why the pattern above writes from the default branch and lets pull requests reach it through restore-keys.

Snapshot runners cover the Ubuntu part of the catalog, so on the macOS side of a Swift pipeline the cache step is the mechanism that carries state between runs.

Which paths should I cache for Swift Package Manager on a macOS runner?

Cache the package's .build directory, which holds the dependency checkouts, downloaded binary artifacts, and the build database, together with the shared SwiftPM directory at ~/Library/Caches/org.swift.swiftpm. When xcodebuild drives resolution instead of swift build, add the SourcePackages directory under DerivedData or pin it with -clonedSourcePackagesDirPath, which is covered in the Xcode DerivedData cache guide.

What should the SwiftPM cache key contain?

A hash of Package.resolved, the runner label or macOS version, and a fingerprint of the Swift toolchain. Package.resolved changes exactly when the pinned dependency set changes, and the other two components stop a cache built by one toolchain or one machine shape from being restored into another. The Swift package builds page shows the same key shape inside a full pipeline with sizing and cost models.

Can a macOS job restore a SwiftPM cache saved by a Linux job?

No. Cache entries carry a version derived from the compression tool and the cached paths, so an entry written on a macOS runner does not match on an Ubuntu runner, as the WarpBuild caching documentation records for the same pair of labels. Keep the SwiftPM cache on the job that consumes it and give the Linux half of a matrix its own key and its own paths.

What about CocoaPods in the same repository?

Pods are resolved and cached on a different pair of paths and keyed on Podfile.lock rather than Package.resolved, so the two caches stay separate even inside one job. The CocoaPods caching answer covers the paths and keys, and the macOS runner catalog lists the labels both workflows run on.

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.