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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy