- Notifications
You must be signed in to change notification settings - Fork3
ci: addupdate-appcast
script#171
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 ourterms of service andprivacy statement. We’ll occasionally send you account related emails.
Already on GitHub?Sign in to your account
Uh oh!
There was an error while loading.Please reload this page.
Changes fromall commits
File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading.Please reload this page.
Jump to
Uh oh!
There was an error while loading.Please reload this page.
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,4 +1,5 @@ | ||
# TODO: Remove this once the grpc-swift-protobuf generator adds a lint disable comment | ||
excluded: | ||
- "**/*.pb.swift" | ||
- "**/*.grpc.swift" | ||
- "**/.build/" |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
disabled_rules: | ||
- todo | ||
- trailing_comma |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
// swift-tools-version: 6.0 | ||
// The swift-tools-version declares the minimum version of Swift required to build this package. | ||
import PackageDescription | ||
let package = Package( | ||
name: "update-appcast", | ||
platforms: [ | ||
.macOS(.v15), | ||
], | ||
dependencies: [ | ||
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"), | ||
.package(url: "https://github.com/loopwerk/Parsley", from: "0.5.0"), | ||
], | ||
targets: [ | ||
.executableTarget( | ||
name: "update-appcast", dependencies: [ | ||
.product(name: "ArgumentParser", package: "swift-argument-parser"), | ||
.product(name: "Parsley", package: "Parsley"), | ||
] | ||
), | ||
] | ||
) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,220 @@ | ||
import ArgumentParser | ||
import Foundation | ||
import RegexBuilder | ||
#if canImport(FoundationXML) | ||
import FoundationXML | ||
#endif | ||
import Parsley | ||
/// UpdateAppcast | ||
/// ------------- | ||
/// Replaces an existing `<item>` for the **stable** or **preview** channel | ||
/// in a Sparkle RSS feed with one containing the new version, signature, and | ||
/// length attributes. The feed will always contain one item for each channel. | ||
/// Whether the passed version is a stable or preview version is determined by the | ||
/// number of components in the version string: | ||
/// - Stable: `X.Y.Z` | ||
/// - Preview: `X.Y.Z.N` | ||
/// `N` is the build number - the number of commits since the last stable release. | ||
@main | ||
struct UpdateAppcast: AsyncParsableCommand { | ||
static let configuration = CommandConfiguration( | ||
abstract: "Updates a Sparkle appcast with a new release entry." | ||
) | ||
@Option(name: .shortAndLong, help: "Path to the appcast file to be updated.") | ||
var input: String | ||
@Option( | ||
name: .shortAndLong, | ||
help: """ | ||
Path to the signature file generated for the release binary. | ||
Signature files are generated by `Sparkle/bin/sign_update | ||
""" | ||
) | ||
var signature: String | ||
@Option(name: .shortAndLong, help: "The project version (X.Y.Z for stable builds, X.Y.Z.N for preview builds).") | ||
var version: String | ||
@Option(name: .shortAndLong, help: "A description of the release written in GFM.") | ||
var description: String? | ||
@Option(name: .shortAndLong, help: "Path where the updated appcast should be written.") | ||
var output: String | ||
mutating func validate() throws { | ||
guard FileManager.default.fileExists(atPath: signature) else { | ||
throw ValidationError("No file exists at path \(signature).") | ||
} | ||
guard FileManager.default.fileExists(atPath: input) else { | ||
throw ValidationError("No file exists at path \(input).") | ||
} | ||
} | ||
// swiftlint:disable:next function_body_length | ||
ethanndickson marked this conversation as resolved. Show resolvedHide resolvedUh oh!There was an error while loading.Please reload this page. | ||
mutating func run() async throws { | ||
let channel: UpdateChannel = isStable(version: version) ? .stable : .preview | ||
let sigLine = try String(contentsOfFile: signature, encoding: .utf8) | ||
.trimmingCharacters(in: .whitespacesAndNewlines) | ||
guard let match = sigLine.firstMatch(of: signatureRegex) else { | ||
throw RuntimeError("Unable to parse signature file: \(sigLine)") | ||
} | ||
let edSignature = match.output.1 | ||
guard let length = match.output.2 else { | ||
throw RuntimeError("Unable to parse length from signature file.") | ||
} | ||
let xmlData = try Data(contentsOf: URL(fileURLWithPath: input)) | ||
let doc = try XMLDocument(data: xmlData, options: .nodePrettyPrint) | ||
guard let channelElem = try doc.nodes(forXPath: "/rss/channel").first as? XMLElement else { | ||
throw RuntimeError("<channel> element not found in appcast.") | ||
} | ||
guard let insertionIndex = (channelElem.children ?? []) | ||
.enumerated() | ||
.first(where: { _, node in | ||
guard let item = node as? XMLElement, | ||
item.name == "item", | ||
item.elements(forName: "sparkle:channel") | ||
.first?.stringValue == channel.rawValue | ||
else { return false } | ||
return true | ||
})?.offset | ||
else { | ||
throw RuntimeError("No existing item found for channel \(channel.rawValue).") | ||
} | ||
// Delete the existing item | ||
channelElem.removeChild(at: insertionIndex) | ||
let item = XMLElement(name: "item") | ||
switch channel { | ||
case .stable: | ||
item.addChild(XMLElement(name: "title", stringValue: "v\(version)")) | ||
case .preview: | ||
item.addChild(XMLElement(name: "title", stringValue: "Preview")) | ||
} | ||
if let description { | ||
let description = description.replacingOccurrences(of: #"\r\n"#, with: "\n") | ||
let descriptionDoc: Document | ||
do { | ||
descriptionDoc = try Parsley.parse(description) | ||
} catch { | ||
throw RuntimeError("Failed to parse GFM description: \(error)") | ||
} | ||
// <description><![CDATA[ …HTML… ]]></description> | ||
let descriptionElement = XMLElement(name: "description") | ||
let cdata = XMLNode(kind: .text, options: .nodeIsCDATA) | ||
let html = descriptionDoc.body | ||
cdata.stringValue = html | ||
descriptionElement.addChild(cdata) | ||
item.addChild(descriptionElement) | ||
} | ||
item.addChild(XMLElement(name: "pubDate", stringValue: rfc822Date())) | ||
item.addChild(XMLElement(name: "sparkle:channel", stringValue: channel.rawValue)) | ||
item.addChild(XMLElement(name: "sparkle:version", stringValue: version)) | ||
item.addChild(XMLElement( | ||
name: "sparkle:fullReleaseNotesLink", | ||
stringValue: "https://github.com/coder/coder-desktop-macos/releases" | ||
)) | ||
item.addChild(XMLElement( | ||
name: "sparkle:minimumSystemVersion", | ||
stringValue: "14.0.0" | ||
)) | ||
let enclosure = XMLElement(name: "enclosure") | ||
func addEnclosureAttr(_ name: String, _ value: String) { | ||
// Force-casting is the intended API usage. | ||
// swiftlint:disable:next force_cast | ||
enclosure.addAttribute(XMLNode.attribute(withName: name, stringValue: value) as! XMLNode) | ||
ethanndickson marked this conversation as resolved. Show resolvedHide resolvedUh oh!There was an error while loading.Please reload this page. | ||
} | ||
addEnclosureAttr("url", downloadURL(for: version, channel: channel)) | ||
addEnclosureAttr("type", "application/octet-stream") | ||
addEnclosureAttr("sparkle:installationType", "package") | ||
addEnclosureAttr("sparkle:edSignature", edSignature) | ||
addEnclosureAttr("length", String(length)) | ||
item.addChild(enclosure) | ||
channelElem.insertChild(item, at: insertionIndex) | ||
let outputStr = doc.xmlString(options: [.nodePrettyPrint]) + "\n" | ||
try outputStr.write(to: URL(fileURLWithPath: output), atomically: true, encoding: .utf8) | ||
} | ||
ethanndickson marked this conversation as resolved. Show resolvedHide resolvedUh oh!There was an error while loading.Please reload this page. | ||
private func isStable(version: String) -> Bool { | ||
// A version is a release version if it has three components (X.Y.Z) | ||
guard let match = version.firstMatch(of: versionRegex) else { return false } | ||
return match.output.4 == nil | ||
} | ||
private func downloadURL(for version: String, channel: UpdateChannel) -> String { | ||
switch channel { | ||
case .stable: "https://github.com/coder/coder-desktop-macos/releases/download/v\(version)/Coder-Desktop.pkg" | ||
case .preview: "https://github.com/coder/coder-desktop-macos/releases/download/preview/Coder-Desktop.pkg" | ||
} | ||
} | ||
private func rfc822Date(date: Date = Date()) -> String { | ||
let fmt = DateFormatter() | ||
fmt.locale = Locale(identifier: "en_US_POSIX") | ||
fmt.timeZone = TimeZone(secondsFromGMT: 0) | ||
fmt.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z" | ||
return fmt.string(from: date) | ||
ethanndickson marked this conversation as resolved. Show resolvedHide resolvedUh oh!There was an error while loading.Please reload this page. | ||
} | ||
} | ||
enum UpdateChannel: String { case stable, preview } | ||
struct RuntimeError: Error, CustomStringConvertible { | ||
var message: String | ||
var description: String { message } | ||
init(_ message: String) { self.message = message } | ||
} | ||
extension Regex: @retroactive @unchecked Sendable {} | ||
// Matches CFBundleVersion format: X.Y.Z or X.Y.Z.N | ||
let versionRegex = Regex { | ||
Anchor.startOfLine | ||
Capture { | ||
OneOrMore(.digit) | ||
} transform: { Int($0)! } | ||
"." | ||
Capture { | ||
OneOrMore(.digit) | ||
} transform: { Int($0)! } | ||
"." | ||
Capture { | ||
OneOrMore(.digit) | ||
} transform: { Int($0)! } | ||
Optionally { | ||
Capture { | ||
"." | ||
OneOrMore(.digit) | ||
} transform: { Int($0.dropFirst())! } | ||
} | ||
Anchor.endOfLine | ||
} | ||
let signatureRegex = Regex { | ||
"sparkle:edSignature=\"" | ||
Capture { | ||
OneOrMore(.reluctant) { | ||
NegativeLookahead { "\"" } | ||
CharacterClass.any | ||
} | ||
} transform: { String($0) } | ||
"\"" | ||
OneOrMore(.whitespace) | ||
"length=\"" | ||
Capture { | ||
OneOrMore(.digit) | ||
} transform: { Int64($0) } | ||
"\"" | ||
} |
Uh oh!
There was an error while loading.Please reload this page.