Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

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

Merged
ethanndickson merged 6 commits intomainfromethan/update-appcast-script
May 30, 2025
Merged
Show file tree
Hide file tree
Changes fromall commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion.gitignore
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -291,7 +291,7 @@ xcuserdata
**/xcshareddata/WorkspaceSettings.xcsettings

### VSCode & Sweetpad ###
.vscode/**
**/.vscode/**
buildServer.json

# End of https://www.toptal.com/developers/gitignore/api/xcode,jetbrains,macos,direnv,swift,swiftpm,objective-c
Expand Down
3 changes: 2 additions & 1 deletion.swiftlint.yml
View file
Open in desktop
Original file line numberDiff line numberDiff 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"
- "**/*.grpc.swift"
- "**/.build/"
3 changes: 3 additions & 0 deletionsscripts/update-appcast/.swiftlint.yml
View file
Open in desktop
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
disabled_rules:
- todo
- trailing_comma
23 changes: 23 additions & 0 deletionsscripts/update-appcast/Package.swift
View file
Open in desktop
Original file line numberDiff line numberDiff 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"),
]
),
]
)
220 changes: 220 additions & 0 deletionsscripts/update-appcast/Sources/main.swift
View file
Open in desktop
Original file line numberDiff line numberDiff 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
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)
}
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)
}

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)
}
}

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) }
"\""
}
Loading

[8]ページ先頭

©2009-2025 Movatter.jp