Skip to content

Commit d4fc465

Browse files
committed
ci: add update-appcast script
1 parent 5785fae commit d4fc465

File tree

3 files changed

+223
-0
lines changed

3 files changed

+223
-0
lines changed

scripts/update-appcast/.swiftlint.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
disabled_rules:
2+
- todo
3+
- trailing_comma

scripts/update-appcast/Package.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// swift-tools-version: 6.0
2+
// The swift-tools-version declares the minimum version of Swift required to build this package.
3+
4+
import PackageDescription
5+
6+
let package = Package(
7+
name: "update-appcast",
8+
platforms: [
9+
.macOS(.v15),
10+
],
11+
dependencies: [
12+
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"),
13+
],
14+
targets: [
15+
.executableTarget(
16+
name: "update-appcast", dependencies: [
17+
.product(name: "ArgumentParser", package: "swift-argument-parser"),
18+
]
19+
),
20+
]
21+
)
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import ArgumentParser
2+
import Foundation
3+
import RegexBuilder
4+
#if canImport(FoundationXML)
5+
import FoundationXML
6+
#endif
7+
8+
/// UpdateAppcast
9+
/// -------------
10+
/// Replaces an existing `<item>` for the **stable** or **preview** channel
11+
/// in a Sparkle RSS feed with one containing the new version, signature, and
12+
/// length attributes. The feed will always contain one item for each channel.
13+
/// Whether the passed version is a stable or preview version is determined by the
14+
/// number of components in the version string:
15+
/// - Stable: `X.Y.Z`
16+
/// - Preview: `X.Y.Z.N`
17+
/// `N` is the build number - the number of commits since the last stable release.
18+
@main
19+
struct UpdateAppcast: AsyncParsableCommand {
20+
static let configuration = CommandConfiguration(
21+
abstract: "Updates a Sparkle appcast with a new release entry."
22+
)
23+
24+
@Argument(help: "Path to the appcast file to be updated.")
25+
var appcastFile: String
26+
27+
@Argument(help: "Path to the signature file generated for the release binary.")
28+
var signatureFile: String
29+
30+
@Argument(help: "The project version (X.Y.Z for stable builds, X.Y.Z.N for preview builds).")
31+
var releaseVersion: String
32+
33+
@Argument(help: "Path where the updated appcast should be written.")
34+
var outputFile: String
35+
36+
mutating func validate() throws {
37+
guard FileManager.default.fileExists(atPath: signatureFile) else {
38+
throw ValidationError("No file exists at path \(signatureFile).")
39+
}
40+
guard FileManager.default.fileExists(atPath: appcastFile) else {
41+
throw ValidationError("No file exists at path \(appcastFile).")
42+
}
43+
}
44+
45+
// swiftlint:disable:next function_body_length
46+
mutating func run() async throws {
47+
let channel: UpdateChannel = isStable(version: releaseVersion) ? .stable : .preview
48+
let sigLine = try String(contentsOfFile: signatureFile, encoding: .utf8)
49+
.trimmingCharacters(in: .whitespacesAndNewlines)
50+
51+
guard let match = sigLine.firstMatch(of: signatureRegex) else {
52+
throw RuntimeError("Unable to parse signature file: \(sigLine)")
53+
}
54+
55+
let edSignature = match.output.1
56+
guard let length = match.output.2 else {
57+
throw RuntimeError("Unable to parse length from signature file.")
58+
}
59+
60+
let xmlData = try Data(contentsOf: URL(fileURLWithPath: appcastFile))
61+
let doc = try XMLDocument(data: xmlData, options: .nodePrettyPrint)
62+
63+
guard let channelElem = try doc.nodes(forXPath: "/rss/channel").first as? XMLElement else {
64+
throw RuntimeError("<channel> element not found in appcast.")
65+
}
66+
67+
guard let insertionIndex = (channelElem.children ?? [])
68+
.enumerated()
69+
.first(where: { _, node in
70+
guard let item = node as? XMLElement,
71+
item.name == "item",
72+
item.elements(forName: "sparkle:channel")
73+
.first?.stringValue == channel.rawValue
74+
else { return false }
75+
return true
76+
})?.offset
77+
else {
78+
throw RuntimeError("No existing item found for channel \(channel.rawValue).")
79+
}
80+
// Delete the existing item
81+
channelElem.removeChild(at: insertionIndex)
82+
83+
let item = XMLElement(name: "item")
84+
switch channel {
85+
case .stable:
86+
item.addChild(XMLElement(name: "title", stringValue: "v\(releaseVersion)"))
87+
case .preview:
88+
item.addChild(XMLElement(name: "title", stringValue: "Preview"))
89+
}
90+
91+
item.addChild(XMLElement(name: "pubDate", stringValue: rfc822Date()))
92+
item.addChild(XMLElement(name: "sparkle:channel", stringValue: channel.rawValue))
93+
item.addChild(XMLElement(name: "sparkle:version", stringValue: releaseVersion))
94+
// We only have chanegelogs for stable releases
95+
if case .stable = channel {
96+
item.addChild(XMLElement(
97+
name: "sparkle:releaseNotesLink",
98+
stringValue: "https://github.com/coder/coder-desktop-macos/releases/tag/v\(releaseVersion)"
99+
))
100+
}
101+
item.addChild(XMLElement(
102+
name: "sparkle:fullReleaseNotesLink",
103+
stringValue: "https://github.com/coder/coder-desktop-macos/releases"
104+
))
105+
item.addChild(XMLElement(
106+
name: "sparkle:minimumSystemVersion",
107+
stringValue: "14.0.0"
108+
))
109+
110+
let enclosure = XMLElement(name: "enclosure")
111+
func addEnclosureAttr(_ name: String, _ value: String) {
112+
// Force-casting is the intended API usage.
113+
// swiftlint:disable:next force_cast
114+
enclosure.addAttribute(XMLNode.attribute(withName: name, stringValue: value) as! XMLNode)
115+
}
116+
addEnclosureAttr("url", downloadURL(for: releaseVersion, channel: channel))
117+
addEnclosureAttr("type", "application/octet-stream")
118+
addEnclosureAttr("sparkle:installationType", "package")
119+
addEnclosureAttr("sparkle:edSignature", edSignature)
120+
addEnclosureAttr("length", String(length))
121+
item.addChild(enclosure)
122+
123+
channelElem.insertChild(item, at: insertionIndex)
124+
125+
let output = doc.xmlString(options: [.nodePrettyPrint]) + "\n"
126+
try output.write(to: URL(fileURLWithPath: outputFile), atomically: true, encoding: .utf8)
127+
}
128+
129+
private func isStable(version: String) -> Bool {
130+
// A version is a release version if it has three components (X.Y.Z)
131+
guard let match = version.firstMatch(of: versionRegex) else { return false }
132+
return match.output.4 == nil
133+
}
134+
135+
private func downloadURL(for version: String, channel: UpdateChannel) -> String {
136+
switch channel {
137+
case .stable: "https://github.com/coder/coder-desktop-macos/releases/download/v\(version)/Coder-Desktop.pkg"
138+
case .preview: "https://github.com/coder/coder-desktop-macos/releases/download/preview/Coder-Desktop.pkg"
139+
}
140+
}
141+
142+
private func rfc822Date(date: Date = Date()) -> String {
143+
let fmt = DateFormatter()
144+
fmt.locale = Locale(identifier: "en_US_POSIX")
145+
fmt.timeZone = TimeZone(secondsFromGMT: 0)
146+
fmt.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z"
147+
return fmt.string(from: date)
148+
}
149+
}
150+
151+
enum UpdateChannel: String { case stable, preview }
152+
153+
struct RuntimeError: Error, CustomStringConvertible {
154+
var message: String
155+
var description: String { message }
156+
init(_ message: String) { self.message = message }
157+
}
158+
159+
extension Regex: @retroactive @unchecked Sendable {}
160+
161+
// Matches CFBundleVersion format: X.Y.Z or X.Y.Z.N
162+
let versionRegex = Regex {
163+
Anchor.startOfLine
164+
Capture {
165+
OneOrMore(.digit)
166+
} transform: { Int($0)! }
167+
"."
168+
Capture {
169+
OneOrMore(.digit)
170+
} transform: { Int($0)! }
171+
"."
172+
Capture {
173+
OneOrMore(.digit)
174+
} transform: { Int($0)! }
175+
Optionally {
176+
Capture {
177+
"."
178+
OneOrMore(.digit)
179+
} transform: { Int($0.dropFirst())! }
180+
}
181+
Anchor.endOfLine
182+
}
183+
184+
let signatureRegex = Regex {
185+
"sparkle:edSignature=\""
186+
Capture {
187+
OneOrMore(.reluctant) {
188+
NegativeLookahead { "\"" }
189+
CharacterClass.any
190+
}
191+
} transform: { String($0) }
192+
"\""
193+
OneOrMore(.whitespace)
194+
"length=\""
195+
Capture {
196+
OneOrMore(.digit)
197+
} transform: { Int64($0) }
198+
"\""
199+
}

0 commit comments

Comments
 (0)