Skip to content

Commit 0a75509

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

File tree

5 files changed

+226
-2
lines changed

5 files changed

+226
-2
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,7 @@ xcuserdata
291291
**/xcshareddata/WorkspaceSettings.xcsettings
292292

293293
### VSCode & Sweetpad ###
294-
.vscode/**
294+
**/.vscode/**
295295
buildServer.json
296296

297297
# End of https://www.toptal.com/developers/gitignore/api/xcode,jetbrains,macos,direnv,swift,swiftpm,objective-c

.swiftlint.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# TODO: Remove this once the grpc-swift-protobuf generator adds a lint disable comment
22
excluded:
33
- "**/*.pb.swift"
4-
- "**/*.grpc.swift"
4+
- "**/*.grpc.swift"
5+
- "**/.build/"

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+
@Option(name: .shortAndLong, help: "Path to the appcast file to be updated.")
25+
var input: String
26+
27+
@Option(name: .shortAndLong, help: "Path to the signature file generated for the release binary.")
28+
var signature: String
29+
30+
@Option(name: .shortAndLong, help: "The project version (X.Y.Z for stable builds, X.Y.Z.N for preview builds).")
31+
var version: String
32+
33+
@Option(name: .shortAndLong, help: "Path where the updated appcast should be written.")
34+
var output: String
35+
36+
mutating func validate() throws {
37+
guard FileManager.default.fileExists(atPath: signature) else {
38+
throw ValidationError("No file exists at path \(signature).")
39+
}
40+
guard FileManager.default.fileExists(atPath: input) else {
41+
throw ValidationError("No file exists at path \(input).")
42+
}
43+
}
44+
45+
// swiftlint:disable:next function_body_length
46+
mutating func run() async throws {
47+
let channel: UpdateChannel = isStable(version: version) ? .stable : .preview
48+
let sigLine = try String(contentsOfFile: signature, 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: input))
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\(version)"))
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: version))
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\(version)"
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: version, 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 outputStr = doc.xmlString(options: [.nodePrettyPrint]) + "\n"
126+
try outputStr.write(to: URL(fileURLWithPath: output), 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