carlos / platform Public

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>
Paul Campbell 2485500e2800c107730b7e18a98849b7d601c635 parent 66740c2
3 files changed, +136 −13
  • internal/console/pipelinepage_test.go +59 −0
  • internal/console/templates/app_releases.html +48 −13
  • internal/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 &lt;site-dir&gt;</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 &lt;site-dir&gt;</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 &lt;site-dir&gt;</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 &lt;site-dir&gt;</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