Integrate Sparkle auto-updates for macOS Developer ID builds

New Release-DeveloperID configuration gates Sparkle behind a SPARKLE
compile flag so the App Store Release build stays Sparkle-free. Adds
SPUStandardUpdaterController wrapper, Check for Updates menu command,
Advanced Settings section with beta channel toggle, and a Ruby script
plus GitHub Actions job that signs each release and publishes the
appcast to gh-pages for consumption by Sparkle and Homebrew cask.
This commit is contained in:
Arkadiusz Fal
2026-04-23 04:51:00 +02:00
parent 29c67d3276
commit a2a4691957
14 changed files with 630 additions and 13 deletions

View File

@@ -15,9 +15,16 @@ on:
type: boolean
default: false
build_mac_notarized:
description: 'Build macOS (notarized)'
description: 'Build macOS (notarized Developer ID + Sparkle appcast)'
type: boolean
default: false
default: true
release_channel:
description: 'Sparkle / Developer ID channel (also toggles GitHub prerelease flag)'
type: choice
options:
- beta
- stable
default: beta
create_release:
description: 'Create GitHub release'
type: boolean
@@ -174,15 +181,18 @@ jobs:
- uses: maierj/fastlane-action@v3.0.0
with:
lane: mac build_and_notarize
- run: |
echo "APP_PATH=fastlane/builds/${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}/macOS/Yattee.app" >> $GITHUB_ENV
echo "ZIP_PATH=fastlane/builds/${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}/macOS/Yattee-${{ env.VERSION_NUMBER }}-macOS.zip" >> $GITHUB_ENV
- name: ZIP build
run: /usr/bin/ditto -c -k --keepParent ${{ env.APP_PATH }} ${{ env.ZIP_PATH }}
- name: Resolve artifact paths
run: |
DIR="fastlane/builds/${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}/macOS"
echo "APP_PATH=$DIR/Yattee.app" >> $GITHUB_ENV
echo "ZIP_PATH=$DIR/Yattee-${{ env.VERSION_NUMBER }}-macOS.zip" >> $GITHUB_ENV
echo "DMG_PATH=$DIR/Yattee-${{ env.VERSION_NUMBER }}-macOS.dmg" >> $GITHUB_ENV
- uses: actions/upload-artifact@v4
with:
name: mac-notarized-build
path: ${{ env.ZIP_PATH }}
path: |
${{ env.ZIP_PATH }}
${{ env.DMG_PATH }}
if-no-files-found: error
release:
@@ -192,9 +202,12 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: write
outputs:
tag: ${{ steps.compute_tag.outputs.tag }}
env:
BUILD_NUMBER: ${{ needs.determine_build_number.outputs.build_number }}
VERSION_NUMBER: ${{ needs.determine_build_number.outputs.version_number }}
RELEASE_CHANNEL: ${{ inputs.release_channel }}
steps:
- uses: actions/checkout@v4
with:
@@ -212,14 +225,121 @@ jobs:
- uses: actions/download-artifact@v4
with:
path: artifacts
- name: Compute release tag
id: compute_tag
run: |
if [ "$RELEASE_CHANNEL" = "beta" ]; then
echo "tag=${VERSION_NUMBER}-beta.${BUILD_NUMBER}" >> "$GITHUB_OUTPUT"
echo "prerelease=true" >> "$GITHUB_OUTPUT"
else
echo "tag=${VERSION_NUMBER}-${BUILD_NUMBER}" >> "$GITHUB_OUTPUT"
echo "prerelease=false" >> "$GITHUB_OUTPUT"
fi
- uses: ncipollo/release-action@v1
with:
artifacts: artifacts/**/*.ipa,artifacts/**/*.zip,artifacts/**/*.pkg
artifacts: artifacts/**/*.ipa,artifacts/**/*.zip,artifacts/**/*.pkg,artifacts/**/*.dmg
commit: ${{ github.ref_name }}
tag: ${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}
prerelease: true
tag: ${{ steps.compute_tag.outputs.tag }}
prerelease: ${{ steps.compute_tag.outputs.prerelease }}
bodyFile: CHANGELOG.md
publish_appcast:
if: ${{ inputs.build_mac_notarized && inputs.create_release && !cancelled() && !failure() }}
needs: [determine_build_number, mac_notarized, release]
name: Publish Sparkle appcast
runs-on: macos-26
permissions:
contents: write
env:
BUILD_NUMBER: ${{ needs.determine_build_number.outputs.build_number }}
VERSION_NUMBER: ${{ needs.determine_build_number.outputs.version_number }}
RELEASE_CHANNEL: ${{ inputs.release_channel }}
RELEASE_TAG: ${{ needs.release.outputs.tag }}
SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }}
REPO: ${{ github.repository }}
steps:
- name: Guard — secret configured
run: |
if [ -z "$SPARKLE_ED_PRIVATE_KEY" ]; then
echo "::error::SPARKLE_ED_PRIVATE_KEY secret is not set. Configure it with the base64-encoded private key exported via 'generate_keys -x'."
exit 1
fi
- uses: actions/checkout@v4
with:
token: ${{ secrets.REPO_TOKEN }}
- name: Download notarized mac artifact
uses: actions/download-artifact@v4
with:
name: mac-notarized-build
path: mac-artifacts
- name: Locate sign_update binary
id: find_sign_update
run: |
# Sparkle's `sign_update` ships as a package artifact. We need SPM to
# resolve the Sparkle package so the binary is present on disk.
xcodebuild -resolvePackageDependencies -project Yattee.xcodeproj -scheme Yattee >/dev/null
SIGN=$(find "$HOME/Library/Developer/Xcode/DerivedData" -name sign_update -type f 2>/dev/null | head -1)
if [ -z "$SIGN" ]; then
SIGN=$(find ~ -name sign_update -type f 2>/dev/null | head -1)
fi
if [ -z "$SIGN" ]; then
echo "::error::Could not locate sign_update binary"
exit 1
fi
echo "sign_update=$SIGN" >> "$GITHUB_OUTPUT"
- name: Checkout gh-pages (create if missing)
run: |
git fetch origin gh-pages || true
if git rev-parse --verify origin/gh-pages >/dev/null 2>&1; then
git worktree add gh-pages origin/gh-pages
else
# First run — create orphan gh-pages with only appcast scaffolding.
git worktree add --detach gh-pages HEAD
cd gh-pages
git checkout --orphan gh-pages
git rm -rf . >/dev/null 2>&1 || true
cp ../scripts/sparkle/appcast_template.xml appcast.xml
cd ..
fi
- name: Write private key to a temp file
id: ed_key
run: |
KEY_FILE=$(mktemp)
printf '%s' "$SPARKLE_ED_PRIVATE_KEY" > "$KEY_FILE"
echo "path=$KEY_FILE" >> "$GITHUB_OUTPUT"
- name: Sign update and update appcast.xml
run: |
ZIP=$(find mac-artifacts -name '*.zip' | head -1)
if [ -z "$ZIP" ]; then
echo "::error::No .zip found in mac-artifacts"
exit 1
fi
./scripts/sparkle/update_appcast.rb \
--zip "$ZIP" \
--version "$VERSION_NUMBER" \
--build "$BUILD_NUMBER" \
--channel "$RELEASE_CHANNEL" \
--tag "$RELEASE_TAG" \
--sign-update-bin "${{ steps.find_sign_update.outputs.sign_update }}" \
--ed-key-file "${{ steps.ed_key.outputs.path }}" \
--appcast gh-pages/appcast.xml \
--repo "$REPO"
- name: Scrub private key
if: always()
run: rm -f "${{ steps.ed_key.outputs.path }}"
- name: Commit & push appcast.xml
run: |
cd gh-pages
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git add appcast.xml
if git diff --cached --quiet; then
echo "No appcast changes to publish"
else
git commit -m "Publish Sparkle appcast: ${VERSION_NUMBER} (${BUILD_NUMBER}) [${RELEASE_CHANNEL}]"
git push origin gh-pages
fi
update_altstore:
needs: [release]
uses: ./.github/workflows/update-altstore.yml

