From bcaec3cce3cbdbbf59c1be2a4f8bfc134b815b3d Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sat, 8 Aug 2026 20:22:16 +0300 Subject: [PATCH] =?UTF-8?q?fix(tray):=20hand=20Sparkle=20the=20SemVer=20co?= =?UTF-8?q?mparator=20=E2=80=94=20its=20own=20calls=20rc.2,=20rc.3=20and?= =?UTF-8?q?=20stable=20EQUAL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #957 SUStandardVersionComparator treats the entire prerelease suffix as noise (verified directly against Sparkle 2.9.3: 0.54.0-rc.2 vs 0.54.0-rc.3 vs 0.54.0 all compare orderedSame), so every RC-to-RC and RC-to-stable update was invisible — 'You're up to date' on a feed that plainly carried a newer item, found live on the v0.54.0-rc.3 dress rehearsal. The FR-006 SemanticVersion util existed but was never plugged into Sparkle; SemVerSparkleComparator is the missing versionComparator(for:) delegate, falling back to Sparkle's comparator only for strings that are not versions. Also, channel policy per maintainer decision: RC users update to the next RC OR the next stable, whichever is newer; stable users only ever see stables. release.yml now copies the stable feed over the beta feeds and fires a second beta-channel site dispatch, so RC users are not stranded on their last RC when a cycle ends. SemVer precedence makes the stable outrank its own RCs. Plus: Sparkle decision diagnostics (feed URL, allowed channels, loaded appcast items — the lines whose absence cost this debugging session) and the vv double-prefix fix in the supersede consent label. Verified end to end against production artifacts: a locally-built rc.2 tray with this fix detected rc.3 on the live beta feed, one-click downloaded the real enclosure, EdDSA-verified with the production key, swapped, relaunched as rc.3, and superseded the old core. --- .github/workflows/release.yml | 24 +++++++++ .../MCPProxy/Core/CoreSupersede.swift | 6 ++- .../MCPProxy/Services/FeedUpdater.swift | 51 ++++++++++++++++++- .../MCPProxyTests/SemanticVersionTests.swift | 30 +++++++++++ 4 files changed, 109 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 18b70e21..89477eba 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1670,6 +1670,18 @@ jobs: grep -q 'sparkle:edSignature' "${f}" || { echo "❌ ${f} has no EdDSA signature"; exit 1; } done + # Channel policy: RC users update to the next RC OR the next stable, + # whichever is newer; stable users only ever see stables. A stable + # release therefore also refreshes the BETA feeds with its (untagged, + # visible-to-every-channel) item — otherwise RC users would sit on + # their last RC forever once the cycle ends. SemVer precedence in the + # tray orders X.Y.Z above X.Y.Z-rc.N, so the stable always wins. + for ARCH in arm64 amd64; do + if [ -f "appcast-out/appcast-${ARCH}.xml" ]; then + cp "appcast-out/appcast-${ARCH}.xml" "appcast-out/appcast-beta-${ARCH}.xml" + fi + done + gh release upload "${GITHUB_REF_NAME}" appcast-out/*.xml --clobber echo "generated=true" >> "$GITHUB_OUTPUT" @@ -1695,6 +1707,18 @@ jobs: event-type: publish-appcast client-payload: '{"version": "${{ github.ref_name }}", "channel": "stable"}' + # Second dispatch: the same stable release also refreshes the beta feeds + # (see the channel-policy comment above). + - name: Publish stable item to the beta feeds + if: steps.appcast.outputs.generated == 'true' + continue-on-error: true + uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 + with: + token: ${{ secrets.MARKETING_SITE_DISPATCH_TOKEN }} + repository: smart-mcp-proxy/mcpproxy.app-website + event-type: publish-appcast + client-payload: '{"version": "${{ github.ref_name }}", "channel": "beta"}' + provenance: needs: [release] permissions: diff --git a/native/macos/MCPProxy/MCPProxy/Core/CoreSupersede.swift b/native/macos/MCPProxy/MCPProxy/Core/CoreSupersede.swift index 1a0a7732..549bd758 100644 --- a/native/macos/MCPProxy/MCPProxy/Core/CoreSupersede.swift +++ b/native/macos/MCPProxy/MCPProxy/Core/CoreSupersede.swift @@ -82,7 +82,11 @@ struct StaleCorePrompt: Equatable { let pid: Int32? var menuTitle: String { - "Old core v\(runningVersion) running — Restart into v\(bundledVersion)" + // Core versions already carry their "v" prefix ("v0.54.0-rc.2"); + // strip before re-prefixing or the label reads "vv0.54.0-rc.2". + let running = runningVersion.hasPrefix("v") ? String(runningVersion.dropFirst()) : runningVersion + let bundled = bundledVersion.hasPrefix("v") ? String(bundledVersion.dropFirst()) : bundledVersion + return "Old core v\(running) running — Restart into v\(bundled)" } } diff --git a/native/macos/MCPProxy/MCPProxy/Services/FeedUpdater.swift b/native/macos/MCPProxy/MCPProxy/Services/FeedUpdater.swift index b6ff4aec..141581fb 100644 --- a/native/macos/MCPProxy/MCPProxy/Services/FeedUpdater.swift +++ b/native/macos/MCPProxy/MCPProxy/Services/FeedUpdater.swift @@ -249,14 +249,44 @@ extension SparkleFeedUpdater: SPUUpdaterDelegate { let resolved = SparkleFeedURL.archSpecific( configured, arch: UpdateService.hostArchToken(), channel: policy.channel ) + NSLog("[MCPProxy] Sparkle check: channel=%@ feed=%@", + policy.channel.rawValue, resolved == configured ? "(Info.plist default)" : resolved) return resolved == configured ? nil : resolved } + /// Diagnostic seam: one line per loaded appcast saying what Sparkle SAW. + /// Without it, "no update" is indistinguishable from "wrong feed", + /// "filtered channel", and "version comparison surprise" — which cost a + /// live debugging session on the v0.54.0-rc.3 rehearsal. + func updater(_ updater: SPUUpdater, didFinishLoading appcast: SUAppcast) { + let items = appcast.items.map { item in + "\(item.displayVersionString)(v=\(item.versionString),ch=\(item.channel ?? "default"))" + }.joined(separator: ", ") + NSLog("[MCPProxy] Sparkle loaded appcast: %d item(s): %@", appcast.items.count, items) + } + /// FR-014: stable users must never be offered RCs. An empty set means /// "default channel only", which is exactly the stable feed; RC users also /// accept the `beta` channel the prerelease pipeline tags. func allowedChannels(for updater: SPUUpdater) -> Set { - policy.channel.allowedSparkleChannels + let allowed = policy.channel.allowedSparkleChannels + NSLog("[MCPProxy] Sparkle allowedChannels consulted: channel=%@ -> %@", + policy.channel.rawValue, allowed.isEmpty ? "(default only)" : allowed.sorted().joined(separator: ",")) + return allowed + } + + /// FR-006 — Sparkle MUST compare versions by SemVer 2.0 precedence. + /// + /// `SUStandardVersionComparator` treats the whole prerelease suffix as + /// noise: it reports 0.54.0-rc.2, 0.54.0-rc.3 AND plain 0.54.0 as EQUAL + /// (verified against Sparkle 2.9.3 directly). Every RC→RC and RC→stable + /// update is therefore invisible to it — "You're up to date" on a feed + /// that plainly carries a newer item, found live on the v0.54.0-rc.3 + /// dress rehearsal. The shared SemanticVersion util already implements + /// the correct precedence (rc.2 < rc.3 < stable); this delegate is the + /// missing plumbing that hands it to Sparkle. + func versionComparator(for updater: SPUUpdater) -> SUVersionComparison? { + SemVerSparkleComparator.shared } func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) { @@ -406,4 +436,23 @@ extension SparkleFeedUpdater: SPUStandardUserDriverDelegate { } } +/// SUVersionComparison backed by the shared SemVer 2.0 util (FR-006). +/// +/// Malformed versions fall back to Sparkle's own comparator rather than +/// guessing: `SemanticVersion.compare` returns nil for anything that is not a +/// version, and a fabricated "equal" there is precisely the bug this class +/// exists to fix. +final class SemVerSparkleComparator: NSObject, SUVersionComparison { + static let shared = SemVerSparkleComparator() + + func compareVersion(_ versionA: String, toVersion versionB: String) -> ComparisonResult { + if let cmp = SemanticVersion.compare(versionA, versionB) { + return cmp < 0 ? .orderedAscending : cmp > 0 ? .orderedDescending : .orderedSame + } + NSLog("[MCPProxy] Sparkle version compare fell back to the standard comparator: %@ vs %@", + versionA, versionB) + return SUStandardVersionComparator.default.compareVersion(versionA, toVersion: versionB) + } +} + #endif diff --git a/native/macos/MCPProxy/MCPProxyTests/SemanticVersionTests.swift b/native/macos/MCPProxy/MCPProxyTests/SemanticVersionTests.swift index 1a543633..e63e195e 100644 --- a/native/macos/MCPProxy/MCPProxyTests/SemanticVersionTests.swift +++ b/native/macos/MCPProxy/MCPProxyTests/SemanticVersionTests.swift @@ -175,3 +175,33 @@ final class SemanticVersionTests: XCTestCase { XCTAssertFalse(SemanticVersion.parse("0.54.0")!.isPrerelease) } } + +// MARK: - Sparkle bridge (FR-006) + +/// SUStandardVersionComparator reports 0.54.0-rc.2, 0.54.0-rc.3 and 0.54.0 as +/// EQUAL — which made every RC→RC and RC→stable update invisible ("You're up +/// to date", live on the v0.54.0-rc.3 rehearsal). These pin the bridge that +/// hands Sparkle the SemVer comparator instead. +final class SemVerSparkleComparatorTests: XCTestCase { + private let comparator = SemVerSparkleComparator.shared + + func testRcToNextRcIsAnUpdate() { + XCTAssertEqual(comparator.compareVersion("0.54.0-rc.2", toVersion: "0.54.0-rc.3"), .orderedAscending) + } + + func testRcToStableIsAnUpdate() { + XCTAssertEqual(comparator.compareVersion("0.54.0-rc.3", toVersion: "0.54.0"), .orderedAscending) + } + + func testStableNeverDowngradesToRc() { + XCTAssertEqual(comparator.compareVersion("0.54.0", toVersion: "0.54.0-rc.9"), .orderedDescending) + } + + func testNumericPrereleaseIdentifiersCompareNumerically() { + XCTAssertEqual(comparator.compareVersion("0.54.0-rc.2", toVersion: "0.54.0-rc.10"), .orderedAscending) + } + + func testEqualVersionsAreEqual() { + XCTAssertEqual(comparator.compareVersion("0.54.0-rc.2", toVersion: "0.54.0-rc.2"), .orderedSame) + } +}