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
40 changes: 37 additions & 3 deletions .github/workflows/prerelease.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
63 changes: 52 additions & 11 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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-<arch>.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:
Expand Down Expand Up @@ -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 }}
Expand All @@ -1713,14 +1717,51 @@ 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 }}
repository: smart-mcp-proxy/mcpproxy.app-website
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:
Expand Down
53 changes: 53 additions & 0 deletions cmd/mcpproxy/update_channel_precedence_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
})
}
26 changes: 17 additions & 9 deletions cmd/mcpproxy/update_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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},
Expand All @@ -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
Expand Down
12 changes: 12 additions & 0 deletions internal/updatecheck/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
26 changes: 17 additions & 9 deletions internal/updatecheck/checker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
}

Expand Down
3 changes: 2 additions & 1 deletion internal/updatecheck/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading