Skip to content

Commit 7b269f8

Browse files
committed
fix(updatecheck): version-pin or omit commands for prerelease targets; banner guidance fallback (Spec 079 US2)
Cross-model review round 2 fixes: - Prerelease targets (rc channel / MCPPROXY_ALLOW_PRERELEASE_UPDATES): prereleases are published only to the GitHub pre-release channel, so the generic package-manager commands would not deliver them — brew/ apt/dnf serve stable artifacts and Go's @latest resolves to the newest stable. When the offered version is a prerelease only go-install now gets a command, pinned to the exact version; every other channel omits the command and falls back to guidance (FR-009). - Web UI banner: channels that intentionally have no safe command (dmg/windows-installer/docker/tarball/unknown) now render the same guidance line as status/doctor instead of nothing, mirroring internal/updatecheck.GuidanceLine (FR-009/FR-010).
1 parent acb5ee6 commit 7b269f8

7 files changed

Lines changed: 140 additions & 2 deletions

File tree

docs/features/version-updates.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,15 @@ because a wrong update command is worse than a generic instruction.
215215
Every surface always deep-links the release notes for the latest version,
216216
whether or not a command is available.
217217

218+
**Prerelease targets** (the `rc` channel or
219+
`MCPPROXY_ALLOW_PRERELEASE_UPDATES=true`) are the exception: prereleases are
220+
published only to the GitHub pre-release channel, so the package-manager
221+
commands above would not deliver them (`brew`/`apt`/`dnf` serve stable
222+
artifacts, and Go's `@latest` resolves to the newest stable). When the offered
223+
version is a prerelease, only `go-install` gets a command — pinned to the
224+
exact version (`…/cmd/mcpproxy@v0.48.0-rc.1`) — and every other channel falls
225+
back to the release-page guidance.
226+
218227
## Updating MCPProxy
219228

220229
### Homebrew (macOS/Linux)

frontend/src/components/UpdateBanner.vue

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,16 @@
4141
<span v-else class="text-success">Copied</span>
4242
</button>
4343
</div>
44+
<!-- Guidance fallback for channels that intentionally have no safe
45+
command (dmg/windows-installer/docker/tarball/unknown), mirroring
46+
internal/updatecheck.GuidanceLine so the Web UI matches the status/
47+
doctor surfaces (FR-009/FR-010). The Release notes link above is
48+
the deep link, so the text references it generically. -->
49+
<div
50+
v-else-if="guidance"
51+
class="mt-2 text-sm"
52+
data-test="update-banner-guidance"
53+
>{{ guidance }}</div>
4454
</div>
4555
<button
4656
class="btn btn-sm btn-ghost btn-square"
@@ -87,6 +97,30 @@ const releaseUrl = computed(() => systemStore.info?.update?.release_url ?? '')
8797
// Spec 079 US2: channel-aware one-line update command (empty when the
8898
// detected channel has no safe command, FR-009).
8999
const updateCommand = computed(() => systemStore.updateCommand)
100+
const installChannel = computed(() => systemStore.installChannel)
101+
102+
// Guidance for no-command channels — mirrors internal/updatecheck.GuidanceLine
103+
// (keep the two in sync). Channels with a real command return '' so the
104+
// banner never renders both; '' (older daemon) renders nothing.
105+
const guidance = computed(() => {
106+
if (updateCommand.value) return ''
107+
switch (installChannel.value) {
108+
case 'dmg':
109+
return 'Download the latest DMG from the releases page.'
110+
case 'windows-installer':
111+
return 'Download the latest Windows installer from the releases page.'
112+
case 'docker':
113+
return 'Pull or rebuild the newer image for your deployment.'
114+
case 'homebrew':
115+
case 'deb':
116+
case 'rpm':
117+
case 'go-install':
118+
case '':
119+
return ''
120+
default: // tarball, unknown, anything unrecognized
121+
return 'Download the latest release from the releases page.'
122+
}
123+
})
90124
91125
const copied = ref(false)
92126
let copiedTimer: ReturnType<typeof setTimeout> | undefined

internal/updatecheck/checker.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,9 +307,17 @@ func (c *Checker) updateVersionInfo(release *GitHubRelease, checkError string, g
307307
// update_command only accompanies an actual update on channels with a
308308
// safe one-line command (Spec 079 FR-009; empty for dmg/windows-installer/
309309
// tarball/docker/unknown — surfaces render guidance text instead).
310+
// Prerelease targets are special: the package-manager channels only serve
311+
// stable artifacts, so their generic commands would not deliver the
312+
// advertised rc — only go-install gets a version-pinned command
313+
// (PrereleaseUpdateCommand).
310314
updateCommand := ""
311315
if updateAvailable {
312-
updateCommand = UpdateCommand(c.installChannel)
316+
if release.Prerelease {
317+
updateCommand = PrereleaseUpdateCommand(c.installChannel, latestVersion)
318+
} else {
319+
updateCommand = UpdateCommand(c.installChannel)
320+
}
313321
}
314322

315323
c.versionInfo = &VersionInfo{

internal/updatecheck/checker_channel_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,42 @@ func TestChecker_PrereleaseOnStableChannelPins(t *testing.T) {
136136
t.Errorf("UpdateCommand = %q, want %q", info.UpdateCommand, "brew upgrade mcpproxy")
137137
}
138138
})
139+
140+
// Prerelease targets: the package-manager channels only serve stable
141+
// artifacts (and `go install @latest` resolves to the newest stable), so
142+
// a generic command would not deliver the advertised rc (FR-009).
143+
t.Run("prerelease target on homebrew: no command", func(t *testing.T) {
144+
checker := New(zap.NewNop(), "v0.47.0")
145+
setTestChannel(checker, ChannelHomebrew)
146+
checker.SetCheckFunc(func() (*GitHubRelease, error) {
147+
return &GitHubRelease{TagName: "v0.48.0-rc.1", HTMLURL: "https://example.com/v0.48.0-rc.1", Prerelease: true}, nil
148+
})
149+
150+
info := checker.CheckNow()
151+
if info == nil || !info.UpdateAvailable {
152+
t.Fatal("expected update available")
153+
}
154+
if info.UpdateCommand != "" {
155+
t.Errorf("UpdateCommand = %q, want empty for a prerelease target (brew serves stable only)", info.UpdateCommand)
156+
}
157+
})
158+
159+
t.Run("prerelease target on go-install: version-pinned command", func(t *testing.T) {
160+
checker := New(zap.NewNop(), "v0.47.0")
161+
setTestChannel(checker, ChannelGoInstall)
162+
checker.SetCheckFunc(func() (*GitHubRelease, error) {
163+
return &GitHubRelease{TagName: "v0.48.0-rc.1", HTMLURL: "https://example.com/v0.48.0-rc.1", Prerelease: true}, nil
164+
})
165+
166+
info := checker.CheckNow()
167+
if info == nil || !info.UpdateAvailable {
168+
t.Fatal("expected update available")
169+
}
170+
want := "go install github.com/smart-mcp-proxy/mcpproxy-go/cmd/mcpproxy@v0.48.0-rc.1"
171+
if info.UpdateCommand != want {
172+
t.Errorf("UpdateCommand = %q, want %q (pinned, @latest would resolve to stable)", info.UpdateCommand, want)
173+
}
174+
})
139175
}
140176

