Purpose
Reported in the Tuist Community Slack: a user defined a foreignBuild() target in a separate Tuist Project (a KMP wrapper) that the iOS app’s Framework1 depends on via .project(target: ..., path: ...). Editing Kotlin sources never triggered a rebuild, and the iOS build kept linking the stale xcframework.
foreignBuild() aggregates produce no product under BUILT_PRODUCTS_DIR. Tuist’s existing cross-project ordering mechanism — a phony copy-files phase whose input is the remote target’s product path (see the comment in LinkGenerator.swift:516-525) — only works when the remote target has a product file Xcode can stat. With no product to key off, neither that workaround nor Xcode’s “build implicit dependencies” picks up the dep across projects, and the script in the aggregate never runs from the consumer’s scheme.
Tuist had no other code path emitting a cross-project PBXTargetDependency until this change.
What changes
A new ForeignBuildCrossProjectDependencyGenerator runs after per-project generation, inside WorkspaceDescriptorGenerator.generate. For each consumer target that depends on a foreign-build aggregate in another project, it adds the standard Xcode cross-project plumbing to the consumer pbxproj:
- A
PBXFileReference to the remote .xcodeproj (deduplicated per remote project, attached to the consumer’s main group).
- A
PBXProject.projects entry pairing that reference with an (empty) Products PBXGroup.
- A
PBXContainerItemProxy with proxyType = .nativeTarget, containerPortal = .fileReference(<remote ref>), and remoteGlobalID = .object(<remote aggregate>).
- A
PBXTargetDependency wrapping the proxy, appended to the consumer target’s dependencies.
To make the consumer pbxproj serialize remoteGlobalIDString to a real UUID instead of a temporary TEMP_… placeholder, each aggregate-bearing pbxproj is encoded once eagerly so XcodeProj’s ReferenceGenerator fixes its object UUIDs before the writer reaches the consumer.
Compared to the first commit
The first commit on this branch attempted to fix the same bug by cloning the script onto every cross-project consumer with SRCROOT overridden. That worked but ran the script once per consumer (rather than once total), duplicated the script in each consuming target, and didn’t match how Xcode normally expresses build-order deps. This commit replaces that approach with the standard Xcode pattern.
How to test locally
- Create two Tuist projects in a workspace:
- Project A (e.g.
App/Project.swift) with a Framework1 target whose dependencies include .project(target: "SharedKMP", path: "../KmpWrapper").
- Project B (e.g.
KmpWrapper/Project.swift) with a single .foreignBuild(name: "SharedKMP", ..., script: "cd \$SRCROOT/../KmpSources && gradle assembleSharedKMPReleaseXCFramework", inputs: [.glob(.relativeToRoot("KmpSources/**/src/**/*.kt"))], output: .xcframework(...)).
- Run
tuist generate and inspect App.xcodeproj/project.pbxproj. The App project should now have:
- A
PBXFileReference to KmpWrapper.xcodeproj and a corresponding projectReferences entry.
- A
PBXContainerItemProxy referencing the remote aggregate (proxyType = 1, containerPortal pointing at the remote xcodeproj ref, remoteGlobalIDString matching the aggregate’s UUID in the remote pbxproj).
- A
PBXTargetDependency on Framework1 wired to that proxy.
- Build the app once, edit a
.kt file, then build again — Xcode should re-run the aggregate’s script and the consumer should link the freshly-built xcframework.
End-to-end verification
Exercised against the reporter’s sample project with a touch /tmp/foreign-build-ran.sentinel script substituted for gradle, a stub xcframework, and macOS destinations (iOS 26.4 device SDK and gradle/Java weren’t available in the test environment — the cross-project wiring code path is identical regardless of platform):
| Scenario |
Result |
| Cross-project explicit dep present in pbxproj |
✓ PBXContainerItemProxy + PBXTargetDependency + projectReferences |
| Xcode resolves the cross-project edge |
✓ Explicit dependency on target 'KmpExampleProject' in project 'KmpExampleProject' in the dep graph |
| First build: aggregate’s script fires |
✓ sentinel created during build |
| Rebuild with no input changes: script skipped |
✓ sentinel mtime unchanged, build 0.5s |
| Edit input source: script re-runs |
✓ sentinel got a new timestamp line |