carlos / platform Public

console: hotfix stays inside the pipeline; rollback meets the same per-channel rules

Two adjacent-door authority gaps found in review of the previous commit:

- handleAPIPromote honoured `hotfix` for a target OUTSIDE the app's pipeline.
  A pipelined app's `stable` carries no pipeline rules, so a hotfixed
  promote skipped the order check and wrote the stable pointer from a
  bearer token — a target that has been an unconditional 403 at this door
  since it existed. The flag is now honoured only for a channel the
  pipeline names; the stable rung outside a pipeline keeps the
  byte-identical legacy refusal, and every other out-of-pipeline target
  falls through with the flag CLEARED so PipelinePromotionAllowed's own
  refusal answers it. canary/* stays always-allowed (spec §2).
- The rollback doors had no per-channel gate at all: a channel carrying
  {passkey, promote_approvals} could be rolled back by any member or token.
  Both doors now meet the same rules a promotion onto that channel meets —
  403 finish-in-console / propose over the API, requireFreshAuth LAST then
  propose in the browser. Proposals pin cur.History[0] and are hotfix-
  flagged (a rollback is out-of-order by nature). Legacy apps unchanged,
  and the API's unconditional stable refusal stays exactly where it was.

executePending now restates its own preconditions as refusals rather than
assumptions — same account and app, quorum actually met — because Task 9
adds a caller and this is the one place a gated write happens.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev e6c66428d99d273f3c38c0109bf684bb8cc13b7e parent 3ffa4bd
4 files changed, +348 −12
  • internal/console/apicli.go +75 −11
  • internal/console/pendingflow_test.go +221 −0
  • internal/console/pendinghandlers.go +17 −0
  • internal/console/server.go +35 −1
diff --git a/internal/console/apicli.go b/internal/console/apicli.go
index 93f9ba32c..0b8886c8d 100644
--- a/internal/console/apicli.go
+++ b/internal/console/apicli.go
@@ -649,26 +649,39 @@ func (s *Server) handleAPIPromote(w http.ResponseWriter, r *http.Request) {
// non-member meets the byte-identical 404 in apiAccountApp before any of
// this is mentioned. Below it, membership (already proved) is the gate;
// PromoteOnLadder enforces the order itself.
- if pipe == nil {
- if rung, _ := release.Rung(channel); rung == release.RungStable {
+ //
+ // hotfix is honoured only where the pipeline can judge what it bypasses:
+ // a target INSIDE the app's declared order. Cleared for anything outside
+ // one, and the reason is a hole this door had for exactly one commit: a
+ // pipelined app's `stable` is not in its pipeline, so it carried no
+ // rules, and a hotfixed promote onto it would have skipped the order
+ // check and written the stable pointer FROM A BEARER TOKEN — a target
+ // that has been an unconditional 403 here since this door existed.
+ // Bypassing an order the pipeline never declared is not a bypass, it is
+ // an escape from the pipeline; the sanctioned escape hatch is a
+ // direct-bucket promote with the root key in hand (spec §6).
+ hotfix := req.Hotfix
+ rung, _ := release.Rung(channel)
+ switch {
+ case pipe == nil:
+ if rung == release.RungStable {
writeJSON(w, http.StatusForbidden, map[string]string{
"error": "stable needs a passkey — finish in the console",
"console_url": s.appConsoleURL(acct, app),
})
return
}
- } else {
+ case pipe.Index(channel) >= 0:
switch rules := channelRules(pipe, channel); {
case rules.PromoteApprovals >= 1:
// Order is checked HERE, before a proposal that could never
// execute goes out for signatures — the same refusal, at the
// same 409, PromoteOnLadder would give below. This is the first
- // line in the handler that reads the hotfix flag, and it is
+ // line in the handler that acts on the hotfix flag, and it is
// strictly after the rules decision this switch is making.
- // pipe is non-nil in this arm by construction (channelRules
- // answers the zero rules for nil), which matters because
- // VersionStage dereferences the pipeline it is handed.
- if !req.Hotfix {
+ // pipe is non-nil in this arm by construction, which matters
+ // because VersionStage dereferences the pipeline it is handed.
+ if !hotfix {
stage, serr := release.VersionStage(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, version, pipe)
if serr != nil {
log.Printf("console: api promote %s/%s: stage: %v", acct.Sqid, app, serr)
@@ -681,7 +694,7 @@ func (s *Server) handleAPIPromote(w http.ResponseWriter, r *http.Request) {
}
}
s.proposeOverAPI(w, acct, app, kindPromote,
- promotePayload{Version: version, Channel: channel, Hotfix: req.Hotfix},
+ promotePayload{Version: version, Channel: channel, Hotfix: hotfix},
rules.PromoteApprovals, addr)
return
case rules.Passkey:
@@ -696,9 +709,27 @@ func (s *Server) handleAPIPromote(w http.ResponseWriter, r *http.Request) {
})
return
}
+ default:
+ // A pipelined app, aimed at a channel its pipeline does not name.
+ // The stable RUNG keeps the byte-identical legacy refusal it has
+ // always had here — an app-defined pipeline is a reason to govern
+ // its own channels, never a way to reach one it left out. Everything
+ // else falls through with hotfix cleared, so
+ // PipelinePromotionAllowed's own refusal (409, naming the app's
+ // channels) answers it. canary/* lands here too and is deliberately
+ // NOT refused: spec §2 keeps it outside every pipeline and always
+ // allowed, and PipelinePromotionAllowed waves it through below.
+ if rung == release.RungStable {
+ writeJSON(w, http.StatusForbidden, map[string]string{
+ "error": "stable needs a passkey — finish in the console",
+ "console_url": s.appConsoleURL(acct, app),
+ })
+ return
+ }
+ hotfix = false
}
res, err := release.PromoteOnLadder(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, channel, version,
- release.PromoteOptions{Actor: addr, Hotfix: req.Hotfix})
+ release.PromoteOptions{Actor: addr, Hotfix: hotfix})
if err != nil {
log.Printf("console: api promote %s %s -> %s: %v", app, version, channel, err)
// The ladder's own refusal text names the rungs — as useful over the
@@ -774,6 +805,16 @@ func (s *Server) channelHeld(ctx context.Context, p *release.Pipeline, acct, app
// SAME byte-identical 403 handleAPIPromote gives stable — before
// release.Rollback is ever called, and before the channel's current pointer
// is even read, so the refusal cannot vary with what that pointer contains.
+// That refusal stays UNCONDITIONAL, pipeline or no pipeline: this door only
+// ever adds gates below, never removes the one it already had.
+//
+// A PIPELINED channel's own rules gate it too (release pipelines v2 §2), for
+// the reason above stated the other way round: a rollback is a pointer write,
+// and what it puts in front of every user of the app is a build the channel's
+// rules exist to govern. A channel demanding a passkey refuses here; one
+// demanding approvals proposes, pinned to the version this rollback would
+// land on and flagged hotfix — a rollback is out-of-order by nature, so the
+// order re-check at execution is the one check it must not meet.
func (s *Server) handleAPIRollback(w http.ResponseWriter, r *http.Request) {
addr, acct, app, ok := s.apiAccountApp(w, r)
if !ok {
@@ -810,12 +851,18 @@ func (s *Server) handleAPIRollback(w http.ResponseWriter, r *http.Request) {
if rung, _ := release.Rung(channel); rung == release.RungStable {
writeJSON(w, http.StatusForbidden, map[string]string{
"error": "stable needs a passkey — finish in the console",
- "console_url": s.cfg.Origin + "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app),
+ "console_url": s.appConsoleURL(acct, app),
})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
+ pipe, perr := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app)
+ if perr != nil {
+ log.Printf("console: api rollback %s/%s: read pipeline: %v", acct.Sqid, app, perr)
+ jsonError(w, http.StatusBadGateway, "could not read the pipeline for "+app)
+ return
+ }
cur, err := release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, channel)
if err != nil {
jsonError(w, http.StatusNotFound, "could not read channel "+channel)
@@ -826,6 +873,23 @@ func (s *Server) handleAPIRollback(w http.ResponseWriter, r *http.Request) {
return
}
target := cur.History[0]
+ // The channel's declared rules, applied to the version this would land
+ // on. Read after the pointer because a proposal must PIN that version;
+ // nothing above it varies with what the pointer contains, so the
+ // stable refusal is still answered before the bucket is touched.
+ switch rules := channelRules(pipe, channel); {
+ case rules.PromoteApprovals >= 1:
+ s.proposeOverAPI(w, acct, app, kindPromote,
+ promotePayload{Version: target, Channel: channel, Hotfix: true},
+ rules.PromoteApprovals, addr)
+ return
+ case rules.Passkey:
+ writeJSON(w, http.StatusForbidden, map[string]string{
+ "error": channel + " needs a passkey — finish in the console",
+ "console_url": s.appConsoleURL(acct, app),
+ })
+ return
+ }
if err := release.Rollback(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, channel); err != nil {
log.Printf("console: api rollback %s %s: %v", app, channel, err)
jsonError(w, http.StatusConflict, err.Error())
diff --git a/internal/console/pendingflow_test.go b/internal/console/pendingflow_test.go
index 7d9789c75..b2ea2362c 100644
--- a/internal/console/pendingflow_test.go
+++ b/internal/console/pendingflow_test.go
@@ -335,6 +335,227 @@ func TestPolicyEditPinsSetAt(t *testing.T) {
}
}
+// TestHotfixOnlyReachesPipelineChannels closes the hole review found: on a
+// PIPELINED app, `stable` is not one of its channels, so it carried no rules
+// — and a hotfixed promote would have skipped the order check and written the
+// stable pointer from a bearer token, a target that has been an unconditional
+// 403 at this door since it existed. The flag now applies only INSIDE the
+// app's declared order; outside it there is no order to bypass, only a
+// pipeline to escape, and the sanctioned escape is a direct-bucket promote
+// with the root key (spec §6).
+func TestHotfixOnlyReachesPipelineChannels(t *testing.T) {
+ fx := newConsoleFixture(t)
+ pipelinedApp(t, fx, "widget", "abc1234",
+ release.PipelineChannel{Name: "edge"},
+ release.PipelineChannel{Name: "beta"},
+ release.PipelineChannel{Name: "production"})
+ token := cliToken(t, fx, "paul@keymail.dev", "code-hotfix-scope")
+ promoteURL := fx.ts.URL + "/api/cli/apps/bab/widget/promote"
+
+ // The stable RUNG, which this pipeline does not name: the byte-identical
+ // legacy refusal, hotfix or no hotfix.
+ plain, plainBody := postRaw(t, promoteURL,
+ map[string]any{"version": "abc1234", "channel": "stable"}, token)
+ hot, hotBody := postRaw(t, promoteURL,
+ map[string]any{"version": "abc1234", "channel": "stable", "hotfix": true}, token)
+ if plain != http.StatusForbidden || hot != http.StatusForbidden {
+ t.Fatalf("stable on a pipelined app = plain %d, hotfixed %d, want both 403", plain, hot)
+ }
+ if plainBody != hotBody {
+ t.Fatalf("the hotfix flag changed the stable refusal:\nplain: %q\nhot: %q", plainBody, hotBody)
+ }
+ if !strings.Contains(hotBody, "stable needs a passkey — finish in the console") {
+ t.Fatalf("stable refusal = %q, want today's exact sentence", hotBody)
+ }
+ channelAbsent(t, fx, "widget", "stable")
+
+ // Any other channel the pipeline does not name: refused, and the refusal
+ // names the channels it does.
+ status, out := postJSON(t, promoteURL,
+ map[string]any{"version": "abc1234", "channel": "wip", "hotfix": true}, token)
+ if status != http.StatusConflict {
+ t.Fatalf("hotfixed promote onto an out-of-pipeline channel = %d %v, want 409", status, out)
+ }
+ if !strings.Contains(str(t, out, "error"), "not one of this app's channels") {
+ t.Fatalf("out-of-pipeline refusal = %v, want it to name the pipeline's channels", out["error"])
+ }
+ channelAbsent(t, fx, "widget", "wip")
+
+ // canary/* is outside every pipeline BY DESIGN (spec §2) and stays
+ // always-allowed — the fleet runs on canary channels.
+ status, out = postJSON(t, promoteURL,
+ map[string]any{"version": "abc1234", "channel": "canary/x"}, token)
+ if status != http.StatusOK || str(t, out, "result") != "promoted" {
+ t.Fatalf("canary promote on a pipelined app = %d %v, want it still allowed", status, out)
+ }
+
+ // And INSIDE the pipeline the flag does what it is for: abc1234 sits on
+ // edge, production is entered from beta, and the flag carries it there.
+ status, out = postJSON(t, promoteURL,
+ map[string]any{"version": "abc1234", "channel": "production", "hotfix": true}, token)
+ if status != http.StatusOK || str(t, out, "result") != "promoted" {
+ t.Fatalf("hotfixed in-pipeline promote = %d %v, want 200 promoted", status, out)
+ }
+ if got := fx.channelVersion(t, "widget", "production"); got.Version != "abc1234" {
+ t.Fatalf("production serves %q, want abc1234", got.Version)
+ }
+}
+
+// TestRollbackMeetsChannelRules: a rollback is a pointer write, and what it
+// puts in front of every user of the app is a build the channel's rules exist
+// to govern. Review found a guarded channel could be rolled back by any
+// member or bearer token with no gate at all.
+func TestRollbackMeetsChannelRules(t *testing.T) {
+ t.Run("api", func(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "widget", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ shipVersion(t, fx, "widget", "v1")
+ shipVersion(t, fx, "widget", "v2")
+ seedChannel(t, fx, "widget", "production", "v1")
+ seedChannel(t, fx, "widget", "production", "v2")
+ setPipeline(t, fx, "widget",
+ release.PipelineChannel{Name: "production", Rules: release.ChannelRules{Passkey: true}})
+ token := cliToken(t, fx, "paul@keymail.dev", "code-rollback-rules")
+ rollbackURL := fx.ts.URL + "/api/cli/apps/bab/widget/rollback"
+
+ status, out := postJSON(t, rollbackURL, map[string]any{"channel": "production"}, token)
+ if status != http.StatusForbidden {
+ t.Fatalf("rollback of a passkey channel = %d %v, want 403", status, out)
+ }
+ if !strings.Contains(str(t, out, "error"), "production needs a passkey") {
+ t.Fatalf("rollback refusal = %v, want it to name the channel and the passkey", out["error"])
+ }
+ if got := fx.channelVersion(t, "widget", "production"); got.Version != "v2" {
+ t.Fatalf("production serves %q after a refused rollback, want v2", got.Version)
+ }
+
+ // Approvals instead: the door proposes, pinned to the version the
+ // rollback would land on.
+ setPipeline(t, fx, "widget",
+ release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 2}})
+ status, out = postJSON(t, rollbackURL, map[string]any{"channel": "production"}, token)
+ if status != http.StatusAccepted {
+ t.Fatalf("rollback of an approvals channel = %d %v, want 202", status, out)
+ }
+ if str(t, out, "result") != "proposed" || num(t, out, "needed") != 2 {
+ t.Fatalf("rollback proposal = %v, want a proposal needing 2", out)
+ }
+ if got := fx.channelVersion(t, "widget", "production"); got.Version != "v2" {
+ t.Fatalf("production serves %q after a proposal, want v2 — nothing moved", got.Version)
+ }
+ pending, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now())
+ if err != nil || len(pending) != 1 {
+ t.Fatalf("pending = %v, %v, want one", pending, err)
+ }
+ if !strings.Contains(pendingSummary(pending[0]), "v1") {
+ t.Fatalf("summary %q does not pin the version the rollback lands on", pendingSummary(pending[0]))
+ }
+ })
+
+ t.Run("legacy apps unchanged", func(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ shipVersion(t, fx, "titogo", "v1")
+ shipVersion(t, fx, "titogo", "v2")
+ seedChannel(t, fx, "titogo", "beta", "v1")
+ seedChannel(t, fx, "titogo", "beta", "v2")
+ token := cliToken(t, fx, "paul@keymail.dev", "code-rollback-legacy")
+
+ status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/rollback",
+ map[string]any{"channel": "beta"}, token)
+ if status != http.StatusOK {
+ t.Fatalf("legacy rollback = %d %v, want 200", status, out)
+ }
+ if got := fx.channelVersion(t, "titogo", "beta"); got.Version != "v1" {
+ t.Fatalf("beta serves %q after a legacy rollback, want v1", got.Version)
+ }
+ })
+
+ t.Run("browser proposes and quorum executes", func(t *testing.T) {
+ fx, c, csrf := memberFixture(t) // paul owns, kim is a member
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "widget", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ shipVersion(t, fx, "widget", "v1")
+ shipVersion(t, fx, "widget", "v2")
+ seedChannel(t, fx, "widget", "production", "v1")
+ seedChannel(t, fx, "widget", "production", "v2")
+ setPipeline(t, fx, "widget",
+ release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 2}})
+
+ status, loc, body := postGated(t, fx, c, fx.u("/apps/widget/rollback"),
+ url.Values{"csrf": {csrf}, "channel": {"production"}, "confirmed": {"1"}})
+ if status != http.StatusSeeOther {
+ t.Fatalf("browser rollback proposal = %d -> %q:\n%s", status, loc, body)
+ }
+ if got := fx.channelVersion(t, "widget", "production"); got.Version != "v2" {
+ t.Fatalf("production serves %q after a proposal, want v2", got.Version)
+ }
+ pending, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now())
+ if err != nil || len(pending) != 1 || len(pending[0].Confirms) != 1 {
+ t.Fatalf("pending = %v, %v, want one at 1 of 2", pending, err)
+ }
+
+ kim := newBrowser(t)
+ fx.setUser("kim@keymail.dev")
+ signUp(t, fx, kim, "code-kim-rollback")
+ kimCSRF := sessionToken(t, fx, kim)
+ status, loc, body = postGated(t, fx, kim,
+ fx.u("/apps/widget/pending/"+pending[0].ID+"/confirm"), url.Values{"csrf": {kimCSRF}})
+ if status != http.StatusSeeOther {
+ t.Fatalf("confirmation = %d -> %q:\n%s", status, loc, body)
+ }
+ if got := fx.channelVersion(t, "widget", "production"); got.Version != "v1" {
+ t.Fatalf("production serves %q after quorum, want the rolled-back v1", got.Version)
+ }
+ })
+}
+
+// TestExecutePendingRefusesItsOwnPreconditions: executePending is billed as
+// the one place a gated write happens, and Task 9 adds a caller. Neither an
+// under-confirmed change nor one belonging to another app may write, and
+// neither may panic — a bug in a caller has to refuse, not take the console
+// down or land an unapproved promotion.
+func TestExecutePendingRefusesItsOwnPreconditions(t *testing.T) {
+ fx := newConsoleFixture(t)
+ pipelinedApp(t, fx, "widget", "abc1234",
+ release.PipelineChannel{Name: "edge"},
+ release.PipelineChannel{Name: "production"})
+ acct, err := fx.st.AccountBySqid(fx.sqid)
+ if err != nil || acct == nil {
+ t.Fatalf("account: %v, %v", acct, err)
+ }
+ payload := []byte(`{"version":"abc1234","channel":"production"}`)
+ ctx := context.Background()
+
+ short, err := fx.st.ProposeChange(OpsAccountID, "widget", "promote", payload, 2,
+ "paul@keymail.dev", true, time.Now())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if msg, err := fx.srv.executePending(ctx, acct, "widget", "paul@keymail.dev", short); err == nil {
+ t.Fatalf("executing a 1-of-2 change succeeded (%q), want a refusal", msg)
+ }
+ channelAbsent(t, fx, "widget", "production")
+
+ elsewhere, err := fx.st.ProposeChange(OpsAccountID, "kass", "promote", payload, 1,
+ "paul@keymail.dev", true, time.Now())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if msg, err := fx.srv.executePending(ctx, acct, "widget", "paul@keymail.dev", elsewhere); err == nil {
+ t.Fatalf("executing another app's change succeeded (%q), want a refusal", msg)
+ }
+ channelAbsent(t, fx, "widget", "production")
+}
+
// TestFastTrackProposesWhenGuarded: change_approvals covers overriding a
// bake as well as editing a channel's entry (spec §2), and the waiver it
// gates is pinned to the build it was proposed for.
diff --git a/internal/console/pendinghandlers.go b/internal/console/pendinghandlers.go
index 0c8bc9a4f..8ec00bce8 100644
--- a/internal/console/pendinghandlers.go
+++ b/internal/console/pendinghandlers.go
@@ -230,7 +230,24 @@ func policyProposal(cur *release.Pipeline, next []release.PipelineChannel) polic
// An error return means nothing landed and the change is still pending: a
// caller must not mark it executed, and the member who confirmed it is told
// why rather than left believing it went through.
+//
+// The first two checks are this function's OWN preconditions, restated rather
+// than assumed. Every caller today establishes them — quorum via
+// ConfirmChange's `met`, the app via pendingScoped's 404 — but this is billed
+// as the one place a gated write happens, and a future caller (the console's
+// own pipeline page, Task 9) that got either wrong would otherwise turn a
+// mistake into an unapproved signed write. They are errors, never panics: a
+// bug here must refuse, not take the console down.
func (s *Server) executePending(ctx context.Context, acct *Account, app, actor string, pc *PendingChange) (string, error) {
+ if pc.AccountID != acct.ID || pc.App != app {
+ log.Printf("console: execute %s %s: change belongs to %d/%q, not %d/%q",
+ pc.Kind, pc.ID, pc.AccountID, pc.App, acct.ID, app)
+ return "", errors.New("that change belongs to a different app")
+ }
+ if len(pc.Confirms) < pc.Quorum {
+ log.Printf("console: execute %s %s: %d of %d confirmations", pc.Kind, pc.ID, len(pc.Confirms), pc.Quorum)
+ return "", fmt.Errorf("that change has %d of %d confirmations", len(pc.Confirms), pc.Quorum)
+ }
if s.cfg.Key == nil {
return "", errors.New("no signing key on this host — the console is running read-only")
}
diff --git a/internal/console/server.go b/internal/console/server.go
index b74281328..b542d6f95 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -2823,7 +2823,10 @@ func (s *Server) promoteGate(w http.ResponseWriter, r *http.Request, addr, raw,
return s.requireFreshAuth(w, r, addr, raw)
}
-// handleRollback swaps a channel back to its previous version.
+// handleRollback swaps a channel back to its previous version. On a pipelined
+// channel it meets that channel's own rules first (see the gate below the
+// confirm card): the same passkey and the same quorum a promotion onto it
+// would meet, because it is the same pointer and the same blast radius.
func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request) {
addr, raw, acct, role, app, ok := s.accountApp(w, r)
if !ok {
@@ -2875,6 +2878,37 @@ func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request) {
back("err", "Channel "+channel+" changed while you were confirming — review the rollback again.")
return
}
+ // A pipelined channel's own rules gate a rollback exactly as they gate a
+ // promotion (release pipelines v2 §2): a rollback is a pointer write, and
+ // the build it puts in front of every user of the app is one the
+ // channel's rules exist to govern. requireFreshAuth stays LAST, after the
+ // confirm card and the pin — and a channel with no rules, or an app with
+ // no pipeline, behaves exactly as it did.
+ pipe, perr := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app)
+ if perr != nil {
+ log.Printf("console: rollback %s/%s: read pipeline: %v", acct.Sqid, app, perr)
+ back("err", "Could not read the pipeline for "+app+". Nothing has been changed.")
+ return
+ }
+ if rules := channelRules(pipe, channel); rules.Passkey || rules.PromoteApprovals >= 1 {
+ if !s.requireFreshAuth(w, r, addr, raw) {
+ return
+ }
+ if rules.PromoteApprovals >= 1 {
+ // Pinned to the version this rollback lands on, and flagged
+ // hotfix: a rollback is out-of-order by nature, so the order
+ // re-check at execution is the one check it must not meet. The
+ // pointer ends up serving the pinned build either way; what
+ // differs from release.Rollback is the shape of the trail it
+ // 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},
+ rules.PromoteApprovals, addr)
+ back(kind, msg)
+ 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)
back("err", "Rollback failed: "+err.Error())