How do you make Swift Package dependencies reproducible on CI: what Package.resolved pins, when versions move, and what the force-resolved flag does?
Predict first
A dependency is tagged 1.1.0 after the lockfile pinned 1.0.0. Which commands move it, and what does force-resolved do after a manifest change?
// === run.sh ===
export GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@x GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@x GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null
W=$PWD; mkdir -p Lib/Sources/Lib App/Sources/App
cat > Lib/Package.swift <<'EOT'
// swift-tools-version: 6.0
import PackageDescription
let package = Package(name: "Lib", products: [.library(name: "Lib", targets: ["Lib"])], targets: [.target(name: "Lib")])
EOT
echo 'public let v = 1' > Lib/Sources/Lib/Lib.swift
(cd Lib && git init -q -b main && git add . && git commit -qm 1 && git tag 1.0.0)
cat > App/Package.swift <<EOT
// swift-tools-version: 6.0
import PackageDescription
let package = Package(name: "App", dependencies: [.package(url: "file://$W/Lib", from: "1.0.0")],
targets: [.executableTarget(name: "App", dependencies: [.product(name: "Lib", package: "Lib")])])
EOT
echo 'import Lib; print(v)' > App/Sources/App/main.swift
ver() { grep '"version" :' App/Package.resolved | head -1 | tr -d ' '; }
(cd App && swift package resolve >/dev/null 2>&1); echo "first resolve: $(ver)"
(cd Lib && echo 'public let v = 2' > Sources/Lib/Lib.swift && git commit -qam 2 && git tag 1.1.0)
(cd App && swift package resolve >/dev/null 2>&1); echo "after upstream 1.1.0: $(ver)"
(cd App && swift package update >/dev/null 2>&1); echo "after update: $(ver)"
cp App/Package.resolved saved.resolved; rm App/Package.resolved
(cd App && swift package resolve >/dev/null 2>&1); echo "no resolved file: $(ver)"
cp saved.resolved App/Package.resolved
sed -i '' 's/from: "1.0.0"/exact: "1.0.0"/' App/Package.swift
(cd App && swift package --force-resolved-versions resolve 2>&1 | grep -o "out-of-date resolved file was detected" | head -1)
- resolve keeps 1.0.0, update moves to 1.1.0, no lockfile gives 1.1.0, and force-resolved fails on a stale lockfile
- resolve always upgrades to the newest version
- force-resolved accepts the stale lockfile
Mental model. The version range is a shopping list (“some flour”); the lockfile is the receipt (“1 kg of this brand, this batch”). CI should cook from the receipt.
A package dependency like from: "1.0.0" is a range; Package.resolved records the exact version (and revision) chosen. The check shows the rules with a local repository: after the first resolve the file says 1.0.0; when upstream later tags 1.1.0, a plain swift package resolve keeps 1.0.0, only swift package update moves to 1.1.0, and with the lockfile deleted a fresh resolve takes the newest allowed version (1.1.0). So commit the file for an app (a library does not impose pins on its clients), and updating dependencies becomes a reviewed change instead of something that happens when the CI cache is cold.
On CI add the guard: --force-resolved-versions (also spelled --disable-automatic-resolution or --only-use-versions-from-resolved-file; in xcodebuild use -disableAutomaticPackageResolution and -clonedSourcePackagesDirPath to control where the checkouts go). If the manifest and the lockfile disagree the build fails with “an out-of-date resolved file was detected”, as in the check, which tells you someone changed a requirement without committing the lockfile. Tags can be moved or deleted by an upstream owner, so the file also records the commit; for stronger guarantees mirror or vendor critical dependencies, and pin binary targets by checksum. Cache the checkout directory with a key derived from the lockfile hash and treat the cache as disposable (Q482). With Xcode projects the file lives inside the project or workspace (…/xcshareddata/swiftpm/Package.resolved); make sure it is not git-ignored.
The lockfile wins until you update it; the flag turns a silent re-resolve into an error.
Common traps
- Ignoring Package.resolved in version control. Every clean checkout may pick different versions and a green build turns red with no code change.
- Updating the manifest without committing the lockfile. With force-resolved enabled CI fails; without it CI quietly resolves something new.
They might follow up with
- Does a library commit its Package.resolved? It can for its own CI, but clients ignore it and resolve from the manifest.
- What about CocoaPods or Carthage? The same idea:
Podfile.lockandCartfile.resolvedpin what the manifest allows (Q365).
Related questions
- 16.2.1 How do Swift Package Manager, CocoaPods, Carthage and XCFrameworks compare, and how do you choose?
- 16.2.2 What is an XCFramework, what do module maps and library evolution have to do with it, and when do you use a…
- 25.2.3 How do you design a release pipeline: hermetic builds, secrets, caching, artefact promotion and the order of…
- 15.5.2 How do you evolve a public API without breaking callers: deprecation, renames, versioning and source versus…
← How do you inspect the binary you are about to ship: the minimum OS… How do build-for-testing and test-without-building split a test run… →