Skip to content

Fix non-deterministic/incorrect serialization of nested target attributes (e.g. SystemCapabilities) - #1639

Open
imadaan wants to merge 3 commits into
yonaskolb:masterfrom
imadaan:fix/system-capabilities-deterministic-serialization
Open

Fix non-deterministic/incorrect serialization of nested target attributes (e.g. SystemCapabilities)#1639
imadaan wants to merge 3 commits into
yonaskolb:masterfrom
imadaan:fix/system-capabilities-deterministic-serialization

Conversation

@imadaan

@imadaan imadaan commented Aug 19, 2026

Copy link
Copy Markdown

Problem

target.attributes values that are nested dictionaries — most commonly SystemCapabilities (e.g. com.apple.Push, com.apple.Keychain) — are never converted to the existing ProjectAttribute.attributeDictionary case. Instead, ProjectAttribute.init(any:) only handles [String] and PBXObject, and falls back to .string("\(value)") for anything else:

extension ProjectAttribute {
    public init(any value: Any) {
        if let array = value as? [String] {
            self = .array(array)
        } else if let object = value as? PBXObject {
            self = .targetReference(object)
        } else {
            self = .string("\(value)")
        }
    }
}

For a nested dictionary like:

attributes:
  SystemCapabilities:
    com.apple.Push:
      enabled: 1
    com.apple.Keychain:
      enabled: 1

this produces a project.pbxproj entry like:

SystemCapabilities = "[\"com.apple.Push\": [\"enabled\": 1], \"com.apple.Keychain\": [\"enabled\": 1]]";

i.e. a raw Swift Dictionary interpolated into a string, rather than a proper nested plist dictionary. This has two consequences:

  1. It isn't valid/expected pbxproj structure for SystemCapabilities — Xcode's own tooling expects a nested dictionary here, not a quoted string.
  2. It's non-deterministic. Dictionary.description iterates keys in the process's random hash-seed order, so regenerating the exact same project.yml can flip the key order inside that string between runs (e.g. Push before Keychain, or vice versa), producing spurious diffs in version control on every xcodegen generate, even though nothing in the spec changed.

Fix

Add a case to ProjectAttribute.init(any:) that recognizes the [String: [String: Any]] shape (which is exactly what .attributeDictionary([String: [String: ProjectAttribute]]) expects) and maps it recursively instead of falling through to .string(...):

} else if let dictionary = value as? [String: [String: Any]] {
    self = .attributeDictionary(dictionary.mapValues { $0.mapValues { ProjectAttribute(any: $0) } })
}

PBXProjEncoder (in XcodeProj) already sorts dictionary keys alphabetically when writing plist dictionaries, so routing through .attributeDictionary also makes the output deterministic across runs, in addition to producing correct nested plist structure.

Testing

  • Added a regression test (generates nested target attributes such as SystemCapabilities) in ProjectGeneratorTests.swift asserting that SystemCapabilities produces a .attributeDictionary (not a .string) with the expected nested values.
  • Verified the change with a syntax/parse check locally; the sandboxed environment I authored this in couldn't fetch SwiftPM dependencies to run the full test suite, so I'd appreciate CI running the suite on this PR.

Repro

Given a target with:

attributes:
  SystemCapabilities:
    com.apple.Push:
      enabled: 1
    com.apple.Keychain:
      enabled: 1

Running xcodegen generate repeatedly (without pinning SWIFT_DETERMINISTIC_HASHING=1) produces a flip-flopping diff purely in the order of keys inside the SystemCapabilities string value. After this fix, it serializes as a proper nested dictionary and is stable across runs.

…utes

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.
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.

1 participant