Skip to content

Add an option to self-update to provide the swiftly version to update #370

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Jul 9, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -469,7 +469,7 @@ swiftly init [--no-modify-profile] [--overwrite] [--platform=<platform>] [--skip
Update the version of swiftly itself.

```
swiftly self-update [--assume-yes] [--verbose] [--version] [--help]
swiftly self-update [--assume-yes] [--verbose] [--version] [--help]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extra space added here

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, the documentation generator is homegrown, and naive. This seems minor enough that I wanted to keep the change to another PR, or possibly adopt the revision of it that's now available in the swift argument parser project.

```

**--assume-yes:**
Expand Down
90 changes: 64 additions & 26 deletions Sources/Swiftly/SelfUpdate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,23 @@ import SwiftlyWebsiteAPI
@preconcurrency import TSCBasic
import TSCUtility

extension SwiftlyVersion: ExpressibleByArgument {
public init?(argument: String) {
try? self.init(parsing: argument)
}
}

struct SelfUpdate: SwiftlyCommand {
public static let configuration = CommandConfiguration(
abstract: "Update the version of swiftly itself."
)

@OptionGroup var root: GlobalOptions

@Option(help: .hidden) var toVersion: SwiftlyVersion

private enum CodingKeys: String, CodingKey {
case root
case root, toVersion
}

mutating func run() async throws {
Expand All @@ -31,50 +39,80 @@ struct SelfUpdate: SwiftlyCommand {
)
}

let _ = try await Self.execute(ctx, verbose: self.root.verbose)
let _ = try await Self.execute(ctx, verbose: self.root.verbose, version: self.toVersion)
}

public static func execute(_ ctx: SwiftlyCoreContext, verbose: Bool) async throws
public static func execute(_ ctx: SwiftlyCoreContext, verbose: Bool, version swiftlyVersion: SwiftlyVersion?) async throws
-> SwiftlyVersion
{
var downloadURL: Foundation.URL?
var version: SwiftlyVersion? = swiftlyVersion

await ctx.message("Checking for swiftly updates...")

let swiftlyRelease = try await ctx.httpClient.getCurrentSwiftlyRelease()
if let version {
#if os(macOS)
downloadURL = URL(string: "https://download.swift.org/swiftly/darwin/swiftly-\(version).pkg")
#elseif os(Linux)
#if arch(x86_64)
downloadURL = URL(string: "https://download.swift.org/swiftly/linux/swiftly-\(version)-x86_64.tar.gz")
#elseif arch(arm64)
downloadURL = URL(string: "https://download.swift.org/swiftly/linux/swiftly-\(version)-aarch64.tar.gz")
#else
fatalError("Unsupported architecture")
#endif
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this throw an error if we somehow get here and don't know what we're downloading?
As it stands, I think this will end up running the update based on getCurrentSwiftlyRelease() since downloadURL won't get set, even though a version was specified.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added fatal errors in the unsupported cases.

#else
fatalError("Unsupported OS")
#endif

guard try swiftlyRelease.swiftlyVersion > SwiftlyCore.version else {
await ctx.message("Already up to date.")
return SwiftlyCore.version
guard version > SwiftlyCore.version else {
await ctx.print("Self-update does not support downgrading to an older version or re-installing the current version. Current version is \(SwiftlyCore.version) and requested version is \(version).")
return SwiftlyCore.version
}

await ctx.print("Self-update requested to swiftly version \(version)")
}

var downloadURL: Foundation.URL?
for platform in swiftlyRelease.platforms {
#if os(macOS)
guard platform.isDarwin else {
continue
if downloadURL == nil {
await ctx.print("Checking for swiftly updates...")

let swiftlyRelease = try await ctx.httpClient.getCurrentSwiftlyRelease()

guard try swiftlyRelease.swiftlyVersion > SwiftlyCore.version else {
await ctx.print("Already up to date.")
return SwiftlyCore.version
}
for platform in swiftlyRelease.platforms {
#if os(macOS)
guard platform.isDarwin else {
continue
}
#elseif os(Linux)
guard platform.isLinux else {
continue
}
guard platform.isLinux else {
continue
}
#endif

#if arch(x86_64)
downloadURL = try platform.x86_64URL
downloadURL = try platform.x86_64URL
#elseif arch(arm64)
downloadURL = try platform.arm64URL
downloadURL = try platform.arm64URL
#endif
}
}

guard let downloadURL else {
throw SwiftlyError(
message:
"The newest release of swiftly is incompatible with your current OS and/or processor architecture."
)
}
guard let downloadURL else {
throw SwiftlyError(
message:
"The newest release of swiftly is incompatible with your current OS and/or processor architecture."
)
}

version = try swiftlyRelease.swiftlyVersion

let version = try swiftlyRelease.swiftlyVersion
await ctx.print("A new version of swiftly is available: \(version!)")
}

await ctx.message("A new version is available: \(version)")
guard let version, let downloadURL else { fatalError() }

let tmpFile = fs.mktemp()
try await fs.create(file: tmpFile, contents: nil)
Expand Down
40 changes: 38 additions & 2 deletions Tests/SwiftlyTests/HTTPClientTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,17 @@ import Testing
}
}

@Test(.tags(.large)) func getSwiftlyRelease() async throws {
@Test(
.tags(.large),
arguments: [
"https://download.swift.org/swiftly/linux/swiftly-x86_64.tar.gz", // Latest
"https://download.swift.org/swiftly/linux/swiftly-1.0.1-x86_64.tar.gz", // Specific version
"https://download.swift.org/swiftly/linux/swiftly-1.0.1-dev-x86_64.tar.gz", // Specific dev prerelease version
"https://download.swift.org/swiftly/linux/swiftly-aarch64.tar.gz", // Latest ARM
"https://download.swift.org/swiftly/linux/swiftly-1.0.1-aarch64.tar.gz", // Specific ARM version
"https://download.swift.org/swiftly/linux/swiftly-1.0.1-dev-aarch64.tar.gz", // Specific dev prerelease version
]
) func getSwiftlyLinuxReleases(url: String) async throws {
let tmpFile = fs.mktemp()
try await fs.create(file: tmpFile, contents: nil)
let tmpFileSignature = fs.mktemp(ext: ".sig")
Expand All @@ -64,7 +74,7 @@ import Testing
try await fs.withTemporary(files: tmpFile, tmpFileSignature, keysFile) {
let httpClient = SwiftlyHTTPClient(httpRequestExecutor: HTTPRequestExecutorImpl())

let swiftlyURL = try #require(URL(string: "https://download.swift.org/swiftly/linux/swiftly-x86_64.tar.gz"))
let swiftlyURL = try #require(URL(string: url))

try await retry {
try await httpClient.getSwiftlyRelease(url: swiftlyURL).download(to: tmpFile)
Expand All @@ -82,6 +92,32 @@ import Testing
}
}

@Test(
.tags(.large),
arguments: [
"https://download.swift.org/swiftly/darwin/swiftly.pkg", // Latest
"https://download.swift.org/swiftly/darwin/swiftly-1.0.1.pkg", // Specific version
"https://download.swift.org/swiftly/darwin/swiftly-1.0.1-dev.pkg", // Specific dev prerelease version
]
) func getSwiftlyMacOSReleases(url: String) async throws {
let tmpFile = fs.mktemp()
try await fs.create(file: tmpFile, contents: nil)
let tmpFileSignature = fs.mktemp(ext: ".sig")
try await fs.create(file: tmpFileSignature, contents: nil)
let keysFile = fs.mktemp(ext: ".asc")
try await fs.create(file: keysFile, contents: nil)

try await fs.withTemporary(files: tmpFile, tmpFileSignature, keysFile) {
let httpClient = SwiftlyHTTPClient(httpRequestExecutor: HTTPRequestExecutorImpl())

let swiftlyURL = try #require(URL(string: url))

try await retry {
try await httpClient.getSwiftlyRelease(url: swiftlyURL).download(to: tmpFile)
}
}
}

@Test(.tags(.large)) func getSwiftlyReleaseMetadataFromSwiftOrg() async throws {
let httpClient = SwiftlyHTTPClient(httpRequestExecutor: HTTPRequestExecutorImpl())
do {
Expand Down
28 changes: 27 additions & 1 deletion Tests/SwiftlyTests/SelfUpdateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,14 @@ import Testing
SwiftlyVersion(major: SwiftlyCore.version.major, minor: SwiftlyCore.version.minor, patch: SwiftlyCore.version.patch + 1)
}

private static var newDevVersion: SwiftlyVersion {
SwiftlyVersion(major: SwiftlyCore.version.major, minor: SwiftlyCore.version.minor, patch: SwiftlyCore.version.patch + 1, suffix: "dev")
}

func runSelfUpdateTest(latestVersion: SwiftlyVersion) async throws {
try await SwiftlyTests.withTestHome {
try await SwiftlyTests.withMockedSwiftlyVersion(latestSwiftlyVersion: latestVersion) {
let updatedVersion = try await SelfUpdate.execute(SwiftlyTests.ctx, verbose: true)
let updatedVersion = try await SelfUpdate.execute(SwiftlyTests.ctx, verbose: true, version: nil)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cmcgee1024 do we want a test specifying the version to cover the new code path?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I've added more test coverage for this

#expect(latestVersion == updatedVersion)
}
}
Expand All @@ -37,4 +41,26 @@ import Testing
@Test func selfUpdateAlreadyUpToDate() async throws {
try await self.runSelfUpdateTest(latestVersion: SwiftlyCore.version)
}

@Test func selfUpdateToUserSpecifiedVersion() async throws {
try await SwiftlyTests.withTestHome {
// GIVEN: swiftly is installed, and at the latest published version
try await SwiftlyTests.withMockedSwiftlyVersion(latestSwiftlyVersion: SwiftlyCore.version) {
// WHEN: An attempt is made to self-update to an equal version
var updatedVersion = try await SelfUpdate.execute(SwiftlyTests.ctx, verbose: true, version: SwiftlyCore.version)
// THEN: There is no change to the swiftly version
#expect(updatedVersion == SwiftlyCore.version)

// WHEN: An attempt is made to self-update to an older version
updatedVersion = try await SelfUpdate.execute(SwiftlyTests.ctx, verbose: true, version: SwiftlyVersion(major: SwiftlyCore.version.major - 1, minor: 0, patch: 0))
// THEN: There is no change to the swiftly version
#expect(updatedVersion == SwiftlyCore.version)

// WHEN: An attempt is made to self-update to a newer development version
updatedVersion = try await SelfUpdate.execute(SwiftlyTests.ctx, verbose: true, version: Self.newDevVersion)
// THEN: swiftly is updated to the new version
#expect(updatedVersion == Self.newDevVersion)
}
}
}
}