Summary
A target’s binary cache hash does not currently account for the set of products that get embedded into it (resource bundles and embedded frameworks). That set is an emergent property of the dependency closure, computed at generation time by GraphTraverser.resourceBundleDependencies / embeddableFrameworks, and it is not derivable from the per-dependency content hashes.
As a result, two builds whose embedding differs can share the same cache key while producing materially different XCFrameworks. This was observed in the wild as two cached artifacts under the same hash where one embedded SPM resource .bundles directly inside the framework and the other did not (the artifacts also linked a different set of frameworks).
This PR folds a deterministic, machine-independent identifier for the resolved embedded-product closure into TargetContentHasher, so a change in what a target embeds always changes that target’s cache key.
Root cause
The content hash is computed over the graph before the generation mappers run. It includes each dependency’s content hash (which already propagates product types, sources, settings, etc.), but it does not include the result of the embedding algorithm, i.e. which resource bundles and frameworks the target will actually copy in.
That result is a function of the whole dependency closure and of the generator’s own embedding logic. So:
- A configuration change (e.g. flipping an SPM product between static and dynamic) is already captured, because it changes a dependency’s
product.rawValue and propagates.
- A generator-behavior change on an otherwise-identical graph (e.g. the static-framework resource embedding revert tracked by
CacheVersion) is not captured by the dependency hashes, because no input changes, only the interpretation does. CacheVersion is the only existing guard for this, and it is a manual, global, all-or-nothing bump.
Hashing the resolved closure converts the embedding algorithm’s output into a hashed input, giving automatic and targeted invalidation: only targets whose embedded set actually changes are invalidated.
What changed
- New
GraphDependencyReference.hashIdentifier (in TuistHasher): a deterministic identifier per reference derived from product/bundle identity rather than absolute paths, so the hash is stable across machines and checkout locations while still changing when the embedded set changes. For frameworks/libraries it retains product + linking since that encodes the static/dynamic distinction.
GraphContentHasher computes resourceBundleDependencies ∪ embeddableFrameworks for each target via the traverser, maps to hashIdentifier, sorts, and passes the list to the target hasher.
TargetContentHasher folds the list into both the local and external hash branches, the debug log, and a new TargetContentHashSubhashes.embeddedProductReferences subhash. It is sorted for order-independence and omitted entirely when empty, so unrelated target hashes are unchanged.
Why this approach over the alternatives
- Just bumping
CacheVersion would invalidate the affected artifacts once, but leaves the gap in place and the bug recurs the next time a generator behavior changes without a bump. It also nukes the entire global cache rather than just the affected targets.
- Hashing the closure narrows the reliance on
CacheVersion for this class of change. CacheVersion remains the backstop for generator changes that alter output without changing this closure (signing, copy mechanics, other mappers).
Validation
swift build --target TuistHasher compiles cleanly.
TuistHasherTests runs green (24 tests, 5 suites), including new tests written test-first:
- hash changes when
embeddedProductReferences differ, for both local and external target branches
- hash is stable regardless of ordering
GraphContentHasher passes the resolved bundle closure (product:Bundle:Bundle.bundle) to the target hasher for a framework that embeds a bundle
hashIdentifier is correct for .product and path-independent for .bundle
- Existing
TargetContentHasher exact-hash assertions still pass, confirming empty closures leave hashes unchanged.
Note for operators
This adds an input to the content hash, so it invalidates cached artifacts once on rollout (expected and desirable, since it evicts any artifacts that collided under the old key). No CacheVersion bump is needed.
🤖 Generated with Claude Code