carlos / platform Public

console: pipeline UI — hidden one-channel vocabulary, settings templates, pending card

- assembleLadder is pipeline-aware: a one-channel pipeline renders no
  ladder/channel vocabulary at all (spec §5), a multi-channel pipeline
  draws its own declared channels in order through the same ladder
  template, with a gate sentence derived from each channel's own rules.
  Legacy (pipeline-less) apps render byte-identically.
- promoteChannelOptions offers pipeline channel names, in pipeline order,
  for a pipelined app instead of the frozen rungs.
- New settings page, GET/POST /a/{sqid}/apps/{app}/pipeline: an editable
  channel-list form (add/remove row) plus the two spec §5 starter
  templates (Edge → Production, Full ladder) on a still-default pipeline.
  The write path is shared with the API PUT (writePipeline) rather than
  duplicated; a guarded edit proposes through the same passkey-backed
  flow the CLI's bearer-token door does not get.
- New pending-changes card on the app overview page: summary, k/N
  confirmed, Confirm/Reject CSRF forms to Task 8's routes.
- Rider 1 (Task 8 review): additive promotePayload.Rollback field, set at
  both rollback-propose sites, so pendingSummary renders "roll back Y to
  vX" instead of the illegible "promote vX to Y" for a rollback proposal.
- Rider 2 (Task 8 review): handleRollback's legacy path now gates a
  stable-rung rollback with owner (byte-identical 404) + requireFreshAuth
  last, mirroring promoteGate's shape — closing the pre-existing gap where
  a legacy stable rollback carried no gate at all in the browser.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev d5e28da31f2c6bc63748eb3391a23452218a1e50 parent e6c6642
17 files changed, +966 −34
  • internal/console/apicli.go +1 −1
  • internal/console/apipipeline.go +5 −6
  • internal/console/confirm_test.go +3 −1
  • internal/console/ladder.go +114 −2
  • internal/console/ladder_test.go +1 −1
  • internal/console/pendinghandlers.go +17 −0
  • internal/console/pipelinepage.go +331 −0
  • internal/console/pipelinepage_test.go +211 −0
  • internal/console/server.go +77 −16
  • internal/console/server_test.go +81 −3
  • internal/console/templates/app_overview.html +1 −0
  • internal/console/templates/app_releases.html +9 −2
  • internal/console/templates/app_settings.html +2 −0
  • internal/console/templates/ladder.html +8 −1
  • internal/console/templates/pending.html +22 −0
  • internal/console/templates/pipeline.html +56 −0
  • internal/console/view.go +27 −1
