console: a one-channel app keeps its canaries and its rollback
The one-channel guard on the Releases tab wrapped the whole details block, so opting into the default one-channel pipeline also removed the canary table, the create-canary form and the rollback control. None of that is channel vocabulary: canary/* lives outside the pipeline entirely and is "always allowed" (spec §2), and a rollback is a pointer write — a one-channel app is precisely the app with no promotion step to fall back on when a bad build lands. The canary section and the tag list move into shared template definitions both shapes render, and the one-channel shape gets its own rollback control (AppView.RollbackChannels: the app's declared channels that have somewhere to go back to — canary/* is retired, never rolled back). The ladder card, the promote form, the promote column and the channel table stay hidden exactly as before.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+136
−13
internal/console/pipelinepage_test.go+59 −0internal/console/templates/app_releases.html+48 −13internal/console/view.go+29 −0
diff --git a/internal/console/pipelinepage_test.go b/internal/console/pipelinepage_test.go| index fc6e2fa44..29f4ec730 100644 |
| --- a/internal/console/pipelinepage_test.go |
| +++ b/internal/console/pipelinepage_test.go |
| @@ -70,6 +70,65 @@ func assertNoChannelVocabulary(t *testing.T, body string) { |
| } |
| } |
| +// TestOneChannelAppKeepsCanariesAndRollback pins the other half of "hide the |
| +// vocabulary": what must NOT be hidden with it. The one-channel guard wrapped |
| +// the whole details block on the Releases tab, so opting into the default |
| +// one-channel pipeline also took away the canary table, the create-canary |
| +// form and the rollback control — none of which is channel vocabulary. |
| +// canary/* is "always allowed" and lives outside the pipeline entirely (spec |
| +// §2), and a rollback is a pointer write every app can need; a one-channel |
| +// app is precisely the app with no promote step to fall back on if a bad |
| +// build lands. The ladder, the promote form and the channel table stay gone. |
| +func TestOneChannelAppKeepsCanariesAndRollback(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-solo-canary") |
| + if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", |
| + map[string]string{"name": "solocan"}, token); status != http.StatusOK { |
| + t.Fatalf("create app = %d %v", status, out) |
| + } |
| + shipVersion(t, fx, "solocan", "v1") |
| + shipVersion(t, fx, "solocan", "v2") |
| + // Two promotions onto the one channel: the second gives the pointer a |
| + // history, which is what a rollback needs to exist at all. |
| + seedChannel(t, fx, "solocan", "edge", "v1") |
| + seedChannel(t, fx, "solocan", "edge", "v2") |
| + seedChannel(t, fx, "solocan", "canary/rc1", "v1") |
| + |
| + c := signInPaul(t, fx) |
| + status, _, body := get(t, c, fx.u("/apps/solocan/releases")) |
| + if status != http.StatusOK { |
| + t.Fatalf("app page = %d", status) |
| + } |
| + _, main, ok := strings.Cut(body, "<main>") |
| + if !ok { |
| + t.Fatalf("page has no <main> to scope the check to:\n%s", body) |
| + } |
| + // The canary table, with the app's live canary in it. |
| + if !strings.Contains(main, "canary/rc1") { |
| + t.Errorf("one-channel app page hides its canary table:\n%s", main) |
| + } |
| + if !strings.Contains(main, `/apps/solocan/canary/retire"`) { |
| + t.Errorf("one-channel app page hides the canary retire control:\n%s", main) |
| + } |
| + if !strings.Contains(main, `/apps/solocan/canary"`) { |
| + t.Errorf("one-channel app page hides the create-canary form:\n%s", main) |
| + } |
| + // The rollback control. |
| + if !strings.Contains(main, `/apps/solocan/rollback"`) { |
| + t.Errorf("one-channel app page hides the rollback control:\n%s", main) |
| + } |
| + if !strings.Contains(main, "Roll back") { |
| + t.Errorf("one-channel app page has no rollback button:\n%s", main) |
| + } |
| + // And still none of the vocabulary the one-channel rule hides: no ladder |
| + // rungs, no promote form, no channel column. |
| + for _, forbidden := range []string{"beta", "stable", "Promoting", "Promote", "ladder"} { |
| + if strings.Contains(main, forbidden) { |
| + t.Errorf("one-channel app page contains %q, want the vocabulary still hidden:\n%s", forbidden, main) |
| + } |
| + } |
| +} |
| + |
| // TestOneChannelAppOverviewHidesFlowDiagram pins the CRITICAL finding from |
| // the Task 9 review: the OVERVIEW page (/a/{sqid}/apps/{app}, what spec §5 |
| // calls "the app page") draws its own release-flow diagram |
diff --git a/internal/console/templates/app_releases.html b/internal/console/templates/app_releases.html| index 4078cfb31..64eccdaeb 100644 |
| --- a/internal/console/templates/app_releases.html |
| +++ b/internal/console/templates/app_releases.html |
| @@ -96,7 +96,14 @@ carlos ship -app {{.Detail.App}} -kind static -version v1 <site-dir></code |
| <p class="small">Releases appear here once shipped, and get promoted to channels from this page.</p> |
| {{end}} |
| -{{if not .Detail.OneChannelPipeline}} |
| +{{if .Detail.OneChannelPipeline}} |
| +<details class="reveal"> |
| +<summary>Test versions and rolling back</summary> |
| +{{template "solorollback" .}} |
| +{{template "canaries" .}} |
| +{{template "apptags" .}} |
| +</details> |
| +{{else}} |
| <details class="reveal"> |
| <summary>Release pipeline — channels, test versions, and the promotion ladder</summary> |
| {{template "ladder" .}} |
| @@ -131,7 +138,24 @@ carlos ship -app {{.Detail.App}} -kind static -version v1 <site-dir></code |
| <p class="small">Go-live times are estimates — each copy keeps its own clock. Rollbacks skip the delay.</p> |
| {{else}}<p class="small">No channels yet — ship a release, then promote it below to create the first one.</p>{{end}} |
| -<h2>Canaries</h2> |
| +{{template "canaries" .}} |
| +{{template "apptags" .}} |
| +</details> |
| +{{end}} |
| +{{if .Detail.OneChannelPipeline}} |
| +<p class="small">This app has one channel — <code>carlos deploy</code> ships straight to it, with no |
| +promotion step in between. <a href="{{.Nav}}/apps/{{.Detail.App}}/pipeline">Set up a pipeline</a> to |
| +add more.</p> |
| +{{end}} |
| +{{if not .CanPromote}}<p class="small">Deploys are unavailable on this console — it has no signing key.</p>{{end}} |
| +{{template "bottom" .}}{{end}} |
| + |
| +{{/* The canary section and the tag list are NOT channel vocabulary, so both |
| + app shapes get them: canary/* lives outside the pipeline entirely and is |
| + always allowed (release pipelines v2 §2), and a tag names a release, not |
| + a rung. Shared through one definition so the two shapes cannot drift into |
| + different canary controls. */}} |
| +{{define "canaries"}}<h2>Canaries</h2> |
| {{if .Detail.Canaries}} |
| <div class="tablewrap"> |
| <table> |
| @@ -177,9 +201,9 @@ carlos ship -app {{.Detail.App}} -kind static -version v1 <site-dir></code |
| {{else}}<pre><code>{{.AttachCmd}}</code></pre> |
| <p class="small">The address comes live within a couple of minutes.</p> |
| {{end}} |
| -{{end}} |
| +{{end}}{{end}} |
| -{{if .Tags}} |
| +{{define "apptags"}}{{if .Tags}} |
| <h2>Tags</h2> |
| {{range .Tags}} |
| <details> |
| @@ -187,13 +211,24 @@ carlos ship -app {{.Detail.App}} -kind static -version v1 <site-dir></code |
| <pre class="changelog">{{.Changelog}}</pre> |
| </details> |
| {{end}} |
| +{{end}}{{end}} |
| + |
| +{{/* The one-channel app's rollback control. A rollback is not channel |
| + vocabulary — it is the pointer write that puts the previous build back, |
| + and a one-channel app is exactly the app with no promotion step to fall |
| + back on when a bad build lands. It names no channel on screen (the name |
| + rides in the hidden field the door needs), draws no table and offers no |
| + promote; the ladder stays hidden. RollbackChannels is empty until the |
| + channel actually has somewhere to go back to. */}} |
| +{{define "solorollback"}}{{if .CanPromote}}{{with .Detail.RollbackChannels}} |
| +<h2>Rolling back</h2> |
| +<p class="small">Puts the previous build back in front of everyone straight away — rollbacks skip the safety delay. You'll be asked to confirm, and told which build it lands on.</p> |
| +{{range .}} |
| +<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="btn-sm btn-danger">Roll back</button> |
| +</form> |
| {{end}} |
| -</details> |
| -{{end}} |
| -{{if .Detail.OneChannelPipeline}} |
| -<p class="small">This app has one channel — <code>carlos deploy</code> ships straight to it, with no |
| -promotion step in between. <a href="{{.Nav}}/apps/{{.Detail.App}}/pipeline">Set up a pipeline</a> to |
| -add more.</p> |
| -{{end}} |
| -{{if not .CanPromote}}<p class="small">Deploys are unavailable on this console — it has no signing key.</p>{{end}} |
| -{{template "bottom" .}}{{end}} |
| +{{end}}{{end}}{{end}} |
diff --git a/internal/console/view.go b/internal/console/view.go| index 2957ee65a..90054c930 100644 |
| --- a/internal/console/view.go |
| +++ b/internal/console/view.go |
| @@ -75,6 +75,35 @@ func (v AppView) OneChannelPipeline() bool { |
| return v.Pipeline != nil && len(v.Pipeline.Channels) == 1 |
| } |
| +// RollbackChannels is the app's OWN declared channels that have somewhere to |
| +// roll back to — for the one-channel page, which hides the channel table the |
| +// rollback control normally rides in but must not hide the control itself: a |
| +// rollback is a pointer write, not channel vocabulary, and a one-channel app |
| +// is precisely the app with no promotion step to fall back on when a bad |
| +// build lands (spec §5 hides the VOCABULARY, not the actions). |
| +// |
| +// Declared channels only: canary/* is retired, never rolled back (its control |
| +// is the canary table's Retire button), and a channel the pipeline does not |
| +// name is not this app's to offer here. A row that failed to read, or has no |
| +// history, is left out — there is nothing to go back to. |
| +func (v AppView) RollbackChannels() []ChannelView { |
| + if v.Pipeline == nil { |
| + return nil |
| + } |
| + declared := make(map[string]bool, len(v.Pipeline.Channels)) |
| + for _, c := range v.Pipeline.Channels { |
| + declared[c.Name] = true |
| + } |
| + var out []ChannelView |
| + for _, ch := range v.Channels { |
| + if ch.Err != "" || !ch.HasHistory || !declared[ch.Name] { |
| + continue |
| + } |
| + out = append(out, ch) |
| + } |
| + return out |
| +} |
| + |
| // MismatchedRoute is one route whose stamped account disagrees with the |
| // account whose page is being viewed: host plus where it actually reads |
| // releases from. The banner names these — a warning that says WHICH routes |