From 44c273ef615c6787f9f59b997b06c458ca32b45e Mon Sep 17 00:00:00 2001 From: Bartosz Dolewski Date: Mon, 29 Jun 2026 14:25:29 +0200 Subject: [PATCH] Add Swift Package Registry (SE-0292) configuration support Add a top-level `registries` key to project.yml. XcodeGen serializes it into the generated project's `project.xcworkspace/xcshareddata/swiftpm/configuration/registries.json`, the file SwiftPM and Xcode read to resolve registry (`.package(id:)`) dependencies. Xcode has no project-level (pbxproj) representation for registry packages, so this configuration file is the supported way to point a generated project at a registry. Dependencies themselves are still declared the existing way, by referencing a local Swift package whose Package.swift uses `.package(id:)`. Includes parsing and serialization tests, ProjectSpec docs, and a CHANGELOG entry. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 3 + Docs/ProjectSpec.md | 27 ++- Sources/ProjectSpec/Project.swift | 13 ++ .../ProjectSpec/SwiftPackageRegistries.swift | 159 ++++++++++++++++++ .../Commands/GenerateCommand.swift | 1 + Sources/XcodeGenKit/FileWriter.swift | 16 ++ Tests/ProjectSpecTests/SpecLoadingTests.swift | 43 +++++ 7 files changed, 261 insertions(+), 1 deletion(-) create mode 100644 Sources/ProjectSpec/SwiftPackageRegistries.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index f876da2ec..c2682371d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Next Version +### Added +- Add `registries` to generate a Swift Package Registry (SE-0292) `registries.json` into the project's SwiftPM configuration, so generated projects can resolve `.package(id:)` dependencies from a registry @bdolewski + ## 2.45.4 ### Fixed diff --git a/Docs/ProjectSpec.md b/Docs/ProjectSpec.md index 17f6c5b33..0ded91323 100644 --- a/Docs/ProjectSpec.md +++ b/Docs/ProjectSpec.md @@ -51,6 +51,7 @@ You can also use environment variables in your configuration file, by using `${S - [Test Plan](#test-plan) - [Scheme Template](#scheme-template) - [Swift Package](#swift-package) +- [Swift Package Registries](#swift-package-registries) - [Remote Package](#remote-package) - [Local Package](#local-package) - [Project Reference](#project-reference) @@ -72,6 +73,7 @@ You can also use environment variables in your configuration file, by using `${S - [ ] **schemeTemplates**: **[String: [Scheme Template](#scheme-template)]** - a list of schemes that can be used as templates for actual schemes which reference them via a `template` property. They can be used to extract common scheme settings. Works great in combination with `include`. - [ ] **targetTemplates**: **[String: [Target Template](#target-template)]** - a list of targets that can be used as templates for actual targets which reference them via a `template` property. They can be used to extract common target settings. Works great in combination with `include`. - [ ] **packages**: **[String: [Swift Package](#swift-package)]** - a map of Swift packages by name. +- [ ] **registries**: **[Swift Package Registries](#swift-package-registries)** - Swift Package Registry (SE-0292) configuration, written to the generated project's SwiftPM `registries.json`. - [ ] **projectReferences**: **[String: [Project Reference](#project-reference)]** - a map of project references by name ### Include @@ -394,7 +396,7 @@ Settings are merged in the following order: `groups`, `base`, `configs` (simple - [ ] **transitivelyLinkDependencies**: **Bool** - If this is not specified the value from the project set in [Options](#options)`.transitivelyLinkDependencies` will be used. - [ ] **directlyEmbedCarthageDependencies**: **Bool** - If this is `true` Carthage framework dependencies will be embedded using an `Embed Frameworks` build phase instead of the `copy-frameworks` script. Defaults to `true` for all targets except iOS/tvOS/watchOS Applications. - [ ] **requiresObjCLinking**: **Bool** - If this is `true` any targets that link to this target will have `-ObjC` added to their `OTHER_LDFLAGS`. This is required if a static library has any categories or extensions on Objective-C code. See [this guide](https://pewpewthespells.com/blog/objc_linker_flags.html#objc) for more details. Defaults to `true` if `type` is `library.static`. If you are 100% sure you don't have categories or extensions on Objective-C code (pure Swift with no use of Foundation/UIKit) you can set this to `false`, otherwise it's best to leave it alone. -- [ ] **onlyCopyFilesOnInstall**: **Bool** – If this is `true`, the `Embed Frameworks` and `Embed App Extensions` (if available) build phases will have the "Copy only when installing" chekbox checked. Defaults to `false`. +- [ ] **onlyCopyFilesOnInstall**: **Bool** - If this is `true`, the `Embed Frameworks` and `Embed App Extensions` (if available) build phases will have the "Copy only when installing" chekbox checked. Defaults to `false`. - [ ] **buildToolPlugins**: **[[Build Tool Plug-ins](#build-tool-plug-ins)]** - Commands for the build system that run automatically *during* the build. - [ ] **preBuildScripts**: **[[Build Script](#build-script)]** - Build scripts that run *before* any other build phases - [ ] **postCompileScripts**: **[[Build Script](#build-script)]** - Build scripts that run after the Compile Sources phase @@ -1279,6 +1281,29 @@ packages: excludeFromProject: false ``` +## Swift Package Registries + +Configures one or more Swift Package Registries (SE-0292) for the generated project. XcodeGen serializes this into `.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/configuration/registries.json`, the file SwiftPM and Xcode read to resolve registry (`.package(id:)`) dependencies. There is no project-level (pbxproj) representation for registry packages, so this configuration file is the supported way to point a generated project at a registry. + +- [ ] **default**: **String** or **{ url, supportsAvailability }** - The default registry, written under SwiftPM's `[default]` key. A bare string is treated as the registry `url`, with `supportsAvailability` defaulting to `false`. +- [ ] **scopes**: **[String: String or { url, supportsAvailability }]** - Scoped registries keyed by package scope. A package whose identity is `.` resolves from the matching scope's registry. + +Each registry value is either a URL string or a mapping: + +- [x] **url**: **String** - The registry base URL. +- [ ] **supportsAvailability**: **Bool** - Whether the registry implements the SE-0292 availability API. Defaults to `false`. + +The dependencies themselves are still declared the supported way: by referencing a local Swift package whose `Package.swift` uses `.package(id:)` (see [Swift Package](#swift-package) `path`). XcodeGen does not write registry references into the `.xcodeproj` itself, because Xcode has no pbxproj representation for them. Authentication credentials are never written here; configure them via `.netrc` or the keychain as usual. + +```yaml +registries: + default: https://tuist.dev/api/registry/swift + scopes: + acme: + url: https://packages.example.com/artifactory/api/swift/swift-registry + supportsAvailability: false +``` + ## Project Reference Project References are defined at a project level, and then you can use the project name to refer its target via a [Scheme](#scheme) diff --git a/Sources/ProjectSpec/Project.swift b/Sources/ProjectSpec/Project.swift index e872a27bf..00e1dce57 100644 --- a/Sources/ProjectSpec/Project.swift +++ b/Sources/ProjectSpec/Project.swift @@ -21,6 +21,8 @@ public struct Project: BuildSettingsContainer { public var packages: [String: SwiftPackage] + public var registries: SwiftPackageRegistries + public var settings: Settings public var settingGroups: [String: Settings] public var configs: [Config] @@ -52,6 +54,7 @@ public struct Project: BuildSettingsContainer { schemes: [Scheme] = [], breakpoints: [Breakpoint] = [], packages: [String: SwiftPackage] = [:], + registries: SwiftPackageRegistries = SwiftPackageRegistries(), options: SpecOptions = SpecOptions(), fileGroups: [String] = [], configFiles: [String: String] = [:], @@ -70,6 +73,7 @@ public struct Project: BuildSettingsContainer { self.schemes = schemes self.breakpoints = breakpoints self.packages = packages + self.registries = registries self.options = options self.fileGroups = fileGroups self.configFiles = configFiles @@ -152,6 +156,7 @@ extension Project: Equatable { lhs.configFiles == rhs.configFiles && lhs.options == rhs.options && lhs.packages == rhs.packages && + lhs.registries == rhs.registries && NSDictionary(dictionary: lhs.attributes).isEqual(to: rhs.attributes) } } @@ -208,6 +213,11 @@ extension Project { } ) } + if jsonDictionary["registries"] != nil { + registries = try jsonDictionary.json(atKeyPath: "registries") + } else { + registries = SwiftPackageRegistries() + } if jsonDictionary["options"] != nil { options = try jsonDictionary.json(atKeyPath: "options") } else { @@ -323,6 +333,9 @@ extension Project: JSONEncodable { dictionary["include"] = include dictionary["attributes"] = attributes dictionary["packages"] = packages.mapValues { $0.toJSONValue() } + if !registries.isEmpty { + dictionary["registries"] = registries.toJSONValue() + } dictionary["targets"] = Dictionary(uniqueKeysWithValues: targetPairs) dictionary["configs"] = Dictionary(uniqueKeysWithValues: configsPairs) dictionary["aggregateTargets"] = Dictionary(uniqueKeysWithValues: aggregateTargetsPairs) diff --git a/Sources/ProjectSpec/SwiftPackageRegistries.swift b/Sources/ProjectSpec/SwiftPackageRegistries.swift new file mode 100644 index 000000000..211a8a16d --- /dev/null +++ b/Sources/ProjectSpec/SwiftPackageRegistries.swift @@ -0,0 +1,159 @@ +import Foundation +import JSONUtilities + +/// Swift Package Registry (SE-0292) configuration. +/// +/// XcodeGen serializes this into the generated project's +/// `project.xcworkspace/xcshareddata/swiftpm/configuration/registries.json`, which is the +/// file SwiftPM and Xcode read to resolve registry (`.package(id:)`) dependencies. Xcode +/// has no project-level (pbxproj) representation for registry packages, so this configuration +/// file is the supported way to point a generated project at a registry. +public struct SwiftPackageRegistries: Equatable { + + /// A single registry endpoint. + public struct Registry: Equatable { + + /// The registry base URL, e.g. `https://tuist.dev/api/registry/swift`. + public var url: String + + /// Whether the registry implements the SE-0292 availability API. Defaults to `false`, + /// matching what `swift package-registry set` writes. + public var supportsAvailability: Bool + + public init(url: String, supportsAvailability: Bool = false) { + self.url = url + self.supportsAvailability = supportsAvailability + } + } + + /// The default registry, written under SwiftPM's `[default]` key. + public var defaultRegistry: Registry? + + /// Scoped registries keyed by package scope. A package whose identity is `.` + /// resolves from the matching scope's registry. + public var scopes: [String: Registry] + + public init(defaultRegistry: Registry? = nil, scopes: [String: Registry] = [:]) { + self.defaultRegistry = defaultRegistry + self.scopes = scopes + } + + /// Whether there is anything to write. When empty, XcodeGen writes no registries.json. + public var isEmpty: Bool { + defaultRegistry == nil && scopes.isEmpty + } +} + +extension SwiftPackageRegistries { + + /// Errors raised while parsing a `registries` block. + public enum ParsingError: Error, CustomStringConvertible { + case invalidRegistry(Any) + case missingURL(JSONDictionary) + + public var description: String { + switch self { + case let .invalidRegistry(value): + return "Invalid registry \"\(value)\". Expected a URL string or a mapping with a \"url\" key." + case let .missingURL(dictionary): + return "Registry \"\(dictionary)\" is missing a \"url\"." + } + } + } +} + +extension SwiftPackageRegistries.Registry { + + /// Decodes a registry from either a bare URL string or a `{ url, supportsAvailability }` mapping. + init(jsonValue: Any) throws { + if let url = jsonValue as? String { + self.init(url: url) + } else if let dictionary = jsonValue as? JSONDictionary { + guard let url = dictionary["url"] as? String else { + throw SwiftPackageRegistries.ParsingError.missingURL(dictionary) + } + let supportsAvailability = (dictionary["supportsAvailability"] as? Bool) ?? false + self.init(url: url, supportsAvailability: supportsAvailability) + } else { + throw SwiftPackageRegistries.ParsingError.invalidRegistry(jsonValue) + } + } +} + +extension SwiftPackageRegistries: JSONObjectConvertible { + + public init(jsonDictionary: JSONDictionary) throws { + if let defaultValue = jsonDictionary["default"] { + defaultRegistry = try Registry(jsonValue: defaultValue) + } else { + defaultRegistry = nil + } + if let scopesDictionary = jsonDictionary["scopes"] as? JSONDictionary { + scopes = try scopesDictionary.mapValues { try Registry(jsonValue: $0) } + } else { + scopes = [:] + } + } +} + +extension SwiftPackageRegistries.Registry: JSONEncodable { + + public func toJSONValue() -> Any { + // Use the compact URL-string form unless a non-default flag needs to be preserved. + if supportsAvailability { + return [ + "url": url, + "supportsAvailability": supportsAvailability, + ] as JSONDictionary + } + return url + } +} + +extension SwiftPackageRegistries: JSONEncodable { + + public func toJSONValue() -> Any { + var dictionary: JSONDictionary = [:] + if let defaultRegistry = defaultRegistry { + dictionary["default"] = defaultRegistry.toJSONValue() + } + if !scopes.isEmpty { + dictionary["scopes"] = scopes.mapValues { $0.toJSONValue() } + } + return dictionary + } +} + +extension SwiftPackageRegistries { + + /// The on-disk `registries.json` representation that SwiftPM and Xcode read. + struct File: Encodable { + struct Entry: Encodable { + let supportsAvailability: Bool + let url: String + } + + let authentication: [String: String] + let registries: [String: Entry] + let version: Int + } + + func makeFile() -> File { + var entries: [String: File.Entry] = [:] + if let defaultRegistry = defaultRegistry { + entries["[default]"] = File.Entry(supportsAvailability: defaultRegistry.supportsAvailability, url: defaultRegistry.url) + } + for (scope, registry) in scopes { + entries[scope] = File.Entry(supportsAvailability: registry.supportsAvailability, url: registry.url) + } + return File(authentication: [:], registries: entries, version: 1) + } + + /// Serializes the configuration to `registries.json` bytes: sorted keys, pretty printed, + /// and slashes left unescaped to match the output of `swift package-registry set`. + public func registriesJSONData() throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + return try encoder.encode(makeFile()) + } +} diff --git a/Sources/XcodeGenCLI/Commands/GenerateCommand.swift b/Sources/XcodeGenCLI/Commands/GenerateCommand.swift index d0e4eabe9..de6f403ae 100644 --- a/Sources/XcodeGenCLI/Commands/GenerateCommand.swift +++ b/Sources/XcodeGenCLI/Commands/GenerateCommand.swift @@ -114,6 +114,7 @@ class GenerateCommand: ProjectCommand { info("⚙️ Writing project...") do { try fileWriter.writeXcodeProject(xcodeProject, to: projectPath) + try fileWriter.writeSwiftPackageRegistries(to: projectPath) success("Created project at \(projectPath)") } catch { diff --git a/Sources/XcodeGenKit/FileWriter.swift b/Sources/XcodeGenKit/FileWriter.swift index 17af96c5d..65c7b16db 100644 --- a/Sources/XcodeGenKit/FileWriter.swift +++ b/Sources/XcodeGenKit/FileWriter.swift @@ -24,6 +24,22 @@ public class FileWriter { try? tempPath.delete() } + /// Writes the Swift Package Registry configuration (registries.json) into the generated + /// project's workspace, so SwiftPM/Xcode can resolve `.package(id:)` dependencies from a + /// registry. No-op when no registries are configured or when the file is unchanged. + public func writeSwiftPackageRegistries(to projectPath: Path? = nil) throws { + guard !project.registries.isEmpty else { return } + let projectPath = projectPath ?? project.defaultProjectPath + let configurationPath = projectPath + "project.xcworkspace/xcshareddata/swiftpm/configuration/registries.json" + let data = try project.registries.registriesJSONData() + if configurationPath.exists, let existing: Data = try? configurationPath.read(), existing == data { + // file is the same + return + } + try configurationPath.parent().mkpath() + try configurationPath.write(data) + } + public func writePlists() throws { let infoPlistGenerator = InfoPlistGenerator() diff --git a/Tests/ProjectSpecTests/SpecLoadingTests.swift b/Tests/ProjectSpecTests/SpecLoadingTests.swift index d3db0d9c0..6ee40abcf 100644 --- a/Tests/ProjectSpecTests/SpecLoadingTests.swift +++ b/Tests/ProjectSpecTests/SpecLoadingTests.swift @@ -1523,6 +1523,49 @@ class SpecLoadingTests: XCTestCase { try expect(parsedSpec) == project } + $0.it("parses swift package registries") { + let project = Project(name: "test", registries: SwiftPackageRegistries( + defaultRegistry: SwiftPackageRegistries.Registry(url: "https://tuist.dev/api/registry/swift"), + scopes: [ + "acme": SwiftPackageRegistries.Registry(url: "https://packages.example.com/artifactory/api/swift/swift-registry"), + "example": SwiftPackageRegistries.Registry(url: "https://example.com/registry", supportsAvailability: true), + ] + )) + + let dictionary: [String: Any] = [ + "name": "test", + "registries": [ + "default": "https://tuist.dev/api/registry/swift", + "scopes": [ + "acme": "https://packages.example.com/artifactory/api/swift/swift-registry", + "example": ["url": "https://example.com/registry", "supportsAvailability": true], + ], + ], + ] + let parsedSpec = try getProjectSpec(dictionary) + try expect(parsedSpec) == project + } + + $0.it("serializes swift package registries to registries.json") { + let registries = SwiftPackageRegistries( + defaultRegistry: SwiftPackageRegistries.Registry(url: "https://tuist.dev/api/registry/swift"), + scopes: ["acme": SwiftPackageRegistries.Registry(url: "https://packages.example.com/artifactory/api/swift/swift-registry")] + ) + let data = try registries.registriesJSONData() + let string = String(data: data, encoding: .utf8) ?? "" + // Slashes must not be escaped, matching `swift package-registry set` output. + try expect(string.contains("https://tuist.dev/api/registry/swift")) == true + try expect(string.contains("\\/")) == false + + let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] + try expect(json["version"] as? Int) == 1 + try expect(json["authentication"] is [String: Any]) == true + let parsedRegistries = json["registries"] as! [String: [String: Any]] + try expect(parsedRegistries["[default]"]?["url"] as? String) == "https://tuist.dev/api/registry/swift" + try expect(parsedRegistries["[default]"]?["supportsAvailability"] as? Bool) == false + try expect(parsedRegistries["acme"]?["url"] as? String) == "https://packages.example.com/artifactory/api/swift/swift-registry" + } + $0.it("parses old local package format") { let project = Project(name: "spm", packages: [ "XcodeGen": .local(path: "../XcodeGen", group: nil, excludeFromProject: false),