carlos / platform Public

console: bump control/serial on box-consumed writes; promote API reports held

Every console door that writes something a box's fast loop consumes now
announces it on control/serial (carlos-deploy spec §3): promote, rollback,
production, fast-track, canary-create, instance create/delete/enable, and
both config write doors. Log-only and clamped to 2s — a lost bump costs
latency, never correctness, and no user-facing write may fail or stall
because its announcement did.

Every bump sits strictly after the handler's own successful write, beside
its audit row: no gate moved, and a refused or no-op request announces
nothing.

The promote API response gains an additive "held" so the CLI can report a
bake hold without a second call. Held is the browser door's own predicate —
the channel has a window AND the app declared production — and an
unreadable flag reports held, because the adopt path fails toward baking.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell f08d1fae4e6ba38ed8983c75b5b5de0a95ca0458 parent 0372633
7 files changed, +437 −1
  • internal/console/apicli.go +23 −1
  • internal/console/apicli_test.go +6 −0
  • internal/console/apiconfig.go +12 −0
  • internal/console/apiinstances.go +10 −0
  • internal/console/serialbump.go +30 −0
  • internal/console/serialbump_test.go +340 −0
  • internal/console/server.go +16 −0
diff --git a/internal/console/apicli.go b/internal/console/apicli.go
index 101bccba3..4de87d8c3 100644
--- a/internal/console/apicli.go
+++ b/internal/console/apicli.go
@@ -356,8 +356,29 @@ func (s *Server) handleAPIPromote(w http.ResponseWriter, r *http.Request) {
return
}
s.audit(acct, app, addr, "promote", "promoted "+version+" to "+channel+" (cli)")
+ s.bumpSerial(ctx)
+ // held answers the CLI's next question without a second round trip: is the
+ // thing I just promoted actually going to be adopted, or is it waiting out
+ // a bake window? Two conditions, exactly as the browser door's flash
+ // computes them (handlePromote, server.go): the CHANNEL has a window at
+ // all, and the APP has declared production — a non-production app's boxes
+ // adopt a held channel immediately, so a hold claim would be a lie.
+ //
+ // Only beta can be both here: canary/* and edge have zero-length windows,
+ // and stable never reaches this line (the rung gate 403s it above). An
+ // UNREADABLE flag reports held, matching what the boxes will actually do —
+ // the adopt path fails TOWARD baking (spec §4), so reporting "not held"
+ // there would be the one wrong answer.
+ held := false
+ if release.HoldFor(release.DefaultHolds, channel) > 0 {
+ flag, ferr := release.GetProduction(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app)
+ if ferr != nil {
+ log.Printf("console: api promote production flag %s: %v", app, ferr)
+ }
+ held = ferr != nil || (flag != nil && flag.Production)
+ }
writeJSON(w, http.StatusOK, map[string]any{
- "result": "promoted", "from": res.From, "to": res.To, "noop": false,
+ "result": "promoted", "from": res.From, "to": res.To, "noop": false, "held": held,
})
}
@@ -430,6 +451,7 @@ func (s *Server) handleAPIRollback(w http.ResponseWriter, r *http.Request) {
return
}
s.audit(acct, app, addr, "rollback", "rolled back "+channel+" to "+target+" (cli)")
+ s.bumpSerial(ctx)
writeJSON(w, http.StatusOK, map[string]string{"result": "rolled back"})
}
diff --git a/internal/console/apicli_test.go b/internal/console/apicli_test.go
index 1f0407a8b..c474dd0f0 100644
--- a/internal/console/apicli_test.go
+++ b/internal/console/apicli_test.go
@@ -470,6 +470,12 @@ func TestAPIPromoteHappy(t *testing.T) {
if v, present := out["noop"]; !present || v != false {
t.Fatalf("promote noop = %v (present=%v), want false", v, present)
}
+ // Additive (carlos-deploy §3): the CLI learns from this same answer
+ // whether what it just moved is waiting out a bake window. A canary never
+ // bakes, so this one is not held.
+ if v, present := out["held"]; !present || v != false {
+ t.Fatalf("promote held = %v (present=%v), want false", v, present)
+ }
cp := fx.channelVersion(t, "titogo", "canary/x")
if cp.Version != "abc1234" {
t.Fatalf("channel after promote = %q, want abc1234", cp.Version)
diff --git a/internal/console/apiconfig.go b/internal/console/apiconfig.go
index ef762382e..c1cd87c26 100644
--- a/internal/console/apiconfig.go
+++ b/internal/console/apiconfig.go
@@ -416,6 +416,13 @@ func (s *Server) handleAPIConfigPut(w http.ResponseWriter, r *http.Request) {
// what happened.
s.audit(acct, app, addr, "config",
fmt.Sprintf("config: %d set, %d unset (%s) (cli)", len(sets), removed, bundle))
+ // Box-consumed (carlos-deploy §3): the fast loop's config-sync leg
+ // renders this bundle onto every route that references it. Announced
+ // per successful write rather than once at the end, for the same
+ // reason the audit row is: the two writes below are separate CAS
+ // operations, and if the second fails the first has still landed and
+ // must still reach the boxes.
+ s.bumpSerial(ctx)
}
if req.ExecDelivery != nil {
// A SECOND CAS write, and deliberately not folded into the one above:
@@ -432,6 +439,7 @@ func (s *Server) handleAPIConfigPut(w http.ResponseWriter, r *http.Request) {
label = execDelivery
}
s.audit(acct, app, addr, "exec-delivery", "exec-delivery "+label+" (cli)")
+ s.bumpSerial(ctx)
}
writeJSON(w, http.StatusOK, map[string]any{"result": "ok", "removed": removed})
}
@@ -468,5 +476,9 @@ func (s *Server) handleAPIConfigSync(w http.ResponseWriter, r *http.Request) {
return
}
s.audit(acct, app, addr, "config-sync", "requested config sync (cli)")
+ // This door exists to make the fleet converge NOW; the generation touch
+ // without the announcement would leave it waiting on the hourly loop it
+ // was written to skip.
+ s.bumpSerial(ctx)
writeJSON(w, http.StatusOK, map[string]any{"result": "requested"})
}
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go
index 31c3a208c..c18cd88ad 100644
--- a/internal/console/apiinstances.go
+++ b/internal/console/apiinstances.go
@@ -320,6 +320,10 @@ func (s *Server) handleAPIInstanceCreate(w http.ResponseWriter, r *http.Request)
// response, behind an interface the deployment configures (nil =
// wildcard-covered). Recorded, not built.
s.audit(acct, app, principal, "instance-create", "created instance "+host+" (cli)")
+ // The record is what a box's reconcile leg mints a route from, so this is
+ // a box-consumed write (carlos-deploy §3): announce it, and the instance
+ // is serving seconds after signup instead of on the hour.
+ s.bumpSerial(ctx)
writeJSON(w, http.StatusCreated, rec)
}
@@ -365,6 +369,9 @@ func (s *Server) handleAPIInstanceDelete(w http.ResponseWriter, r *http.Request)
// the store, and the fact worth recording is that this principal asked
// for this host to stop existing.
s.audit(acct, app, principal, "instance-delete", "deleted instance "+host+" (cli)")
+ // Retiring a route is reconcile's work too, and the direction people want
+ // FAST: the announcement is what takes the route down in seconds.
+ s.bumpSerial(ctx)
writeJSON(w, http.StatusOK, map[string]any{"result": "deleted"})
}
@@ -549,6 +556,9 @@ func (s *Server) handleAPIInstanceEnable(w http.ResponseWriter, r *http.Request)
return
}
s.audit(acct, app, addr, "instance-enable", "enabled instances under "+domain+" (cli)")
+ // The manifest names the channel every minted route follows, so a change
+ // here changes what boxes converge onto — reconcile reads it each pass.
+ s.bumpSerial(ctx)
writeJSON(w, http.StatusOK, map[string]any{
"app": app, "domain": m.Domain, "pool": m.Pool, "channel": m.Channel,
"regions": m.Regions, "seed": m.Seed,
diff --git a/internal/console/serialbump.go b/internal/console/serialbump.go
new file mode 100644
index 000000000..9f3facea1
--- /dev/null
+++ b/internal/console/serialbump.go
@@ -0,0 +1,30 @@
+package console
+
+import (
+ "context"
+ "log"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// bumpSerial announces "the bucket has news" (carlos-deploy spec §3) after a
+// write a box loop consumes. Log-only on failure and clamped to 2s: a lost
+// bump costs latency (the hourly floor still converges), and no user-facing
+// write may fail or stall because its announcement did.
+//
+// Every call site sits strictly AFTER the handler's own successful write, next
+// to its audit row — never before a gate, and never in place of one. The
+// serial carries no authority at all (it is the one unsigned object in the
+// bucket); the worst a spurious bump can do is make a box run its ordinary
+// signature-verified pass set a little early.
+func (s *Server) bumpSerial(ctx context.Context) {
+ if s.cfg.Releases == nil {
+ return // no bucket on this console: nothing to announce into
+ }
+ ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
+ defer cancel()
+ if err := release.BumpSerial(ctx, s.cfg.Releases, time.Now()); err != nil {
+ log.Printf("console: serial bump: %v (boxes fall back to the hourly pass)", err)
+ }
+}
diff --git a/internal/console/serialbump_test.go b/internal/console/serialbump_test.go
new file mode 100644
index 000000000..c3e03aaf7
--- /dev/null
+++ b/internal/console/serialbump_test.go
@@ -0,0 +1,340 @@
+package console
+
+// The console half of the deploy signal (carlos-deploy spec §3): every bucket
+// write a box's fast loop consumes bumps control/serial, so a box notices in
+// seconds instead of waiting out its hourly floor. The properties pinned here,
+// in the order they'd hurt if lost: the write's own success never depends on
+// the bump landing, the bump DOES land on each door that moves something a box
+// reads, and the promote API says whether what it just moved is held by a bake
+// window — so the CLI can report the hold without a second round trip.
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// serial reads the bucket's serial counter — 0 when the object was never
+// written, which is exactly what "nothing bumped" looks like.
+func serial(t *testing.T, fx *consoleFixture) int64 {
+ t.Helper()
+ doc, _, err := release.ReadSerial(context.Background(), fx.rel)
+ if err != nil {
+ t.Fatalf("read serial: %v", err)
+ }
+ return doc.Serial
+}
+
+// TestAPIPromoteBumpsSerial: the CLI's promote door moves a channel pointer —
+// the archetypal box-consumed write — so the serial goes from absent (0) to 1.
+func TestAPIPromoteBumpsSerial(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", "abc1234")
+ token := cliToken(t, fx, "paul@keymail.dev", "code-serial-promote")
+ if got := serial(t, fx); got != 0 {
+ t.Fatalf("serial before promote = %d, want 0", got)
+ }
+
+ status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote",
+ map[string]string{"version": "abc1234", "channel": "canary/x"}, token)
+ if status != http.StatusOK {
+ t.Fatalf("promote = %d %v", status, out)
+ }
+ if got := serial(t, fx); got != 1 {
+ t.Fatalf("serial after promote = %d, want 1", got)
+ }
+}
+
+// TestAPIPromoteNoOpDoesNotBump: a promotion that moved nothing announces
+// nothing. The serial is "the bucket has news"; a no-op is not news, and a
+// bump for it would wake every box in the fleet for a pointer that did not
+// change.
+func TestAPIPromoteNoOpDoesNotBump(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", "abc1234")
+ seedChannel(t, fx, "titogo", "canary/x", "abc1234")
+ token := cliToken(t, fx, "paul@keymail.dev", "code-serial-noop")
+
+ status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote",
+ map[string]string{"version": "abc1234", "channel": "canary/x"}, token)
+ if status != http.StatusOK || str(t, out, "result") != "noop" {
+ t.Fatalf("promote = %d %v, want 200 noop", status, out)
+ }
+ if got := serial(t, fx); got != 0 {
+ t.Fatalf("serial after a no-op promote = %d, want 0", got)
+ }
+}
+
+// TestAPIPromoteRefusedDoesNotBump: a refusal wrote nothing, so it announces
+// nothing — the bump sits strictly after the write, never before the gates.
+func TestAPIPromoteRefusedDoesNotBump(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", "abc1234")
+ token := cliToken(t, fx, "paul@keymail.dev", "code-serial-refused")
+
+ status, _ := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote",
+ map[string]string{"version": "abc1234", "channel": "stable"}, token)
+ if status != http.StatusForbidden {
+ t.Fatalf("stable promote = %d, want 403", status)
+ }
+ if got := serial(t, fx); got != 0 {
+ t.Fatalf("serial after a refused promote = %d, want 0", got)
+ }
+}
+
+// held decodes only the field this task adds, which is also the shape the CLI
+// reads: an absent "held" and an explicit false are the same answer.
+func held(t *testing.T, body string) bool {
+ t.Helper()
+ var out struct {
+ Held bool `json:"held"`
+ }
+ if err := json.Unmarshal([]byte(body), &out); err != nil {
+ t.Fatalf("decode %q: %v", body, err)
+ }
+ return out.Held
+}
+
+// TestAPIPromoteHeldOnProductionBeta: beta is the one bakeable rung this door
+// can move, and a PRODUCTION app's beta head waits out the window before any
+// host adopts it. The response says so, so `carlos deploy` can report the hold
+// without a second call.
+func TestAPIPromoteHeldOnProductionBeta(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", "abc1234")
+ // The ladder: beta is entered from edge only.
+ seedChannel(t, fx, "titogo", "edge", "abc1234")
+ if err := release.SetProduction(context.Background(), fx.rel, fx.priv,
+ release.OpsAccount, "titogo", true, "test"); err != nil {
+ t.Fatal(err)
+ }
+ token := cliToken(t, fx, "paul@keymail.dev", "code-serial-held")
+
+ status, body := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote",
+ map[string]string{"version": "abc1234", "channel": "beta"}, token)
+ if status != http.StatusOK {
+ t.Fatalf("beta promote = %d %s", status, body)
+ }
+ if !held(t, body) {
+ t.Fatalf("beta promote on a production app = %s, want held true", body)
+ }
+ // The existing fields are untouched — `held` is additive.
+ if !strings.Contains(body, `"result":"promoted"`) {
+ t.Fatalf("promote body lost its result field: %s", body)
+ }
+}
+
+// TestAPIPromoteNotHeldOffProduction: the same beta promotion on an app that
+// has NOT declared production adopts immediately — nothing bakes — so held is
+// false. Canary never bakes at all, in either state.
+func TestAPIPromoteNotHeldOffProduction(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", "abc1234")
+ seedChannel(t, fx, "titogo", "edge", "abc1234")
+ token := cliToken(t, fx, "paul@keymail.dev", "code-serial-notheld")
+
+ status, body := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote",
+ map[string]string{"version": "abc1234", "channel": "beta"}, token)
+ if status != http.StatusOK {
+ t.Fatalf("beta promote = %d %s", status, body)
+ }
+ if held(t, body) {
+ t.Fatalf("beta promote on a non-production app = %s, want held false", body)
+ }
+
+ // Canary on the SAME app once production is on: the rung has no window,
+ // so the flag cannot make it held.
+ if err := release.SetProduction(context.Background(), fx.rel, fx.priv,
+ release.OpsAccount, "titogo", true, "test"); err != nil {
+ t.Fatal(err)
+ }
+ status, body = postRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote",
+ map[string]string{"version": "abc1234", "channel": "canary/x"}, token)
+ if status != http.StatusOK {
+ t.Fatalf("canary promote = %d %s", status, body)
+ }
+ if held(t, body) {
+ t.Fatalf("canary promote on a production app = %s, want held false — canaries never bake", body)
+ }
+}
+
+// TestAPIRollbackBumpsSerial: a rollback moves the same pointer a promote
+// does, and is the one people are in the biggest hurry about.
+func TestAPIRollbackBumpsSerial(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", "aaa1111")
+ shipVersion(t, fx, "titogo", "bbb2222")
+ seedChannel(t, fx, "titogo", "canary/x", "aaa1111")
+ seedChannel(t, fx, "titogo", "canary/x", "bbb2222")
+ token := cliToken(t, fx, "paul@keymail.dev", "code-serial-rollback")
+
+ before := serial(t, fx)
+ status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/rollback",
+ map[string]string{"channel": "canary/x"}, token)
+ if status != http.StatusOK {
+ t.Fatalf("rollback = %d %v", status, out)
+ }
+ if got := serial(t, fx); got != before+1 {
+ t.Fatalf("serial after rollback = %d, want %d", got, before+1)
+ }
+}
+
+// TestAPIConfigPutBumpsSerial: the config write the box's exec config-sync
+// leg consumes — the difference between an env var arriving in seconds and
+// arriving on the hour.
+func TestAPIConfigPutBumpsSerial(t *testing.T) {
+ fx, token := configFixture(t, "code-serial-config")
+ status, out := putJSON(t, configURL(fx, "titogo"),
+ map[string]any{"set": map[string]any{"PORT": map[string]any{"kind": "env", "value": "8080"}}}, token)
+ if status != http.StatusOK {
+ t.Fatalf("config put = %d %v", status, out)
+ }
+ if got := serial(t, fx); got != 1 {
+ t.Fatalf("serial after a config write = %d, want 1", got)
+ }
+}
+
+// TestAPIConfigSyncBumpsSerial: `carlos env sync` exists to make the fleet
+// converge NOW; the generation touch without the announcement would leave it
+// waiting on the hourly loop it was written to skip.
+func TestAPIConfigSyncBumpsSerial(t *testing.T) {
+ fx, token := configFixture(t, "code-serial-configsync")
+ status, out := postJSON(t, configURL(fx, "titogo")+"/sync", map[string]any{}, token)
+ if status != http.StatusOK {
+ t.Fatalf("config sync = %d %v", status, out)
+ }
+ if got := serial(t, fx); got != 1 {
+ t.Fatalf("serial after a config sync = %d, want 1", got)
+ }
+}
+
+// TestAPIInstanceCreateBumpsSerial: an instance record is what the box's
+// reconcile leg mints a route from, so the record write is box-consumed.
+func TestAPIInstanceCreateBumpsSerial(t *testing.T) {
+ fx, token := instanceFixture(t, "code-serial-instance")
+ enableInstances(t, fx, "titogo", titoManifest())
+
+ status, out := postJSON(t, instancesURL(fx, "bab", "titogo"),
+ map[string]any{"host": "acme.go.tito.io", "region": "ie"}, token)
+ if status != http.StatusCreated {
+ t.Fatalf("instance create = %d %v", status, out)
+ }
+ if got := serial(t, fx); got != 1 {
+ t.Fatalf("serial after an instance create = %d, want 1", got)
+ }
+
+ // And the delete, which retires the route the same loop reconciles.
+ req, err := http.NewRequest(http.MethodDelete, instancesURL(fx, "bab", "titogo")+"/acme.go.tito.io", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Header.Set("Authorization", "Bearer "+token)
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ res.Body.Close()
+ if res.StatusCode != http.StatusOK {
+ t.Fatalf("instance delete = %d", res.StatusCode)
+ }
+ if got := serial(t, fx); got != 2 {
+ t.Fatalf("serial after an instance delete = %d, want 2", got)
+ }
+}
+
+// TestAPIInstanceEnableBumpsSerial: the manifest carries the channel every
+// minted route follows, so changing it changes what boxes converge onto.
+func TestAPIInstanceEnableBumpsSerial(t *testing.T) {
+ fx, token := instanceFixture(t, "code-serial-enable")
+ fx.srv.cfg.AppsDomain = "oncarlos.com"
+
+ status, out := putJSON(t, enableURL(fx, "bab", "titogo"),
+ map[string]any{"channel": "beta"}, token)
+ if status != http.StatusOK {
+ t.Fatalf("instance enable = %d %v", status, out)
+ }
+ if got := serial(t, fx); got != 1 {
+ t.Fatalf("serial after instances enable = %d, want 1", got)
+ }
+}
+
+// TestWebPromoteBumpsSerial: the browser door writes the same pointer as the
+// API one — a promotion from the console UI must reach boxes just as fast.
+func TestWebPromoteBumpsSerial(t *testing.T) {
+ fx := newConsoleFixture(t)
+ shipVersion(t, fx, "kass", "v9")
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+
+ status, _, body := postForm(t, c, fx.u("/apps/kass/promote"), url.Values{
+ "csrf": {csrf}, "channel": {"canary/x"}, "version": {"v9"}, "confirmed": {"1"},
+ })
+ if status != http.StatusSeeOther {
+ t.Fatalf("web promote = %d, want 303:\n%s", status, body)
+ }
+ if got := serial(t, fx); got != 1 {
+ t.Fatalf("serial after a web promote = %d, want 1", got)
+ }
+}
+
+// TestWebProductionBumpsSerial: the production flag is read on every box's
+// adopt path (it decides whether a channel bakes at all), so flipping it is
+// box-consumed — and turning it OFF releases held versions, which is the
+// direction people wait on.
+func TestWebProductionBumpsSerial(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+
+ status, _, body := postForm(t, c, fx.u("/apps/kass/production"),
+ url.Values{"csrf": {csrf}, "production": {"on"}})
+ if status != http.StatusSeeOther {
+ t.Fatalf("production on = %d, want 303:\n%s", status, body)
+ }
+ if got := serial(t, fx); got != 1 {
+ t.Fatalf("serial after a production flip = %d, want 1", got)
+ }
+}
+
+// TestBumpSerialSurvivesABrokenStore: the whole contract in one test — a bump
+// against a store that cannot be written returns normally and logs. Every call
+// site depends on this: a user-facing write must never fail, or stall, because
+// its announcement did.
+func TestBumpSerialSurvivesABrokenStore(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.Releases = brokenStore{fx.rel}
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ fx.srv.bumpSerial(context.Background())
+ }()
+ select {
+ case <-done:
+ case <-time.After(10 * time.Second):
+ t.Fatal("bumpSerial did not return — a lost bump must never stall a write")
+ }
+}
diff --git a/internal/console/server.go b/internal/console/server.go
index eac1fd1df..072f5c222 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -1780,6 +1780,11 @@ func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request) {
}
}
}
+ // One announcement (carlos-deploy §3), placed after the optional flag flip
+ // rather than beside the promote's own audit row above, so it covers BOTH
+ // of this handler's box-consumed writes — the pointer and, when the
+ // checkbox was ticked, the production flag — in a single bump.
+ s.bumpSerial(ctx)
if baking && release.HoldFor(release.DefaultHolds, channel) > 0 {
msg += " Adopts after the bake window on each host — rollback never bakes."
}
@@ -1866,6 +1871,7 @@ func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request) {
return
}
s.audit(acct, app, addr, "rollback", "rolled back "+channel+" to "+target)
+ s.bumpSerial(ctx)
back("ok", "Rolled back "+channel+" to "+target+".")
}
@@ -1925,6 +1931,10 @@ func (s *Server) handleProduction(w http.ResponseWriter, r *http.Request) {
back("err", "Could not update the production flag: "+err.Error())
return
}
+ // The flag is read on every box's adopt path (it decides whether a channel
+ // bakes at all), so both directions are box-consumed. OFF especially: it
+ // releases held versions, and that is the direction people wait on.
+ s.bumpSerial(ctx)
if on {
s.audit(acct, app, addr, "production", "enabled the supply-chain bake (production: on)")
back("ok", "Supply-chain bake enabled — beta and stable now hold before hosts adopt.")
@@ -2015,6 +2025,9 @@ func (s *Server) handleFastTrack(w http.ResponseWriter, r *http.Request) {
return
}
s.audit(acct, app, addr, "fast-track", "waived the bake window for "+ver+" on "+channel)
+ // A waiver re-signs the pointer to say "adopt this now" — the one write
+ // whose entire point is that boxes act on it immediately.
+ s.bumpSerial(ctx)
back("ok", "Fast-tracked "+ver+" on "+channel+" — hosts adopt on their next pass.")
}
@@ -2116,6 +2129,9 @@ func (s *Server) handleNewCanary(w http.ResponseWriter, r *http.Request) {
return
}
s.audit(acct, app, addr, "canary-create", "created canary "+name+" at "+version)
+ // A channel pointer like any other: a route already following canary/<name>
+ // (attach-then-create is a real order) adopts it on the announced pass.
+ s.bumpSerial(ctx)
// Best-effort here: which URL to show in the success flash is cosmetic,
// so a registry hiccup just falls back to the generic "attach one" copy
// rather than failing the create that already succeeded.