carlos / platform Public

promote --retry: re-sign a promotion so a box retries a refused build

With --retry, a repeat promotion of the version a channel already names is re-signed with a fresh PromotedAt (waiting into the next second, since PromotedAt is truncated), so a box's D1 refusal, which blocks one promotion, lets the retry through. Retry onto a different version or an empty channel is refused. Plumbed through the CLI flag, the API session and the console promote door, under the same Authorize checks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev a13bd63fedb44e31b00fbb3951a36a5181739cdd parent fb6d12f
11 files changed, +124 −17
  • apimode.go +3 −2
  • apimode_test.go +6 −6
  • auth_e2e_test.go +1 −1
  • canary.go +1 −1
  • cli.go +3 −2
  • deploy.go +1 −1
  • internal/console/apicli.go +5 −1
  • internal/console/apicli_test.go +29 −0
  • internal/release/release.go +32 −2
  • internal/release/release_test.go +42 −0
  • mutation_message_test.go +1 −1
diff --git a/apimode.go b/apimode.go
index 7ed847883..409353694 100644
--- a/apimode.go
+++ b/apimode.go
@@ -944,14 +944,15 @@ func proposedFromBody(body []byte) error {
// console's promote door reads it strictly after its own rules decision
// (handleAPIPromote's doc comment), so sending it is never itself a bypass
// of anything this call site doesn't already ask for.
-func (s *apiSession) promote(ctx context.Context, a apiAcct, app, version, channel string, hotfix bool, messages ...string) (apiPromoteResult, error) {
+func (s *apiSession) promote(ctx context.Context, a apiAcct, app, version, channel string, hotfix, retry bool, messages ...string) (apiPromoteResult, error) {
message := firstMutationMessage(messages)
in := struct {
Version string `json:"version"`
Channel string `json:"channel"`
Hotfix bool `json:"hotfix,omitempty"`
+ Retry bool `json:"retry,omitempty"`
Message string `json:"message,omitempty"`
- }{version, channel, hotfix, message}
+ }{version, channel, hotfix, retry, message}
status, body, err := s.postJSON(ctx, apiPath(a, app, "/promote"), in)
if err != nil {
return apiPromoteResult{}, err
diff --git a/apimode_test.go b/apimode_test.go
index 28e2e7717..0c7ab101b 100644
--- a/apimode_test.go
+++ b/apimode_test.go
@@ -446,7 +446,7 @@ func TestAPISessionStableRefusal(t *testing.T) {
defer ts.Close()
s := newTestSession(ts.URL)
- _, err := s.promote(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "canary", false)
+ _, err := s.promote(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "canary", false, false)
if err == nil {
t.Fatal("promote against a stable refusal must error")
}
@@ -493,7 +493,7 @@ func TestAPISessionPromoteSuccess(t *testing.T) {
defer ts.Close()
s := newTestSession(ts.URL)
- res, err := s.promote(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "canary/a", false)
+ res, err := s.promote(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "canary/a", false, false)
if err != nil {
t.Fatalf("promote: %v", err)
}
@@ -514,7 +514,7 @@ func TestAPISessionPromoteNoOp(t *testing.T) {
defer ts.Close()
s := newTestSession(ts.URL)
- res, err := s.promote(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "canary/a", false)
+ res, err := s.promote(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "canary/a", false, false)
if err != nil {
t.Fatalf("promote: %v", err)
}
@@ -538,7 +538,7 @@ func TestAPISessionPromoteUnshippedVersionSurfacesServerText(t *testing.T) {
defer ts.Close()
s := newTestSession(ts.URL)
- _, err := s.promote(context.Background(), apiAcct{Seg: "acct"}, "app", "9.9.9", "canary/a", false)
+ _, err := s.promote(context.Background(), apiAcct{Seg: "acct"}, "app", "9.9.9", "canary/a", false, false)
if err == nil || !strings.Contains(err.Error(), "not a shipped release") {
t.Fatalf("promote 404 with a JSON diagnosis = %v, want the server's own text", err)
}
@@ -558,7 +558,7 @@ func TestAPISessionPromoteAccountGateNotFound(t *testing.T) {
defer ts.Close()
s := newTestSession(ts.URL)
- _, err := s.promote(context.Background(), apiAcct{Seg: "nope"}, "app", "1.2.3", "canary/a", false)
+ _, err := s.promote(context.Background(), apiAcct{Seg: "nope"}, "app", "1.2.3", "canary/a", false, false)
if err == nil || !strings.Contains(err.Error(), "not found") || !strings.Contains(err.Error(), "-account") {
t.Fatalf("promote against the account/app gate 404 = %v, want the membership message", err)
}
@@ -4028,7 +4028,7 @@ func TestCarlosDeployRoutinePathUsesOnlyDeployDoors(t *testing.T) {
_, err := s.ship(ctx, a, "app", "1.2.3", "binary", "", "", map[string]string{"alpha-amd64": file}, nil)
return err
}},
- {"promote", func() error { _, err := s.promote(ctx, a, "app", "1.2.3", "canary", false); return err }},
+ {"promote", func() error { _, err := s.promote(ctx, a, "app", "1.2.3", "canary", false, false); return err }},
{"rollback", func() error { return s.rollback(ctx, a, "app", "canary") }},
{"channels", func() error { _, err := s.channels(ctx, a, "app"); return err }},
{"instanceList", func() error { _, _, _, err := s.instanceList(ctx, a, "app", "", ""); return err }},
diff --git a/auth_e2e_test.go b/auth_e2e_test.go
index 6d2189118..38bc15301 100644
--- a/auth_e2e_test.go
+++ b/auth_e2e_test.go
@@ -551,7 +551,7 @@ func TestE2ECLIThroughConsole(t *testing.T) {
// apiSession level instead: the exact call runPromote's API-mode branch
// makes, against the same real server, over the same credential.
sess := &apiSession{c: newConsoleClient(ts.URL, token), console: ts.URL, address: addr}
- if _, err := sess.promote(context.Background(), apiAcct{Seg: "ops"}, "titogo", "abc1234", "stable", false); err == nil {
+ if _, err := sess.promote(context.Background(), apiAcct{Seg: "ops"}, "titogo", "abc1234", "stable", false, false); err == nil {
t.Fatal("promote to stable must refuse")
} else {
if !strings.Contains(err.Error(), ts.URL) {
diff --git a/canary.go b/canary.go
index 0ad379f0f..02f9fd1d1 100644
--- a/canary.go
+++ b/canary.go
@@ -644,7 +644,7 @@ func runCanaryDeploy(args []string) {
// Never refused — on any app, from any rung, pipelined or not. hotfix is
// false because there is no ladder rule here to bypass.
- if _, err = sess.promote(ctx, acct, app2, *ver, channel, false); err != nil {
+ if _, err = sess.promote(ctx, acct, app2, *ver, channel, false, false); err != nil {
log.Fatalf("canary: promote %s to %s: %v", *ver, channel, err)
}
fmt.Fprintf(stdout, "%s %s to %s\n", pal.ok("promoted"), *ver, channel)
diff --git a/cli.go b/cli.go
index f96163eed..6c92cf1a9 100644
--- a/cli.go
+++ b/cli.go
@@ -2354,6 +2354,7 @@ func runPromote(args []string) {
messageFlags := addMutationMessageFlags(fs)
app := appFlag(fs, "")
hotfix := fs.Bool("hotfix", false, "bypass the channel ladder (recorded as a hotfix)")
+ retry := fs.Bool("retry", false, "re-sign the channel's current promotion of this version with a fresh time, so a box that refused it tries again")
acctRaw, acctIDRaw, acctOf := accountFlags(fs)
consoleFlag := consoleOption(fs, "console origin to promote through (API mode only; default: the only logged-in one)")
fs.Usage = flagUsage(fs, "")
@@ -2394,7 +2395,7 @@ func runPromote(args []string) {
if merr != nil {
log.Fatal(merr)
}
- res, err := sess.promote(context.Background(), acct, app2, version, channel, *hotfix, message)
+ res, err := sess.promote(context.Background(), acct, app2, version, channel, *hotfix, *retry, message)
if err != nil {
var prop *apiProposedError
if errors.As(err, &prop) {
@@ -2416,7 +2417,7 @@ func runPromote(args []string) {
}
res, err := releaseSigner().PromoteOnLadder(context.Background(), openStore(),
acctOf(), *app, channel, version,
- release.PromoteOptions{Hotfix: *hotfix, Actor: promoteActor()})
+ release.PromoteOptions{Hotfix: *hotfix, Retry: *retry, Actor: promoteActor()})
if err != nil {
log.Fatal(err)
}
diff --git a/deploy.go b/deploy.go
index fb820eb6e..3d71ff985 100644
--- a/deploy.go
+++ b/deploy.go
@@ -303,7 +303,7 @@ func runDeploy(args []string) {
}
// `carlos deploy` carries no --hotfix flag of its own — it always ships in
// order, so this is a plain false, never a refusal.
- res, err := sess.promote(ctx, acct, *app, *ver, channel, false, message)
+ res, err := sess.promote(ctx, acct, *app, *ver, channel, false, false, message)
if err != nil {
// A guarded channel turns promote into a 202 proposal (release
// pipelines v2 §4) rather than a landed write — *apiProposedError,
diff --git a/internal/console/apicli.go b/internal/console/apicli.go
index 338e42ee3..191948298 100644
--- a/internal/console/apicli.go
+++ b/internal/console/apicli.go
@@ -741,6 +741,10 @@ func (s *Server) handleAPIPromote(w http.ResponseWriter, r *http.Request) {
// Hotfix is spec §5's order bypass, and it is read nowhere above the
// rules decision below — see this handler's doc comment.
Hotfix bool `json:"hotfix"`
+ // Retry renews the channel's current promotion of this version with a
+ // fresh PromotedAt. It goes through the same rules, scope and
+ // Authorize checks as any promote.
+ Retry bool `json:"retry"`
}
if !readJSON(w, r, &req) {
return
@@ -897,7 +901,7 @@ func (s *Server) handleAPIPromote(w http.ResponseWriter, r *http.Request) {
}
}
res, err := signer.PromoteOnLadder(ctx, s.cfg.Releases, acct.Sqid, app, channel, version,
- release.PromoteOptions{Actor: addr, Hotfix: hotfix, Authorize: authorize})
+ release.PromoteOptions{Actor: addr, Hotfix: hotfix, Retry: req.Retry, Authorize: authorize})
if err != nil {
var ae *release.AuthorizationError
if errors.As(err, &ae) {
diff --git a/internal/console/apicli_test.go b/internal/console/apicli_test.go
index 33a57533e..5c99143d0 100644
--- a/internal/console/apicli_test.go
+++ b/internal/console/apicli_test.go
@@ -2786,3 +2786,32 @@ func TestAPIShipRefusesAnUnreadableAppRow(t *testing.T) {
t.Fatal("the release was stored despite the 503")
}
}
+
+// TestAPIPromoteRetryResignsThePointer: `carlos promote --retry` over the API
+// door re-signs a channel's current promotion with a fresh PromotedAt, so a
+// box that refused that promotion tries it again. The version never moves.
+func TestAPIPromoteRetryResignsThePointer(t *testing.T) {
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ shipVersion(t, fx, "titogo", "abc1234")
+ token := cliToken(t, fx, "paul@keymail.dev", "code-promote-retry")
+
+ url := fx.ts.URL + "/api/cli/apps/bab/titogo/promote"
+ if status, out := postJSON(t, url, map[string]string{"version": "abc1234", "channel": "canary/x"}, token); status != http.StatusOK {
+ t.Fatalf("first promote = %d %v", status, out)
+ }
+ before := fx.channelVersion(t, "titogo", "canary/x")
+ time.Sleep(1100 * time.Millisecond) // PromotedAt is truncated to the second
+
+ status, out := postJSON(t, url, map[string]any{"version": "abc1234", "channel": "canary/x", "retry": true}, token)
+ if status != http.StatusOK {
+ t.Fatalf("retry = %d %v, want 200", status, out)
+ }
+ after := fx.channelVersion(t, "titogo", "canary/x")
+ if after.Version != "abc1234" || !after.PromotedAt.After(before.PromotedAt) {
+ t.Fatalf("pointer after retry = %+v, want abc1234 with a later PromotedAt than %v", after, before.PromotedAt)
+ }
+}
diff --git a/internal/release/release.go b/internal/release/release.go
index 579f9e253..ab18f828b 100644
--- a/internal/release/release.go
+++ b/internal/release/release.go
@@ -156,8 +156,12 @@ type Promotion struct {
Hotfix bool `json:"hotfix,omitempty"`
// FastTrack records that this write waived the bake window — same
// reasoning as Hotfix: a bypass nobody can see is a broken gate.
- FastTrack bool `json:"fast_track,omitempty"`
- At time.Time `json:"at"`
+ FastTrack bool `json:"fast_track,omitempty"`
+ // Retry records that this write only renewed an existing promotion of
+ // the same version, so a box's refusal of the earlier one no longer
+ // blocks it.
+ Retry bool `json:"retry,omitempty"`
+ At time.Time `json:"at"`
}
// historyMax bounds a pointer's rollback history; auditMax bounds its
@@ -789,6 +793,10 @@ type PromoteOptions struct {
// past a gate, and recorded as such (spec §3: a bypass that is invisible
// is indistinguishable from a broken gate).
Hotfix bool
+ // Retry re-signs a pointer that already names this version, with a
+ // fresh PromotedAt: the way past a box's D1 refusal (refusal.go), which
+ // blocks one promotion, not a version. Never changes the version.
+ Retry bool
// Actor is who is promoting, recorded in the pointer's audit trail: a
// console member's address, or the operator's user@host at the CLI.
Actor string
@@ -920,6 +928,17 @@ func (sg Signer) PromoteOnLadder(ctx context.Context, s Store, acct, app, channe
return res, &AuthorizationError{Err: aerr}
}
}
+ if opt.Retry {
+ res.From = res.To
+ rec := Promotion{From: res.To, To: res.To, Actor: opt.Actor, Retry: true}
+ if !time.Now().UTC().Truncate(time.Second).After(p.PromotedAt) {
+ time.Sleep(time.Until(p.PromotedAt.Add(time.Second)))
+ }
+ if err := sg.promote(ctx, s, acct, app, channel, version, opt.Authorize, rec); err != nil {
+ return res, err
+ }
+ return res, nil
+ }
res.NoOp = true
// The same fallback res.To takes above, for the same reason: the
// build IS on this channel (that is what makes this a repeat), so
@@ -935,6 +954,17 @@ func (sg Signer) PromoteOnLadder(ctx context.Context, s Store, acct, app, channe
} else if !errors.Is(err, os.ErrNotExist) {
return res, fmt.Errorf("promote: read %s: %w", ChannelKey(acct, app, channel), err)
}
+ // Reached only when the pointer is absent or names another version: a
+ // retry renews a promotion, it never makes one.
+ if opt.Retry {
+ cur := "nothing"
+ if c, _, err := s.Get(ctx, ChannelKey(acct, app, channel)); err == nil {
+ if p, verr := verifyPointer(pub, c, acct, app, channel); verr == nil {
+ cur = p.Version
+ }
+ }
+ return res, fmt.Errorf("promote --retry renews a promotion: %s names %s, not %s", channel, cur, version)
+ }
// An app with NOTHING under its account-qualified prefix cannot be
// promoted — Promote needs a shipped manifest there — so refuse now, while
diff --git a/internal/release/release_test.go b/internal/release/release_test.go
index 841f4ffa2..25b9726c3 100644
--- a/internal/release/release_test.go
+++ b/internal/release/release_test.go
@@ -827,3 +827,45 @@ func TestValidateManifestLabelNotesLimits(t *testing.T) {
t.Fatal("overlong notes accepted")
}
}
+
+func TestPromoteRetryRenewsPromotedAt(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ shipRelease(t, s, "bab", "console", "v1", adoptPlatform, []byte("b"))
+ if _, err := PromoteOnLadder(ctx, s, priv, "bab", "console", "canary/x", "v1", PromoteOptions{}); err != nil {
+ t.Fatal(err)
+ }
+ before, _ := Channel(ctx, s, pub, "bab", "console", "canary/x")
+ time.Sleep(1100 * time.Millisecond) // PromotedAt is truncated to the second
+ res, err := PromoteOnLadder(ctx, s, priv, "bab", "console", "canary/x", "v1", PromoteOptions{})
+ if err != nil || !res.NoOp {
+ t.Fatalf("plain repeat = %+v, %v; want the existing no-op", res, err)
+ }
+ same, _ := Channel(ctx, s, pub, "bab", "console", "canary/x")
+ if !same.PromotedAt.Equal(before.PromotedAt) {
+ t.Fatal("a plain repeat rewrote the pointer")
+ }
+ if _, err := PromoteOnLadder(ctx, s, priv, "bab", "console", "canary/x", "v1", PromoteOptions{Retry: true}); err != nil {
+ t.Fatalf("retry: %v", err)
+ }
+ after, _ := Channel(ctx, s, pub, "bab", "console", "canary/x")
+ if after.Version != "v1" || !after.PromotedAt.After(before.PromotedAt) {
+ t.Fatalf("after retry = %+v, want v1 with a later PromotedAt", after)
+ }
+}
+
+func TestPromoteRetryNeverChangesTheVersion(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, _ := testKeys(t)
+ shipRelease(t, s, "bab", "console", "v1", adoptPlatform, []byte("a"))
+ shipRelease(t, s, "bab", "console", "v2", adoptPlatform, []byte("b"))
+ if _, err := PromoteOnLadder(ctx, s, priv, "bab", "console", "canary/x", "v1", PromoteOptions{}); err != nil {
+ t.Fatal(err)
+ }
+ _, err := PromoteOnLadder(ctx, s, priv, "bab", "console", "canary/x", "v2", PromoteOptions{Retry: true})
+ if err == nil || !strings.Contains(err.Error(), "renews a promotion") {
+ t.Fatalf("retry onto a different version = %v, want a refusal", err)
+ }
+}
diff --git a/mutation_message_test.go b/mutation_message_test.go
index d8b5cba0f..bf1349533 100644
--- a/mutation_message_test.go
+++ b/mutation_message_test.go
@@ -120,7 +120,7 @@ func TestMutationMessagesTravelOnJSONWrites(t *testing.T) {
if _, err := s.restart(ctx, a, "titogo", "restart after the config change"); err != nil {
t.Fatal(err)
}
- if _, err := s.promote(ctx, a, "titogo", "abc1234", "edge", false, "release the new checkout"); err != nil {
+ if _, err := s.promote(ctx, a, "titogo", "abc1234", "edge", false, false, "release the new checkout"); err != nil {
t.Fatal(err)
}
if err := s.rollback(ctx, a, "titogo", "edge", "restore the previous checkout"); err != nil {