Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
24 changes: 24 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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:
Expand Down
6 changes: 5 additions & 1 deletion native/macos/MCPProxy/MCPProxy/Core/CoreSupersede.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
}
}

Expand Down
51 changes: 50 additions & 1 deletion native/macos/MCPProxy/MCPProxy/Services/FeedUpdater.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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) {
Expand Down Expand Up @@ -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
30 changes: 30 additions & 0 deletions native/macos/MCPProxy/MCPProxyTests/SemanticVersionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading