diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index f3a3f422a..4c97daf7e 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -1200,14 +1200,48 @@ jobs: path: appcast-out/* if-no-files-found: ignore - # Same site hand-off as release.yml, beta channel. Non-blocking; the - # site workflow is manually re-runnable (workflow_dispatch). + # Same site hand-off as release.yml, beta channel. MUST fail loudly (see + # the matching comment in release.yml): the mcpproxy.app beta feed is the + # ONLY stable URL for the rc channel — GitHub's releases/latest/download + # never resolves to a prerelease — so a silently lost dispatch strands + # every beta user on the previous RC. All release assets are uploaded + # before this step; a red job blocks nothing. Backfill by re-running this + # job, or via the site's publish-appcast workflow_dispatch. - name: Publish beta feeds to mcpproxy.app 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"}' + + # End-to-end freshness check, mirroring release.yml's: poll until the + # live site serves the exact bytes this job generated, on one 15-min + # deadline shared across the feeds. + - name: Verify live feeds are fresh + if: steps.appcast.outputs.generated == 'true' + run: | + set -euo pipefail + DEADLINE=$(( $(date +%s) + 15 * 60 )) + FAILED=0 + for f in appcast-out/*.xml; do + NAME=$(basename "$f") + WANT=$(shasum -a 256 "$f" | cut -d' ' -f1) + OK="" + # Check-first: every feed gets at least one probe even after an + # earlier feed has exhausted the shared deadline. + while :; do + GOT=$(curl -fsSL "https://mcpproxy.app/${NAME}" 2>/dev/null | shasum -a 256 | cut -d' ' -f1 || true) + if [ "$GOT" = "$WANT" ]; then OK=yes; break; fi + if [ "$(date +%s)" -ge "$DEADLINE" ]; then break; fi + sleep 15 + done + if [ -n "$OK" ]; then + echo "✅ https://mcpproxy.app/${NAME} serves the ${GITHUB_REF_NAME} feed" + else + echo "::error::https://mcpproxy.app/${NAME} still does not serve the ${GITHUB_REF_NAME} feed after 15 minutes — beta users will be offered a stale version" + FAILED=1 + fi + done + exit "$FAILED" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e1a2ea445..98ce9ca12 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1572,14 +1572,13 @@ jobs: # native/macos/MCPProxy/MCPProxy/Services/SparkleFeedURL.swift, and the file # names below are the other half of that contract. # - # TODO(maintainer decision #1 in docs/research/auto-updater-issue-957-2026-08-07.html): - # the shipped Info.plist points at https://mcpproxy.app/appcast.xml, which the - # WEBSITE repo would have to serve — this repository cannot publish there. Until - # that is wired, the feeds are (a) attached to the release and (b) exported as a - # workflow artifact (`sparkle-appcast`) for the website repo to consume. The - # GitHub-hosted stable URL + # The shipped Info.plist points at https://mcpproxy.app/appcast.xml (decision + # #1 in docs/research/auto-updater-issue-957-2026-08-07.html). This job feeds + # the site via a repository-dispatch below; the feeds are also (a) attached to + # the release and (b) exported as a workflow artifact (`sparkle-appcast`) as + # backfill sources. The GitHub-hosted stable URL # https://github.com/OWNER/REPO/releases/latest/download/appcast-.xml - # works today as an interim SUFeedURL for the STABLE channel only (it resolves to + # works as an alternative SUFeedURL for the STABLE channel only (it resolves to # the newest non-prerelease release); set repository variable SPARKLE_FEED_URL to # use it. It does NOT serve the beta channel — see prerelease.yml. sparkle-appcast: @@ -1697,11 +1696,16 @@ jobs: # Tell mcpproxy.app to serve the freshly uploaded feeds (they are public # release assets by now — the site's publish-appcast workflow downloads # them from the release, so no artifact plumbing crosses repos). - # Non-blocking: the feeds stay downloadable from the release either way, - # and the site workflow can be re-run by hand (workflow_dispatch). + # This step MUST fail loudly: on v0.60.0 an expired + # MARKETING_SITE_DISPATCH_TOKEN made this dispatch 401 under + # continue-on-error, the site kept serving the 0.59.0 feed, and Sparkle + # told users they were up to date while doctor/tray offered v0.60.0. + # Failing here blocks nothing — every release asset (feeds included) is + # uploaded by earlier steps; a red job is the recovery signal. Backfill by + # re-running this job, or by hand via the site's publish-appcast + # workflow_dispatch. - name: Publish feeds to mcpproxy.app 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 }} @@ -1713,7 +1717,6 @@ jobs: # (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 }} @@ -1721,6 +1724,44 @@ jobs: event-type: publish-appcast client-payload: '{"version": "${{ github.ref_name }}", "channel": "beta"}' + # End-to-end freshness check: the dispatch above only queues the site's + # publish-appcast workflow — a failed site run, a queued run replaced by + # its sibling in the site's concurrency group, or a stale Cloudflare + # Pages deploy would still leave the live feed behind. Poll until the + # LIVE bytes match what this job generated (the site's own verify step + # does the same from its side, but it never runs if the dispatch is + # lost). One 15-min deadline SHARED across all feeds: dispatch → site + # workflow queue → commit → Pages deploy is ~3-5 min per channel, and a + # shared budget keeps a total site outage from holding this macOS runner + # for 15 min per file. The beta copies are byte-identical to the stable + # feeds (cp above), so this loop verifies both dispatches. + - name: Verify live feeds are fresh + if: steps.appcast.outputs.generated == 'true' + run: | + set -euo pipefail + DEADLINE=$(( $(date +%s) + 15 * 60 )) + FAILED=0 + for f in appcast-out/*.xml; do + NAME=$(basename "$f") + WANT=$(shasum -a 256 "$f" | cut -d' ' -f1) + OK="" + # Check-first: every feed gets at least one probe even after an + # earlier feed has exhausted the shared deadline. + while :; do + GOT=$(curl -fsSL "https://mcpproxy.app/${NAME}" 2>/dev/null | shasum -a 256 | cut -d' ' -f1 || true) + if [ "$GOT" = "$WANT" ]; then OK=yes; break; fi + if [ "$(date +%s)" -ge "$DEADLINE" ]; then break; fi + sleep 15 + done + if [ -n "$OK" ]; then + echo "✅ https://mcpproxy.app/${NAME} serves the ${GITHUB_REF_NAME} feed" + else + echo "::error::https://mcpproxy.app/${NAME} still does not serve the ${GITHUB_REF_NAME} feed after 15 minutes — Sparkle users will be offered a stale version" + FAILED=1 + fi + done + exit "$FAILED" + provenance: needs: [release] permissions: diff --git a/cmd/mcpproxy/update_channel_precedence_test.go b/cmd/mcpproxy/update_channel_precedence_test.go new file mode 100644 index 000000000..88fc08b67 --- /dev/null +++ b/cmd/mcpproxy/update_channel_precedence_test.go @@ -0,0 +1,53 @@ +package main + +import ( + "testing" +) + +// prereleasePreference must apply the same build-version-authoritative rule as +// the daemon's Checker.IncludePrereleases (Spec 079 FR-014 / FR-023): a stable +// build never resolves against the prerelease list, whatever a stale +// `channel: rc` config says, and an RC build always does. +func TestPrereleasePreference_BuildIdentityAuthoritative(t *testing.T) { + // Pin the env opt-in off so ambient shell exports (an RC dogfooder's + // MCPPROXY_ALLOW_PRERELEASE_UPDATES=true) can't flip the negative cases. + t.Setenv("MCPPROXY_ALLOW_PRERELEASE_UPDATES", "") + + t.Run("stable build ignores stale rc config", func(t *testing.T) { + if prereleasePreferenceFor("v0.60.0", true) { + t.Fatal("stable build must never offer prereleases, even with channel: rc config") + } + }) + + t.Run("stable build ignores env opt-in", func(t *testing.T) { + t.Setenv("MCPPROXY_ALLOW_PRERELEASE_UPDATES", "true") + if prereleasePreferenceFor("v0.60.0", false) { + t.Fatal("stable build must never offer prereleases, even with env opt-in") + } + }) + + t.Run("rc build always tracks prereleases", func(t *testing.T) { + if !prereleasePreferenceFor("v0.61.0-rc.1", false) { + t.Fatal("rc build must track the rc channel regardless of config") + } + }) + + t.Run("dev build honors config opt-in", func(t *testing.T) { + if !prereleasePreferenceFor("development", true) { + t.Fatal("unstamped build with channel: rc must opt in") + } + }) + + t.Run("dev build honors env opt-in", func(t *testing.T) { + t.Setenv("MCPPROXY_ALLOW_PRERELEASE_UPDATES", "true") + if !prereleasePreferenceFor("development", false) { + t.Fatal("unstamped build with env opt-in must opt in") + } + }) + + t.Run("dev build defaults to stable", func(t *testing.T) { + if prereleasePreferenceFor("development", false) { + t.Fatal("unstamped build without any opt-in must stay on stable") + } + }) +} diff --git a/cmd/mcpproxy/update_cmd.go b/cmd/mcpproxy/update_cmd.go index 0b842f506..54d49c40d 100644 --- a/cmd/mcpproxy/update_cmd.go +++ b/cmd/mcpproxy/update_cmd.go @@ -176,6 +176,10 @@ Examples: func newUpdateRunner(out, errOut io.Writer, flags updateFlags) (*updateRunner, error) { version := httpapi.GetBuildVersion() channel := updatecheck.DetectChannel(version) + // go-install builds carry the non-semver ldflags default; promote to the + // module version exactly like the daemon's checker does, so the channel + // precedence below classifies the build identically (FR-023). + version = updatecheck.PromoteGoInstallVersion(version, channel) execPath, err := resolvedExecutablePath() if err != nil { @@ -191,7 +195,7 @@ func newUpdateRunner(out, errOut io.Writer, flags updateFlags) (*updateRunner, e currentVersion: version, channel: channel, execPath: execPath, - includePrereleases: prereleasePreference(), + includePrereleases: prereleasePreference(version), flags: flags, releases: &githubReleaseSource{client: client}, httpClient: &http.Client{Timeout: downloadTimeout}, @@ -202,17 +206,21 @@ func newUpdateRunner(out, errOut io.Writer, flags updateFlags) (*updateRunner, e } // prereleasePreference mirrors the checker's precedence (Spec 079 FR-014): +// the released build's own identity is authoritative, then // MCPPROXY_ALLOW_PRERELEASE_UPDATES wins over update_check.channel, so // `mcpproxy update` offers exactly what the daemon's nudge offered (FR-023). -func prereleasePreference() bool { - if os.Getenv(updatecheck.EnvAllowPrereleaseUpdates) == "true" { - return true +func prereleasePreference(buildVersion string) bool { + cfgPrerelease := false + if cfg, err := loadCLIConfig(configFile); err == nil && cfg != nil { + cfgPrerelease = cfg.UpdateCheck.IncludePrereleases() } - cfg, err := loadCLIConfig(configFile) - if err != nil || cfg == nil { - return false - } - return cfg.UpdateCheck.IncludePrereleases() + return prereleasePreferenceFor(buildVersion, cfgPrerelease) +} + +// prereleasePreferenceFor is the config-independent core of +// prereleasePreference, split out for tests. +func prereleasePreferenceFor(buildVersion string, cfgPrerelease bool) bool { + return updatecheck.IncludePrereleasesForBuild(buildVersion, cfgPrerelease) } // resolvedExecutablePath returns the symlink-resolved path of the running diff --git a/internal/updatecheck/channel.go b/internal/updatecheck/channel.go index 12b4beb4c..d0b5e27a5 100644 --- a/internal/updatecheck/channel.go +++ b/internal/updatecheck/channel.go @@ -269,6 +269,18 @@ func (d *channelDetector) resolvedExecPath() string { // checks. For every other channel, or when build info is unusable, the // original version is returned unchanged. func promoteGoInstallVersion(version, channel string, readBuildInfo func() (*debug.BuildInfo, bool)) string { + return promoteGoInstallVersionImpl(version, channel, readBuildInfo) +} + +// PromoteGoInstallVersion is the production-wired form of +// promoteGoInstallVersion, shared with `mcpproxy update` so the CLI resolves +// the same build identity (and therefore the same channel precedence) as the +// daemon's checker (Spec 079 FR-023). +func PromoteGoInstallVersion(version, channel string) string { + return promoteGoInstallVersionImpl(version, channel, debug.ReadBuildInfo) +} + +func promoteGoInstallVersionImpl(version, channel string, readBuildInfo func() (*debug.BuildInfo, bool)) string { if channel != ChannelGoInstall { return version } diff --git a/internal/updatecheck/checker.go b/internal/updatecheck/checker.go index acc7accdf..37c3336da 100644 --- a/internal/updatecheck/checker.go +++ b/internal/updatecheck/checker.go @@ -209,12 +209,22 @@ func (c *Checker) enabledLocked() bool { // MCPPROXY_ALLOW_PRERELEASE_UPDATES=true wins over the config channel // (Spec 079 FR-014 precedence: env > config); otherwise channel=rc opts in. func (c *Checker) IncludePrereleases() bool { - // The running build's own version is authoritative (issue: a stable user - // must never be offered an RC; an RC user may be offered stable or the next - // RC). This deliberately overrides the config/env opt-in for RELEASED - // builds, so a stale `channel: rc` config left over from a - // previously-installed RC cannot resurrect RC offers on a stable build. - switch versionChannelKind(c.version) { + c.mu.RLock() + cfgPrerelease := c.cfgPrerelease + c.mu.RUnlock() + return IncludePrereleasesForBuild(c.version, cfgPrerelease) +} + +// IncludePrereleasesForBuild applies the full channel precedence for a build +// version. The running build's own version is authoritative (issue: a stable +// user must never be offered an RC; an RC user may be offered stable or the +// next RC). This deliberately overrides the config/env opt-in for RELEASED +// builds, so a stale `channel: rc` config left over from a +// previously-installed RC cannot resurrect RC offers on a stable build. +// Shared by the daemon's Checker and `mcpproxy update` so both resolve the +// same channel (Spec 079 FR-023). +func IncludePrereleasesForBuild(buildVersion string, cfgPrerelease bool) bool { + switch versionChannelKind(buildVersion) { case buildChannelStable: // A stable build never tracks prereleases, whatever the config/env say. return false @@ -232,9 +242,7 @@ func (c *Checker) IncludePrereleases() bool { if os.Getenv(EnvAllowPrereleaseUpdates) == "true" { return true } - c.mu.RLock() - defer c.mu.RUnlock() - return c.cfgPrerelease + return cfgPrerelease } } diff --git a/internal/updatecheck/types.go b/internal/updatecheck/types.go index 6c4262ccb..bf45e172e 100644 --- a/internal/updatecheck/types.go +++ b/internal/updatecheck/types.go @@ -4,7 +4,8 @@ package updatecheck import "time" // VersionInfo represents the current version and update availability. -// This is stored in-memory only and refreshed on startup + every 4 hours. +// This is stored in-memory only and refreshed on startup + every +// DefaultCheckInterval (24h; stretched by backoff after failed checks). type VersionInfo struct { // CurrentVersion is the version of the running MCPProxy instance. // Format: semver with "v" prefix (e.g., "v1.2.3") or "development"