console: releases tab — channels, canaries, releases, promote flows
Forms carry back=releases (allowlisted literal, never echoed) so a promote made on the tab lands back on the tab; ladder flows still land on the app page.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+180
−4
internal/console/server.go+37 −4internal/console/server_test.go+28 −0internal/console/templates/app_releases.html+115 −0
diff --git a/internal/console/server.go b/internal/console/server.go| index 29823d184..6c7885762 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -260,6 +260,7 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| s.mux.HandleFunc("GET /a/{sqid}/apps/new", s.handleNewAppForm) |
| s.mux.HandleFunc("POST /a/{sqid}/apps/new", s.handleNewApp) |
| s.mux.HandleFunc("GET /a/{sqid}/apps/{app}", s.handleApp) |
| + s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/releases", s.handleAppReleases) |
| s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/promote", s.handlePromote) |
| s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/rollback", s.handleRollback) |
| s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/production", s.handleProduction) |
| @@ -1345,6 +1346,22 @@ func (s *Server) handleApp(w http.ResponseWriter, r *http.Request) { |
| s.render(w, http.StatusOK, "app", p) |
| } |
| +// handleAppReleases is the deploy surface: every shipped release, the |
| +// channels they promote onto, and the canaries riding ahead. |
| +func (s *Server) handleAppReleases(w http.ResponseWriter, r *http.Request) { |
| + p, acct, ok := s.appPage(w, r, "releases") |
| + if !ok { |
| + return |
| + } |
| + versions, err := Versions(s.cfg.Releases, acct.Sqid, p.AppName) |
| + if err != nil { |
| + log.Printf("console: versions %s: %v", p.AppName, err) |
| + p.FlashErr = "Could not list this app's releases." |
| + } |
| + p.Versions = versions |
| + s.render(w, http.StatusOK, "app_releases", p) |
| +} |
| + |
| // eventsCap bounds the app page's recent-activity list. |
| const eventsCap = 10 |
| @@ -1552,7 +1569,11 @@ func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request) { |
| } |
| back := func(kind, msg string) { |
| s.setFlash(w, kind, msg) |
| - http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app), http.StatusSeeOther) |
| + dest := "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) |
| + if r.FormValue("back") == "releases" { |
| + dest += "/releases" |
| + } |
| + http.Redirect(w, r, dest, http.StatusSeeOther) |
| } |
| channel := strings.TrimSpace(r.FormValue("new_channel")) |
| if channel == "" { |
| @@ -1685,7 +1706,11 @@ func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request) { |
| } |
| back := func(kind, msg string) { |
| s.setFlash(w, kind, msg) |
| - http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app), http.StatusSeeOther) |
| + dest := "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) |
| + if r.FormValue("back") == "releases" { |
| + dest += "/releases" |
| + } |
| + http.Redirect(w, r, dest, http.StatusSeeOther) |
| } |
| channel := strings.TrimSpace(r.FormValue("channel")) |
| if !release.ValidChannel(channel) { |
| @@ -1890,7 +1915,11 @@ func (s *Server) handleNewCanary(w http.ResponseWriter, r *http.Request) { |
| } |
| back := func(kind, msg string) { |
| s.setFlash(w, kind, msg) |
| - http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app), http.StatusSeeOther) |
| + dest := "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) |
| + if r.FormValue("back") == "releases" { |
| + dest += "/releases" |
| + } |
| + http.Redirect(w, r, dest, http.StatusSeeOther) |
| } |
| name := strings.ToLower(strings.TrimSpace(r.FormValue("name"))) |
| if !canaryNameRe.MatchString(name) { |
| @@ -1962,7 +1991,11 @@ func (s *Server) handleRetireCanary(w http.ResponseWriter, r *http.Request) { |
| } |
| back := func(kind, msg string) { |
| s.setFlash(w, kind, msg) |
| - http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app), http.StatusSeeOther) |
| + dest := "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) |
| + if r.FormValue("back") == "releases" { |
| + dest += "/releases" |
| + } |
| + http.Redirect(w, r, dest, http.StatusSeeOther) |
| } |
| name := strings.ToLower(strings.TrimSpace(r.FormValue("name"))) |
| if !canaryNameRe.MatchString(name) { |
diff --git a/internal/console/server_test.go b/internal/console/server_test.go| index dc3446686..835fc5c4b 100644 |
| --- a/internal/console/server_test.go |
| +++ b/internal/console/server_test.go |
| @@ -488,6 +488,25 @@ func TestAppTabBar(t *testing.T) { |
| } |
| } |
| +func TestReleasesTab(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + status, _, body := get(t, c, fx.u("/apps/kass/releases")) |
| + if status != 200 { |
| + t.Fatalf("GET releases tab = %d", status) |
| + } |
| + for _, want := range []string{ |
| + `aria-current="page">Releases</a>`, |
| + "<h2>Channels</h2>", "<h2>Canaries</h2>", "<h2>Releases</h2>", |
| + `name="back" value="releases"`, |
| + } { |
| + if !strings.Contains(body, want) { |
| + t.Fatalf("releases tab missing %q", want) |
| + } |
| + } |
| + assertNonMember404(t, fx, "/apps/kass/releases") |
| +} |
| + |
| // assertNonMember404 signs bob (no membership in the fixture account) in and |
| // asserts the account-scoped path answers him byte-identically to an account |
| // that does not exist at all. |
| @@ -771,6 +790,15 @@ func TestRollbackFlow(t *testing.T) { |
| if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "Rolled back stable to v1.") { |
| t.Fatal("no rollback flash") |
| } |
| + |
| + // Same happy path, but posted from the releases tab (back=releases): the |
| + // redirect must land back on the tab, not the app page. stable is v1 with |
| + // v2 in history after the rollback above, so a second rollback is valid. |
| + status, loc, _ = postForm(t, c, fx.u("/apps/kass/rollback"), |
| + url.Values{"csrf": {csrf}, "channel": {"stable"}, "back": {"releases"}}) |
| + if status != http.StatusSeeOther || loc != fx.path("/apps/kass/releases") { |
| + t.Fatalf("rollback with back=releases = %d -> %q, want %q", status, loc, fx.path("/apps/kass/releases")) |
| + } |
| } |
| // addRoute upserts a registry route into the fixture's registry. |
diff --git a/internal/console/templates/app_releases.html b/internal/console/templates/app_releases.html| new file mode 100644 |
| index 000000000..3050813df |
| --- /dev/null |
| +++ b/internal/console/templates/app_releases.html |
| @@ -0,0 +1,115 @@ |
| +{{define "app_releases"}}{{template "top" .}} |
| +{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}} |
| +{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}} |
| +{{template "appnav" .}} |
| + |
| +<h2>Channels</h2> |
| +{{if .Detail.Channels}} |
| +<div class="tablewrap"> |
| +<table> |
| + <tr><th>Channel</th><th>Version</th><th>Promoted</th><th>Bake</th><th></th></tr> |
| + {{range .Detail.Channels}} |
| + <tr> |
| + <td>{{.Name}}</td> |
| + {{if .Err}} |
| + <td colspan="3" class="error">{{.Err}}</td> |
| + {{else}} |
| + <td><code>{{.Version}}</code></td> |
| + <td>{{fmtTime .PromotedAt}}</td> |
| + <td>{{if .BakedUntil.IsZero}}live{{else}}bakes until ~{{fmtTime .BakedUntil}}{{end}}{{if .FastTrack}} · fast-tracked{{end}}</td> |
| + {{end}} |
| + <td>{{if and $.CanPromote .HasHistory (not .Err)}} |
| + <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/rollback"> |
| + <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| + <input type="hidden" name="channel" value="{{.Name}}"> |
| + <input type="hidden" name="back" value="releases"> |
| + <button type="submit" class="linkish danger">Roll back</button> |
| + </form> |
| + {{end}}</td> |
| + </tr> |
| + {{end}} |
| +</table> |
| +</div> |
| +<p class="small">Bake times are an approximation from each channel's default hold and the promotion time; the authoritative clock is each box's own first-seen record. Rollback never bakes.</p> |
| +{{else}}<p class="small">No channels yet.</p>{{end}} |
| + |
| +<h2>Canaries</h2> |
| +{{if .Detail.Canaries}} |
| +<div class="tablewrap"> |
| +<table> |
| + <tr><th>Name</th><th>Channel</th><th>Version</th><th>Created</th><th>URL</th><th></th></tr> |
| + {{range .Detail.Canaries}} |
| + <tr> |
| + <td>{{.Name}}</td> |
| + <td><code>{{.Channel}}</code></td> |
| + {{if .Err}} |
| + <td colspan="3" class="error">{{.Err}}</td> |
| + {{else}} |
| + <td><code>{{.Version}}</code></td> |
| + <td>{{fmtTime .PromotedAt}}</td> |
| + <td>{{if .Hosts}}{{range .Hosts}}<a href="https://{{.}}">{{.}}</a> {{end}}{{else}}no host yet{{end}}</td> |
| + {{end}} |
| + <td>{{if $.CanPromote}} |
| + <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/canary/retire"> |
| + <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| + <input type="hidden" name="name" value="{{.Name}}"> |
| + <input type="hidden" name="back" value="releases"> |
| + <button type="submit" class="linkish danger">Retire</button> |
| + </form> |
| + {{end}}</td> |
| + </tr> |
| + {{end}} |
| +</table> |
| +</div> |
| +{{else}}<p class="small">No canaries — a canary is a fresh channel serving one chosen release immediately, with no bake window.</p>{{end}} |
| +{{if and .CanPromote .Versions}} |
| +<form class="signin" method="post" action="{{.Base}}/apps/{{.Detail.App}}/canary"> |
| + <input type="hidden" name="csrf" value="{{.CSRF}}"> |
| + <input type="hidden" name="back" value="releases"> |
| + <input type="text" name="name" placeholder="rc-2"> |
| + <select name="version"> |
| + {{range .Versions}}{{if not .Err}}<option value="{{.Version}}">{{.Version}}</option>{{end}}{{end}} |
| + </select> |
| + <button type="submit">Create canary</button> |
| +</form> |
| +<p class="small">Creates channel <code>canary/<name></code> pointing at the chosen release — serving immediately, no bake. It gets its URL from a host that follows the channel: <code>carlos add -app {{.Detail.App}} -channel canary/<name> <host></code>.</p> |
| +{{end}} |
| + |
| +<h2>Releases</h2> |
| +{{if .Versions}} |
| +<div class="tablewrap"> |
| +<table> |
| + <tr><th>Version</th><th>Kind</th><th>Built</th>{{if .CanPromote}}<th>Promote</th>{{end}}</tr> |
| + {{range .Versions}} |
| + <tr> |
| + <td><code>{{.Version}}</code></td> |
| + {{if .Err}} |
| + <td colspan="2" class="error">{{.Err}}</td> |
| + {{else}} |
| + <td>{{.Kind}}</td> |
| + <td>{{if .BuiltAt}}{{.BuiltAt}}{{else}}—{{end}}</td> |
| + {{end}} |
| + {{if $.CanPromote}}<td> |
| + <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/promote"> |
| + <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| + <input type="hidden" name="version" value="{{.Version}}"> |
| + <input type="hidden" name="back" value="releases"> |
| + <select name="channel"> |
| + {{range $.Detail.Channels}}<option value="{{.Name}}">{{.Name}}</option>{{end}} |
| + </select> |
| + <input type="text" name="new_channel" placeholder="or a new channel" size="14"> |
| + <button type="submit" class="linkish">Promote</button> |
| + </form> |
| + </td>{{end}} |
| + </tr> |
| + {{end}} |
| +</table> |
| +</div> |
| +{{else}} |
| +<p>Nothing shipped yet. Ship your first release from your repo:</p> |
| +<pre><code>carlos ship -app {{.Detail.App}} -version $(git rev-parse --short HEAD) -target linux-arm64 <binary> |
| +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 not .CanPromote}}<p class="small">Promotions disabled: no signing key on this host.</p>{{end}} |
| +{{template "bottom" .}}{{end}} |