Skip to content

fix: identify nameless copy files and script phases in project.xcproj - #1181

Merged
pepicrft merged 1 commit into
tuist:mainfrom
antigluten:fix/xcproj-nameless-copy-and-script-phases
Sep 18, 2026
Merged

pepicrft merged 1 commit into
tuist:mainfrom
antigluten:fix/xcproj-nameless-copy-and-script-phases

Conversation

@antigluten

Copy link
Copy Markdown
Contributor

There is no issue for this one either. I hit it while adding project.xcproj support to XcodeGen.

Short description 📝

A target with a copy files or script build phase that has no name produces a project Xcode refuses to open:

xcodebuild: error: Unable to read project 'CopyOnly.xcodeproj'.
	Reason: Failed to open “CopyOnly.xcodeproj”.

Could not uniquely resolve the build phase name “copy” in the target “App”

	Encountered while decoding: a file reference named “Empty.h”, in a group named “Headers”.

A file's membership is written as a reference to the phase that owns it. For a phase with no name that reference is the kind on its own, "App/copy". Xcode resolves a bare kind against the one phase of that kind a target holds, and a target can hold any number of copy files and script phases, so it rejects the reference. It does so even when the target has exactly one copy phase, which is what makes this easy to misread as an ambiguity problem.

I went through the kinds one at a time, each on a target holding a single nameless phase of that kind with one file in it:

kind "App/<kind>"
compile-sources opens
frameworks opens
resources opens
headers opens
copy rejected
script rejected

Exactly the two kinds a target can hold more than one of. Adding a name by hand to the rejected file makes it open, so a name or an identifier is what Xcode wants, and PBXCopyFilesBuildPhase.name is optional and usually nil in a project.pbxproj.

Both Apple's schema validator and this library's round trip accept the output, so nothing catches it before Xcode does.

Whether a generator hits this comes down to whether it names its phases. Tuist does not: copyFiles and scripts both take a required name in its manifest, all ten phase constructions in BuildPhaseGenerator pass one, and a project generated with tuist 4.210.0-canary.9 writes "App/copy/Copy Headers" and opens. XcodeGen does, because the copy files phase it builds for a copyFiles source has no name at all.

Solution 📦

indexBuildPhases already collects the phases that need an identifier because a name would not pick them out. A nameless phase of a repeatable kind joins them:

for (phase, key) in keyed where key.name == nil && Self.isRepeatableKind(key.kind) {
    ambiguousPhases.insert(phase.reference)
}

isRepeatableKind switches over BuildPhase exhaustively rather than falling through a default, so a kind added later has to be classified instead of silently treated as unique.

Nothing else changes. needsObjectID still requires the phase to be one something points at, so a nameless script phase that owns no files stays inline with no identifier, which is what aScriptPhaseWithoutANameStaysNameless covers. Phases that already resolve by name keep doing so, and the output for the four unique kinds is untouched.

I considered giving a nameless copy phase a default name, the way Xcode labels one in its UI. That invents data the property list never had, and it would come back through the decoder as a named phase, so the round trip would no longer describe the same project.

Implementation 👩‍💻👨‍💻

  • Treat a nameless phase of a repeatable kind as needing an identifier, in XCProjEncoder.indexBuildPhases.
  • Add isRepeatableKind, an exhaustive switch over BuildPhase.
  • Add namelessRepeatablePhasesCarryAnIdentifier to XCProjEncoderTests.

Testing

The new test is parameterized over the six kinds this library can encode. For each one it builds a target holding a single nameless phase of that kind with one file, encodes, and checks whether the membership came out as "App/<kind>" or as an identifier. It expects an identifier for copy and script and a name for the other four, then decodes the result and checks the membership survived either way. On main it fails on copy and script and passes on the other four, so it pins the boundary and not just the fix.

The schema validator accepts both spellings, so the unit test cannot tell us whether Xcode agrees. I checked the output against Xcode 27.0 as well:

  • One .xcodeproj per kind, each written through XcodeProj with a single nameless phase. Before the change xcodebuild -list rejects copy and script; after it, all six open.
  • XcodeGen's own TestProject fixture, which is what led me here, regenerated as project.xcproj. Every "Could not uniquely resolve the build phase name" error is gone. It now stops on an unrelated Invalid reference: “Projects/AnotherProject” from a cross project target dependency, which I am looking at separately.

383 XCTest and 84 Swift Testing tests pass on macOS, and mise run lint passes. I did not run the Linux or iOS jobs locally, and I have Xcode 27.0 here rather than 27.2, so the table above is what 27.0 accepts and rejects.

@pepicrft
pepicrft force-pushed the fix/xcproj-nameless-copy-and-script-phases branch from 63d1c5e to 2badffe Compare September 18, 2026 08:26
@pepicrft

Copy link
Copy Markdown
Contributor

Heads up that I rebased this onto main and force pushed, since #1182 landed first and the two tests were added at the same spot in XCProjEncoderTests.swift. The resolution just keeps both tests, nothing in your change moved.

I also validated this against Xcode 27.2 here, since you mentioned you had 27.0. Your table holds exactly: with a single nameless phase of each kind, 27.2 rejects copy and script with "Could not uniquely resolve the build phase name" and accepts the other four, and with this change all six open. The four unique kinds still resolve by name rather than spending an identifier, so the boundary is where you put it.

Waiting on CI and then I will merge. Thanks for digging into this one.

@pepicrft
pepicrft merged commit 2f64dd4 into tuist:main Sep 18, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants