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>
17 files changed,
+966
−34
internal/console/apicli.go+1 −1internal/console/apipipeline.go+5 −6internal/console/confirm_test.go+3 −1internal/console/ladder.go+114 −2internal/console/ladder_test.go+1 −1internal/console/pendinghandlers.go+17 −0internal/console/pipelinepage.go+331 −0internal/console/pipelinepage_test.go+211 −0internal/console/server.go+77 −16internal/console/server_test.go+81 −3internal/console/templates/app_overview.html+1 −0internal/console/templates/app_releases.html+9 −2internal/console/templates/app_settings.html+2 −0internal/console/templates/ladder.html+8 −1internal/console/templates/pending.html+22 −0internal/console/templates/pipeline.html+56 −0internal/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 <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}} |
| <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 <site-dir></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 |