diff --git a/internal/console/apicli.go b/internal/console/apicli.go
index 0b8886c8d..a6c7bd4b3 100644
--- a/internal/console/apicli.go
+++ b/internal/console/apicli.go
@@ -880,7 +880,7 @@ func (s *Server) handleAPIRollback(w http.ResponseWriter, r *http.Request) {
switch rules := channelRules(pipe, channel); {
case rules.PromoteApprovals >= 1:
s.proposeOverAPI(w, acct, app, kindPromote,
- promotePayload{Version: target, Channel: channel, Hotfix: true},
+ promotePayload{Version: target, Channel: channel, Hotfix: true, Rollback: true},
rules.PromoteApprovals, addr)
return
case rules.Passkey:
diff --git a/internal/console/apipipeline.go b/internal/console/apipipeline.go
index 8f6ad4a3b..6513fad44 100644
--- a/internal/console/apipipeline.go
+++ b/internal/console/apipipeline.go
@@ -150,7 +150,11 @@ func (s *Server) handleAPIPipelinePut(w http.ResponseWriter, r *http.Request) {
s.proposeOverAPI(w, acct, app, kindPolicy, policyProposal(cur, in.Channels), n, addr)
return
}
- set, err := release.SetPipeline(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, in.Channels, addr)
+ // writePipeline (pipelinepage.go) is the shared write tail: SetPipeline,
+ // the old-binary bake fallback, the audit row and the serial bump — the
+ // browser settings POST (Task 9) funnels through the same function
+ // rather than a second copy of it.
+ set, err := s.writePipeline(ctx, acct, app, in.Channels, addr, "cli")
if err != nil {
status, msg := classifySetPipelineError(err)
if status != http.StatusUnprocessableEntity {
@@ -164,11 +168,6 @@ func (s *Server) handleAPIPipelinePut(w http.ResponseWriter, r *http.Request) {
jsonError(w, status, msg)
return
}
- s.ensureProductionForBakes(ctx, acct, app, set, addr)
- s.audit(acct, app, addr, "pipeline", pipelineAuditDetail(set, "cli"))
- // Box-consumed (carlos-deploy §3): boxes read pipeline.json on the same
- // fast pass channel pointers and config already ride.
- s.bumpSerial(ctx)
writeJSON(w, http.StatusOK, map[string]any{"pipeline": set})
}
diff --git a/internal/console/confirm_test.go b/internal/console/confirm_test.go
index 4a53ab2e8..10d6e9e03 100644
--- a/internal/console/confirm_test.go
+++ b/internal/console/confirm_test.go
@@ -181,7 +181,9 @@ func TestConfirmRollbackCardShowsBothVersions(t *testing.T) {
}
form.Set("confirmed", "1")
- if status, _, body := postForm(t, c, fx.u("/apps/kass/rollback"), form); status != http.StatusSeeOther {
+ // stable is gated (owner + step-up, mirroring promoteGate's legacy path
+ // — Task 8/9 review), so the confirmed post walks the passkey round trip.
+ if status, _, body := postGated(t, fx, c, fx.u("/apps/kass/rollback"), form); status != http.StatusSeeOther {
t.Fatalf("confirmed rollback = %d:\n%s", status, body)
}
if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v1" {
diff --git a/internal/console/ladder.go b/internal/console/ladder.go
index 535ed1f57..439952e86 100644
--- a/internal/console/ladder.go
+++ b/internal/console/ladder.go
@@ -26,6 +26,7 @@ package console
// remembers deploying. Visibility only — an expired canary keeps serving.
import (
+ "fmt"
"sort"
"strings"
"time"
@@ -123,14 +124,26 @@ var gates = map[string]string{
release.RungStable: "An account owner promotes here with their passkey, after the safety delay.",
}
-// assembleLadder folds an app's channels and routes into the four rungs.
+// assembleLadder folds an app's channels and routes into rungs — the four
+// frozen ones for a legacy (pipeline-less) app, or the app's own declared
+// channel order once it has opted into a pipeline (release pipelines v2
+// §2, §5). The two shapes are deliberately exclusive: pipeline == nil takes
+// the legacy path below, BYTE-IDENTICALLY to before this pipeline-awareness
+// existed — every existing legacy app renders exactly as it always has —
+// and a non-nil pipeline hands off to assemblePipelineLadder entirely,
+// which also owns the one-channel "hide the vocabulary" rule (spec §5): it
+// returns nil for a single-channel pipeline, and the app's pages read that
+// absence as "show no ladder card at all", not as "empty rungs".
//
// A channel outside the ladder's vocabulary (and any host following one) is
// not on the ladder and does not appear here — the Channels table below still
// lists it. A host following a rung channel that has no pointer DOES appear,
// as an error line: a host serving nothing is exactly the kind of thing this
// section exists to stop being invisible.
-func assembleLadder(channels []ChannelView, routes []RouteView, now time.Time) []LadderRung {
+func assembleLadder(channels []ChannelView, routes []RouteView, pipeline *release.Pipeline, now time.Time) []LadderRung {
+ if pipeline != nil {
+ return assemblePipelineLadder(pipeline, channels, routes, now)
+ }
prod := prodWiredCanaryHosts(routes)
expiredFor := canaryExpiry(channels, now)
byChannel := map[string][]LadderRoute{}
@@ -213,6 +226,105 @@ func stepUp(from, to string, chans []LadderChannel) *LadderStep {
return s
}
+// assemblePipelineLadder is assembleLadder's pipeline-shaped half: one rung
+// per declared channel, in the pipeline's own order, rather than the four
+// frozen rungs. canary/* is deliberately absent here — it lives outside the
+// pipeline (spec §2) and stays visible through the Canaries table the app
+// page already draws unconditionally, independent of Ladder.
+//
+// A single-channel pipeline returns nil: spec §5's "hide the vocabulary
+// entirely" rule. The app's pages read a nil (or one-channel) pipeline's
+// AppView.OneChannelPipeline() and skip the ladder card, the promote form
+// and the channel table outright — this function simply never hands them
+// anything to draw in that case.
+func assemblePipelineLadder(p *release.Pipeline, channels []ChannelView, routes []RouteView, now time.Time) []LadderRung {
+ if len(p.Channels) < 2 {
+ return nil
+ }
+ byChannel := map[string]ChannelView{}
+ for _, cv := range channels {
+ byChannel[cv.Name] = cv
+ }
+ prod := prodWiredCanaryHosts(routes)
+ byRoute := map[string][]LadderRoute{}
+ for _, rt := range routes {
+ byRoute[rt.Channel] = append(byRoute[rt.Channel], LadderRoute{
+ Host: rt.Host, Environment: rt.Environment,
+ ProdWired: prod[rt.Host], Caps: rt.Caps,
+ })
+ }
+ out := make([]LadderRung, 0, len(p.Channels))
+ for i, c := range p.Channels {
+ var chans []LadderChannel
+ if cv, ok := byChannel[c.Name]; ok {
+ lc := LadderChannel{
+ Channel: cv.Name, Version: cv.Version, PromotedAt: cv.PromotedAt,
+ Actor: cv.Actor, Hotfix: cv.Hotfix, Err: cv.Err,
+ FastTrack: cv.FastTrack, Routes: byRoute[c.Name],
+ }
+ // The window is THIS channel's own (holdWindow, apipipeline.go) —
+ // never the legacy per-app BakedUntil the shared ChannelView
+ // slice may already carry, which was computed from the legacy
+ // production flag and the frozen HoldFor table (view.go's
+ // assembleChannels) and means nothing for a name the legacy
+ // table has never heard of (spec §3: a pipelined app's bake
+ // policy comes solely from its own pipeline).
+ if cv.Err == "" && !cv.FastTrack {
+ if d := holdWindow(p, c.Name); d > 0 {
+ if until := cv.PromotedAt.Add(d); now.Before(until) {
+ lc.BakedUntil = until
+ }
+ }
+ }
+ chans = []LadderChannel{lc}
+ } else if rts := byRoute[c.Name]; len(rts) > 0 {
+ chans = []LadderChannel{{Channel: c.Name, Routes: rts,
+ Err: "nothing has been published here yet — these addresses are waiting for a version"}}
+ }
+ rung := LadderRung{Rung: c.Name, Gate: gateSentence(c.Rules), Channels: chans}
+ if i+1 < len(p.Channels) {
+ rung.Up = pipelineStepUp(c, p.Channels[i+1], chans)
+ }
+ out = append(out, rung)
+ }
+ return out
+}
+
+// gateSentence is gates' pipelined analogue: what THIS channel's own rules
+// demand, in the same one-line register the frozen map speaks (spec §5's
+// UX list, in the order it gives them). The zero value — no rule at all —
+// is "Anyone on the team promotes here.", spec §2's default-permissive
+// posture made legible rather than left blank.
+func gateSentence(rules release.ChannelRules) string {
+ var parts []string
+ if rules.Passkey {
+ parts = append(parts, "Needs a passkey.")
+ }
+ if rules.PromoteApprovals > 0 {
+ parts = append(parts, fmt.Sprintf("Needs %d members to confirm.", rules.PromoteApprovals))
+ }
+ if len(parts) == 0 {
+ return "Anyone on the team promotes here."
+ }
+ return strings.Join(parts, " ")
+}
+
+// pipelineStepUp is stepUp's pipelined analogue: the move from one declared
+// channel to the next one in the list, rather than to a frozen rung name.
+// There is no "nothing is promoted onto edge" special case here — a
+// pipeline's entry channel simply has no Up step at all (assemblePipelineLadder
+// only builds one for i+1 < len(Channels)), so every Up this function builds
+// describes a genuine promotion.
+func pipelineStepUp(from, to release.PipelineChannel, curChans []LadderChannel) *LadderStep {
+ s := &LadderStep{To: to.Name, StepUp: to.Rules.Passkey}
+ if len(curChans) == 1 && curChans[0].Err == "" && curChans[0].Version != "" {
+ s.Version = curChans[0].Version
+ return s
+ }
+ s.Note = "Nothing on " + from.Name + " to promote."
+ return s
+}
+
// apexHost picks the app's primary host — fewest labels, then shortest,
// then alphabetical — the same "which host is the apex" answer
// prodWiredCanaryHosts uses. "" when there are no named routes.
diff --git a/internal/console/ladder_test.go b/internal/console/ladder_test.go
index 3d4ccc712..592e87e16 100644
--- a/internal/console/ladder_test.go
+++ b/internal/console/ladder_test.go
@@ -498,7 +498,7 @@ func TestLadderExpiredCanaryIsBadgedWithAge(t *testing.T) {
{Host: "rc.example.com", Channel: "canary/x"},
}
- rungs := assembleLadder(channels, routes, now)
+ rungs := assembleLadder(channels, routes, nil, now)
var canary *LadderRung
for i := range rungs {
if rungs[i].Rung == release.RungCanary {
diff --git a/internal/console/pendinghandlers.go b/internal/console/pendinghandlers.go
index 8ec00bce8..8a380064d 100644
--- a/internal/console/pendinghandlers.go
+++ b/internal/console/pendinghandlers.go
@@ -59,6 +59,15 @@ type promotePayload struct {
Version string `json:"version"`
Channel string `json:"channel"`
Hotfix bool `json:"hotfix,omitempty"`
+ // Rollback marks a proposal that came from the rollback door rather than
+ // promote (server.go's browser handleRollback, apicli.go's API rollback
+ // door) — additive, so a row written before this field existed still
+ // parses and simply renders as a promotion, exactly as it did before
+ // (Task 8 review: pendingSummary said "promote vX to Y" for a rollback,
+ // which is illegible to a confirmer who never asked for vX). Display
+ // only: both doors already pin the OLDER version as a promotion with
+ // Hotfix set, and execution (executePendingPromote) is unchanged by it.
+ Rollback bool `json:"rollback,omitempty"`
}
// policyPayload pins a pipeline edit: the full proposed channel list, plus
@@ -120,6 +129,14 @@ func pendingSummary(pc PendingChange) string {
if err := json.Unmarshal(pc.Payload, &p); err != nil {
return "promote " + pc.App
}
+ if p.Rollback {
+ // "roll back Y to vX", not "promote vX to Y" — the same pin, read
+ // the way a confirmer who never asked for vX can actually judge
+ // it (Task 8 review): a rollback proposal pins the OLDER version
+ // as a promotion (Hotfix, order bypassed), and this is display
+ // only — execution is untouched.
+ return "roll back " + p.Channel + " to " + p.Version
+ }
out := "promote " + p.Version + " to " + p.Channel
if p.Hotfix {
out += " (hotfix — out of order)"
diff --git a/internal/console/pipelinepage.go b/internal/console/pipelinepage.go
new file mode 100644
index 000000000..402334976
--- /dev/null
+++ b/internal/console/pipelinepage.go
@@ -0,0 +1,331 @@
+// The console UI half of release pipelines v2
+// (docs/superpowers/specs/2026-08-20-release-pipelines-v2-design.md): the
+// pending-changes card every app page carries (PendingChangeView,
+// pendingChangeViews) and the pipeline settings page, GET/POST
+// /a/{sqid}/apps/{app}/pipeline (pipelinepage.go's own name).
+//
+// The settings POST is deliberately NOT a second copy of the API PUT's
+// guarded-write logic (apipipeline.go's handleAPIPipelinePut): both doors
+// share guardedEdit's decision (pending.go's rule for what counts as
+// touching a guarded channel) and writePipeline's write tail (SetPipeline,
+// the old-binary bake fallback, the audit row, the serial bump) below. What
+// differs, and stays separate, is how each door turns a guarded edit into a
+// pending change — proposeOverAPI's zero-confirmation bearer shape for the
+// CLI, proposeThenExecute's passkey-backed shape here, called only after
+// requireFreshAuth has actually run.
+package console
+
+import (
+ "context"
+ "crypto/subtle"
+ "fmt"
+ "log"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// ---- the pending-changes card -------------------------------------------
+
+// PendingChangeView is one row of the app page's pending-changes card: the
+// human-legible summary (pendingSummary, pendinghandlers.go) and quorum
+// progress a viewer needs to decide whether to confirm or reject it. It
+// carries nothing pendinghandlers.go's own doors don't already recompute
+// from the id on the confirm/reject POST — this is display only.
+type PendingChangeView struct {
+ ID string
+ Kind string
+ Summary string
+ Confirmed int
+ Needed int
+}
+
+// pendingChangeViews folds a store listing into the card's rows, oldest
+// first — PendingChangesFor's own order, unchanged, so the card and the
+// CLI's `carlos pipeline` listing (Task 10) read the same queue the same way.
+func pendingChangeViews(pcs []PendingChange) []PendingChangeView {
+ out := make([]PendingChangeView, len(pcs))
+ for i, pc := range pcs {
+ out[i] = PendingChangeView{
+ ID: pc.ID, Kind: pc.Kind, Summary: pendingSummary(pc),
+ Confirmed: len(pc.Confirms), Needed: pc.Quorum,
+ }
+ }
+ return out
+}
+
+// ---- the settings page's form rows ---------------------------------------
+
+// PipelineFormRow is one editable row of the pipeline settings form — a
+// draft of a release.PipelineChannel, indexed ch.<i>.name / ch.<i>.bake /
+// ch.<i>.passkey / ch.<i>.promote_approvals / ch.<i>.change_approvals on the
+// wire, matching the brief's field-naming exactly.
+type PipelineFormRow struct {
+ Name string
+ Bake string
+ Passkey bool
+ PromoteApprovals int
+ ChangeApprovals int
+}
+
+// pipelineRowsFromChannels drafts the form from a real (or default) channel
+// list — GET's starting point, and what a save error re-renders on top of.
+func pipelineRowsFromChannels(channels []release.PipelineChannel) []PipelineFormRow {
+ rows := make([]PipelineFormRow, len(channels))
+ for i, c := range channels {
+ rows[i] = PipelineFormRow{
+ Name: c.Name, Bake: c.Bake, Passkey: c.Rules.Passkey,
+ PromoteApprovals: c.Rules.PromoteApprovals, ChangeApprovals: c.Rules.ChangeApprovals,
+ }
+ }
+ return rows
+}
+
+// pipelineChannelsFromRows is pipelineRowsFromChannels backwards, for the
+// POST that writes the draft. A row left with a blank name — exactly what
+// "Add channel" produces until it's named — is dropped silently rather than
+// refused: SetPipeline's own validation is the one place a genuinely bad
+// name gets a real error, so a still-blank scratch row never blocks it.
+func pipelineChannelsFromRows(rows []PipelineFormRow) []release.PipelineChannel {
+ out := make([]release.PipelineChannel, 0, len(rows))
+ for _, r := range rows {
+ name := strings.TrimSpace(r.Name)
+ if name == "" {
+ continue
+ }
+ out = append(out, release.PipelineChannel{
+ Name: name, Bake: strings.TrimSpace(r.Bake),
+ Rules: release.ChannelRules{
+ Passkey: r.Passkey, PromoteApprovals: r.PromoteApprovals, ChangeApprovals: r.ChangeApprovals,
+ },
+ })
+ }
+ return out
+}
+
+// pipelineIsDefault reports whether rows is still exactly the untouched
+// one-channel default (release.DefaultPipelineChannels()) a fresh app is
+// created with — the state in which the two starter templates below are
+// worth offering at all; past it, they'd silently discard an edit.
+func pipelineIsDefault(rows []PipelineFormRow) bool {
+ return len(rows) == 1 && rows[0] == PipelineFormRow{Name: "edge"}
+}
+
+// edgeProductionTemplate is spec §5's first starter: a single production
+// channel behind a passkey. The CONTROLLER RULING this brief carries: any
+// template that sets a rule on a channel also sets change_approvals >= 1 on
+// it — a passkey any member can strip in one PUT is decoration, and a solo
+// account still clears it (1 <= its own member count) with one console
+// confirmation of their own passkey.
+func edgeProductionTemplate() []PipelineFormRow {
+ return []PipelineFormRow{
+ {Name: "edge"},
+ {Name: "production", Bake: "24h", Passkey: true, ChangeApprovals: 1},
+ }
+}
+
+// fullLadderTemplate is spec §5's second starter: today's edge/beta/stable
+// defaults, reproduced as a pipeline — beta bakes 24h with no rule of its
+// own, stable bakes 72h behind a passkey and the same change_approvals
+// ruling as the template above.
+func fullLadderTemplate() []PipelineFormRow {
+ return []PipelineFormRow{
+ {Name: "edge"},
+ {Name: "beta", Bake: "24h"},
+ {Name: "stable", Bake: "72h", Passkey: true, ChangeApprovals: 1},
+ }
+}
+
+// parsePipelineRows reads the submitted ch.<i>.* fields back into rows, in
+// order, stopping at the first index whose name field is simply absent —
+// every row the settings template rendered posts a ch.<i>.name field (even
+// an empty one, for a still-unnamed scratch row), so absence is genuinely
+// "no more rows" rather than "this one happens to be blank".
+func parsePipelineRows(r *http.Request) []PipelineFormRow {
+ r.ParseForm()
+ var rows []PipelineFormRow
+ for i := 0; ; i++ {
+ prefix := "ch." + strconv.Itoa(i) + "."
+ if _, ok := r.PostForm[prefix+"name"]; !ok {
+ break
+ }
+ rows = append(rows, PipelineFormRow{
+ Name: r.PostFormValue(prefix + "name"),
+ Bake: r.PostFormValue(prefix + "bake"),
+ Passkey: r.PostFormValue(prefix+"passkey") != "",
+ PromoteApprovals: nonNegativeAtoi(r.PostFormValue(prefix + "promote_approvals")),
+ ChangeApprovals: nonNegativeAtoi(r.PostFormValue(prefix + "change_approvals")),
+ })
+ }
+ return rows
+}
+
+// nonNegativeAtoi parses a form field as a non-negative count, treating
+// anything unparsable or negative as 0 rather than refusing the request —
+// the same forgiving posture the rest of this form takes toward a
+// half-edited row; SetPipeline's own validation still refuses a genuinely
+// negative value if one somehow reaches it another way.
+func nonNegativeAtoi(s string) int {
+ n, err := strconv.Atoi(strings.TrimSpace(s))
+ if err != nil || n < 0 {
+ return 0
+ }
+ return n
+}
+
+// ---- the shared write tail ------------------------------------------------
+
+// writePipeline lands an UNGUARDED edit — the one place either the API PUT
+// or this settings POST actually writes: SetPipeline, the old-binary bake
+// fallback (ensureProductionForBakes, apipipeline.go), the audit row and the
+// serial bump. via names the door for the audit row's own vocabulary
+// (pipelineAuditDetail), exactly as handleAPIPipelinePut's inline tail did
+// before this was extracted for both callers to share. Box-consumed
+// (carlos-deploy §3): boxes read pipeline.json on the same fast pass channel
+// pointers and config already ride, which is what the serial bump is for.
+func (s *Server) writePipeline(ctx context.Context, acct *Account, app string, channels []release.PipelineChannel, actor, via string) (*release.Pipeline, error) {
+ set, err := release.SetPipeline(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, channels, actor)
+ if err != nil {
+ return nil, err
+ }
+ s.ensureProductionForBakes(ctx, acct, app, set, actor)
+ s.audit(acct, app, actor, "pipeline", pipelineAuditDetail(set, via))
+ s.bumpSerial(ctx)
+ return set, nil
+}
+
+// ---- the settings page: GET --------------------------------------------
+
+// handlePipelineSettings renders the current (or default) pipeline as an
+// editable form. Read-only, so membership is the whole gate — same as the
+// rest of the app's settings tab.
+func (s *Server) handlePipelineSettings(w http.ResponseWriter, r *http.Request) {
+ p, acct, ok := s.appPage(w, r, "settings")
+ if !ok {
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), pipelineStoreTimeout)
+ defer cancel()
+ pipe, err := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, p.AppName)
+ channels := release.DefaultPipelineChannels()
+ if err != nil {
+ log.Printf("console: pipeline settings %s/%s: %v", acct.Sqid, p.AppName, err)
+ p.FlashErr = "Could not read the pipeline — showing the default. Try again to see the real one."
+ } else if pipe != nil {
+ channels = pipe.Channels
+ }
+ p.PipelineRows = pipelineRowsFromChannels(channels)
+ p.PipelineIsDefault = pipelineIsDefault(p.PipelineRows)
+ s.render(w, http.StatusOK, "pipeline_settings", p)
+}
+
+// renderPipelineSettings re-renders the settings form on top of a page
+// already built by appPage — used for the draft-only actions (add/remove
+// row, load a template) and for a save that failed, so the human's
+// in-progress edit is never silently thrown away.
+func (s *Server) renderPipelineSettings(w http.ResponseWriter, p page, rows []PipelineFormRow, errMsg string) {
+ p.PipelineRows = rows
+ p.PipelineIsDefault = pipelineIsDefault(rows)
+ if errMsg != "" {
+ p.FlashErr = errMsg
+ }
+ s.render(w, http.StatusOK, "pipeline_settings", p)
+}
+
+// ---- the settings page: POST ---------------------------------------------
+
+// handlePipelineSettingsSave is the settings form's one door: draft actions
+// (add/remove a row, load a starter template) re-render the form with
+// nothing written; "save" carries the edit through the same guarded path
+// the API PUT takes (writePipeline directly when nothing is guarded,
+// proposeThenExecute's passkey-backed shape when a touched channel demands
+// a quorum), reusing guardedEdit's decision rather than a second copy of it.
+//
+// Gate order: session + membership (appPage → accountApp, byte-identical
+// 404 for a non-member) → CSRF → [the cheap, non-revealing refusals: the
+// draft actions, the signing-key check, the read-current-pipeline and
+// member-ceiling checks below] → requireFreshAuth LAST, and only for the
+// one branch that actually writes — a policy edit is account authority in
+// the same register as the API PUT (apipipeline.go's own doc comment), and
+// ANY member may reach it, matching the API door's human-any-role rule.
+func (s *Server) handlePipelineSettingsSave(w http.ResponseWriter, r *http.Request) {
+ p, acct, ok := s.appPage(w, r, "settings")
+ if !ok {
+ return
+ }
+ if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(p.CSRF)) != 1 {
+ s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
+ return
+ }
+ addr, raw, app := p.Address, p.CSRF, p.AppName
+ rows := parsePipelineRows(r)
+
+ switch action := r.FormValue("action"); {
+ case action == "add":
+ rows = append(rows, PipelineFormRow{})
+ s.renderPipelineSettings(w, p, rows, "")
+ return
+ case strings.HasPrefix(action, "remove-"):
+ if idx, err := strconv.Atoi(strings.TrimPrefix(action, "remove-")); err == nil && idx >= 0 && idx < len(rows) {
+ rows = append(rows[:idx], rows[idx+1:]...)
+ }
+ s.renderPipelineSettings(w, p, rows, "")
+ return
+ case action == "template-edge-production":
+ s.renderPipelineSettings(w, p, edgeProductionTemplate(), "")
+ return
+ case action == "template-full-ladder":
+ s.renderPipelineSettings(w, p, fullLadderTemplate(), "")
+ return
+ }
+
+ if s.cfg.Key == nil {
+ s.renderMessage(w, r, http.StatusServiceUnavailable, "Pipeline edits disabled",
+ "No signing key on this host — the console is running read-only.")
+ return
+ }
+ channels := pipelineChannelsFromRows(rows)
+ ctx, cancel := context.WithTimeout(r.Context(), pipelineStoreTimeout)
+ defer cancel()
+ cur, err := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app)
+ if err != nil {
+ log.Printf("console: pipeline settings save %s/%s: read current: %v", acct.Sqid, app, err)
+ s.renderPipelineSettings(w, p, rows, "Could not read the current pipeline — nothing was changed. Try again.")
+ return
+ }
+ members, err := s.store.Members(acct.ID)
+ if err != nil {
+ log.Printf("console: pipeline settings save %s/%s: members: %v", acct.Sqid, app, err)
+ s.renderPipelineSettings(w, p, rows, "Could not check the account's members — nothing was changed. Try again.")
+ return
+ }
+ proposed := &release.Pipeline{Schema: 1, Account: acct.Sqid, App: app, Channels: channels}
+ if n := proposed.MaxApprovals(); n > len(members) {
+ s.renderPipelineSettings(w, p, rows, fmt.Sprintf(
+ "Approvals of %d exceed the account's %d member(s) — add members first.", n, len(members)))
+ return
+ }
+ if !s.requireFreshAuth(w, r, addr, raw) {
+ return
+ }
+ dest := p.Base + "/apps/" + url.PathEscape(app) + "/pipeline"
+ if _, n := guardedEdit(cur, channels); n > 0 {
+ kind, msg := s.proposeThenExecute(ctx, acct, app, kindPolicy, policyProposal(cur, channels), n, addr)
+ s.setFlash(w, kind, msg)
+ http.Redirect(w, r, dest, http.StatusSeeOther)
+ return
+ }
+ if _, err := s.writePipeline(ctx, acct, app, channels, addr, "console"); err != nil {
+ status, msg := classifySetPipelineError(err)
+ if status != http.StatusUnprocessableEntity {
+ log.Printf("console: pipeline settings save %s/%s: set: %v", acct.Sqid, app, err)
+ }
+ s.renderPipelineSettings(w, p, rows, msg)
+ return
+ }
+ s.setFlash(w, "ok", "Pipeline updated.")
+ http.Redirect(w, r, dest, http.StatusSeeOther)
+}
diff --git a/internal/console/pipelinepage_test.go b/internal/console/pipelinepage_test.go
new file mode 100644
index 000000000..6fdcea77d
--- /dev/null
+++ b/internal/console/pipelinepage_test.go
@@ -0,0 +1,211 @@
+package console
+
+// Console UI for release pipelines v2 (pipelinepage.go, ladder.go's
+// pipeline-aware assembleLadder, app_overview.html's pending card,
+// app_releases.html's hidden-vocabulary wrap): the three properties the
+// brief pins, in the order they'd hurt if lost — a one-channel app shows
+// none of the ladder's vocabulary, a multi-channel pipeline draws its own
+// channels (in order, with their own gate sentence) and the settings page
+// offers the two starter templates on a still-default pipeline, and the
+// pending-changes card shows real quorum progress with working controls.
+
+import (
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestOneChannelAppHidesChannelVocabulary(t *testing.T) {
+ fx := newConsoleFixture(t)
+ token := cliToken(t, fx, "paul@keymail.dev", "code-hide-vocab")
+ if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new",
+ map[string]string{"name": "solo"}, token); status != http.StatusOK {
+ t.Fatalf("create app = %d %v", status, out)
+ }
+
+ c := signInPaul(t, fx)
+ status, _, body := get(t, c, fx.u("/apps/solo/releases"))
+ if status != http.StatusOK {
+ t.Fatalf("app page = %d", status)
+ }
+ assertNoChannelVocabulary(t, body)
+ // The releases list and the live marker are still there — hidden means
+ // no CHANNEL vocabulary, not no page.
+ if !strings.Contains(body, "Nothing shipped yet") {
+ t.Errorf("one-channel app page does not show the releases list:\n%s", body)
+ }
+ if !strings.Contains(body, "Nothing is live yet.") {
+ t.Errorf("one-channel app page does not show the live marker:\n%s", body)
+ }
+
+ // Ship a build and land it on the entry channel — still no vocabulary,
+ // now that there's actually something to promote.
+ shipVersion(t, fx, "solo", "v1")
+ seedChannel(t, fx, "solo", "edge", "v1")
+ _, _, body2 := get(t, c, fx.u("/apps/solo/releases"))
+ assertNoChannelVocabulary(t, body2)
+}
+
+// assertNoChannelVocabulary checks the RENDERED CONTENT — <main>...</main>,
+// not the shared chrome above it — for the ladder's hidden words. The page's
+// one shared stylesheet (layout.html's `<style>`, included on every page via
+// "top") names a `.ladder` CSS class and a code comment describing it, on
+// EVERY page regardless of this app's pipeline; that is chrome, not content,
+// and is not what spec §5's "hide the vocabulary entirely" is about.
+func assertNoChannelVocabulary(t *testing.T, body string) {
+ t.Helper()
+ _, main, ok := strings.Cut(body, "<main>")
+ if !ok {
+ t.Fatalf("page has no <main> to scope the check to:\n%s", body)
+ }
+ for _, forbidden := range []string{"Promote", "edge", "ladder"} {
+ if strings.Contains(main, forbidden) {
+ t.Errorf("one-channel app page contains %q, want the vocabulary hidden entirely:\n%s", forbidden, main)
+ }
+ }
+}
+
+func TestPipelineCardAndSettingsTemplates(t *testing.T) {
+ fx := newConsoleFixture(t)
+ token := cliToken(t, fx, "paul@keymail.dev", "code-pipeline-card")
+ if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new",
+ map[string]string{"name": "multi"}, token); status != http.StatusOK {
+ t.Fatalf("create app = %d %v", status, out)
+ }
+ c := signInPaul(t, fx)
+
+ // Still the one-channel default: the settings page shows the form, with
+ // the single "edge" row, and offers the two starter templates.
+ _, _, sbody := get(t, c, fx.u("/apps/multi/pipeline"))
+ for _, want := range []string{
+ `action="/a/bab/apps/multi/pipeline"`,
+ `name="ch.0.name" value="edge"`,
+ `value="template-edge-production"`,
+ `value="template-full-ladder"`,
+ } {
+ if !strings.Contains(sbody, want) {
+ t.Errorf("settings page missing %q:\n%s", want, sbody)
+ }
+ }
+
+ // PUT a two-channel pipeline: edge, then production behind a passkey.
+ status, out := putJSON(t, fx.ts.URL+"/api/cli/apps/bab/multi/pipeline",
+ map[string]any{"channels": []map[string]any{
+ {"name": "edge"},
+ {"name": "production", "bake": "24h", "rules": map[string]any{"passkey": true}},
+ }}, token)
+ if status != http.StatusOK {
+ t.Fatalf("pipeline put = %d %v", status, out)
+ }
+
+ _, _, body := get(t, c, fx.u("/apps/multi/releases"))
+ if !strings.Contains(body, `id="rung-edge"`) || !strings.Contains(body, `id="rung-production"`) {
+ t.Fatalf("pipeline card does not show both channels:\n%s", body)
+ }
+ if i, j := strings.Index(body, `id="rung-edge"`), strings.Index(body, `id="rung-production"`); i < 0 || j < 0 || i > j {
+ t.Fatalf("channels are not rendered in pipeline order (edge before production):\n%s", body)
+ }
+ prodBlock := rungBlock(t, body, "production")
+ if !strings.Contains(prodBlock, "Needs a passkey.") {
+ t.Errorf("production rung does not carry its own gate sentence:\n%s", prodBlock)
+ }
+ edgeBlock := rungBlock(t, body, "edge")
+ if !strings.Contains(edgeBlock, "Anyone on the team promotes here.") {
+ t.Errorf("edge rung (no rules) does not say anyone promotes there:\n%s", edgeBlock)
+ }
+
+ // The settings page reflects the write, and — no longer the untouched
+ // default — stops offering the starter templates.
+ _, _, sbody2 := get(t, c, fx.u("/apps/multi/pipeline"))
+ if !strings.Contains(sbody2, `name="ch.1.name" value="production"`) {
+ t.Errorf("settings page does not show the saved production row:\n%s", sbody2)
+ }
+ if strings.Contains(sbody2, `value="template-edge-production"`) {
+ t.Error("settings page still offers the starter templates once the pipeline is no longer the default")
+ }
+}
+
+func TestPendingCardShowsQuorumProgress(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ token := cliToken(t, fx, "paul@keymail.dev", "code-pending-card")
+ if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new",
+ map[string]string{"name": "queue"}, token); status != http.StatusOK {
+ t.Fatalf("create app = %d %v", status, out)
+ }
+ if status, out := putJSON(t, fx.ts.URL+"/api/cli/apps/bab/queue/pipeline",
+ map[string]any{"channels": []map[string]any{
+ {"name": "edge"},
+ {"name": "prod", "rules": map[string]any{"promote_approvals": 2}},
+ }}, token); status != http.StatusOK {
+ t.Fatalf("pipeline put = %d %v", status, out)
+ }
+ shipVersion(t, fx, "queue", "v1")
+ seedChannel(t, fx, "queue", "edge", "v1")
+
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+ // The proposer's own step-up earns them the first of the two
+ // confirmations (spec §4) — postGated walks that round trip.
+ status, _, _ := postGated(t, fx, c, fx.u("/apps/queue/promote"),
+ url.Values{"csrf": {csrf}, "channel": {"prod"}, "version": {"v1"}, "confirmed": {"1"}})
+ if status != http.StatusSeeOther {
+ t.Fatalf("propose promote = %d", status)
+ }
+
+ _, _, body := get(t, c, fx.u("/apps/queue"))
+ if !strings.Contains(body, "promote v1 to prod") {
+ t.Fatalf("pending card does not name the proposed change:\n%s", body)
+ }
+ if !strings.Contains(body, "1/2 confirmed") {
+ t.Fatalf("pending card does not show quorum progress:\n%s", body)
+ }
+ if !strings.Contains(body, "/confirm") {
+ t.Fatalf("pending card has no Confirm control:\n%s", body)
+ }
+ if !strings.Contains(body, "/reject") {
+ t.Fatalf("pending card has no Reject control:\n%s", body)
+ }
+
+ // Confirming as the second member carries it out.
+ fx.setUser("sam@keymail.dev")
+ cm := newBrowser(t)
+ signUp(t, fx, cm, "code-sam-confirm")
+ _, _, mbody := get(t, cm, fx.u("/apps/queue"))
+ id := pendingIDFromBody(t, mbody)
+ status2, _, _ := postGated(t, fx, cm, fx.u("/apps/queue/pending/"+id+"/confirm"),
+ url.Values{"csrf": {sessionToken(t, fx, cm)}})
+ if status2 != http.StatusSeeOther {
+ t.Fatalf("member confirm = %d", status2)
+ }
+ if p := fx.channelVersion(t, "queue", "prod"); p.Version != "v1" {
+ t.Fatalf("prod = %s after quorum was met, want v1", p.Version)
+ }
+ _, _, doneBody := get(t, c, fx.u("/apps/queue"))
+ if strings.Contains(doneBody, "1/2 confirmed") || strings.Contains(doneBody, "0/2 confirmed") {
+ t.Fatalf("pending card still shows the change after it was carried out:\n%s", doneBody)
+ }
+}
+
+// pendingIDFromBody pulls the id out of a pending change's confirm-form
+// action, so the second member can act on the exact row the first created —
+// the card renders the id in the URL and nowhere else structured enough to
+// read back.
+func pendingIDFromBody(t *testing.T, body string) string {
+ t.Helper()
+ marker := "/pending/"
+ i := strings.Index(body, marker)
+ if i < 0 {
+ t.Fatalf("no pending id in body:\n%s", body)
+ }
+ rest := body[i+len(marker):]
+ j := strings.Index(rest, "/")
+ if j < 0 {
+ t.Fatalf("malformed pending action url in body:\n%s", body)
+ }
+ return rest[:j]
+}
diff --git a/internal/console/server.go b/internal/console/server.go
index b542d6f95..758caa93a 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -737,6 +737,11 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/logs", s.handleAppLogs)
s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/activity", s.handleAppActivity)
s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/settings", s.handleAppSettings)
+ // The pipeline settings page (pipelinepage.go, release pipelines v2 §5):
+ // GET renders the current (or default) channel list as an editable
+ // form; POST goes through the same guarded path the API PUT does.
+ s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/pipeline", s.handlePipelineSettings)
+ s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/pipeline", s.handlePipelineSettingsSave)
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)
@@ -910,6 +915,22 @@ type page struct {
Events []Event // app page: recent console actions, newest first
CanPromote bool // a signing key is configured on this host
+ // Pending is the app page's pending-changes card (release pipelines v2
+ // §4, pendinghandlers.go): every unexpired, unexecuted change this app
+ // has, oldest first — a proposed promote, policy edit or fast-track,
+ // with its quorum progress and the confirm/reject buttons that post to
+ // Task 8's routes.
+ Pending []PendingChangeView
+
+ // The pipeline settings page (pipelinepage.go), GET/POST
+ // /a/{sqid}/apps/{app}/pipeline: PipelineRows is the current (or
+ // mid-edit draft) channel list as editable form rows, indexed ch.<i>.*;
+ // PipelineIsDefault is whether it's still the untouched one-channel
+ // default a fresh app is created with, which is when the two starter
+ // templates (Edge → Production, Full ladder) are worth offering.
+ PipelineRows []PipelineFormRow
+ PipelineIsDefault bool
+
// App page: how a canary channel gets a URL, rendered as commands the
// reader can actually run from where they are reading. Computed
// server-side (attachCommands) because the host shape comes from the
@@ -2150,6 +2171,11 @@ func (s *Server) handleApp(w http.ResponseWriter, r *http.Request) {
} else {
p.Events = events
}
+ if pcs, err := s.store.PendingChangesFor(acct.ID, app, time.Now()); err != nil {
+ log.Printf("console: pending changes %s/%s: %v", acct.Sqid, app, err)
+ } else {
+ p.Pending = pendingChangeViews(pcs)
+ }
s.render(w, http.StatusOK, "app_overview", p)
}
@@ -2165,7 +2191,7 @@ func (s *Server) handleAppReleases(w http.ResponseWriter, r *http.Request) {
return
}
p.ReleaseSummary = assembleReleaseSummary(p.Detail.Channels)
- p.PromoteChannels = promoteChannelOptions(p.Detail.Channels)
+ p.PromoteChannels = promoteChannelOptions(p.Detail.Channels, p.Detail.Pipeline)
versions, err := Versions(s.cfg.Releases, acct.Sqid, p.AppName)
if err != nil {
log.Printf("console: versions %s: %v", p.AppName, err)
@@ -2903,11 +2929,28 @@ func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request) {
// leaves — a promotion of the older build, confirmed by a
// quorum, rather than a history pop.
kind, msg := s.proposeThenExecute(ctx, acct, app, kindPromote,
- promotePayload{Version: target, Channel: channel, Hotfix: true},
+ promotePayload{Version: target, Channel: channel, Hotfix: true, Rollback: true},
rules.PromoteApprovals, addr)
back(kind, msg)
return
}
+ } else if pipe == nil {
+ // Pre-existing gap (Task 8 review): the legacy ladder's stable rung
+ // carried NO gate at all on this door, while promoteGate takes
+ // owner+passkey for a PROMOTION onto it and the API door 403s a
+ // rollback of it outright — a rollback lands the same pointer write
+ // in front of the same users. Mirror promoteGate's legacy-path shape
+ // exactly: byte-identical 404 for a non-owner, requireFreshAuth last.
+ // Every other legacy rung is unchanged — membership alone, as before.
+ if rung, _ := release.Rung(channel); rung == release.RungStable {
+ if role != RoleOwner {
+ http.NotFound(w, r)
+ return
+ }
+ if !s.requireFreshAuth(w, r, addr, raw) {
+ return
+ }
+ }
}
if err := release.Rollback(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, channel); err != nil {
log.Printf("console: rollback %s %s: %v", app, channel, err)
@@ -3338,20 +3381,38 @@ func (s *Server) handleRetireCanary(w http.ResponseWriter, r *http.Request) {
back("ok", "Retired canary "+name+" — its channel pointer is gone; shipped releases are untouched.")
}
-// promoteChannelOptions is the per-row promote select's option list: the
-// ladder rungs first (so a fresh app with zero channels — nothing in
-// existing — still has somewhere to promote its first release), then any of
-// the app's existing channels not already a rung (canary/* and the like),
-// in their existing order. Ladder rungs are included whether or not the app
-// has them yet — promoting onto a rung that doesn't exist is exactly how it
-// comes to exist.
-func promoteChannelOptions(existing []ChannelView) []string {
- rungs := []string{release.RungEdge, release.RungBeta, release.RungStable}
- seen := make(map[string]bool, len(rungs))
- options := make([]string, 0, len(rungs)+len(existing))
- for _, r := range rungs {
- options = append(options, r)
- seen[r] = true
+// promoteChannelOptions is the per-row promote select's option list.
+//
+// For a pipelined app (pipe != nil), it's the pipeline's own declared
+// channels, IN PIPELINE ORDER — the app's promotion order comes from the
+// pipeline now (spec §5), not the frozen rungs, and offering a rung name a
+// pipelined app never declared would let a member promote somewhere the
+// pipeline itself refuses. Any existing channel outside the pipeline
+// (canary/*, a -hotfix target) is still offered after them, in existing
+// order, for the same reason the legacy path offers canary/*: it is a real,
+// already-published channel, whatever governs promoting onto it.
+//
+// For a legacy app (pipe == nil), it's the ladder rungs first (so a fresh
+// app with zero channels — nothing in existing — still has somewhere to
+// promote its first release), then any of the app's existing channels not
+// already a rung (canary/* and the like), in their existing order. Ladder
+// rungs are included whether or not the app has them yet — promoting onto a
+// rung that doesn't exist is exactly how it comes to exist.
+func promoteChannelOptions(existing []ChannelView, pipe *release.Pipeline) []string {
+ var first []string
+ if pipe != nil {
+ first = make([]string, len(pipe.Channels))
+ for i, c := range pipe.Channels {
+ first[i] = c.Name
+ }
+ } else {
+ first = []string{release.RungEdge, release.RungBeta, release.RungStable}
+ }
+ seen := make(map[string]bool, len(first))
+ options := make([]string, 0, len(first)+len(existing))
+ for _, name := range first {
+ options = append(options, name)
+ seen[name] = true
}
for _, ch := range existing {
if seen[ch.Name] {
diff --git a/internal/console/server_test.go b/internal/console/server_test.go
index 3d57f1c67..0813f6b7e 100644
--- a/internal/console/server_test.go
+++ b/internal/console/server_test.go
@@ -938,7 +938,10 @@ func TestRollbackFlow(t *testing.T) {
if _, _, body := get(t, c, fx.u("/apps/kass/releases")); !strings.Contains(body, `name="channel" value="stable"`) {
t.Fatal("no rollback button after stable gained history")
}
- status, loc, _ := postForm(t, c, fx.u("/apps/kass/rollback"),
+ // stable is gated (owner + step-up, mirroring promoteGate's legacy path
+ // — Task 8/9 review), so this walks the passkey round trip like every
+ // other stable-rung action.
+ status, loc, _ := postGated(t, fx, c, fx.u("/apps/kass/rollback"),
url.Values{"csrf": {csrf}, "channel": {"stable"}, "confirmed": {"1"}})
if status != http.StatusSeeOther || loc != fx.path("/apps/kass") {
t.Fatalf("rollback = %d -> %q", status, loc)
@@ -954,13 +957,86 @@ func TestRollbackFlow(t *testing.T) {
// 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"),
+ status, loc, _ = postGated(t, fx, c, fx.u("/apps/kass/rollback"),
url.Values{"csrf": {csrf}, "channel": {"stable"}, "back": {"releases"}, "confirmed": {"1"}})
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"))
}
}
+// TestLegacyStableRollbackGatedByOwnerAndStepUp pins the Task 8/9 review's
+// pre-existing-gap fix: a legacy (pipeline-less) app's stable rollback used
+// to carry NO gate at all on this door — while the API door 403s it outright
+// and a PROMOTION onto stable takes owner + passkey (promoteGate). It now
+// mirrors promoteGate's legacy-path shape exactly: byte-identical 404 for a
+// non-owner, requireFreshAuth last for an owner. A non-stable rung is
+// unchanged — membership alone, no owner or step-up gate.
+func TestLegacyStableRollbackGatedByOwnerAndStepUp(t *testing.T) {
+ fx := newConsoleFixture(t)
+ seedChannel(t, fx, "kass", "stable", "v2") // history to roll back to
+ if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ // The owner, signed in FIRST — fx.setUser below points the fake OAuth
+ // provider at sam for the member checks, and signInPaul relies on the
+ // fixture's default (paul) at the moment it signs up.
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+
+ // A member: the byte-identical 404, same as a promotion onto stable.
+ fx.setUser("sam@keymail.dev")
+ cm := newBrowser(t)
+ signUp(t, fx, cm, "code-sam-rollback-stable")
+ if status, _, _ := postForm(t, cm, fx.u("/apps/kass/rollback"),
+ url.Values{"csrf": {sessionToken(t, fx, cm)}, "channel": {"stable"}, "confirmed": {"1"}}); status != http.StatusNotFound {
+ t.Fatalf("member stable rollback = %d, want 404", status)
+ }
+ if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v2" {
+ t.Fatalf("stable moved to %s on a refused member rollback", p.Version)
+ }
+
+ // The owner: a plain POST with no step-up does not move the pointer — it
+ // is redirected into Keymail's re-authentication, same as a stable
+ // promotion (TestLadderPromoteToStableNeedsStepUp). The fake OAuth
+ // provider's identity is reset to paul first — the member check above
+ // left it pointed at sam, and completing the step-up exchanges a code
+ // against whatever address is currently configured there.
+ fx.setUser("paul@keymail.dev")
+ status, loc, _ := postForm(t, c, fx.u("/apps/kass/rollback"),
+ url.Values{"csrf": {csrf}, "channel": {"stable"}, "confirmed": {"1"}})
+ if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) {
+ t.Fatalf("un-stepped-up owner stable rollback = %d -> %q, want a redirect into Keymail", status, loc)
+ }
+ if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v2" {
+ t.Fatalf("stable moved to %s with no passkey confirmation", p.Version)
+ }
+
+ // Completing the step-up carries the rollback out.
+ if status, _, body := completeStepUp(t, fx, c, loc); status != http.StatusSeeOther {
+ t.Fatalf("resumed rollback = %d:\n%s", status, body)
+ }
+ if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v1" {
+ t.Fatalf("stable = %s after a confirmed, stepped-up rollback, want v1", p.Version)
+ }
+
+ // A non-stable rung is unchanged: membership alone, no owner/step-up gate.
+ shipVersion(t, fx, "kass", "v8")
+ shipVersion(t, fx, "kass", "v9")
+ seedChannel(t, fx, "kass", "beta", "v8")
+ seedChannel(t, fx, "kass", "beta", "v9")
+ fx.setUser("sam@keymail.dev")
+ cm2 := newBrowser(t)
+ signUp(t, fx, cm2, "code-sam-rollback-beta")
+ status, loc, _ = postForm(t, cm2, fx.u("/apps/kass/rollback"),
+ url.Values{"csrf": {sessionToken(t, fx, cm2)}, "channel": {"beta"}, "confirmed": {"1"}})
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass") {
+ t.Fatalf("member beta rollback = %d -> %q, want a plain 303 into the app page (no gate)", status, loc)
+ }
+ if p := fx.channelVersion(t, "kass", "beta"); p.Version != "v8" {
+ t.Fatalf("beta = %s after a member's ungated rollback, want v8", p.Version)
+ }
+}
+
// addRoute upserts a registry route into the fixture's registry.
func (fx *consoleFixture) addRoute(t *testing.T, rt registry.Route) {
t.Helper()
@@ -1203,7 +1279,9 @@ func TestRecentActivity(t *testing.T) {
seedChannel(t, fx, "kass", "stable", "v2")
postForm(t, c, fx.u("/apps/kass/promote"),
url.Values{"csrf": {csrf}, "version": {"v3"}, "new_channel": {"edge"}, "confirmed": {"1"}})
- postForm(t, c, fx.u("/apps/kass/rollback"),
+ // stable is gated (owner + step-up, mirroring promoteGate's legacy path
+ // — Task 8/9 review), so the confirmed post walks the passkey round trip.
+ postGated(t, fx, c, fx.u("/apps/kass/rollback"),
url.Values{"csrf": {csrf}, "channel": {"stable"}, "confirmed": {"1"}})
postForm(t, c, fx.u("/apps/kass/canary"),
url.Values{"csrf": {csrf}, "name": {"rc"}, "version": {"v1"}})
diff --git a/internal/console/templates/app_overview.html b/internal/console/templates/app_overview.html
index 3d53ef2c4..5cbf9123a 100644
--- a/internal/console/templates/app_overview.html
+++ b/internal/console/templates/app_overview.html
@@ -78,6 +78,7 @@
<p class="small">Safety delay: off — new versions go live as soon as they're promoted.{{if .Nudge}} {{icon "triangle-alert"}} This app looks like it's serving real users — consider turning it on, on <a href="{{$.Nav}}/apps/{{$.AppName}}/releases">Versions</a>.{{end}}</p>
{{end}}
{{end}}
+{{template "pending" .}}
{{if .Events}}
<h2>Recent activity</h2>
<ul class="feed">
diff --git a/internal/console/templates/app_releases.html b/internal/console/templates/app_releases.html
index ae7a6a299..eb6cbbb4c 100644
--- a/internal/console/templates/app_releases.html
+++ b/internal/console/templates/app_releases.html
@@ -60,7 +60,7 @@
<summary>All versions ({{len .Versions}})</summary>
<div class="tablewrap">
<table>
- <tr><th>Version</th><th>Label</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 and .CanPromote (not .Detail.OneChannelPipeline)}}<th>Promote</th>{{end}}</tr>
{{range .Versions}}
<tr>
<td><code>{{.Version}}</code>{{if .Tag}} <span class="chip chip-env">{{.Tag}}</span>{{end}}</td>
@@ -73,7 +73,7 @@
<td>{{.Kind}}</td>
<td>{{if .BuiltAt}}{{.BuiltAt}}{{else}}—{{end}}</td>
{{end}}
- {{if $.CanPromote}}<td>
+ {{if and $.CanPromote (not $.Detail.OneChannelPipeline)}}<td>
<form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/promote">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="version" value="{{.Version}}">
@@ -96,6 +96,7 @@ 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}}
<details class="reveal">
<summary>Release pipeline — channels, test versions, and the promotion ladder</summary>
{{template "ladder" .}}
@@ -188,5 +189,11 @@ carlos ship -app {{.Detail.App}} -kind static -version v1 &lt;site-dir&gt;</code
{{end}}
{{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}}
diff --git a/internal/console/templates/app_settings.html b/internal/console/templates/app_settings.html
index adff2cacf..b552e594b 100644
--- a/internal/console/templates/app_settings.html
+++ b/internal/console/templates/app_settings.html
@@ -10,6 +10,8 @@
<tr><td><code>{{.Detail.App}}</code></td><td>{{.Account.Name}} <code>{{.Account.Sqid}}</code></td></tr>
</table>
</div>
+<p class="small"><a href="{{.Base}}/apps/{{.Detail.App}}/pipeline">Release pipeline</a> — the channels a
+release moves through, and what each one takes to promote onto.</p>
{{if .IsOwner}}
{{if .Settings.CanRename}}
<form method="post" action="{{.Base}}/apps/{{.Detail.App}}/rename" class="settings-row">
diff --git a/internal/console/templates/ladder.html b/internal/console/templates/ladder.html
index 5adf19d0f..76121aa04 100644
--- a/internal/console/templates/ladder.html
+++ b/internal/console/templates/ladder.html
@@ -1,4 +1,10 @@
{{define "ladder"}}
+{{if $.Detail.Pipeline}}
+<h2>Pipeline</h2>
+<p class="small">Every version moves through these channels in order — the first is where
+<code>carlos deploy</code> lands. Promoting moves it one step up; whatever follows that step
+picks it up. Each channel's own rules, not a fixed name, decide what it takes to get there.</p>
+{{else}}
<h2>The ladder</h2>
<p class="small">Every version climbs the same steps — canary (test) → edge → beta → stable (live).
Promoting moves it one step up; whatever follows that step picks it up.</p>
@@ -52,6 +58,7 @@ Promoting moves it one step up; whatever follows that step picks it up.</p>
{{end}}
</div>
{{end}}
+{{end}}
<ol class="ladder">
{{range .Detail.Ladder}}
<li class="rung" id="rung-{{.Rung}}">
@@ -99,7 +106,7 @@ Promoting moves it one step up; whatever follows that step picks it up.</p>
{{if .Up}}
<div class="step">
{{icon "arrow-down"}}
- {{if and .Up.Version $.CanPromote $.IsOwner}}
+ {{if and .Up.Version $.CanPromote (or $.Detail.Pipeline $.IsOwner)}}
<form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/promote">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="channel" value="{{.Up.To}}">
diff --git a/internal/console/templates/pending.html b/internal/console/templates/pending.html
new file mode 100644
index 000000000..28eff25ca
--- /dev/null
+++ b/internal/console/templates/pending.html
@@ -0,0 +1,22 @@
+{{define "pending"}}
+{{if .Pending}}
+<h2>Pending changes</h2>
+<p class="small">Proposed by a member and waiting on the rest — confirm with your passkey, or
+withdraw it. A change nobody acts on expires on its own after 7 days.</p>
+<ul class="feed">
+ {{range .Pending}}
+ <li>
+ <p>{{.Summary}} <span class="small">— {{.Confirmed}}/{{.Needed}} confirmed</span></p>
+ <form method="post" action="{{$.Base}}/apps/{{$.AppName}}/pending/{{.ID}}/confirm" class="inline">
+ <input type="hidden" name="csrf" value="{{$.CSRF}}">
+ <button type="submit" class="btn-sm">Confirm</button>
+ </form>
+ <form method="post" action="{{$.Base}}/apps/{{$.AppName}}/pending/{{.ID}}/reject" class="inline">
+ <input type="hidden" name="csrf" value="{{$.CSRF}}">
+ <button type="submit" class="btn-sm btn-danger">Reject</button>
+ </form>
+ </li>
+ {{end}}
+</ul>
+{{end}}
+{{end}}
diff --git a/internal/console/templates/pipeline.html b/internal/console/templates/pipeline.html
new file mode 100644
index 000000000..e45c45329
--- /dev/null
+++ b/internal/console/templates/pipeline.html
@@ -0,0 +1,56 @@
+{{define "pipeline_settings"}}{{template "top" .}}
+{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}}
+{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
+{{template "appnav" .}}
+
+<h2>Release pipeline</h2>
+<p class="small">The ordered list of channels a release moves through — the first is where
+<code>carlos deploy</code> lands. Each channel can ask for a passkey and a number of members
+to confirm before a promotion onto it, or an edit to it, lands. One channel needs none of
+this at all — the vocabulary only shows up once you add a second.</p>
+
+{{if .PipelineIsDefault}}
+<div class="tiles">
+ <form method="post" action="{{.Base}}/apps/{{.AppName}}/pipeline" class="inline">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <button type="submit" name="action" value="template-edge-production" class="btn-sm">Edge → Production</button>
+ </form>
+ <form method="post" action="{{.Base}}/apps/{{.AppName}}/pipeline" class="inline">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <button type="submit" name="action" value="template-full-ladder" class="btn-sm">Full ladder</button>
+ </form>
+</div>
+<p class="small">Pick a starting point, then save it or edit it further below — or build your own
+from scratch with the row below. Nothing is written until you press Save.</p>
+{{end}}
+
+<form method="post" action="{{.Base}}/apps/{{.AppName}}/pipeline">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <div class="tablewrap">
+ <table>
+ <tr><th>Channel</th><th>Bake</th><th>Passkey</th><th>Promote approvals</th><th>Change approvals</th><th></th></tr>
+ {{range $i, $row := .PipelineRows}}
+ <tr>
+ <td><input type="text" name="ch.{{$i}}.name" value="{{$row.Name}}" placeholder="channel-name"
+ aria-label="Channel {{$i}} name"></td>
+ <td><input type="text" name="ch.{{$i}}.bake" value="{{$row.Bake}}" placeholder="24h"
+ aria-label="Channel {{$i}} bake window"></td>
+ <td><input type="checkbox" name="ch.{{$i}}.passkey" value="1"{{if $row.Passkey}} checked{{end}}
+ aria-label="Channel {{$i}} needs a passkey"></td>
+ <td><input type="number" min="0" name="ch.{{$i}}.promote_approvals" value="{{$row.PromoteApprovals}}"
+ aria-label="Channel {{$i}} promote approvals"></td>
+ <td><input type="number" min="0" name="ch.{{$i}}.change_approvals" value="{{$row.ChangeApprovals}}"
+ aria-label="Channel {{$i}} change approvals"></td>
+ <td><button type="submit" name="action" value="remove-{{$i}}" class="btn-sm btn-danger">Remove</button></td>
+ </tr>
+ {{end}}
+ </table>
+ </div>
+ <button type="submit" name="action" value="add" class="btn-sm">Add channel</button>
+ <button type="submit" name="action" value="save" class="btn">Save</button>
+</form>
+<p class="small">Promoting into a channel with rules follows the order above; <code>-hotfix</code>
+bypasses the order but never the rules. An edit that touches a channel the SAVED pipeline
+already guards with change approvals becomes a proposal here too, at the quorum that channel
+already demands.</p>
+{{template "bottom" .}}{{end}}
diff --git a/internal/console/view.go b/internal/console/view.go
index 1bb2eedb2..f3f7717da 100644
--- a/internal/console/view.go
+++ b/internal/console/view.go
@@ -47,6 +47,21 @@ type AppView struct {
// is not deploying" a claim the page can back.
Mismatched []MismatchedRoute
AllMismatched bool
+ // Pipeline is the app's declared release pipeline (release pipelines v2
+ // §2) — nil for a legacy app that has never opted in, or when the read
+ // failed (best-effort for a VIEW: a page that cannot read the object
+ // degrades to the legacy ladder rather than failing outright; a WRITE
+ // door such as handlePromote reads its own copy and refuses on error
+ // instead, which is the place that distinction is load-bearing).
+ Pipeline *release.Pipeline
+}
+
+// OneChannelPipeline reports whether this app has opted into a pipeline with
+// exactly one channel — spec §5's "hide the vocabulary entirely" case: no
+// ladder card, no promote form, no channel table on the app's pages at all,
+// only the releases list and the live marker.
+func (v AppView) OneChannelPipeline() bool {
+ return v.Pipeline != nil && len(v.Pipeline.Channels) == 1
}
// MismatchedRoute is one route whose stamped account disagrees with the
@@ -445,6 +460,16 @@ func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, acct strin
}
seen[app] = true
channels := assembleChannels(ctx, s, pub, acct, app, now)
+ // Best-effort, view-only (see AppView.Pipeline's doc comment): an
+ // unreadable pipeline degrades this render to the legacy ladder
+ // rather than failing the whole page, matching every other
+ // best-effort read in this loop (ensureAccountManifest's sibling
+ // posture, assembleProduction's own Err-carrying fallback).
+ pipe, perr := release.GetPipeline(ctx, s, pub, acct, app)
+ if perr != nil {
+ log.Printf("console: assemble %s/%s: read pipeline: %v", acct, app, perr)
+ pipe = nil
+ }
mm := mismatchedByApp[app]
mismatch := ""
if len(mm) > 0 {
@@ -469,11 +494,12 @@ func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, acct strin
Domains: domainsByApp[app],
Channels: channels,
Canaries: assembleCanaries(channels, byApp[app]),
- Ladder: assembleLadder(channels, byApp[app], now),
+ Ladder: assembleLadder(channels, byApp[app], pipe, now),
Production: pv,
RouteAccount: mismatch,
Mismatched: mm,
AllMismatched: len(mm) > 0 && len(mm) == routableByApp[app],
+ Pipeline: pipe,
})
}
return out, nil