Skip to content

Commit 96da5ae

Browse files
ci: add update-appcast script (#171)
Third PR for #47. Adds a script to update an existing `appcast.xml`. This will be called in CI to update the appcast before uploading it back to our feed URL (`releases.coder.com/...`). It's currently not used anywhere. Invoked like: ``` swift run update-appcast -i appcast.xml -s CoderDesktop.pkg.sig -v 0.5.1 -o appcast.xml -d ${{ github.event.release.body }} ``` To update an appcast that looks like: <details> <summary>appcast.xml</summary> ```xml <?xml version="1.0" encoding="utf-8" standalone="yes"?> <rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0"> <channel> <title>Coder Desktop</title> <item> <title>v0.5.1</title> <description><![CDATA[<h2>What's Changed</h2> <ul> <li>fix: don't create http client if signed out by @ethanndickson in <a href="https://github.com/coder/coder-deskt%E2%80%A6r-desktop-macos/pull/170">https://github.com/coder/coder-deskt…r-desktop-macos/pull/170</a></li> </ul> <p><strong>Full Changelog</strong>: <a href="https://github.com/coder/coder-desktop-macos/compare/v0.5.0...v0.5.1">https://github.com/coder/coder-desktop-macos/compare/v0.5.0...v0.5.1</a></p>]]></description> <pubDate>Thu, 29 May 2025 06:08:56 +0000</pubDate> <sparkle:channel>stable</sparkle:channel> <sparkle:version>0.5.1</sparkle:version> <sparkle:fullReleaseNotesLink>https://github.com/coder/coder-desktop-macos/releases</sparkle:fullReleaseNotesLink> <sparkle:minimumSystemVersion>14.0.0</sparkle:minimumSystemVersion> <enclosure url="https://github.com/coder/coder-desktop-macos/releases/download/v0.5.1/Coder-Desktop.pkg" type="application/octet-stream" sparkle:installationType="package" sparkle:edSignature="NkyCj7Lzpw95P0N95SQHiBCjDLZYVukbRR3aOjGZAuL5Dc+I//DfTCRFCxoQNhA38uu/CCAR8v9E4SgMkDdmAA==" length="39630183"></enclosure> </item> <item> <title>Preview</title> <pubDate>Thu, 29 May 2025 06:08:08 +0000</pubDate> <sparkle:channel>preview</sparkle:channel> <sparkle:version>0.5.0.3</sparkle:version> <sparkle:fullReleaseNotesLink>https://github.com/coder/coder-desktop-macos/releases</sparkle:fullReleaseNotesLink> <sparkle:minimumSystemVersion>14.0.0</sparkle:minimumSystemVersion> <enclosure url="https://github.com/coder/coder-desktop-macos/releases/download/preview/Coder-Desktop.pkg" type="application/octet-stream" sparkle:installationType="package" sparkle:edSignature="L0cFeyoy+D/Zgm3eXok87SKmgIUka8m2b+g7UWPReF4UhFUb4RlDsZ5PxXKd5MrtsaODGUz2iRMWraO7aQg+DA==" length="39630898"></enclosure> </item> </channel> </rss> ``` </details> Producing a notification like: <img width="620" alt="image" src="https://github.com/user-attachments/assets/acae89d6-5d39-4464-bf60-7beac66af9c7" />
1 parent 65f4619 commit 96da5ae

File tree

5 files changed

+249
-2
lines changed

5 files changed

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

0 commit comments

Comments
 (0)