Hive
SwifterPM reuses stale resolved pins when a local package changes dependencies based on the environment
GitHub issue · Open
What happened?
With a warm SwifterPM source cache, tuist install can reuse a Package.resolved that no longer matches a local package’s environment-dependent dependencies. Installation reports success, but tuist generate then fails because the newly enabled product was never resolved.
This is related to #12130, fixed by #12144 and backported in #12157, but occurs at a different validation step. In this reproduction, the local package’s cached manifest is refreshed correctly and declares the new dependency; its .envhash sidecar exists. The resolved pins still describe the previous graph.
I reproduced this with Tuist 4.209.0 using the standalone sample below. The original failure also occurs with 4.206.0 in OpenSwiftUI’s Example project when enabling its Compute source backend.
How do we reproduce it?
The script below creates a complete sample in a temporary directory and uses an isolated XDG_CACHE_HOME. It downloads two public packages and does not compile application or library targets.
The graph is:
App/Tuist/Package.swiftalways depends on localFeatureandswift-numerics 1.1.1.Feature/Package.swiftaddsswift-collections 1.5.0and theCollectionsproduct only whenREPRO_EXTRA=1.
Steps performed by the script:
- Install twice with
REPRO_EXTRA=0to populate the source cache and exercise the warm path. Generate successfully. - Change only
REPRO_EXTRAto1; neither manifest file changes. - Run
tuist installwith SwifterPM. It reports success but still restores onlyswift-numerics. - Run
tuist generate. It fails with`Collections` is not a valid configured external dependency. - Run install and generation with
TUIST_USE_SWIFTERPM=0. Both succeed and the generated graph includesswift-collections.
Save the following as reproduce.sh, then run it with the Tuist version to test:
TUIST_BIN="$(command -v tuist)" bash reproduce.sh
#!/usr/bin/env bash
set -euo pipefail
: "${TUIST_BIN:?Set TUIST_BIN to the Tuist executable}"
REPRO_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tuist-lockfile-repro.XXXXXX")
export XDG_CACHE_HOME="$REPRO_DIR/cache"
mkdir -p "$REPRO_DIR/App/Tuist" "$REPRO_DIR/App/Sources" "$REPRO_DIR/Feature/Sources/Feature"
printf 'Reproduction directory: %s\n' "$REPRO_DIR"
cat > "$REPRO_DIR/App/Tuist.swift" <<'SWIFT'
import ProjectDescription
let tuist = Tuist(project: .tuist(
generationOptions: .options(optionalAuthentication: true, enableCaching: false),
cacheOptions: .options(storages: [.local])
))
SWIFT
cat > "$REPRO_DIR/App/Tuist/Package.swift" <<'SWIFT'
// swift-tools-version: 6.0
import PackageDescription
let package = Package(name: "Dependencies", dependencies: [
.package(path: "../../Feature"),
.package(url: "https://github.com/apple/swift-numerics", exact: "1.1.1"),
])
SWIFT
cat > "$REPRO_DIR/App/Project.swift" <<'SWIFT'
import ProjectDescription
let project = Project(name: "App", targets: [
.target(name: "App", destinations: [.mac], product: .staticFramework,
bundleId: "org.example.lockfile-repro", deploymentTargets: .macOS("13.0"),
infoPlist: .default, sources: ["Sources/**"],
dependencies: [.external(name: "Feature")]),
])
SWIFT
cat > "$REPRO_DIR/Feature/Package.swift" <<'SWIFT'
// swift-tools-version: 6.0
import PackageDescription
let extra = Context.environment["REPRO_EXTRA"] == "1"
let package = Package(
name: "Feature",
products: [.library(name: "Feature", targets: ["Feature"])],
dependencies: extra ? [
.package(url: "https://github.com/apple/swift-collections", exact: "1.5.0"),
] : [],
targets: [.target(name: "Feature", dependencies: extra ? [
.product(name: "Collections", package: "swift-collections"),
] : [])]
)
SWIFT
printf 'public struct App {}\n' > "$REPRO_DIR/App/Sources/App.swift"
printf 'public struct Feature {}\n' > "$REPRO_DIR/Feature/Sources/Feature/Feature.swift"
cd "$REPRO_DIR/App"
# The first install fills the isolated source cache; the second takes the warm path.
REPRO_EXTRA=0 TUIST_USE_SWIFTERPM=1 "$TUIST_BIN" install > "$REPRO_DIR/seed.log" 2>&1
REPRO_EXTRA=0 TUIST_USE_SWIFTERPM=1 "$TUIST_BIN" install > "$REPRO_DIR/warm.log" 2>&1
REPRO_EXTRA=0 TUIST_USE_SWIFTERPM=1 "$TUIST_BIN" generate --no-open > "$REPRO_DIR/baseline.log" 2>&1
cp Tuist/Package.resolved "$REPRO_DIR/baseline.resolved"
# No manifest file changes. Only the local package's environment changes.
REPRO_EXTRA=1 TUIST_USE_SWIFTERPM=1 "$TUIST_BIN" install > "$REPRO_DIR/changed-install.log" 2>&1
cp Tuist/Package.resolved "$REPRO_DIR/changed.resolved"
if REPRO_EXTRA=1 TUIST_USE_SWIFTERPM=1 "$TUIST_BIN" generate --no-open > "$REPRO_DIR/changed-generate.log" 2>&1; then
printf 'The changed graph generated successfully; the bug did not reproduce.\n'
exit 1
fi
if ! grep -Fq 'is not a valid configured external dependency' "$REPRO_DIR/changed-generate.log"; then
cat "$REPRO_DIR/changed-generate.log"
exit 1
fi
printf '\nObserved generation failure:\n'
cat "$REPRO_DIR/changed-generate.log"
# Resolve the same graph with native SwiftPM and retry generation.
REPRO_EXTRA=1 TUIST_USE_SWIFTERPM=0 "$TUIST_BIN" install > "$REPRO_DIR/native-install.log" 2>&1
REPRO_EXTRA=1 TUIST_USE_SWIFTERPM=0 "$TUIST_BIN" generate --no-open > "$REPRO_DIR/native-generate.log" 2>&1
printf '\nReproduced: SwifterPM kept stale pins; native SwiftPM generated the graph successfully.\n'
Observed evidence
After step 3:
Package.resolvedis identical to the baseline and contains onlyswift-numerics.- Its
originHashstill matches the unchanged rootPackage.swift. Feature’s manifest in.build/swifterpm/package-info/packages/correctly declaresswift-collectionsand has an.envhashsidecar.- The package-info index does not contain
swift-collections.
The manifest cache has therefore picked up the environment change, but dependency resolution has not.
Expected behavior
tuist install should include the newly required package in the resolved graph before reporting success, even when the root Package.swift file has not changed.
Suspected cause
In 4.209.0, resolveOrLoad returns the existing pins when ResolvedFile.readIfCurrent accepts the lockfile. That check compares originHash with the root Package.swift bytes, without checking the changed dependency graph from local manifests.
The environment-aware manifest cache added by #12144 does not invalidate this separate lockfile fast path. A cold source cache can hide the problem because that path delegates to native SwiftPM; the sample explicitly warms the cache first.
Error log
The changed-environment install completes successfully:
swift-numerics 1.1.1 0c0290ff6b24942dadb83a929ffaaa1481df04a2 https://github.com/apple/swift-numerics
restored 1 source-control packages into <sample>/App/Tuist/.build/checkouts
cached package manifest JSON into <sample>/App/Tuist/.build/swifterpm/package-info
✔ Success
Plugins resolved and fetched successfully.
Generation then fails:
Loading and constructing the graph
It might take a while if the cache is empty
✖ Error
`Collections` is not a valid configured external dependency
Workaround
Use native SwiftPM for both commands under the intended environment:
REPRO_EXTRA=1 TUIST_USE_SWIFTERPM=0 tuist install
REPRO_EXTRA=1 TUIST_USE_SWIFTERPM=0 tuist generate --no-open
This succeeds in the sample and in the original OpenSwiftUI project.
macOS version
26.6.2 (25G83), Apple Silicon
Tuist version
4.209.0 (standalone reproduction); 4.206.0 (original project)
Xcode version
26.6 (17F113), Apple Swift 6.3.3
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.