Hive Hive
Sign in

SwifterPM reuses stale resolved pins when a local package changes dependencies based on the environment

GitHub issue · Open

Metadata
Source
tuist/tuist #13660
Updated
Sep 27, 2026
Details

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.swift always depends on local Feature and swift-numerics 1.1.1.
  • Feature/Package.swift adds swift-collections 1.5.0 and the Collections product only when REPRO_EXTRA=1.

Steps performed by the script:

  1. Install twice with REPRO_EXTRA=0 to populate the source cache and exercise the warm path. Generate successfully.
  2. Change only REPRO_EXTRA to 1; neither manifest file changes.
  3. Run tuist install with SwifterPM. It reports success but still restores only swift-numerics.
  4. Run tuist generate. It fails with `Collections` is not a valid configured external dependency.
  5. Run install and generation with TUIST_USE_SWIFTERPM=0. Both succeed and the generated graph includes swift-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.resolved is identical to the baseline and contains only swift-numerics.
  • Its originHash still matches the unchanged root Package.swift.
  • Feature’s manifest in .build/swifterpm/package-info/packages/ correctly declares swift-collections and has an .envhash sidecar.
  • 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

Flights

Investigate, reproduce, or fix this item in an isolated repository. Each Flight preserves its outcome and agent session.

New Flights are paused Configure model inference, GitHub, and a sandbox provider to start another Flight. Existing results remain available below.
No Flights yet

Start a Flight and preserve its objective, outcome, and session here.

Comments

No GitHub comments yet.