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