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>
6 files changed,
+191
−23
internal/console/apiversion.go+5 −12internal/console/server.go+38 −2internal/console/server_test.go+91 −0internal/console/templates/app_releases.html+17 −4internal/console/templates/layout.html+4 −0internal/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 <site-dir></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) |