Hive
SwifterPM: stale package-info cache after the native cold path → Default source path not found for target …
GitHub issue · Open
What happened?
After a dependency changes version, tuist hash cache (and any other graph load) can use the package description of the previously installed version for the checkout that is now on disk. It fails when the two versions have different targets:
Default source path not found for target IssueReporting in package at …/Tuist/.build/checkouts/xctest-dynamic-overlay. Source path must be one of ["Sources/IssueReporting", "Source/IssueReporting", "src/IssueReporting", "srcs/IssueReporting"]
Package.resolved, workspace-state.json and the checkout all have the right version; only .build/swifterpm/package-info is stale. Touching the checkout’s Package.swift makes the same command pass.
When the targets happen to match, nothing fails: the graph is just built from the wrong description (products, dependencies and settings of another version), so binary-cache hashes can be wrong too.
Expected: after tuist install, the graph loader uses the description of the version that is checked out.
We hit this in CI, in a job that hashes several git refs in one checkout (git checkout <ref> && tuist install && tuist hash cache per ref). Its committed Package.resolved named a pin that wasn’t in the SwifterPM store yet, which is what sends tuist install down the native path described below.
Cause
- The graph loader takes a package’s description from
package-infoby checkout path and accepts it when the cached file is not older than the checkout’sPackage.swift(isFreshPackageInfo, lookup). The revision recorded in the index is never compared withworkspace-state.json. - When some pin is missing from the store, SwifterPM resolves through native SwiftPM (
shouldUseNativeColdPath) and returns beforePackageInfoCacheWriter.write, sopackage-infokeeps the previous version’s description. - On that path,
cacheNativeSourceCheckoutsreplaces SwiftPM’s fresh checkout with the store’s copy whenever the store already holds that revision. The copy keeps the store’s original mtime, which is older than the cached description, so step 1 treats the stale entry as fresh.
Possible fixes: compare the cached entry’s revision with workspace-state.json in the loader, and/or rewrite (or delete) package-info at the end of the native cold path.
How do we reproduce it?
Self-contained script: it creates a one-target project and fresh Tuist/SwifterPM caches in a temp directory, then installs xctest-dynamic-overlay 1.1.2 → 1.11.0 → 1.1.2. Runs in ~15 s.
The rm Tuist/Package.resolved before the last install isn’t required for the bug. It is just the shortest way to make SwifterPM take the native cold path, which it also takes whenever Package.resolved pins a package that isn’t in the store yet (that’s how we hit it in CI).
TUIST=/path/to/tuist ./repro.sh
#!/bin/bash
# Usage: TUIST=/path/to/tuist ./repro.sh
#
# Self-contained: creates a one-target Tuist project and fresh Tuist/SwifterPM caches in a temp
# directory, then switches its only dependency between two versions on the same Tuist/.build:
#
# 1. xctest-dynamic-overlay 1.1.2, cold: native SwiftPM resolves, the checkout lands in the store.
# 2. xctest-dynamic-overlay 1.11.0: SwifterPM resolves and writes .build/swifterpm/package-info,
# which now maps .build/checkouts/xctest-dynamic-overlay to the 1.11.0 package description.
# 3. xctest-dynamic-overlay 1.1.2 without a Package.resolved, so SwifterPM takes the native cold
# path again. It replaces SwiftPM's checkout with the store's copy from step 1, whose
# Package.swift is older than the step 2 package-info, and does not rewrite package-info.
# `tuist hash cache` then loads the 1.11.0 description for the 1.1.2 checkout and fails:
# 1.11.0 has an IssueReporting target that 1.1.2 lacks.
set -uo pipefail
TUIST=${TUIST:-tuist}
WORK=$(mktemp -d)
PROJECT=$WORK/project
echo "work dir: $WORK"
# Copied checkouts and store directories are no git checkouts, so SwiftPM's "uncommitted changes"
# check walks up to the enclosing repository. Steps 2 and 3 edit Tuist/Package.swift, so inside a
# git repository that check fails first.
if git -C "$WORK" rev-parse --git-dir >/dev/null 2>&1; then
echo "$WORK is inside a git repository; point TMPDIR elsewhere"
exit 1
fi
export XDG_CACHE_HOME=$WORK/cache XDG_STATE_HOME=$WORK/state XDG_DATA_HOME=$WORK/data
mkdir -p "$PROJECT/Tuist" "$PROJECT/Sources/App"
cd "$PROJECT" || exit 1
cat > Tuist.swift <<'EOF'
import ProjectDescription
let tuist = Tuist(project: .tuist())
EOF
cat > Project.swift <<'EOF'
import ProjectDescription
let project = Project(
name: "Repro",
targets: [
.target(
name: "App",
destinations: .iOS,
product: .framework,
bundleId: "dev.tuist.repro.app",
sources: ["Sources/App/**"],
dependencies: [.external(name: "XCTestDynamicOverlay")]
),
]
)
EOF
cat > Sources/App/App.swift <<'EOF'
import XCTestDynamicOverlay
public func fail() {
XCTFail("repro")
}
EOF
write_package_swift() {
cat > Tuist/Package.swift <<EOF
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "Repro",
dependencies: [
.package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", exact: "$1"),
]
)
EOF
}
step() {
echo "=== xctest-dynamic-overlay $1"
write_package_swift "$1"
"$TUIST" install || { echo "install failed"; exit 1; }
if "$TUIST" hash cache > /dev/null; then echo "hash cache: OK"; else echo "hash cache: FAILED"; fi
}
"$TUIST" version
step 1.1.2
step 1.11.0
rm Tuist/Package.resolved
step 1.1.2
Output on 4.212.0, trimmed:
=== xctest-dynamic-overlay 1.1.2
hash cache: OK
=== xctest-dynamic-overlay 1.11.0
…
cached package manifest JSON into …/Tuist/.build/swifterpm/package-info
hash cache: OK
=== xctest-dynamic-overlay 1.1.2
Creating working copy for https://github.com/pointfreeco/xctest-dynamic-overlay
Working copy of https://github.com/pointfreeco/xctest-dynamic-overlay resolved at 1.1.2
✔ Success
✖ Error
Default source path not found for target IssueReporting in package at …/Tuist/.build/checkouts/xctest-dynamic-overlay. …
hash cache: FAILED
| Tuist | Result |
|---|---|
| 4.206.0 | ✅ passes; every install ends with cached package manifest JSON into …/package-info |
| 4.207.0 – 4.210.0 | ❌ fails one step earlier, in tuist install (Permission denied on .git/logs/HEAD, #13457) |
| 4.211.0, 4.212.0, 4.213.0-rc.1, 4.213.0-canary.31 | ❌ Default source path not found (this issue) |
So it most likely came with the native cold path in #12661 (4.207.0), and has been reachable since #13528 (4.211.0) fixed #13457.
Error log
✖ Error
Default source path not found for target IssueReporting in package at /private/var/folders/mm/1g4_22v926g2_xv6cyd3l5tc0000gn/T/tmp.3rgjp5QNen/project/Tuist/.build/checkouts/xctest-dynamic-overlay. Source path must be one of ["Sources/IssueReporting", "Source/IssueReporting", "src/IssueReporting", "srcs/IssueReporting"]
Sorry this didn’t work. Here’s what to try next:
▸ If the error is actionable, address it
▸ If the error is not actionable, let's discuss it in the <Troubleshooting & how to: https://community.tuist.dev/c/troubleshooting-how-to/6>
▸ If you are very certain it's a bug, <file an issue: https://github.com/tuist/tuist>
▸ Check out the logs at /var/folders/mm/1g4_22v926g2_xv6cyd3l5tc0000gn/T/tmp.3rgjp5QNen/state/tuist/sessions/FC13C523-ED84-4172-A8C8-671E1F07BC09/logs.txt
macOS version
26.7.1
Tuist version
4.212.0 (also 4.211.0, 4.213.0-rc.1, 4.213.0-canary.31)
Xcode version
26.6
Investigate, reproduce, or fix this item in an isolated repository. Each Flight preserves its outcome and agent session.
Start a Flight and preserve its objective, outcome, and session here.
No GitHub comments yet.