diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 29f3f8ea..fdde043a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index d632f693..16aa2b83 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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) diff --git a/Yattee.xcodeproj/project.pbxproj b/Yattee.xcodeproj/project.pbxproj index f3d39f4d..2f7e297c 100644 --- a/Yattee.xcodeproj/project.pbxproj +++ b/Yattee.xcodeproj/project.pbxproj @@ -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" */; diff --git a/Yattee.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Yattee.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 250ba00c..6fdae54d 100644 --- a/Yattee.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Yattee.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -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 diff --git a/Yattee/Info.plist b/Yattee/Info.plist index bdb4a4a1..ae444871 100644 --- a/Yattee/Info.plist +++ b/Yattee/Info.plist @@ -4,6 +4,15 @@ ITSAppUsesNonExemptEncryption + + SUFeedURL + https://yattee.github.io/yattee/appcast.xml + SUPublicEDKey + BLtSfi3Epsl97XpMy734PhlbscxWwWpi6moT/S++A+4= + SUEnableAutomaticChecks + + SUEnableInstallerLauncherService + BGTaskSchedulerPermittedIdentifiers stream.yattee.app.feedRefresh diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index 7ef6e12d..32311f8d 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -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" : { diff --git a/Yattee/Services/Updates/SparkleUpdater.swift b/Yattee/Services/Updates/SparkleUpdater.swift new file mode 100644 index 00000000..7caea2e7 --- /dev/null +++ b/Yattee/Services/Updates/SparkleUpdater.swift @@ -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 { + let beta = wantsBetaProvider?() ?? wantsBeta + // Empty set = stable only. Adding "beta" means users get both + // untagged (stable) items AND items tagged beta. + return beta ? ["beta"] : [] + } +} + +#endif diff --git a/Yattee/Views/Settings/AdvancedSettingsView.swift b/Yattee/Views/Settings/AdvancedSettingsView.swift index fc9e738f..7ab499d5 100644 --- a/Yattee/Views/Settings/AdvancedSettingsView.swift +++ b/Yattee/Views/Settings/AdvancedSettingsView.swift @@ -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") { diff --git a/Yattee/Views/macOS/CheckForUpdatesMenuItem.swift b/Yattee/Views/macOS/CheckForUpdatesMenuItem.swift new file mode 100644 index 00000000..f32566d9 --- /dev/null +++ b/Yattee/Views/macOS/CheckForUpdatesMenuItem.swift @@ -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 diff --git a/Yattee/YatteeApp.swift b/Yattee/YatteeApp.swift index bee3bcac..04c397f6 100644 --- a/Yattee/YatteeApp.swift +++ b/Yattee/YatteeApp.swift @@ -310,6 +310,11 @@ struct YatteeApp: App { CommandGroup(replacing: .appSettings) { SettingsWindowMenuItem() } + #if SPARKLE + CommandGroup(after: .appInfo) { + CheckForUpdatesMenuItem() + } + #endif } #endif } diff --git a/fastlane/Fastfile b/fastlane/Fastfile index c2d17e74..0183e8f7 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -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 diff --git a/scripts/sparkle/README.md b/scripts/sparkle/README.md new file mode 100644 index 00000000..31dabc2f --- /dev/null +++ b/scripts/sparkle/README.md @@ -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 `` into `gh-pages/appcast.xml` (de-duplicating if the same version+build already exists). + - Tags beta items with `beta`; stable items are untagged (Sparkle's default). +5. Commits and pushes `gh-pages`. + +The `release_channel` workflow input (`beta` | `stable`, default `beta`) controls: +- `` 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 - +``` diff --git a/scripts/sparkle/appcast_template.xml b/scripts/sparkle/appcast_template.xml new file mode 100644 index 00000000..a21b8fff --- /dev/null +++ b/scripts/sparkle/appcast_template.xml @@ -0,0 +1,9 @@ + + + + Yattee + https://yattee.github.io/yattee/appcast.xml + Most recent changes with links to updates for Yattee (macOS Developer ID build). + en + + diff --git a/scripts/sparkle/update_appcast.rb b/scripts/sparkle/update_appcast.rb new file mode 100755 index 00000000..759bc8eb --- /dev/null +++ b/scripts/sparkle/update_appcast.rb @@ -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 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 \ +# --version 2.0.1 \ +# --build 260 \ +# --channel stable|beta \ +# --tag 2.0.1-260 \ +# --sign-update-bin \ +# --ed-key-file \ +# --appcast \ +# [--minimum-system-version 15.0] \ +# [--release-notes-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 ' + +# ---- 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 ---- +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(\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}"