141177
// FR-021 contract: adding install_channel/update_command must not remove or

internal/updatecheck/guidance.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,21 @@ func UpdateCommand(channel string) string {
2424
}
2525
}
2626

27+
// PrereleaseUpdateCommand returns the one-line update command when the
28+
// offered release is a prerelease. Prereleases are published only to the
29+
// GitHub pre-release channel (docs/prerelease-builds.md) — the Homebrew tap
30+
// and apt/dnf repos serve stable artifacts, and Go's `@latest` module query
31+
// resolves to the newest stable release — so the generic UpdateCommand
32+
// output would not deliver the advertised version (FR-009: never emit a
33+
// command that could be wrong). Only go-install can name the exact version;
34+
// every other channel returns "" and falls back to guidance/release-URL.
35+
func PrereleaseUpdateCommand(channel, version string) string {
36+
if channel == ChannelGoInstall && version != "" {
37+
return "go install github.com/smart-mcp-proxy/mcpproxy-go/cmd/mcpproxy@" + ensureVPrefix(version)
38+
}
39+
return ""
40+
}
41+
2742
// GuidanceLine returns a human-readable guided-update line for channels that
2843
// have no safe one-line command, deep-linking the release when releaseURL is
2944
// provided (FR-010). Channels with a real command (see UpdateCommand) return

