From 666262b10b8c4773658bb8c72e3ef49481734b63 Mon Sep 17 00:00:00 2001 From: Alex Hoppen Date: Wed, 17 Jun 2026 17:51:26 +0200 Subject: [PATCH 1/2] Add an action to upgrade all dependencies to the latest version, including new major version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When maintaining a non-library package, like a service executable, you may sometimes want to update all your dependencies to the latest version, including new major versions. This is particularly common when depending on packages that have a `0.x` version. Without an explicit update of your dependency requirement, you may easily be stuck on an older version of the package and not pick up bug fixes that are, eg. included in `0.12.0` while you are still on `0.10.0`. Currently, this is a very manual process: For every package dependency, you need to eg. open the package in GitHub, check if the package has a new release and, if so, update the `from:` clause in the package manifest accordingly. This adds a new `swift package upgrade-dependencies` action, which updates the package manifest to reference all the lastest versions. If these new versions introduce API breakages, it is expected to be the user’s responsibilities to update their package accordingly. --- .../Commands/PackageCommands/AddProduct.swift | 29 +- .../Commands/PackageCommands/AddTarget.swift | 34 +- .../PackageCommands/AddTargetDependency.swift | 28 +- .../PackageCommands/AddTargetPlugin.swift | 29 +- .../Commands/PackageCommands/Migrate.swift | 1 + ...tate+readPackageManifestAsSyntaxTree.swift | 47 +++ .../PackageCommands/SwiftPackageCommand.swift | 1 + .../PackageCommands/UpgradeDependencies.swift | 107 ++++++ .../GitRepositoryExtensions.swift | 8 +- .../SwiftTesting+Tags.swift | 1 + Tests/CommandsTests/PackageCommandTests.swift | 312 ++++++++++++++++++ 11 files changed, 486 insertions(+), 111 deletions(-) create mode 100644 Sources/Commands/PackageCommands/SwiftCommandState+readPackageManifestAsSyntaxTree.swift create mode 100644 Sources/Commands/PackageCommands/UpgradeDependencies.swift diff --git a/Sources/Commands/PackageCommands/AddProduct.swift b/Sources/Commands/PackageCommands/AddProduct.swift index 2a0ab921b28..246cbe0740f 100644 --- a/Sources/Commands/PackageCommands/AddProduct.swift +++ b/Sources/Commands/PackageCommands/AddProduct.swift @@ -15,7 +15,6 @@ import Basics import CoreCommands import Foundation import PackageGraph -import SwiftParser @_spi(PackageRefactor) import SwiftRefactor import SwiftSyntax import TSCBasic @@ -55,30 +54,7 @@ extension SwiftPackageCommand { var targets: [String] = [] func run(_ swiftCommandState: SwiftCommandState) throws { - let workspace = try swiftCommandState.getActiveWorkspace() - - guard let packagePath = try swiftCommandState.getWorkspaceRoot().packages.first else { - throw StringError("unknown package") - } - - // Load the manifest file - let fileSystem = workspace.fileSystem - let manifestPath = packagePath.appending("Package.swift") - let manifestContents: ByteString - do { - manifestContents = try fileSystem.readFileContents(manifestPath) - } catch { - throw StringError("cannot find package manifest in \(manifestPath)") - } - - // Parse the manifest. - let manifestSyntax = manifestContents.withData { data in - data.withUnsafeBytes { buffer in - buffer.withMemoryRebound(to: UInt8.self) { buffer in - Parser.parse(source: buffer) - } - } - } + let (manifestSyntax, manifestPath) = try swiftCommandState.readPackageManifestAsSyntaxTree() // Map the product type. let type: ProductDescription.ProductType = switch self.type { @@ -101,7 +77,7 @@ extension SwiftPackageCommand { ) try editResult.applyEdits( - to: fileSystem, + to: swiftCommandState.getActiveWorkspace().fileSystem, manifest: manifestSyntax, manifestPath: manifestPath, verbose: !globalOptions.logging.quiet @@ -109,4 +85,3 @@ extension SwiftPackageCommand { } } } - diff --git a/Sources/Commands/PackageCommands/AddTarget.swift b/Sources/Commands/PackageCommands/AddTarget.swift index 58cfb4dd996..454aa36f79c 100644 --- a/Sources/Commands/PackageCommands/AddTarget.swift +++ b/Sources/Commands/PackageCommands/AddTarget.swift @@ -16,7 +16,6 @@ import CoreCommands import Foundation import PackageGraph import PackageModel -import SwiftParser @_spi(PackageRefactor) import SwiftRefactor import SwiftSyntax import SwiftSyntaxBuilder @@ -76,35 +75,12 @@ extension SwiftPackageCommand { var testingLibrary: AddPackageTarget.TestHarness = .default func run(_ swiftCommandState: SwiftCommandState) async throws { - let workspace = try swiftCommandState.getActiveWorkspace() - - guard let packagePath = try swiftCommandState.getWorkspaceRoot().packages.first else { - throw StringError("unknown package") - } - - // Load the manifest file - let fileSystem = workspace.fileSystem - let manifestPath = packagePath.appending("Package.swift") - let manifestContents: ByteString - do { - manifestContents = try fileSystem.readFileContents(manifestPath) - } catch { - throw StringError("cannot find package manifest in \(manifestPath)") - } - - // Parse the manifest. - let manifestSyntax = manifestContents.withData { data in - data.withUnsafeBytes { buffer in - buffer.withMemoryRebound(to: UInt8.self) { buffer in - Parser.parse(source: buffer) - } - } - } + let (manifestSyntax, manifestPath) = try swiftCommandState.readPackageManifestAsSyntaxTree() // Move sources into their own folder if they're directly in `./Sources`. try await self.moveSingleTargetSources( - workspace: workspace, - packagePath: packagePath, + workspace: swiftCommandState.getActiveWorkspace(), + packagePath: manifestPath, verbose: !self.globalOptions.logging.quiet, observabilityScope: swiftCommandState.observabilityScope ) @@ -140,7 +116,7 @@ extension SwiftPackageCommand { ) try editResult.applyEdits( - to: fileSystem, + to: swiftCommandState.getActiveWorkspace().fileSystem, manifest: manifestSyntax, manifestPath: manifestPath, verbose: !self.globalOptions.logging.quiet @@ -150,7 +126,7 @@ extension SwiftPackageCommand { try self.addAuxiliaryFiles( target: target, testHarness: self.testingLibrary, - fileSystem: fileSystem, + fileSystem: swiftCommandState.getActiveWorkspace().fileSystem, rootPath: manifestPath.parentDirectory ) } diff --git a/Sources/Commands/PackageCommands/AddTargetDependency.swift b/Sources/Commands/PackageCommands/AddTargetDependency.swift index 47cded60850..98e71895fd5 100644 --- a/Sources/Commands/PackageCommands/AddTargetDependency.swift +++ b/Sources/Commands/PackageCommands/AddTargetDependency.swift @@ -16,7 +16,6 @@ import CoreCommands import Foundation import PackageGraph import PackageModel -import SwiftParser @_spi(PackageRefactor) import SwiftRefactor import SwiftSyntax import TSCBasic @@ -43,30 +42,7 @@ extension SwiftPackageCommand { var package: String? func run(_ swiftCommandState: SwiftCommandState) throws { - let workspace = try swiftCommandState.getActiveWorkspace() - - guard let packagePath = try swiftCommandState.getWorkspaceRoot().packages.first else { - throw StringError("unknown package") - } - - // Load the manifest file - let fileSystem = workspace.fileSystem - let manifestPath = packagePath.appending("Package.swift") - let manifestContents: ByteString - do { - manifestContents = try fileSystem.readFileContents(manifestPath) - } catch { - throw StringError("cannot find package manifest in \(manifestPath)") - } - - // Parse the manifest. - let manifestSyntax = manifestContents.withData { data in - data.withUnsafeBytes { buffer in - buffer.withMemoryRebound(to: UInt8.self) { buffer in - Parser.parse(source: buffer) - } - } - } + let (manifestSyntax, manifestPath) = try swiftCommandState.readPackageManifestAsSyntaxTree() let dependency: PackageTarget.Dependency if let package { @@ -84,7 +60,7 @@ extension SwiftPackageCommand { ) try editResult.applyEdits( - to: fileSystem, + to: swiftCommandState.getActiveWorkspace().fileSystem, manifest: manifestSyntax, manifestPath: manifestPath, verbose: !globalOptions.logging.quiet diff --git a/Sources/Commands/PackageCommands/AddTargetPlugin.swift b/Sources/Commands/PackageCommands/AddTargetPlugin.swift index 6b513e407b7..60c93e5d1fb 100644 --- a/Sources/Commands/PackageCommands/AddTargetPlugin.swift +++ b/Sources/Commands/PackageCommands/AddTargetPlugin.swift @@ -16,7 +16,6 @@ import CoreCommands import Foundation import PackageGraph import PackageModel -import SwiftParser @_spi(PackageRefactor) import SwiftRefactor import SwiftSyntax import TSCBasic @@ -42,30 +41,7 @@ extension SwiftPackageCommand { var package: String? func run(_ swiftCommandState: SwiftCommandState) throws { - let workspace = try swiftCommandState.getActiveWorkspace() - - guard let packagePath = try swiftCommandState.getWorkspaceRoot().packages.first else { - throw StringError("unknown package") - } - - // Load the manifest file - let fileSystem = workspace.fileSystem - let manifestPath = packagePath.appending("Package.swift") - let manifestContents: ByteString - do { - manifestContents = try fileSystem.readFileContents(manifestPath) - } catch { - throw StringError("cannot find package manifest in \(manifestPath)") - } - - // Parse the manifest. - let manifestSyntax = manifestContents.withData { data in - data.withUnsafeBytes { buffer in - buffer.withMemoryRebound(to: UInt8.self) { buffer in - Parser.parse(source: buffer) - } - } - } + let (manifestSyntax, manifestPath) = try swiftCommandState.readPackageManifestAsSyntaxTree() let pluginUsage: PackageTarget.PluginUsage = .plugin(name: pluginName, package: package) @@ -78,7 +54,7 @@ extension SwiftPackageCommand { ) try editResult.applyEdits( - to: fileSystem, + to: swiftCommandState.getActiveWorkspace().fileSystem, manifest: manifestSyntax, manifestPath: manifestPath, verbose: !globalOptions.logging.quiet @@ -86,4 +62,3 @@ extension SwiftPackageCommand { } } } - diff --git a/Sources/Commands/PackageCommands/Migrate.swift b/Sources/Commands/PackageCommands/Migrate.swift index d7485164f6e..ae80678267f 100644 --- a/Sources/Commands/PackageCommands/Migrate.swift +++ b/Sources/Commands/PackageCommands/Migrate.swift @@ -286,6 +286,7 @@ extension SwiftPackageCommand { switch error { case .cannotFindPackage, .cannotAddSettingsToPluginTarget, + .cannotFindDependencies, .existingDependency, .malformedManifest: break diff --git a/Sources/Commands/PackageCommands/SwiftCommandState+readPackageManifestAsSyntaxTree.swift b/Sources/Commands/PackageCommands/SwiftCommandState+readPackageManifestAsSyntaxTree.swift new file mode 100644 index 00000000000..05cf209c426 --- /dev/null +++ b/Sources/Commands/PackageCommands/SwiftCommandState+readPackageManifestAsSyntaxTree.swift @@ -0,0 +1,47 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014-2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Basics +import CoreCommands +import Foundation +import PackageGraph +import PackageModel +import SwiftParser +import SwiftSyntax +import TSCBasic +import Workspace + +extension SwiftCommandState { + /// Read the package manifest of the current package on disk and parse it as a syntax tree. + func readPackageManifestAsSyntaxTree() throws -> (syntax: SourceFileSyntax, path: Basics.AbsolutePath) { + guard let packagePath = try getWorkspaceRoot().packages.first else { + throw StringError("unknown package") + } + + let manifestPath = packagePath.appending(component: Manifest.filename) + let manifestContents: ByteString + do { + manifestContents = try getActiveWorkspace().fileSystem.readFileContents(manifestPath) + } catch { + throw StringError("cannot find package manifest in \(manifestPath)") + } + + let sourceFileSyntax = manifestContents.withData { data in + data.withUnsafeBytes { buffer in + buffer.withMemoryRebound(to: UInt8.self) { buffer in + Parser.parse(source: buffer) + } + } + } + return (sourceFileSyntax, manifestPath) + } +} diff --git a/Sources/Commands/PackageCommands/SwiftPackageCommand.swift b/Sources/Commands/PackageCommands/SwiftPackageCommand.swift index 14f31ba83b4..bfea18aa404 100644 --- a/Sources/Commands/PackageCommands/SwiftPackageCommand.swift +++ b/Sources/Commands/PackageCommands/SwiftPackageCommand.swift @@ -40,6 +40,7 @@ public struct SwiftPackageCommand: AsyncParsableCommand { PurgeCache.self, Reset.self, Update.self, + UpgradeDependencies.self, Describe.self, Init.self, Format.self, diff --git a/Sources/Commands/PackageCommands/UpgradeDependencies.swift b/Sources/Commands/PackageCommands/UpgradeDependencies.swift new file mode 100644 index 00000000000..51998a2a6e5 --- /dev/null +++ b/Sources/Commands/PackageCommands/UpgradeDependencies.swift @@ -0,0 +1,107 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014-2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import ArgumentParser +import Basics +import CoreCommands +import PackageGraph +import PackageModel +@_spi(PackageRefactor) import SwiftRefactor +import SwiftSyntax +import Workspace + +import struct TSCUtility.Version + +extension SwiftPackageCommand { + /// Rewrites every `from:` or `exact:` version requirement in the package + /// manifest to its latest released version, including new major versions. + /// + /// Path, branch, revision, and registry dependencies are left untouched — + /// the underlying `UpgradePackageDependencies` refactor only applies to + /// remote source-control dependencies with an `url:` parameter and a + /// `from:` or `exact:` version requirement. + struct UpgradeDependencies: AsyncSwiftCommand { + static let configuration = CommandConfiguration( + commandName: "upgrade-dependencies", + abstract: "Upgrade all package dependencies with `from:` or `exact:` version requirements to their latest released version, including new major versions.", + helpNames: [.short, .long, .customLong("help", withSingleDash: true)] + ) + + @OptionGroup(visibility: .hidden) + var globalOptions: GlobalOptions + + func run(_ swiftCommandState: SwiftCommandState) async throws { + let (manifestSyntax, manifestPath) = try swiftCommandState.readPackageManifestAsSyntaxTree() + let workspace = try swiftCommandState.getActiveWorkspace() + + let observabilityScope = swiftCommandState.observabilityScope + let context = try await UpgradePackageDependencies.Context( + resolvingLatestVersionIn: manifestSyntax + ) { url, currentVersion in + await workspace.latestVersion( + of: url, + currentVersion: currentVersion, + observabilityScope: observabilityScope + ) + } + + let edits = try UpgradePackageDependencies.textRefactor(syntax: manifestSyntax, in: context) + + try edits.applyEdits( + to: workspace.fileSystem, + manifest: manifestSyntax, + manifestPath: manifestPath, + verbose: !self.globalOptions.logging.quiet + ) + } + } +} + +fileprivate extension Workspace { + /// Returns the latest released version of the package at `url` that the + /// caller is willing to upgrade to, or `nil` if it cannot be resolved. + /// + /// Pre-release versions (those with a SemVer pre-release suffix such as + /// `-alpha.1`) are excluded when `currentVersion` is itself not a + /// pre-release: a manifest pinned to `4.115.0` should not silently + /// follow a freshly-published `5.0.0-alpha.1`. When `currentVersion` is + /// already a pre-release the latest available version is returned, even + /// if it is itself a pre-release. + func latestVersion( + of url: String, + currentVersion: String, + observabilityScope: ObservabilityScope + ) async -> String? { + let sourceControlURL = SourceControlURL(url) + let identity = PackageIdentity(url: sourceControlURL) + let reference = PackageReference.remoteSourceControl(identity: identity, url: sourceControlURL) + // If the current version literal does not parse as a SemVer, treat it + // as a release for the purposes of this filter: hiding pre-releases is + // the safer default for the kinds of unparseable values seen in the + // wild (e.g. partially-edited manifests). + let allowsPrereleases = Version(currentVersion).map { !$0.prereleaseIdentifiers.isEmpty } ?? false + do { + let container = try await self.getContainer( + for: reference, + updateStrategy: .always, + observabilityScope: observabilityScope + ) + return try await container.toolsVersionsAppropriateVersionsDescending() + .first(where: { allowsPrereleases || $0.prereleaseIdentifiers.isEmpty })?.description + } catch { + observabilityScope.emit( + warning: "could not resolve latest version of \(url): \(error.interpolationDescription)" + ) + return nil + } + } +} diff --git a/Sources/_InternalTestSupport/GitRepositoryExtensions.swift b/Sources/_InternalTestSupport/GitRepositoryExtensions.swift index 46a83a67aa1..c97938ce737 100644 --- a/Sources/_InternalTestSupport/GitRepositoryExtensions.swift +++ b/Sources/_InternalTestSupport/GitRepositoryExtensions.swift @@ -53,13 +53,17 @@ package extension GitRepository { } /// Commit the staged changes. If the message is not provided a dummy message will be used for the commit. - func commit(message: String? = nil) throws { + func commit(message: String? = nil, allowEmpty: Bool = false) throws { // FIXME: We don't need to set these every time but we usually only commit once or twice for a test repo. try Process.checkNonZeroExit(args: Git.tool, "-C", self.path.pathString, "config", "user.email", "example@example.com") try Process.checkNonZeroExit(args: Git.tool, "-C", self.path.pathString, "config", "user.name", "Example Example") try Process.checkNonZeroExit(args: Git.tool, "-C", self.path.pathString, "config", "commit.gpgsign", "false") try Process.checkNonZeroExit(args: Git.tool, "-C", self.path.pathString, "config", "tag.gpgsign", "false") - try Process.checkNonZeroExit(args: Git.tool, "-C", self.path.pathString, "commit", "-m", message ?? "Add some files.") + var arguments = [Git.tool, "-C", self.path.pathString, "commit", "-m", message ?? "Add some files."] + if allowEmpty { + arguments.append("--allow-empty") + } + try Process.checkNonZeroExit(arguments: arguments) } /// Tag the git repo. diff --git a/Sources/_InternalTestSupport/SwiftTesting+Tags.swift b/Sources/_InternalTestSupport/SwiftTesting+Tags.swift index 0535e3d5c52..e7b35b4c27f 100644 --- a/Sources/_InternalTestSupport/SwiftTesting+Tags.swift +++ b/Sources/_InternalTestSupport/SwiftTesting+Tags.swift @@ -166,6 +166,7 @@ extension Tag.Feature.Command.Package { @Tag public static var ToolsVersion: Tag @Tag public static var Unedit: Tag @Tag public static var Update: Tag + @Tag public static var UpgradeDependencies: Tag } extension Tag.Feature.Command.PackageRegistry { diff --git a/Tests/CommandsTests/PackageCommandTests.swift b/Tests/CommandsTests/PackageCommandTests.swift index 6e0d7b6d874..f0fb6d464d3 100644 --- a/Tests/CommandsTests/PackageCommandTests.swift +++ b/Tests/CommandsTests/PackageCommandTests.swift @@ -2596,6 +2596,318 @@ struct PackageCommandTests { } } + @Suite( + .tags( + .Feature.Command.Package.UpgradeDependencies, + ) + ) + struct UpgradeDependenciesCommandTests { + @Test( + arguments: SupportedBuildSystemOnAllPlatforms + ) + func unresolvableDependencyLeavesManifestUnchanged( + buildSystem: BuildSystemProvider.Kind + ) async throws { + try await testWithTemporaryDirectory { tmpPath in + let path = tmpPath.appending("PackageB") + try localFileSystem.createDirectory(path) + + let manifest = """ + // swift-tools-version: 5.9 + import PackageDescription + let package = Package( + name: "client", + dependencies: [ + .package(path: "invalid", from: "1.0.0"), + ], + targets: [ .target(name: "client", dependencies: [ "library" ]) ] + ) + """ + try localFileSystem.writeFileContents(path.appending("Package.swift"), string: manifest) + + _ = try await execute( + ["upgrade-dependencies"], + packagePath: path, + configuration: .debug, + buildSystem: buildSystem, + ) + + try expectManifest(path) { contents in + #expect(contents == manifest) + } + } + } + + @Test( + arguments: SupportedBuildSystemOnAllPlatforms + ) + func missingPackageManifestReportsUnknownPackage( + buildSystem: BuildSystemProvider.Kind + ) async throws { + try await testWithTemporaryDirectory { tmpPath in + await expectThrowsCommandExecutionError( + try await execute( + ["upgrade-dependencies"], + packagePath: tmpPath, + configuration: .debug, + buildSystem: buildSystem, + ) + ) { error in + #expect(error.stderr.contains("Could not find Package.swift")) + } + } + } + + @Test( + .disabled(if: ProcessInfo.hostOperatingSystem == .windows, "POSIX file permissions don't apply on Windows"), + arguments: SupportedBuildSystemOnAllPlatforms + ) + func unreadablePackageManifestReportsCannotFindManifest( + buildSystem: BuildSystemProvider.Kind + ) async throws { + try await testWithTemporaryDirectory { tmpPath in + let manifestPath = tmpPath.appending("Package.swift") + try localFileSystem.writeFileContents( + manifestPath, + string: """ + // swift-tools-version: 5.9 + import PackageDescription + let package = Package(name: "client") + """ + ) + + // Strip every permission bit so the manifest is unreadable to the + // current user. + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0))], + ofItemAtPath: manifestPath.pathString + ) + defer { + try? FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o644))], + ofItemAtPath: manifestPath.pathString + ) + } + + // Running as root bypasses POSIX read permissions, so the manifest would remain readable and the + // expected error would not fire. Skip in that case. + guard !FileManager.default.isReadableFile(atPath: manifestPath.pathString) else { + return + } + + await expectThrowsCommandExecutionError( + try await execute( + ["upgrade-dependencies"], + packagePath: tmpPath, + configuration: .debug, + buildSystem: buildSystem, + ) + ) { error in + #expect(error.stderr.contains("cannot find package manifest")) + } + } + } + + @Test( + arguments: SupportedBuildSystemOnAllPlatforms + ) + func zeroDependenciesWithNewerVersionLeavesManifestUnchanged( + buildSystem: BuildSystemProvider.Kind + ) async throws { + try await fixture(name: "DependencyResolution/External/Simple", createGitRepo: true) { fixturePath in + let packageRoot = fixturePath.appending("Bar") + let manifestPath = packageRoot.appending("Package.swift") + + // The fixture ships pinning Foo to `from: "1.0.0"` but the git repo created by `createGitRepo` + // publishes it as 1.2.3. Bump + // the pin to 1.2.3 so that there is no newer version to upgrade to. + let original: String = try localFileSystem.readFileContents(manifestPath) + let manifest = original.replacingOccurrences( + of: #"from: "1.0.0""#, + with: #"from: "1.2.3""# + ) + try localFileSystem.writeFileContents(manifestPath, string: manifest) + + _ = try await execute( + ["upgrade-dependencies"], + packagePath: packageRoot, + configuration: .debug, + buildSystem: buildSystem, + ) + + try expectManifest(packageRoot) { contents in + #expect(contents == manifest) + } + } + } + + @Test( + arguments: SupportedBuildSystemOnAllPlatforms + ) + func upgradeTwoDependenciesToLatestTag( + buildSystem: BuildSystemProvider.Kind + ) async throws { + try await fixture(name: "DependencyResolution/External/Complex", createGitRepo: true) { fixturePath in + let packageRoot = fixturePath.appending("deck-of-playing-cards") + + try GitRepository(path: fixturePath.appending("PlayingCard")).tag(name: "2.0.0") + try GitRepository(path: fixturePath.appending("FisherYates")).tag(name: "3.0.0") + + // Smoke check that the manifest is on the pre-upgrade versions. + try expectManifest(packageRoot) { contents in + #expect(!contents.contains(#"from: "2.0.0""#)) + #expect(!contents.contains(#"from: "3.0.0""#)) + } + + _ = try await execute( + ["upgrade-dependencies"], + packagePath: packageRoot, + configuration: .debug, + buildSystem: buildSystem, + ) + + try expectManifest(packageRoot) { contents in + #expect(contents.contains(#".package(url: "../PlayingCard", from: "2.0.0")"#)) + #expect(contents.contains(#".package(url: "../FisherYates", from: "3.0.0")"#)) + } + } + } + + @Test( + arguments: SupportedBuildSystemOnAllPlatforms + ) + func upgradesFromVersionToLatestTag( + buildSystem: BuildSystemProvider.Kind + ) async throws { + try await fixture(name: "DependencyResolution/External/Simple", createGitRepo: true) { fixturePath in + let packageRoot = fixturePath.appending("Bar") + + try GitRepository(path: fixturePath.appending("Foo")).tag(name: "2.0.0") + + // Smoke check that the package doesn't contain a 2.0.0 dependency initially. + try expectManifest(packageRoot) { contents in + #expect(!contents.contains(#"from: "2.0.0""#)) + } + + _ = try await execute( + ["upgrade-dependencies"], + packagePath: packageRoot, + configuration: .debug, + buildSystem: buildSystem, + ) + + try expectManifest(packageRoot) { contents in + #expect(contents.contains(#"from: "2.0.0""#)) + } + } + } + + @Test( + arguments: SupportedBuildSystemOnAllPlatforms + ) + func picksUpNewlyPublishedTagAfterInitialResolve( + buildSystem: BuildSystemProvider.Kind + ) async throws { + try await fixture(name: "DependencyResolution/External/Simple", createGitRepo: true) { fixturePath in + let packageRoot = fixturePath.appending("Bar") + + // Populate `.build/checkouts` with the initial tag. + _ = try await execute( + ["resolve"], + packagePath: packageRoot, + configuration: .debug, + buildSystem: buildSystem, + ) + + // Smoke check that the package doesn't contain a 2.0.0 dependency initially. + try expectManifest(packageRoot) { contents in + #expect(!contents.contains(#"from: "2.0.0""#)) + } + + // Publish a new major version upstream after bar has checked out 1.2.3 as the Foo dependency. + let foo = GitRepository(path: fixturePath.appending("Foo")) + try foo.commit(allowEmpty: true) + try foo.tag(name: "2.0.0") + + _ = try await execute( + ["upgrade-dependencies"], + packagePath: packageRoot, + configuration: .debug, + buildSystem: buildSystem, + ) + + try expectManifest(packageRoot) { contents in + #expect(contents.contains(#"from: "2.0.0""#)) + } + } + } + + @Test( + arguments: SupportedBuildSystemOnAllPlatforms + ) + func skipsPreReleasesWhenCurrentVersionIsRelease( + buildSystem: BuildSystemProvider.Kind + ) async throws { + try await fixture(name: "DependencyResolution/External/Simple", createGitRepo: true) { fixturePath in + let packageRoot = fixturePath.appending("Bar") + let foo = GitRepository(path: fixturePath.appending("Foo")) + + // Publish a pre-release on top of the fixture's 1.2.3 tag. + try foo.commit(allowEmpty: true) + try foo.tag(name: "2.0.0-alpha.1") + + _ = try await execute( + ["upgrade-dependencies"], + packagePath: packageRoot, + configuration: .debug, + buildSystem: buildSystem, + ) + + try expectManifest(packageRoot) { contents in + #expect(contents.contains(#"from: "1.2.3""#)) + #expect(!contents.contains("2.0.0-alpha.1")) + } + } + } + + @Test( + arguments: SupportedBuildSystemOnAllPlatforms + ) + func picksUpPreReleasesWhenCurrentVersionIsPreRelease( + buildSystem: BuildSystemProvider.Kind + ) async throws { + try await fixture(name: "DependencyResolution/External/Simple", createGitRepo: true) { fixturePath in + let packageRoot = fixturePath.appending("Bar") + let foo = GitRepository(path: fixturePath.appending("Foo")) + + // Rewrite the client manifest to depend on a pre-release. + let manifestPath = packageRoot.appending("Package.swift") + let original: String = try localFileSystem.readFileContents(manifestPath) + try localFileSystem.writeFileContents( + manifestPath, + string: original.replacingOccurrences( + of: #"from: "1.0.0""#, + with: #"from: "1.0.0-beta.1""# + ) + ) + + try foo.commit(allowEmpty: true) + try foo.tag(name: "2.0.0-alpha.1") + + _ = try await execute( + ["upgrade-dependencies"], + packagePath: packageRoot, + configuration: .debug, + buildSystem: buildSystem, + ) + + try expectManifest(packageRoot) { contents in + #expect(contents.contains(#"from: "2.0.0-alpha.1""#)) + } + } + } + } + @Suite( .tags( .Feature.Command.Package.AddTarget, From eac28da0c59f22835daf65ab313dbc6867be0d96 Mon Sep 17 00:00:00 2001 From: Alex Hoppen Date: Thu, 2 Jul 2026 15:15:26 +0200 Subject: [PATCH 2/2] Support upgrading packages to the latest version from a package registry --- .../PackageCommands/UpgradeDependencies.swift | 48 +- Tests/CommandsTests/PackageCommandTests.swift | 312 ------------- .../UpgradeDependenciesCommandTests.swift | 418 ++++++++++++++++++ 3 files changed, 450 insertions(+), 328 deletions(-) create mode 100644 Tests/CommandsTests/UpgradeDependenciesCommandTests.swift diff --git a/Sources/Commands/PackageCommands/UpgradeDependencies.swift b/Sources/Commands/PackageCommands/UpgradeDependencies.swift index 51998a2a6e5..b8e1fa92858 100644 --- a/Sources/Commands/PackageCommands/UpgradeDependencies.swift +++ b/Sources/Commands/PackageCommands/UpgradeDependencies.swift @@ -24,11 +24,6 @@ import struct TSCUtility.Version extension SwiftPackageCommand { /// Rewrites every `from:` or `exact:` version requirement in the package /// manifest to its latest released version, including new major versions. - /// - /// Path, branch, revision, and registry dependencies are left untouched — - /// the underlying `UpgradePackageDependencies` refactor only applies to - /// remote source-control dependencies with an `url:` parameter and a - /// `from:` or `exact:` version requirement. struct UpgradeDependencies: AsyncSwiftCommand { static let configuration = CommandConfiguration( commandName: "upgrade-dependencies", @@ -46,9 +41,9 @@ extension SwiftPackageCommand { let observabilityScope = swiftCommandState.observabilityScope let context = try await UpgradePackageDependencies.Context( resolvingLatestVersionIn: manifestSyntax - ) { url, currentVersion in + ) { dependency, currentVersion in await workspace.latestVersion( - of: url, + of: dependency, currentVersion: currentVersion, observabilityScope: observabilityScope ) @@ -66,9 +61,33 @@ extension SwiftPackageCommand { } } -fileprivate extension Workspace { - /// Returns the latest released version of the package at `url` that the - /// caller is willing to upgrade to, or `nil` if it cannot be resolved. +extension UpgradePackageDependencies.Dependency { + /// A human-readable identifier for diagnostics — the URL for a + /// source-control dep, the identity for a registry dep. + fileprivate var displayName: String { + switch self { + case .sourceControl(let url): return url + case .registry(let identity): return identity + } + } + + fileprivate var packageReference: PackageReference { + switch self { + case .sourceControl(let url): + let sourceControlURL = SourceControlURL(url) + return .remoteSourceControl( + identity: PackageIdentity(url: sourceControlURL), + url: sourceControlURL + ) + case .registry(let identity): + return .registry(identity: PackageIdentity.plain(identity)) + } + } +} + +extension Workspace { + /// Returns the latest released version of `dependency` that the caller + /// is willing to upgrade to, or `nil` if it cannot be resolved. /// /// Pre-release versions (those with a SemVer pre-release suffix such as /// `-alpha.1`) are excluded when `currentVersion` is itself not a @@ -77,13 +96,10 @@ fileprivate extension Workspace { /// already a pre-release the latest available version is returned, even /// if it is itself a pre-release. func latestVersion( - of url: String, + of dependency: UpgradePackageDependencies.Dependency, currentVersion: String, observabilityScope: ObservabilityScope ) async -> String? { - let sourceControlURL = SourceControlURL(url) - let identity = PackageIdentity(url: sourceControlURL) - let reference = PackageReference.remoteSourceControl(identity: identity, url: sourceControlURL) // If the current version literal does not parse as a SemVer, treat it // as a release for the purposes of this filter: hiding pre-releases is // the safer default for the kinds of unparseable values seen in the @@ -91,7 +107,7 @@ fileprivate extension Workspace { let allowsPrereleases = Version(currentVersion).map { !$0.prereleaseIdentifiers.isEmpty } ?? false do { let container = try await self.getContainer( - for: reference, + for: dependency.packageReference, updateStrategy: .always, observabilityScope: observabilityScope ) @@ -99,7 +115,7 @@ fileprivate extension Workspace { .first(where: { allowsPrereleases || $0.prereleaseIdentifiers.isEmpty })?.description } catch { observabilityScope.emit( - warning: "could not resolve latest version of \(url): \(error.interpolationDescription)" + warning: "could not resolve latest version of \(dependency.displayName): \(error.interpolationDescription)" ) return nil } diff --git a/Tests/CommandsTests/PackageCommandTests.swift b/Tests/CommandsTests/PackageCommandTests.swift index f0fb6d464d3..6e0d7b6d874 100644 --- a/Tests/CommandsTests/PackageCommandTests.swift +++ b/Tests/CommandsTests/PackageCommandTests.swift @@ -2596,318 +2596,6 @@ struct PackageCommandTests { } } - @Suite( - .tags( - .Feature.Command.Package.UpgradeDependencies, - ) - ) - struct UpgradeDependenciesCommandTests { - @Test( - arguments: SupportedBuildSystemOnAllPlatforms - ) - func unresolvableDependencyLeavesManifestUnchanged( - buildSystem: BuildSystemProvider.Kind - ) async throws { - try await testWithTemporaryDirectory { tmpPath in - let path = tmpPath.appending("PackageB") - try localFileSystem.createDirectory(path) - - let manifest = """ - // swift-tools-version: 5.9 - import PackageDescription - let package = Package( - name: "client", - dependencies: [ - .package(path: "invalid", from: "1.0.0"), - ], - targets: [ .target(name: "client", dependencies: [ "library" ]) ] - ) - """ - try localFileSystem.writeFileContents(path.appending("Package.swift"), string: manifest) - - _ = try await execute( - ["upgrade-dependencies"], - packagePath: path, - configuration: .debug, - buildSystem: buildSystem, - ) - - try expectManifest(path) { contents in - #expect(contents == manifest) - } - } - } - - @Test( - arguments: SupportedBuildSystemOnAllPlatforms - ) - func missingPackageManifestReportsUnknownPackage( - buildSystem: BuildSystemProvider.Kind - ) async throws { - try await testWithTemporaryDirectory { tmpPath in - await expectThrowsCommandExecutionError( - try await execute( - ["upgrade-dependencies"], - packagePath: tmpPath, - configuration: .debug, - buildSystem: buildSystem, - ) - ) { error in - #expect(error.stderr.contains("Could not find Package.swift")) - } - } - } - - @Test( - .disabled(if: ProcessInfo.hostOperatingSystem == .windows, "POSIX file permissions don't apply on Windows"), - arguments: SupportedBuildSystemOnAllPlatforms - ) - func unreadablePackageManifestReportsCannotFindManifest( - buildSystem: BuildSystemProvider.Kind - ) async throws { - try await testWithTemporaryDirectory { tmpPath in - let manifestPath = tmpPath.appending("Package.swift") - try localFileSystem.writeFileContents( - manifestPath, - string: """ - // swift-tools-version: 5.9 - import PackageDescription - let package = Package(name: "client") - """ - ) - - // Strip every permission bit so the manifest is unreadable to the - // current user. - try FileManager.default.setAttributes( - [.posixPermissions: NSNumber(value: Int16(0))], - ofItemAtPath: manifestPath.pathString - ) - defer { - try? FileManager.default.setAttributes( - [.posixPermissions: NSNumber(value: Int16(0o644))], - ofItemAtPath: manifestPath.pathString - ) - } - - // Running as root bypasses POSIX read permissions, so the manifest would remain readable and the - // expected error would not fire. Skip in that case. - guard !FileManager.default.isReadableFile(atPath: manifestPath.pathString) else { - return - } - - await expectThrowsCommandExecutionError( - try await execute( - ["upgrade-dependencies"], - packagePath: tmpPath, - configuration: .debug, - buildSystem: buildSystem, - ) - ) { error in - #expect(error.stderr.contains("cannot find package manifest")) - } - } - } - - @Test( - arguments: SupportedBuildSystemOnAllPlatforms - ) - func zeroDependenciesWithNewerVersionLeavesManifestUnchanged( - buildSystem: BuildSystemProvider.Kind - ) async throws { - try await fixture(name: "DependencyResolution/External/Simple", createGitRepo: true) { fixturePath in - let packageRoot = fixturePath.appending("Bar") - let manifestPath = packageRoot.appending("Package.swift") - - // The fixture ships pinning Foo to `from: "1.0.0"` but the git repo created by `createGitRepo` - // publishes it as 1.2.3. Bump - // the pin to 1.2.3 so that there is no newer version to upgrade to. - let original: String = try localFileSystem.readFileContents(manifestPath) - let manifest = original.replacingOccurrences( - of: #"from: "1.0.0""#, - with: #"from: "1.2.3""# - ) - try localFileSystem.writeFileContents(manifestPath, string: manifest) - - _ = try await execute( - ["upgrade-dependencies"], - packagePath: packageRoot, - configuration: .debug, - buildSystem: buildSystem, - ) - - try expectManifest(packageRoot) { contents in - #expect(contents == manifest) - } - } - } - - @Test( - arguments: SupportedBuildSystemOnAllPlatforms - ) - func upgradeTwoDependenciesToLatestTag( - buildSystem: BuildSystemProvider.Kind - ) async throws { - try await fixture(name: "DependencyResolution/External/Complex", createGitRepo: true) { fixturePath in - let packageRoot = fixturePath.appending("deck-of-playing-cards") - - try GitRepository(path: fixturePath.appending("PlayingCard")).tag(name: "2.0.0") - try GitRepository(path: fixturePath.appending("FisherYates")).tag(name: "3.0.0") - - // Smoke check that the manifest is on the pre-upgrade versions. - try expectManifest(packageRoot) { contents in - #expect(!contents.contains(#"from: "2.0.0""#)) - #expect(!contents.contains(#"from: "3.0.0""#)) - } - - _ = try await execute( - ["upgrade-dependencies"], - packagePath: packageRoot, - configuration: .debug, - buildSystem: buildSystem, - ) - - try expectManifest(packageRoot) { contents in - #expect(contents.contains(#".package(url: "../PlayingCard", from: "2.0.0")"#)) - #expect(contents.contains(#".package(url: "../FisherYates", from: "3.0.0")"#)) - } - } - } - - @Test( - arguments: SupportedBuildSystemOnAllPlatforms - ) - func upgradesFromVersionToLatestTag( - buildSystem: BuildSystemProvider.Kind - ) async throws { - try await fixture(name: "DependencyResolution/External/Simple", createGitRepo: true) { fixturePath in - let packageRoot = fixturePath.appending("Bar") - - try GitRepository(path: fixturePath.appending("Foo")).tag(name: "2.0.0") - - // Smoke check that the package doesn't contain a 2.0.0 dependency initially. - try expectManifest(packageRoot) { contents in - #expect(!contents.contains(#"from: "2.0.0""#)) - } - - _ = try await execute( - ["upgrade-dependencies"], - packagePath: packageRoot, - configuration: .debug, - buildSystem: buildSystem, - ) - - try expectManifest(packageRoot) { contents in - #expect(contents.contains(#"from: "2.0.0""#)) - } - } - } - - @Test( - arguments: SupportedBuildSystemOnAllPlatforms - ) - func picksUpNewlyPublishedTagAfterInitialResolve( - buildSystem: BuildSystemProvider.Kind - ) async throws { - try await fixture(name: "DependencyResolution/External/Simple", createGitRepo: true) { fixturePath in - let packageRoot = fixturePath.appending("Bar") - - // Populate `.build/checkouts` with the initial tag. - _ = try await execute( - ["resolve"], - packagePath: packageRoot, - configuration: .debug, - buildSystem: buildSystem, - ) - - // Smoke check that the package doesn't contain a 2.0.0 dependency initially. - try expectManifest(packageRoot) { contents in - #expect(!contents.contains(#"from: "2.0.0""#)) - } - - // Publish a new major version upstream after bar has checked out 1.2.3 as the Foo dependency. - let foo = GitRepository(path: fixturePath.appending("Foo")) - try foo.commit(allowEmpty: true) - try foo.tag(name: "2.0.0") - - _ = try await execute( - ["upgrade-dependencies"], - packagePath: packageRoot, - configuration: .debug, - buildSystem: buildSystem, - ) - - try expectManifest(packageRoot) { contents in - #expect(contents.contains(#"from: "2.0.0""#)) - } - } - } - - @Test( - arguments: SupportedBuildSystemOnAllPlatforms - ) - func skipsPreReleasesWhenCurrentVersionIsRelease( - buildSystem: BuildSystemProvider.Kind - ) async throws { - try await fixture(name: "DependencyResolution/External/Simple", createGitRepo: true) { fixturePath in - let packageRoot = fixturePath.appending("Bar") - let foo = GitRepository(path: fixturePath.appending("Foo")) - - // Publish a pre-release on top of the fixture's 1.2.3 tag. - try foo.commit(allowEmpty: true) - try foo.tag(name: "2.0.0-alpha.1") - - _ = try await execute( - ["upgrade-dependencies"], - packagePath: packageRoot, - configuration: .debug, - buildSystem: buildSystem, - ) - - try expectManifest(packageRoot) { contents in - #expect(contents.contains(#"from: "1.2.3""#)) - #expect(!contents.contains("2.0.0-alpha.1")) - } - } - } - - @Test( - arguments: SupportedBuildSystemOnAllPlatforms - ) - func picksUpPreReleasesWhenCurrentVersionIsPreRelease( - buildSystem: BuildSystemProvider.Kind - ) async throws { - try await fixture(name: "DependencyResolution/External/Simple", createGitRepo: true) { fixturePath in - let packageRoot = fixturePath.appending("Bar") - let foo = GitRepository(path: fixturePath.appending("Foo")) - - // Rewrite the client manifest to depend on a pre-release. - let manifestPath = packageRoot.appending("Package.swift") - let original: String = try localFileSystem.readFileContents(manifestPath) - try localFileSystem.writeFileContents( - manifestPath, - string: original.replacingOccurrences( - of: #"from: "1.0.0""#, - with: #"from: "1.0.0-beta.1""# - ) - ) - - try foo.commit(allowEmpty: true) - try foo.tag(name: "2.0.0-alpha.1") - - _ = try await execute( - ["upgrade-dependencies"], - packagePath: packageRoot, - configuration: .debug, - buildSystem: buildSystem, - ) - - try expectManifest(packageRoot) { contents in - #expect(contents.contains(#"from: "2.0.0-alpha.1""#)) - } - } - } - } - @Suite( .tags( .Feature.Command.Package.AddTarget, diff --git a/Tests/CommandsTests/UpgradeDependenciesCommandTests.swift b/Tests/CommandsTests/UpgradeDependenciesCommandTests.swift new file mode 100644 index 00000000000..8adf924ca35 --- /dev/null +++ b/Tests/CommandsTests/UpgradeDependenciesCommandTests.swift @@ -0,0 +1,418 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014-2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Basics +@testable import Commands +import Foundation +import PackageGraph +import PackageLoading +import PackageModel +import PackageRegistry +import SourceControl +@_spi(PackageRefactor) import SwiftRefactor +import Testing +import Workspace +import _InternalTestSupport + +import struct SPMBuildCore.BuildSystemProvider +import struct TSCUtility.Version + +@discardableResult +fileprivate func execute( + _ args: [String] = [], + packagePath: AbsolutePath? = nil, + manifest: String? = nil, + env: Environment? = nil, + configuration: BuildConfiguration, + buildSystem: BuildSystemProvider.Kind +) async throws -> (stdout: String, stderr: String) { + var environment = env ?? [:] + if let manifest, let packagePath { + try localFileSystem.writeFileContents(packagePath.appending("Package.swift"), string: manifest) + } + + // don't ignore local packages when caching + environment["SWIFTPM_TESTS_PACKAGECACHE"] = "1" + return try await executeSwiftPackage( + packagePath, + configuration: configuration, + extraArgs: args, + env: environment, + buildSystem: buildSystem, + ) +} + +// Helper function to arbitrarily assert on manifest content +private func expectManifest(_ packagePath: AbsolutePath, _ callback: (String) throws -> Void) throws { + let manifestPath = packagePath.appending("Package.swift") + expectFileExists(at: manifestPath) + let contents: String = try localFileSystem.readFileContents(manifestPath) + try callback(contents) +} + +@Suite( + .tags( + .Feature.Command.Package.UpgradeDependencies, + ) +) +struct UpgradeDependenciesCommandTests { + @Test( + arguments: SupportedBuildSystemOnAllPlatforms + ) + func upgradesFromVersionToLatestTag( + buildSystem: BuildSystemProvider.Kind + ) async throws { + try await fixture(name: "DependencyResolution/External/Simple", createGitRepo: true) { fixturePath in + let packageRoot = fixturePath.appending("Bar") + + try GitRepository(path: fixturePath.appending("Foo")).tag(name: "2.0.0") + + // Smoke check that the package doesn't contain a 2.0.0 dependency initially. + try expectManifest(packageRoot) { contents in + #expect(!contents.contains(#"from: "2.0.0""#)) + } + + _ = try await execute( + ["upgrade-dependencies"], + packagePath: packageRoot, + configuration: .debug, + buildSystem: buildSystem, + ) + + try expectManifest(packageRoot) { contents in + #expect(contents.contains(#"from: "2.0.0""#)) + } + } + } + + @Test( + arguments: SupportedBuildSystemOnAllPlatforms + ) + func upgradeTwoDependenciesToLatestTag( + buildSystem: BuildSystemProvider.Kind + ) async throws { + try await fixture(name: "DependencyResolution/External/Complex", createGitRepo: true) { fixturePath in + let packageRoot = fixturePath.appending("deck-of-playing-cards") + + try GitRepository(path: fixturePath.appending("PlayingCard")).tag(name: "2.0.0") + try GitRepository(path: fixturePath.appending("FisherYates")).tag(name: "3.0.0") + + // Smoke check that the manifest is on the pre-upgrade versions. + try expectManifest(packageRoot) { contents in + #expect(!contents.contains(#"from: "2.0.0""#)) + #expect(!contents.contains(#"from: "3.0.0""#)) + } + + _ = try await execute( + ["upgrade-dependencies"], + packagePath: packageRoot, + configuration: .debug, + buildSystem: buildSystem, + ) + + try expectManifest(packageRoot) { contents in + #expect(contents.contains(#".package(url: "../PlayingCard", from: "2.0.0")"#)) + #expect(contents.contains(#".package(url: "../FisherYates", from: "3.0.0")"#)) + } + } + } + + @Test( + arguments: SupportedBuildSystemOnAllPlatforms + ) + func skipsPreReleasesWhenCurrentVersionIsRelease( + buildSystem: BuildSystemProvider.Kind + ) async throws { + try await fixture(name: "DependencyResolution/External/Simple", createGitRepo: true) { fixturePath in + let packageRoot = fixturePath.appending("Bar") + let foo = GitRepository(path: fixturePath.appending("Foo")) + + // Publish a pre-release on top of the fixture's 1.2.3 tag. + try foo.commit(allowEmpty: true) + try foo.tag(name: "2.0.0-alpha.1") + + _ = try await execute( + ["upgrade-dependencies"], + packagePath: packageRoot, + configuration: .debug, + buildSystem: buildSystem, + ) + + try expectManifest(packageRoot) { contents in + #expect(contents.contains(#"from: "1.2.3""#)) + #expect(!contents.contains("2.0.0-alpha.1")) + } + } + } + + @Test( + arguments: SupportedBuildSystemOnAllPlatforms + ) + func picksUpPreReleasesWhenCurrentVersionIsPreRelease( + buildSystem: BuildSystemProvider.Kind + ) async throws { + try await fixture(name: "DependencyResolution/External/Simple", createGitRepo: true) { fixturePath in + let packageRoot = fixturePath.appending("Bar") + let foo = GitRepository(path: fixturePath.appending("Foo")) + + // Rewrite the client manifest to depend on a pre-release. + let manifestPath = packageRoot.appending("Package.swift") + let original: String = try localFileSystem.readFileContents(manifestPath) + try localFileSystem.writeFileContents( + manifestPath, + string: original.replacingOccurrences( + of: #"from: "1.0.0""#, + with: #"from: "1.0.0-beta.1""# + ) + ) + + try foo.commit(allowEmpty: true) + try foo.tag(name: "2.0.0-alpha.1") + + _ = try await execute( + ["upgrade-dependencies"], + packagePath: packageRoot, + configuration: .debug, + buildSystem: buildSystem, + ) + + try expectManifest(packageRoot) { contents in + #expect(contents.contains(#"from: "2.0.0-alpha.1""#)) + } + } + } + + @Test( + arguments: SupportedBuildSystemOnAllPlatforms + ) + func picksUpNewlyPublishedTagAfterInitialResolve( + buildSystem: BuildSystemProvider.Kind + ) async throws { + try await fixture(name: "DependencyResolution/External/Simple", createGitRepo: true) { fixturePath in + let packageRoot = fixturePath.appending("Bar") + + // Populate `.build/checkouts` with the initial tag. + _ = try await execute( + ["resolve"], + packagePath: packageRoot, + configuration: .debug, + buildSystem: buildSystem, + ) + + // Smoke check that the package doesn't contain a 2.0.0 dependency initially. + try expectManifest(packageRoot) { contents in + #expect(!contents.contains(#"from: "2.0.0""#)) + } + + // Publish a new major version upstream after bar has checked out 1.2.3 as the Foo dependency. + let foo = GitRepository(path: fixturePath.appending("Foo")) + try foo.commit(allowEmpty: true) + try foo.tag(name: "2.0.0") + + _ = try await execute( + ["upgrade-dependencies"], + packagePath: packageRoot, + configuration: .debug, + buildSystem: buildSystem, + ) + + try expectManifest(packageRoot) { contents in + #expect(contents.contains(#"from: "2.0.0""#)) + } + } + } + + @Test( + arguments: SupportedBuildSystemOnAllPlatforms + ) + func zeroDependenciesWithNewerVersionLeavesManifestUnchanged( + buildSystem: BuildSystemProvider.Kind + ) async throws { + try await fixture(name: "DependencyResolution/External/Simple", createGitRepo: true) { fixturePath in + let packageRoot = fixturePath.appending("Bar") + let manifestPath = packageRoot.appending("Package.swift") + + // The fixture ships pinning Foo to `from: "1.0.0"` but the git repo created by `createGitRepo` + // publishes it as 1.2.3. Bump the pin to 1.2.3 so that there is no newer version to upgrade to. + let original: String = try localFileSystem.readFileContents(manifestPath) + let manifest = original.replacingOccurrences( + of: #"from: "1.0.0""#, + with: #"from: "1.2.3""# + ) + try localFileSystem.writeFileContents(manifestPath, string: manifest) + + _ = try await execute( + ["upgrade-dependencies"], + packagePath: packageRoot, + configuration: .debug, + buildSystem: buildSystem, + ) + + try expectManifest(packageRoot) { contents in + #expect(contents == manifest) + } + } + } + + @Test( + arguments: SupportedBuildSystemOnAllPlatforms + ) + func unresolvableDependencyLeavesManifestUnchanged( + buildSystem: BuildSystemProvider.Kind + ) async throws { + try await testWithTemporaryDirectory { tmpPath in + let path = tmpPath.appending("PackageB") + try localFileSystem.createDirectory(path) + + let manifest = """ + // swift-tools-version: 5.9 + import PackageDescription + let package = Package( + name: "client", + dependencies: [ + .package(path: "invalid", from: "1.0.0"), + ], + targets: [ .target(name: "client", dependencies: [ "library" ]) ] + ) + """ + try localFileSystem.writeFileContents(path.appending("Package.swift"), string: manifest) + + _ = try await execute( + ["upgrade-dependencies"], + packagePath: path, + configuration: .debug, + buildSystem: buildSystem, + ) + + try expectManifest(path) { contents in + #expect(contents == manifest) + } + } + } + + @Test( + arguments: SupportedBuildSystemOnAllPlatforms + ) + func missingPackageManifestReportsUnknownPackage( + buildSystem: BuildSystemProvider.Kind + ) async throws { + try await testWithTemporaryDirectory { tmpPath in + await expectThrowsCommandExecutionError( + try await execute( + ["upgrade-dependencies"], + packagePath: tmpPath, + configuration: .debug, + buildSystem: buildSystem, + ) + ) { error in + #expect(error.stderr.contains("Could not find Package.swift")) + } + } + } + + @Test( + .disabled(if: ProcessInfo.hostOperatingSystem == .windows, "POSIX file permissions don't apply on Windows"), + arguments: SupportedBuildSystemOnAllPlatforms + ) + func unreadablePackageManifestReportsCannotFindManifest( + buildSystem: BuildSystemProvider.Kind + ) async throws { + try await testWithTemporaryDirectory { tmpPath in + let manifestPath = tmpPath.appending("Package.swift") + try localFileSystem.writeFileContents( + manifestPath, + string: """ + // swift-tools-version: 5.9 + import PackageDescription + let package = Package(name: "client") + """ + ) + + // Strip every permission bit so the manifest is unreadable to the + // current user. + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0))], + ofItemAtPath: manifestPath.pathString + ) + defer { + try? FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o644))], + ofItemAtPath: manifestPath.pathString + ) + } + + // Running as root may bypass POSIX read permissions, so the manifest would remain readable and the + // expected error would not fire. Skip in that case. + guard !FileManager.default.isReadableFile(atPath: manifestPath.pathString) else { + return + } + + await expectThrowsCommandExecutionError( + try await execute( + ["upgrade-dependencies"], + packagePath: tmpPath, + configuration: .debug, + buildSystem: buildSystem, + ) + ) { error in + #expect(error.stderr.contains("cannot find package manifest")) + } + } + } + + @Test + func latestVersionPicksUpNewVersionFromRegistry() async throws { + // We cannot inject a `MockRegistry` into the setup that runs the upgrade-dependencies command. The above checks + // already check the whole Package manifest edit path, so just check that `workspace.latestVersion` is able to + // pick up new versions from a package registry here. + let fs = InMemoryFileSystem() + try fs.createMockToolchain() + + let identity = PackageIdentity.plain("scope.name") + let packagePath = AbsolutePath.root + + let registry = MockRegistry( + filesystem: fs, + identityResolver: DefaultIdentityResolver(), + checksumAlgorithm: MockHashAlgorithm(), + fingerprintStorage: MockPackageFingerprintStorage(), + signingEntityStorage: MockPackageSigningEntityStorage() + ) + + let packageSource = InMemoryRegistryPackageSource( + fileSystem: fs, + path: .root.appending(components: "registry", "server", identity.description) + ) + try packageSource.writePackageContent() + + registry.addPackage(identity: identity, versions: ["1.0.0", "2.0.0", "3.1.0"], source: packageSource) + + let workspace = try Workspace._init( + fileSystem: fs, + environment: .mockEnvironment, + location: .init(forRootPackage: packagePath, fileSystem: fs), + customHostToolchain: .mockHostToolchain(fs), + customManifestLoader: MockManifestLoader(manifests: [:]), + customRegistryClient: registry.registryClient + ) + let observability = ObservabilitySystem.makeForTesting() + + let latest = await workspace.latestVersion( + of: .registry(identity: "scope.name"), + currentVersion: "1.0.0", + observabilityScope: observability.topScope + ) + + #expect(latest == "3.1.0") + expectNoDiagnostics(observability.diagnostics) + } +}