carlos / platform Public

console: releases page shows labels, tags and the changelog

Adds ChannelView.Tag and VersionView.Notes, a single tagByRelease helper
in view.go that joins a release's version against release.ListTags (also
adopted by apiversion.go's handleAPIReleasesList, retiring its own inline
join), and renders labels/notes/tag pills/changelogs on app_releases.html.
handlePromote's flash now names the tag on both the fresh-promote and the
no-op branch, since PromoteOnLadder's stableTag can heal a nameless tag on
a repeat.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 28d90ede2f36431fdd33737707890b93ec7b5880 parent 22120da
6 files changed, +191 −23
  • internal/console/apiversion.go +5 −12
  • internal/console/server.go +38 −2
  • internal/console/server_test.go +91 −0
  • internal/console/templates/app_releases.html +17 −4
  • internal/console/templates/layout.html +4 −0
  • internal/console/view.go +36 −5
diff --git a/internal/console/apiversion.go b/internal/console/apiversion.go
index 7ea793304..1a96b73db 100644
--- a/internal/console/apiversion.go
+++ b/internal/console/apiversion.go
@@ -195,24 +195,17 @@ func (s *Server) handleAPIReleasesList(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
- ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
- defer cancel()
+ // Neither Versions nor tagByRelease takes a context (both read via
+ // context.Background() internally, view.go's existing convention for
+ // this kind of best-effort page-render read) — so, unlike this file's
+ // other handlers, there is no request-scoped deadline to wire through.
versions, err := Versions(s.cfg.Releases, acct.Sqid, app)
if err != nil {
log.Printf("console: api releases list %s/%s: %v", acct.Sqid, app, err)
jsonError(w, http.StatusBadGateway, "could not read the releases")
return
}
- tags, err := release.ListTags(ctx, s.cfg.Releases, acct.Sqid, app)
- if err != nil {
- log.Printf("console: api releases list %s/%s: tags: %v", acct.Sqid, app, err)
- jsonError(w, http.StatusBadGateway, "could not read the tags")
- return
- }
- byRelease := make(map[string]string, len(tags))
- for _, t := range tags {
- byRelease[t.Release] = t.Version
- }
+ byRelease := tagByRelease(s.cfg.Releases, acct.Sqid, app)
out := make([]map[string]any, 0, len(versions))
for _, v := range versions {
out = append(out, map[string]any{
diff --git a/internal/console/server.go b/internal/console/server.go
index eac1fd1df..623be2101 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -361,6 +361,7 @@ type page struct {
Apps []appRow // account home
Detail *AppView // /a/{sqid}/apps/{app}
Versions []VersionView // app page: shipped releases, newest first
+ Tags []release.Tag // releases tab: every tag this app has cut, changelog and all
Instances *InstanceReport // app page: the app's self-report, nil when unpolled
Events []Event // app page: recent console actions, newest first
CanPromote bool // a signing key is configured on this host
@@ -1414,6 +1415,22 @@ func (s *Server) handleAppReleases(w http.ResponseWriter, r *http.Request) {
log.Printf("console: versions %s: %v", p.AppName, err)
p.FlashErr = "Could not list this app's releases."
}
+ // Versions itself never reads ListTags (view.go), so this tab does the
+ // join its own template needs: the tag pill on the Releases table, and
+ // the Tags section's changelogs below it (release versioning spec,
+ // 2026-08-08). tagByRelease is the one place the release->tag map gets
+ // built; the raw tag list for the changelog section is a second read of
+ // the same tags/ prefix, since a pill needs only the name but the Tags
+ // section needs the whole record.
+ if tags, terr := release.ListTags(context.Background(), s.cfg.Releases, acct.Sqid, p.AppName); terr != nil {
+ log.Printf("console: tags %s: %v", p.AppName, terr)
+ } else {
+ p.Tags = tags
+ byRelease := tagByRelease(s.cfg.Releases, acct.Sqid, p.AppName)
+ for i := range versions {
+ versions[i].Tag = byRelease[versions[i].Version]
+ }
+ }
p.Versions = versions
// The canary form's "how do I give this a URL" copy — this tab's data, not
// shared chrome: the canary form lives here and nowhere else. Bounded and
@@ -1654,6 +1671,25 @@ func (s *Server) handleNewApp(w http.ResponseWriter, r *http.Request) {
}
}
+// tagSuffix names what a stable promotion's tag ended up as, or why it
+// carries none (release versioning spec, 2026-08-08) — shared between the
+// fresh-promote and no-op flashes in handlePromote below it, because
+// PromoteOnLadder's stableTag runs on EVERY landing on stable, no-op
+// included: a repeat promotion can heal a release that ended up nameless
+// from a previous attempt's failed write, and the flash must say so exactly
+// as loudly as the promotion that cut the tag the first time would have.
+// "" off stable and for a version that carries no tag either way.
+func tagSuffix(res release.PromoteResult) string {
+ suffix := ""
+ if res.Tag != "" {
+ suffix += " — tagged " + res.Tag
+ }
+ if res.TagWarning != "" {
+ suffix += " (" + res.TagWarning + ")"
+ }
+ return suffix
+}
+
// handlePromote points a channel at a shipped version. It is the app's ONE
// promote door — the releases table posts to it, and so does every step of the
// ladder section — which is what makes the ladder and the passkey gate below
@@ -1734,11 +1770,11 @@ func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request) {
return
}
if res.NoOp {
- back("ok", channel+" already serves "+version+" — nothing to do.")
+ back("ok", channel+" already serves "+version+tagSuffix(res)+" — nothing to do.")
return
}
s.audit(acct, app, addr, "promote", "promoted "+version+" to "+channel)
- msg := "Promoted " + version + " to " + channel + "."
+ msg := "Promoted " + version + " to " + channel + tagSuffix(res) + "."
// Whether this app bakes at all is the production flag's business (bake
// controls spec §2): a non-production app's boxes adopt a held channel
// immediately, so the window note below would be a lie for them. Read the
diff --git a/internal/console/server_test.go b/internal/console/server_test.go
index 7e2edace1..901ded9da 100644
--- a/internal/console/server_test.go
+++ b/internal/console/server_test.go
@@ -1992,3 +1992,94 @@ func TestFleetMetricsRendersFiringAlertsWithUnits(t *testing.T) {
t.Errorf("lag alert value rendered as a percentage — lag is seconds:\n%s", page)
}
}
+
+// TestReleasesPageShowsLabelsAndTags: the Releases tab's own read joins
+// Versions against ListTags (view.go's tagByRelease, release versioning
+// spec 2026-08-08) — a shipped iteration's label and notes are visible, and
+// once a tag has been cut for it, the tag pill and its changelog show too.
+func TestReleasesPageShowsLabelsAndTags(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ ctx := context.Background()
+
+ if err := release.Ship(ctx, fx.rel, release.OpsAccount, release.Manifest{
+ Schema: 1, App: "kass", Version: "0.5.0-1", Kind: "binary", BuiltAt: "2026-08-08T10:00:00Z",
+ Label: "fixing modals", Notes: "- x",
+ Artifacts: map[string]release.Artifact{"linux-amd64": {File: "kass"}},
+ }, map[string][]byte{"linux-amd64": []byte("bin")}); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := release.EnsureTag(ctx, fx.rel, release.OpsAccount, "kass", "0.5.0-1", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+
+ status, _, body := get(t, c, fx.u("/apps/kass/releases"))
+ if status != http.StatusOK {
+ t.Fatalf("/apps/kass/releases = %d", status)
+ }
+ for _, want := range []string{"fixing modals", "0.5.0-1", "- x"} {
+ if !strings.Contains(body, want) {
+ t.Fatalf("releases page missing %q in:\n%s", want, body)
+ }
+ }
+ // The tag pill sits on the Releases table's Version cell as a chip, not
+ // merely somewhere on the page.
+ if !strings.Contains(body, `<span class="chip chip-env">0.5.0</span>`) {
+ t.Fatalf("no tag pill for 0.5.0 in:\n%s", body)
+ }
+ // The Tags section below the Releases table carries the compiled
+ // changelog — the same notes text, reached a different way.
+ if !strings.Contains(body, "<h2>Tags</h2>") {
+ t.Fatalf("no Tags section in:\n%s", body)
+ }
+}
+
+// TestChannelRowShowsStableTag: a channel whose current version carries a
+// tag shows it next to the version in the Channels table.
+func TestChannelRowShowsStableTag(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+
+ shipVersion(t, fx, "kass", "0.6.0-1")
+ seedChannel(t, fx, "kass", "stable", "0.6.0-1")
+ if _, _, err := release.EnsureTag(context.Background(), fx.rel, release.OpsAccount, "kass", "0.6.0-1", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+
+ _, _, body := get(t, c, fx.u("/apps/kass/releases"))
+ if !strings.Contains(body, `<td><code>0.6.0-1</code> <span class="chip chip-env">0.6.0</span></td>`) {
+ t.Fatalf("stable channel row missing tag pill for 0.6.0:\n%s", body)
+ }
+}
+
+// TestPromoteFlashNamesTag drives the browser promote-to-stable path (the
+// same ladder walk TestPromoteFlow uses) with an iteration-shaped version,
+// and checks the flash names the tag PromoteOnLadder cut. The repeat
+// promotion is the CARRY-FORWARD binding from the task-10 brief: a no-op
+// still names the tag, because PromoteOnLadder's stableTag re-cuts (or
+// heals) it on every stable landing, no-op included, and a flash that went
+// quiet on a repeat would hide a healed tag from the operator who just
+// asked for one.
+func TestPromoteFlashNamesTag(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+
+ shipVersion(t, fx, "kass", "0.7.0-1")
+ postForm(t, c, fx.u("/apps/kass/promote"),
+ url.Values{"csrf": {csrf}, "version": {"0.7.0-1"}, "new_channel": {"edge"}, "confirmed": {"1"}})
+ postForm(t, c, fx.u("/apps/kass/promote"),
+ url.Values{"csrf": {csrf}, "version": {"0.7.0-1"}, "new_channel": {"beta"}, "confirmed": {"1"}})
+ postGated(t, fx, c, fx.u("/apps/kass/promote"),
+ url.Values{"csrf": {csrf}, "version": {"0.7.0-1"}, "channel": {"stable"}, "confirmed": {"1"}})
+
+ if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "tagged 0.7.0") {
+ t.Fatalf("promote-to-stable flash missing tag name:\n%s", body)
+ }
+
+ postGated(t, fx, c, fx.u("/apps/kass/promote"),
+ url.Values{"csrf": {csrf}, "version": {"0.7.0-1"}, "channel": {"stable"}, "confirmed": {"1"}})
+ if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "already serves 0.7.0-1") || !strings.Contains(body, "tagged 0.7.0") {
+ t.Fatalf("no-op stable promote flash missing tag name:\n%s", body)
+ }
+}
diff --git a/internal/console/templates/app_releases.html b/internal/console/templates/app_releases.html
index 7c1c5a8c8..1d4d9e65f 100644
--- a/internal/console/templates/app_releases.html
+++ b/internal/console/templates/app_releases.html
@@ -25,7 +25,7 @@
{{if .Err}}
<td colspan="3" class="error">{{.Err}}</td>
{{else}}
- <td><code>{{.Version}}</code></td>
+ <td><code>{{.Version}}</code>{{if .Tag}} <span class="chip chip-env">{{.Tag}}</span>{{end}}</td>
<td><span title="{{fmtTime .PromotedAt}}">{{ago .PromotedAt}}</span></td>
<td>{{if .BakedUntil.IsZero}}live{{else}}bakes until ~{{fmtTime .BakedUntil}}{{end}}{{if .FastTrack}} · fast-tracked{{end}}</td>
{{end}}
@@ -98,13 +98,16 @@ never bakes.</p>
{{if .Versions}}
<div class="tablewrap">
<table>
- <tr><th>Version</th><th>Kind</th><th>Built</th>{{if .CanPromote}}<th>Promote</th>{{end}}</tr>
+ <tr><th>Version</th><th>Label</th><th>Kind</th><th>Built</th>{{if .CanPromote}}<th>Promote</th>{{end}}</tr>
{{range .Versions}}
<tr>
- <td><code>{{.Version}}</code></td>
+ <td><code>{{.Version}}</code>{{if .Tag}} <span class="chip chip-env">{{.Tag}}</span>{{end}}</td>
{{if .Err}}
- <td colspan="2" class="error">{{.Err}}</td>
+ <td colspan="3" class="error">{{.Err}}</td>
{{else}}
+ <td>{{if .Label}}{{.Label}}{{end}}{{if .Notes}}
+ <details><summary>notes</summary><pre class="changelog">{{.Notes}}</pre></details>
+ {{end}}{{if and (not .Label) (not .Notes)}}—{{end}}</td>
<td>{{.Kind}}</td>
<td>{{if .BuiltAt}}{{.BuiltAt}}{{else}}—{{end}}</td>
{{end}}
@@ -130,5 +133,15 @@ never bakes.</p>
carlos ship -app {{.Detail.App}} -kind static -version v1 &lt;site-dir&gt;</code></pre>
<p class="small">Releases appear here once shipped, and get promoted to channels from this page.</p>
{{end}}
+
+{{if .Tags}}
+<h2>Tags</h2>
+{{range .Tags}}
+<details>
+ <summary><code>{{.Version}}</code> — <code>{{.Release}}</code>{{if .Actor}} · {{.Actor}}{{end}}</summary>
+ <pre class="changelog">{{.Changelog}}</pre>
+</details>
+{{end}}
+{{end}}
{{if not .CanPromote}}<p class="small">Promotions disabled: no signing key on this host.</p>{{end}}
{{template "bottom" .}}{{end}}
diff --git a/internal/console/templates/layout.html b/internal/console/templates/layout.html
index 362bdd63b..55235fdab 100644
--- a/internal/console/templates/layout.html
+++ b/internal/console/templates/layout.html
@@ -39,6 +39,10 @@
overflow-x: auto; font-family: var(--mono); font-size: 0.85rem; line-height: 1.6;
}
pre code { background: none; padding: 0; }
+ /* A tag's changelog and a release's notes are prose, not code — long lines
+ should wrap inside the box rather than force the horizontal scrollbar
+ every other <pre> on this page gets for a shell command. */
+ .changelog { white-space: pre-wrap; }
select, input[type=text] {
font: inherit; font-family: var(--sans); font-size: 0.9rem; color: var(--ink); background: var(--bg);
border: 1px solid var(--rule); border-radius: 0.4rem; padding: 0.3rem 0.5rem;
diff --git a/internal/console/view.go b/internal/console/view.go
index 9bdce2c86..b1eafe102 100644
--- a/internal/console/view.go
+++ b/internal/console/view.go
@@ -173,6 +173,11 @@ type ChannelView struct {
// FastTrack is the pointer's bake waiver (release.ChannelPointer) — the
// badge is permanent, like the hotfix badge.
FastTrack bool
+ // Tag is the semver tag pointing at this channel's current release, if
+ // any (release versioning spec, 2026-08-08) — set by joining against
+ // release.ListTags via tagByRelease. "" for a release nobody tagged, and
+ // for every channel below stable, which no tag ever names.
+ Tag string
// Err degrades a missing or invalid channel pointer to one row's error
// text instead of a failed page.
Err string
@@ -210,10 +215,14 @@ type VersionView struct {
// existed, and for one whose manifest failed to read (Err is set
// instead).
Label string
- // Tag is the tag name pointing at this release, if any — populated by
- // the CLI API's releases handler (apiversion.go), not here: computing
- // it needs ListTags, which Versions has no reason to read for the app
- // page's own callers.
+ // Notes is the manifest's longer freeform changelog entry, alongside
+ // Label — shown on the releases page inside a <details> so the table
+ // stays scannable. Same "" rules as Label.
+ Notes string
+ // Tag is the tag name pointing at this release, if any. Versions itself
+ // never reads ListTags — every caller that wants Tag populated joins
+ // against tagByRelease's map itself (the CLI API's releases handler in
+ // apiversion.go, and the console releases page in handleAppReleases).
Tag string
}
@@ -436,6 +445,26 @@ func assembleCanaries(channels []ChannelView, routes []RouteView) []CanaryView {
return out
}
+// tagByRelease maps release version -> tag version, one bucket list
+// (release versioning spec, 2026-08-08). This is the ONE place the join
+// between a shipped release and the tag that names it lives — every caller
+// that wants to know "does this release carry a tag" (assembleChannels
+// below, and apiversion.go's handleAPIReleasesList) reads this map rather
+// than building its own from release.ListTags. A listing failure degrades
+// to a nil map (every lookup then reports "no tag"), matching the rest of
+// this file's read-degrades-gracefully posture for a page render.
+func tagByRelease(s release.Store, acct, app string) map[string]string {
+ tags, err := release.ListTags(context.Background(), s, acct, app)
+ if err != nil {
+ return nil
+ }
+ m := make(map[string]string, len(tags))
+ for _, t := range tags {
+ m[t.Release] = t.Version
+ }
+ return m
+}
+
// assembleChannels lists the app's account-qualified channel pointers and
// resolves each one. An app whose objects are still at the legacy unqualified
// keys lists as empty here, exactly as the fleet sees it — the console showing
@@ -447,6 +476,7 @@ func assembleChannels(ctx context.Context, s release.Store, pub ed25519.PublicKe
if err != nil {
return []ChannelView{{Name: "(all)", Err: "listing channels: " + err.Error()}}
}
+ tags := tagByRelease(s, acct, app)
var names []string
for _, k := range keys {
name := strings.TrimPrefix(k, prefix)
@@ -467,6 +497,7 @@ func assembleChannels(ctx context.Context, s release.Store, pub ed25519.PublicKe
Name: name, Version: p.Version, PromotedAt: p.PromotedAt,
HasHistory: len(p.History) > 0,
FastTrack: p.FastTrack,
+ Tag: tags[p.Version],
}
// The trail is newest first, but a rollback carries it forward
// without appending: the entry that explains the CURRENT version is
@@ -669,7 +700,7 @@ func Versions(s release.Store, acct, app string) ([]VersionView, error) {
out = append(out, VersionView{Version: v, Err: err.Error()})
continue
}
- out = append(out, VersionView{Version: v, Kind: m.Kind, BuiltAt: m.BuiltAt, Label: m.Label})
+ out = append(out, VersionView{Version: v, Kind: m.Kind, BuiltAt: m.BuiltAt, Label: m.Label, Notes: m.Notes})
}
parse := func(s string) time.Time {
t, _ := time.Parse(time.RFC3339, s)