internal/updatecheck/guidance_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,40 @@ func TestUpdateCommand_NoCommandChannels(t *testing.T) {
3434
}
3535
}
3636

37+
func TestPrereleaseUpdateCommand(t *testing.T) {
38+
// Prereleases live only on the GitHub pre-release channel: the Homebrew
39+
// tap and apt/dnf repos serve stable artifacts, and `go install @latest`
40+
// resolves to the newest stable. Only go-install can pin the exact
41+
// version; every other channel must stay silent (FR-009).
42+
t.Run("go-install pins the exact prerelease version", func(t *testing.T) {
43+
want := "go install github.com/smart-mcp-proxy/mcpproxy-go/cmd/mcpproxy@v0.48.0-rc.1"
44+
if got := PrereleaseUpdateCommand(ChannelGoInstall, "v0.48.0-rc.1"); got != want {
45+
t.Errorf("PrereleaseUpdateCommand(go-install) = %q, want %q", got, want)
46+
}
47+
})
48+
49+
t.Run("version without v prefix is normalized", func(t *testing.T) {
50+
want := "go install github.com/smart-mcp-proxy/mcpproxy-go/cmd/mcpproxy@v0.48.0-rc.1"
51+
if got := PrereleaseUpdateCommand(ChannelGoInstall, "0.48.0-rc.1"); got != want {
52+
t.Errorf("PrereleaseUpdateCommand(go-install) = %q, want %q", got, want)
53+
}
54+
})
55+
56+
t.Run("go-install without a version stays silent", func(t *testing.T) {
57+
if got := PrereleaseUpdateCommand(ChannelGoInstall, ""); got != "" {
58+
t.Errorf("PrereleaseUpdateCommand(go-install, \"\") = %q, want empty", got)
59+
}
60+
})
61+
62+
t.Run("package-manager channels never get a prerelease command", func(t *testing.T) {
63+
for _, channel := range []string{ChannelHomebrew, ChannelDeb, ChannelRPM, ChannelDMG, ChannelWindowsInstaller, ChannelTarball, ChannelDocker, ChannelUnknown, ""} {
64+
if got := PrereleaseUpdateCommand(channel, "v0.48.0-rc.1"); got != "" {
65+
t.Errorf("PrereleaseUpdateCommand(%q) = %q, want empty", channel, got)
66+
}
67+
}
68+
})
69+
}
70+
3771
func TestGuidanceLine_PerChannel(t *testing.T) {
3872
const url = "https://github.com/smart-mcp-proxy/mcpproxy-go/releases/tag/v0.48.0"
3973

internal/updatecheck/types.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@ type VersionInfo struct {
4141
// UpdateCommand is the exact one-line update command for InstallChannel.
4242
// Only set when UpdateAvailable is true AND the channel has a safe
4343
// command (homebrew, deb, rpm, go-install); empty otherwise (Spec 079
44-
// FR-009, additive per FR-021).
44+
// FR-009, additive per FR-021). When the offered version is a
45+
// prerelease, only go-install gets a command (version-pinned) — the
46+
// package-manager channels serve stable artifacts only.
4547
UpdateCommand string `json:"update_command,omitempty"`
4648
}
4749

0 commit comments

Comments
 (0)