View File

@@ -13,6 +13,18 @@ This project targets the latest OS versions only - use newest APIs freely withou
**Test (single):** `xcodebuild test -scheme Yattee -destination 'platform=macOS' -only-testing:YatteeTests/TestSuiteName/testMethodName`
**Lint:** `periphery scan` (config: `.periphery.yml`)
## Build Configurations
Three configurations exist, mapped to distribution channels:
| Configuration | Sparkle (`#if SPARKLE`) | Used for |
|---|---|---|
| `Debug` | off | local development, tests |
| `Release` | off | App Store / TestFlight (`fastlane mac beta`) — must stay Sparkle-free, App Review rejects auto-update frameworks |
| `Release-DeveloperID` | **on** | Developer ID notarized build (`fastlane mac build_and_notarize`), distributed via GitHub Releases + Homebrew cask, receives Sparkle updates |
All Sparkle-dependent code must be wrapped in `#if SPARKLE ... #endif` so the `Release` variant links zero Sparkle symbols. When adding new Sparkle features, test both configs build clean on macOS.
## Code Style
**Language:** Swift 5.0+ with strict concurrency (Swift 6 mode enabled)

View File

@@ -7,6 +7,7 @@
objects = {
/* Begin PBXBuildFile section */
370E71982F9A1A41000E04B2 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 370E71972F9A1A41000E04B2 /* Sparkle */; };
37767AB32F05766100D248FC /* Nuke in Frameworks */ = {isa = PBXBuildFile; productRef = 37767AB22F05766100D248FC /* Nuke */; };
37767AB52F05766100D248FC /* NukeUI in Frameworks */ = {isa = PBXBuildFile; productRef = 37767AB42F05766100D248FC /* NukeUI */; };
378CF2FE2EF21767002C1CD7 /* MPVKit-GPL in Frameworks */ = {isa = PBXBuildFile; productRef = 378CF2FD2EF21767002C1CD7 /* MPVKit-GPL */; };
@@ -125,6 +126,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
370E71982F9A1A41000E04B2 /* Sparkle in Frameworks */,
37767AB32F05766100D248FC /* Nuke in Frameworks */,
378CF2FE2EF21767002C1CD7 /* MPVKit-GPL in Frameworks */,
378CF3012EF21783002C1CD7 /* MPVKit-GPL in Frameworks */,
@@ -206,6 +208,7 @@
378CF3002EF21783002C1CD7 /* MPVKit-GPL */,
37767AB22F05766100D248FC /* Nuke */,
37767AB42F05766100D248FC /* NukeUI */,
370E71972F9A1A41000E04B2 /* Sparkle */,
);
productName = Yattee;
productReference = 372D1A272EDB163800F58F7A /* Yattee.app */;
@@ -315,6 +318,7 @@
packageReferences = (
378CF2FF2EF21783002C1CD7 /* XCRemoteSwiftPackageReference "MPVKit" */,
37767AB12F05766100D248FC /* XCRemoteSwiftPackageReference "Nuke" */,
370E71962F9A1A41000E04B2 /* XCRemoteSwiftPackageReference "Sparkle" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 372D1A282EDB163800F58F7A /* Products */;
@@ -1179,6 +1183,14 @@
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
370E71962F9A1A41000E04B2 /* XCRemoteSwiftPackageReference "Sparkle" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/sparkle-project/Sparkle";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.9.1;
};
};
37767AB12F05766100D248FC /* XCRemoteSwiftPackageReference "Nuke" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/kean/Nuke";
@@ -1198,6 +1210,11 @@
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
370E71972F9A1A41000E04B2 /* Sparkle */ = {
isa = XCSwiftPackageProductDependency;
package = 370E71962F9A1A41000E04B2 /* XCRemoteSwiftPackageReference "Sparkle" */;
productName = Sparkle;
};
37767AB22F05766100D248FC /* Nuke */ = {
isa = XCSwiftPackageProductDependency;
package = 37767AB12F05766100D248FC /* XCRemoteSwiftPackageReference "Nuke" */;

View File

@@ -1,5 +1,5 @@
{
"originHash" : "50421f80aba6d558399198148743d5d479edb3f6cc10024acd55828f8cf63959",
"originHash" : "263c985773143365961084fa2985b6cfc81ede4476036a7c65b223ed7ced8b32",
"pins" : [
{
"identity" : "mpvkit",
@@ -18,6 +18,15 @@
"revision" : "83e19143355b02e9261edb2323b3e1e93287ebb9",
"version" : "12.9.0"
}
},
{
"identity" : "sparkle",
"kind" : "remoteSourceControl",
"location" : "https://github.com/sparkle-project/Sparkle",
"state" : {
"revision" : "066e75a8b3e99962685d6a90cdd5293ebffd9261",
"version" : "2.9.1"
}
}
],
"version" : 3

View File

@@ -4,6 +4,15 @@
<dict>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<!-- Sparkle auto-updates (Developer ID / Homebrew cask build only; ignored on App Store + iOS + tvOS) -->
<key>SUFeedURL</key>
<string>https://yattee.github.io/yattee/appcast.xml</string>
<key>SUPublicEDKey</key>
<string>BLtSfi3Epsl97XpMy734PhlbscxWwWpi6moT/S++A+4=</string>
<key>SUEnableAutomaticChecks</key>
<true/>
<key>SUEnableInstallerLauncherService</key>
<true/>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>stream.yattee.app.feedRefresh</string>

View File

@@ -5142,6 +5142,16 @@
}
}
},
"menu.app.checkForUpdates" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Check for updates..."
}
}
}
},
"menu.app.settings" : {
"localizations" : {
"en" : {
@@ -14338,6 +14348,36 @@
}
}
},
"settings.updates.footer" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Check for and install new versions of Yattee. Beta updates include prereleases."
}
}
}
},
"settings.updates.receiveBeta" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Receive beta updates"
}
}
}
},
"settings.updates.title" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Updates"
}
}
}
},
"settings.videoActions.header" : {
"localizations" : {
"en" : {

View File

@@ -0,0 +1,109 @@
//
// SparkleUpdater.swift
// Yattee
//
// Sparkle-backed in-app updater for the Developer ID build.
// Compiled in only when the `SPARKLE` Swift flag is set, which is
// enabled for the `Release-DeveloperID` build configuration and
// unset for `Release` (App Store / TestFlight) and `Debug`.
//
// See AGENTS.md "Build Configurations" for the channel split.
//
#if SPARKLE
import Foundation
import SwiftUI
import Sparkle
/// Observable wrapper around `SPUStandardUpdaterController` so SwiftUI views
/// can bind enable/disable state and trigger manual update checks.
@MainActor
@Observable
final class AppUpdater {
static let shared = AppUpdater()
/// Whether the "Check for Updates" menu item should be enabled.
/// Mirrors `SPUUpdater.canCheckForUpdates` and tracks it via KVO.
private(set) var canCheckForUpdates = false
/// User preference: receive prerelease (beta) updates in addition to
/// stable ones. Persisted in UserDefaults so it survives relaunches.
var wantsBetaChannel: Bool {
didSet {
guard oldValue != wantsBetaChannel else { return }
UserDefaults.standard.set(wantsBetaChannel, forKey: Self.wantsBetaKey)
// Trigger re-check so the delegate re-reads channels. Deferred off
// the main thread resetUpdateCycle may synchronously touch the
// scheduler's feed cache which can stutter SwiftUI scrolling.
let updater = self.updaterController.updater
Task.detached(priority: .utility) {
updater.resetUpdateCycle()
}
}
}
private static let wantsBetaKey = "AppUpdater.wantsBetaChannel"
private let delegate = AppUpdaterDelegate()
private let updaterController: SPUStandardUpdaterController
private var observation: NSKeyValueObservation?
private init() {
// Load persisted beta preference before constructing the delegate
let wantsBeta = UserDefaults.standard.bool(forKey: Self.wantsBetaKey)
self.wantsBetaChannel = wantsBeta
self.delegate.wantsBeta = wantsBeta
// `startingUpdater: true` boots the scheduler automatically;
// Info.plist `SUEnableAutomaticChecks` governs whether it polls.
self.updaterController = SPUStandardUpdaterController(
startingUpdater: true,
updaterDelegate: delegate,
userDriverDelegate: nil
)
// Keep delegate in sync when the toggle changes at runtime.
self.delegate.wantsBetaProvider = { [weak self] in
self?.wantsBetaChannel ?? false
}
// Observe canCheckForUpdates so menu items can enable/disable correctly.
// KVO fires on the thread that mutated the property; hop to @MainActor
// explicitly. Dedupe to avoid SwiftUI re-render storms if Sparkle
// toggles it rapidly during a feed check cycle.
self.observation = updaterController.updater.observe(
\.canCheckForUpdates,
options: [.initial, .new]
) { [weak self] updater, _ in
let newValue = updater.canCheckForUpdates
Task { @MainActor [weak self] in
guard let self, self.canCheckForUpdates != newValue else { return }
self.canCheckForUpdates = newValue
}
}
}
/// Triggered by the "Check for Updates" menu command.
func checkForUpdates() {
updaterController.checkForUpdates(nil)
}
}
/// Delegate that exposes the user's channel preference to Sparkle.
/// Sparkle calls `allowedChannels(for:)` each feed refresh.
private final class AppUpdaterDelegate: NSObject, SPUUpdaterDelegate {
/// Cached snapshot; read on the main actor by `AppUpdater`.
var wantsBeta: Bool = false
/// Live accessor so the delegate reflects the current toggle state.
var wantsBetaProvider: (() -> Bool)?
func allowedChannels(for _: SPUUpdater) -> Set<String> {
let beta = wantsBetaProvider?() ?? wantsBeta
// Empty set = stable only. Adding "beta" means users get both
// untagged (stable) items AND items tagged <sparkle:channel>beta</>.
return beta ? ["beta"] : []
}
}
#endif

View File

@@ -27,6 +27,9 @@ struct AdvancedSettingsView: View {
var body: some View {
SettingsFormContainer {
#if SPARKLE && os(macOS)
updatesSection
#endif
streamDetailsSection
mpvSection
settingsSection
@@ -350,6 +353,26 @@ struct AdvancedSettingsView: View {
#if SPARKLE && os(macOS)
@ViewBuilder
private var updatesSection: some View {
SettingsFormSection("settings.updates.title", footer: "settings.updates.footer") {
Toggle(isOn: Binding(
get: { AppUpdater.shared.wantsBetaChannel },
set: { AppUpdater.shared.wantsBetaChannel = $0 }
)) {
Label(String(localized: "settings.updates.receiveBeta"), systemImage: "testtube.2")
}
Button {
AppUpdater.shared.checkForUpdates()
} label: {
Label(String(localized: "menu.app.checkForUpdates"), systemImage: "arrow.triangle.2.circlepath")
}
.disabled(!AppUpdater.shared.canCheckForUpdates)
}
}
#endif
@ViewBuilder
private var developerSection: some View {
SettingsFormSection(footer: "settings.developer.footer") {

View File

@@ -0,0 +1,27 @@
//
// CheckForUpdatesMenuItem.swift
// Yattee
//
// "Check for Updates" menu item shown in the macOS app menu for
// Developer ID builds. Wired to the Sparkle-backed `AppUpdater`.
//
// Compiled in only when the `SPARKLE` Swift flag is set (i.e. the
// `Release-DeveloperID` configuration). See AGENTS.md.
//
#if SPARKLE && os(macOS)
import SwiftUI
struct CheckForUpdatesMenuItem: View {
@State private var updater = AppUpdater.shared
var body: some View {
Button(String(localized: "menu.app.checkForUpdates")) {
updater.checkForUpdates()
}
.disabled(!updater.canCheckForUpdates)
}
}
#endif

View File

@@ -310,6 +310,11 @@ struct YatteeApp: App {
CommandGroup(replacing: .appSettings) {
SettingsWindowMenuItem()
}
#if SPARKLE
CommandGroup(after: .appInfo) {
CheckForUpdatesMenuItem()
}
#endif
}
#endif
}

View File

@@ -322,11 +322,13 @@ platform :mac do
team_id: TEAM_ID,
code_sign_identity: "Developer ID Application",
profile_name: "match Direct #{DEVELOPER_APP_IDENTIFIER} macos",
targets: [SCHEME]
targets: [SCHEME],
build_configurations: ["Release-DeveloperID"]
)
build_mac_app(
scheme: SCHEME,
configuration: "Release-DeveloperID",
output_directory: "fastlane/builds/#{version}-#{build}/macOS",
output_name: APP_NAME,
export_method: "developer-id",
@@ -343,5 +345,23 @@ platform :mac do
api_key: api_key,
print_log: true
)
# Staple the notarization ticket so Gatekeeper doesn't need to phone home
# when the user first launches from Sparkle/DMG.
sh "xcrun stapler staple 'fastlane/builds/#{version}-#{build}/macOS/#{APP_NAME}.app'"
# Package the stapled .app into a .zip (Sparkle) and .dmg (Homebrew / direct).
dir = "fastlane/builds/#{version}-#{build}/macOS"
app = "#{dir}/#{APP_NAME}.app"
zip = "#{dir}/#{APP_NAME}-#{version}-macOS.zip"
dmg = "#{dir}/#{APP_NAME}-#{version}-macOS.dmg"
# .zip: `ditto -c -k --keepParent` is Sparkle's expected archive layout
# (preserves the .app wrapper, resource forks, symlinks).
sh "/usr/bin/ditto -c -k --keepParent '#{app}' '#{zip}'"
# .dmg: read-only UDZO, overwrites any stale image from previous runs.
sh "/bin/rm -f '#{dmg}'"
sh "/usr/bin/hdiutil create -volname '#{APP_NAME} #{version}' -srcfolder '#{app}' -ov -format UDZO '#{dmg}'"
end
end

65
scripts/sparkle/README.md Normal file
View File

@@ -0,0 +1,65 @@
# Sparkle appcast pipeline
This directory contains the scripts and templates that sign Sparkle updates and maintain `appcast.xml` on the `gh-pages` branch. The feed is served at https://yattee.github.io/yattee/appcast.xml and consumed by Sparkle inside the Developer ID build of Yattee (see `Yattee/Services/Updates/SparkleUpdater.swift`).
## One-time setup
1. **Generate the EdDSA signing key** on a trusted machine (once, ever):
```bash
# Location inside your Xcode DerivedData, after building Yattee once.
SPARKLE=~/Library/Developer/Xcode/DerivedData/Yattee-*/SourcePackages/artifacts/sparkle/Sparkle/bin
$SPARKLE/generate_keys # creates the keypair in Keychain
$SPARKLE/generate_keys -p # prints the public key; also visible in Info.plist (SUPublicEDKey)
```
2. **Export the private key for CI**:
```bash
$SPARKLE/generate_keys -x /tmp/sparkle_ed_private.key
cat /tmp/sparkle_ed_private.key # copy the single-line base64 contents
rm /tmp/sparkle_ed_private.key # DO NOT commit, DO NOT leave on disk
```
Paste the contents into GitHub → Settings → Secrets and variables → Actions → **New repository secret** named `SPARKLE_ED_PRIVATE_KEY`.
3. **Enable GitHub Pages**: repository Settings → Pages → Source: `Deploy from a branch` → Branch: `gh-pages` / `(root)`. The first `publish_appcast` workflow run creates the branch with the seed `appcast.xml`.
4. **Confirm the public key matches `Info.plist`**: `Yattee/Info.plist`'s `SUPublicEDKey` value must equal the output of `generate_keys -p`. Any mismatch and Sparkle refuses to install updates signed with the CI's private key.
## Per-release flow (automated)
The `.github/workflows/release.yml` `publish_appcast` job:
1. Downloads the `mac-notarized-build` artifact (contains both `.zip` and `.dmg`).
2. Locates Sparkle's `sign_update` binary from the resolved SPM dependency.
3. Writes `SPARKLE_ED_PRIVATE_KEY` to a tempfile (scrubbed in `always()` step).
4. Invokes `./scripts/sparkle/update_appcast.rb` which:
- Signs the `.zip` → `sparkle:edSignature` + `length`.
- Prepends a new `<item>` into `gh-pages/appcast.xml` (de-duplicating if the same version+build already exists).
- Tags beta items with `<sparkle:channel>beta</>`; stable items are untagged (Sparkle's default).
5. Commits and pushes `gh-pages`.
The `release_channel` workflow input (`beta` | `stable`, default `beta`) controls:
- `<sparkle:channel>` in the appcast item
- GitHub Release prerelease flag
- Release tag shape: `2.0.1-beta.261` vs `2.0.1-261`
## Manual ad-hoc signing
```bash
./scripts/sparkle/update_appcast.rb \
--zip path/to/Yattee-2.0.1-macOS.zip \
--version 2.0.1 \
--build 261 \
--channel beta \
--tag 2.0.1-beta.261 \
--sign-update-bin ~/Library/Developer/Xcode/DerivedData/Yattee-*/SourcePackages/artifacts/sparkle/Sparkle/bin/sign_update \
--ed-key-file /tmp/sparkle_ed_private.key \
--appcast appcast.xml \
--repo yattee/yattee
```
## Verification
```bash
# After a release:
curl -sSL https://yattee.github.io/yattee/appcast.xml | xmllint --noout -
```

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" xmlns:dc="http://purl.org/dc/elements/1.1/" version="2.0">
<channel>
<title>Yattee</title>
<link>https://yattee.github.io/yattee/appcast.xml</link>
<description>Most recent changes with links to updates for Yattee (macOS Developer ID build).</description>
<language>en</language>
</channel>
</rss>

152
scripts/sparkle/update_appcast.rb Executable file
View File

@@ -0,0 +1,152 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
# Signs a Sparkle update .zip with the provided EdDSA private key and prepends
# a new <item> entry to the appcast.xml file. Creates the appcast from
# scripts/sparkle/appcast_template.xml if it does not yet exist.
#
# Usage:
# update_appcast.rb \
# --zip <path-to-Yattee-X.Y.Z-macOS.zip> \
# --version 2.0.1 \
# --build 260 \
# --channel stable|beta \
# --tag 2.0.1-260 \
# --sign-update-bin <path-to-sign_update> \
# --ed-key-file <path-to-ed-private-key-file> \
# --appcast <path-to-appcast.xml> \
# [--minimum-system-version 15.0] \
# [--release-notes-url <url>]
require 'optparse'
require 'time'
require 'rexml/document'
require 'fileutils'
require 'open3'
options = {
channel: 'stable',
minimum_system_version: '15.0'
}
OptionParser.new do |opts|
opts.on('--zip PATH') { |v| options[:zip] = v }
opts.on('--version V') { |v| options[:version] = v }
opts.on('--build B') { |v| options[:build] = v }
opts.on('--channel NAME') { |v| options[:channel] = v }
opts.on('--tag TAG') { |v| options[:tag] = v }
opts.on('--sign-update-bin PATH') { |v| options[:sign_update_bin] = v }
opts.on('--ed-key-file PATH') { |v| options[:ed_key_file] = v }
opts.on('--appcast PATH') { |v| options[:appcast] = v }
opts.on('--minimum-system-version V') { |v| options[:minimum_system_version] = v }
opts.on('--release-notes-url URL') { |v| options[:release_notes_url] = v }
opts.on('--repo OWNER/NAME') { |v| options[:repo] = v }
end.parse!
%i[zip version build tag sign_update_bin ed_key_file appcast repo].each do |k|
raise "Missing required argument: --#{k.to_s.tr('_', '-')}" if options[k].nil? || options[k].empty?
end
raise "Zip not found: #{options[:zip]}" unless File.exist?(options[:zip])
raise "sign_update binary not found: #{options[:sign_update_bin]}" unless File.executable?(options[:sign_update_bin])
raise "Ed key file not found: #{options[:ed_key_file]}" unless File.exist?(options[:ed_key_file])
# ---- 1. Produce EdDSA signature via Sparkle's sign_update ----
#
# sign_update prints: sparkle:edSignature="..." length="..."
cmd = [options[:sign_update_bin], '--ed-key-file', options[:ed_key_file], options[:zip]]
puts "[appcast] signing: #{cmd.join(' ')}"
stdout, status = Open3.capture2(*cmd)
raise "sign_update failed (exit #{status.exitstatus}):\n#{stdout}" unless status.success?
sig_line = stdout.strip.lines.last.to_s.strip
ed_signature = sig_line[/edSignature="([^"]+)"/, 1]
length = sig_line[/length="([^"]+)"/, 1]
raise "Could not parse sign_update output: #{stdout.inspect}" if ed_signature.nil? || length.nil?
# ---- 2. Load (or seed) the appcast document ----
appcast_path = options[:appcast]
unless File.exist?(appcast_path)
template = File.join(File.dirname(__FILE__), 'appcast_template.xml')
FileUtils.mkdir_p(File.dirname(appcast_path))
FileUtils.cp(template, appcast_path)
end
doc = REXML::Document.new(File.read(appcast_path))
doc.context[:attribute_quote] = :quote
channel = doc.root.elements['channel'] or raise 'appcast.xml missing <channel>'
# ---- 3. Remove any existing item for the same version+build (idempotent re-runs) ----
channel.elements.each('item') do |item|
existing_build = item.elements['sparkle:version']&.text
existing_version = item.elements['sparkle:shortVersionString']&.text
if existing_build == options[:build].to_s && existing_version == options[:version]
channel.delete_element(item)
end
end
# ---- 4. Build the new <item> ----
zip_basename = File.basename(options[:zip])
download_url = "https://github.com/#{options[:repo]}/releases/download/#{options[:tag]}/#{zip_basename}"
item = REXML::Element.new('item')
title = REXML::Element.new('title')
title.text = "Version #{options[:version]} (#{options[:build]})"
item.add_element(title)
pubdate = REXML::Element.new('pubDate')
pubdate.text = Time.now.utc.rfc2822
item.add_element(pubdate)
sparkle_version = REXML::Element.new('sparkle:version')
sparkle_version.text = options[:build].to_s
item.add_element(sparkle_version)
short_version = REXML::Element.new('sparkle:shortVersionString')
short_version.text = options[:version]
item.add_element(short_version)
min_sys = REXML::Element.new('sparkle:minimumSystemVersion')
min_sys.text = options[:minimum_system_version]
item.add_element(min_sys)
# Channel tag only on non-stable items. Sparkle treats untagged items as stable.
if options[:channel] && !options[:channel].empty? && options[:channel] != 'stable'
channel_el = REXML::Element.new('sparkle:channel')
channel_el.text = options[:channel]
item.add_element(channel_el)
end
if options[:release_notes_url]
notes = REXML::Element.new('sparkle:releaseNotesLink')
notes.text = options[:release_notes_url]
item.add_element(notes)
end
enclosure = REXML::Element.new('enclosure')
enclosure.add_attribute('url', download_url)
enclosure.add_attribute('type', 'application/octet-stream')
enclosure.add_attribute('sparkle:edSignature', ed_signature)
enclosure.add_attribute('length', length)
item.add_element(enclosure)
# ---- 5. Prepend the new item (most recent first) ----
first_item = channel.elements['item']
if first_item
channel.insert_before(first_item, item)
else
channel.add_element(item)
end
# ---- 6. Write back, pretty-printed ----
formatter = REXML::Formatters::Pretty.new(2)
formatter.compact = true
File.open(appcast_path, 'w') do |f|
f.write(%Q(<?xml version="1.0" encoding="utf-8"?>\n))
formatter.write(doc.root, f)
f.write("\n")
end
puts "[appcast] wrote #{appcast_path}"
puts "[appcast] item: version=#{options[:version]} build=#{options[:build]} channel=#{options[:channel]} length=#{length}"