From 38168f4604eb43f021d7555ecd8e3229f56cea25 Mon Sep 17 00:00:00 2001 From: Ishani Madaan Date: Wed, 19 Aug 2026 16:50:45 +1000 Subject: [PATCH 1/3] Fix non-deterministic/incorrect serialization of nested target attributes ProjectAttribute.init(any:) only handled [String], PBXObject, and fell back to .string("\(value)") for everything else. Nested dictionary attributes such as target.attributes.SystemCapabilities therefore never produced the existing .attributeDictionary case, and instead were serialized as a raw Swift Dictionary interpolated into a string, e.g.: SystemCapabilities = "[\"com.apple.Push\": [\"enabled\": 1], ...]"; Dictionary.description iterates in the process's random hash-seed order, so the exact same input could produce a different key order on every run, producing spurious project.pbxproj diffs. It also isn't a valid nested plist dictionary, which some tooling flags as needing a project 'fix'. This adds a case that maps [String: [String: Any]] values (the shape SystemCapabilities and similar nested attributes take) to .attributeDictionary, so they serialize as a proper nested plist dictionary. PBXProjEncoder already writes dictionary keys in sorted order, so this also makes the output deterministic across runs. --- Sources/ProjectSpec/Settings.swift | 8 +++++++ .../ProjectGeneratorTests.swift | 21 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/Sources/ProjectSpec/Settings.swift b/Sources/ProjectSpec/Settings.swift index f0a5344a..05cc48e8 100644 --- a/Sources/ProjectSpec/Settings.swift +++ b/Sources/ProjectSpec/Settings.swift @@ -144,6 +144,14 @@ extension ProjectAttribute { self = .array(array) } else if let object = value as? PBXObject { self = .targetReference(object) + } else if let dictionary = value as? [String: [String: Any]] { + // e.g. `SystemCapabilities`, which is a dictionary of dictionaries. + // Without this case, such values fall through to the `.string("\(value)")` + // branch below, which interpolates the raw Swift `Dictionary`. Its + // `description` iterates in the process's random hash-seed order, so the + // same input can non-deterministically produce a different key order (and + // isn't a valid nested plist dictionary in the first place). + self = .attributeDictionary(dictionary.mapValues { $0.mapValues { ProjectAttribute(any: $0) } }) } else { self = .string("\(value)") } diff --git a/Tests/XcodeGenKitTests/ProjectGeneratorTests.swift b/Tests/XcodeGenKitTests/ProjectGeneratorTests.swift index dd07d770..2ddb45f8 100644 --- a/Tests/XcodeGenKitTests/ProjectGeneratorTests.swift +++ b/Tests/XcodeGenKitTests/ProjectGeneratorTests.swift @@ -324,6 +324,27 @@ class ProjectGeneratorTests: XCTestCase { try expect(targetAttributes[appTarget]?["DevelopmentTeam"]?.stringValue) == "123" } + $0.it("generates nested target attributes such as SystemCapabilities") { + var appTargetWithAttributes = app + appTargetWithAttributes.attributes = [ + "SystemCapabilities": [ + "com.apple.Push": ["enabled": 1], + "com.apple.Keychain": ["enabled": 1], + ], + ] + let project = Project(name: "test", targets: [appTargetWithAttributes]) + let pbxProject = try project.generatePbxProj() + + let targetAttributes = try unwrap(pbxProject.projects.first?.targetAttributes) + let appTarget = try unwrap(pbxProject.targets(named: app.name).first) + + guard case let .attributeDictionary(systemCapabilities)? = targetAttributes[appTarget]?["SystemCapabilities"] else { + throw failure("Expected SystemCapabilities to be a nested attribute dictionary, not a string") + } + try expect(systemCapabilities["com.apple.Push"]?["enabled"]?.stringValue) == "1" + try expect(systemCapabilities["com.apple.Keychain"]?["enabled"]?.stringValue) == "1" + } + $0.it("generates platform version") { let target = Target(name: "Target", type: .application, platform: .watchOS, deploymentTarget: "2.0") let project = Project(name: "", targets: [target], options: .init(deploymentTarget: DeploymentTarget(iOS: "10.0", watchOS: "3.0"))) From f2c05e0c96372412e63f05776a1fcce8f06af3d2 Mon Sep 17 00:00:00 2001 From: Ishani Madaan Date: Wed, 19 Aug 2026 16:51:05 +1000 Subject: [PATCH 2/3] Add changelog entry --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4886713..3ce41097 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Change Log +## Master + +### Fixed +- Fix nested target attributes (e.g. `attributes.SystemCapabilities`) being serialized as a stringified Swift `Dictionary` description instead of a proper nested plist dictionary, which also caused non-deterministic key ordering in generated `project.pbxproj` files across runs @imadaan + ## 2.46.0 ### Added From 1c4f149af951d4f6413acfbba63b7ee955480dcb Mon Sep 17 00:00:00 2001 From: Ishani Madaan Date: Wed, 19 Aug 2026 17:07:30 +1000 Subject: [PATCH 3/3] Fix test: include MyFramework target dependency in SystemCapabilities test --- Tests/XcodeGenKitTests/ProjectGeneratorTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/XcodeGenKitTests/ProjectGeneratorTests.swift b/Tests/XcodeGenKitTests/ProjectGeneratorTests.swift index 2ddb45f8..600e4d61 100644 --- a/Tests/XcodeGenKitTests/ProjectGeneratorTests.swift +++ b/Tests/XcodeGenKitTests/ProjectGeneratorTests.swift @@ -332,7 +332,7 @@ class ProjectGeneratorTests: XCTestCase { "com.apple.Keychain": ["enabled": 1], ], ] - let project = Project(name: "test", targets: [appTargetWithAttributes]) + let project = Project(name: "test", targets: [appTargetWithAttributes, framework]) let pbxProject = try project.generatePbxProj() let targetAttributes = try unwrap(pbxProject.projects.first?.targetAttributes)