Summary
- Xcode 26.5’s explicit-modules dep scanner canonicalises
-fmodule-map-file= paths before opening them. Tuist emits these paths as \$(SRCROOT)/../../tuist-derived/... for external SwiftPM consumers, which physically traverses .build/checkouts/. CI caches that replace .build/checkouts/ with a symlink to a separate volume (notably Namespace’s nscloud-cache-action, but also any tool that backs .build/ with a content-addressed mount) cause the .. segments to resolve into the cache volume’s .build/, where tuist-derived/ doesn’t exist. The build fails with module map file ... not found. The same workflow on Xcode 26.4.1 worked because that toolchain’s dep scanner normalised the path lexically before opening it.
- Switch the anchor on those flags from
\$(SRCROOT) to \$(PROJECT_DIR). For external SwiftPM projects Tuist’s ExternalDependencyPathWorkspaceMapper overrides SRCROOT to point back into the SwiftPM checkouts/ directory, but \$(PROJECT_DIR) is not overridden — it stays at the generated .xcodeproj’s parent, which lives under <scratch>/tuist-derived/Projects/<Pkg>/. The substituted flag value (\$(PROJECT_DIR)/../../ModuleMaps/<X>/<X>.modulemap) therefore stays entirely inside tuist-derived/ and never traverses the symlinked checkouts/ subtree, even after Xcode 26.5 canonicalises symlinks.
- Bump the
generated_app_with_realm fixture from realm-swift 10.46 to 20.0.4 so it compiles on Xcode 26.5 (the older pin bundled an s2geometry copy that specialises std::is_pod, removed from the iOS 26.5 SDK’s libc++).
Root cause
ModuleMapMapper.swift computed flag values via moduleMap.relative(to: targetID.projectPath) and prefixed them with \$(SRCROOT). For external SwiftPM projects, \$(SRCROOT) is the SwiftPM checkout dir (.build/checkouts/<Pkg>/), and the modulemap lives in .build/tuist-derived/ModuleMaps/. The lexical relative path is ../../tuist-derived/.... Up through Xcode 26.4.1 clang opened those paths via standard POSIX resolution, which the customer’s CI exercised happily with .build/checkouts/ symlinked. Xcode 26.5’s tightened explicit-modules dep scanner resolves the symlink first, then traverses .. from the symlink target — landing in the cache volume’s .build/ where tuist-derived/ does not exist. The kernel returns ENOENT and the dep scanner reports module map file ... not found.
Why \$(PROJECT_DIR), not bare absolute paths or a custom variable
Several shapes fix the symlink trap. The constraints they have to satisfy:
- Substituted path must contain no
.. segments through .build/checkouts/. Anything that traverses the symlinked subtree gets canonicalised wrong on Xcode 26.5+.
- Hashed flag string must be machine-portable.
SettingsContentHasher hashes setting values verbatim, so any absolute path that varies by \$HOME / runner work-dir / CI identity will invalidate binary-cache and selective-testing hashes across machines.
| Approach |
Satisfies (1) |
Satisfies (2) |
Notes |
Bare absolute path (/Users/.../.build/tuist-derived/...) |
✓ |
✗ |
Cache hashes diverge across machines |
New user-defined setting (\$(TUIST_SPM_DERIVED_DIR)) |
✓ |
✓ if hasher filters the key |
Works but adds a new build setting and a hasher exception |
\$(PROJECT_DIR) (this PR) |
✓ |
✓ |
Existing Xcode-native variable; resolves to .xcodeproj parent (inside tuist-derived/); not overridden by ExternalDependencyPathWorkspaceMapper’s SRCROOT override |
Move tuist-derived/ inside each checkout |
✓ |
✓ |
But SwiftPM owns .build/checkouts/<Pkg>/ and would wipe it on resolve/update. Rejected. |
Place generated .xcodeproj directly at <scratch>/tuist-derived/<Pkg>.xcodeproj |
✓ |
✓ |
Removes .. entirely but is a wider restructure of ExternalDependencyPathWorkspaceMapper. Out of scope. |
\$(PROJECT_DIR) is the smallest viable change: zero new build settings, zero hasher changes, no fixture data poisoning, and the literal \$(PROJECT_DIR) token in flag strings hashes identically on every machine.
Scope
- Only paths inside
tuist-derived/ change emission shape. Paths that legitimately live in sibling SwiftPM checkouts (e.g. \$(SRCROOT)/../realm-core/src/module.modulemap) or inside the consumer’s own \$(SRCROOT) keep the existing form — they stay within the symlinked subtree and resolve correctly under canonicalisation.
- Only external SwiftPM projects (
Project.type == .external, with swiftPackageManagerScratchDirectory set) opt into the \$(PROJECT_DIR) anchor. Local projects are unchanged because for local projects \$(PROJECT_DIR) and \$(SRCROOT) resolve to the same directory by default.
Validation
Reproduced and verified A/B against two fixtures. generated_app_with_realm (bumped to realm-swift 20.0.4 in this PR) is a literal one-to-one match for the customer’s failing log — same package, same Bid.modulemap / s2geometry.modulemap paths, same realm-swift/../.. and realm-core/../.. traversal patterns. generated_ios_app_with_spm_dependencies (KSCrash) gives a second, smaller-graph repro using the same structural pattern.
| Fixture |
Xcode 26.5, no symlink |
Xcode 26.5, .build/checkouts symlinked — before fix |
Xcode 26.5, .build/checkouts symlinked — with fix |
generated_app_with_realm (Realm 20.0.4) |
BUILD SUCCEEDED [115s] |
module map file '.../checkouts/realm-swift/../../tuist-derived/ModuleMaps/Bid/Bid.modulemap' not found (and .../s2geometry/s2geometry.modulemap) → BUILD FAILED |
BUILD SUCCEEDED [114s] |
generated_ios_app_with_spm_dependencies |
BUILD SUCCEEDED |
module map file '.../checkouts/KSCrash/../../tuist-derived/ModuleMaps/KSCrashCore/KSCrashCore.modulemap' not found → BUILD FAILED |
BUILD SUCCEEDED [49.6s] |
All four pre-fix-failure cells were exercised by regenerating each fixture with the released Tuist binary (which still emits the old \$(SRCROOT)/../../tuist-derived/... shape) and keeping the symlink layout from Namespace’s cache action. The post-fix-success cells were exercised by regenerating with a tuist binary built from this branch (which emits \$(PROJECT_DIR)/../../ModuleMaps/...).
Confirmed in the post-fix pbxproj that the flag emission is -fmodule-map-file=\$(PROJECT_DIR)/../../ModuleMaps/<Module>/<Module>.modulemap for paths under tuist-derived/, and that no new user-defined build settings are injected.
ModuleMapMapperTests and SettingsContentHasherTests both pass against this branch.
Test plan
-
xcsiftbuild test -workspace Tuist.xcworkspace -scheme Tuist-Workspace -only-testing TuistGeneratorTests/ModuleMapMapperTests
-
xcsiftbuild test -workspace Tuist.xcworkspace -scheme Tuist-Workspace -only-testing TuistHasherTests/SettingsContentHasherTests
- Manual: regenerate
generated_app_with_realm or generated_ios_app_with_spm_dependencies, confirm the pbxproj emits \$(PROJECT_DIR)/../../<rel> shapes for tuist-derived/-resident paths and that no \$(SRCROOT)/../../tuist-derived/... shapes remain
🤖 Generated with Claude Code