| index 4de87d8c3..2880051b1 100644 |
| --- a/internal/console/apicli.go |
| +++ b/internal/console/apicli.go |
| @@ -352,36 +352,50 @@ func (s *Server) handleAPIPromote(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| if res.NoOp { |
| - writeJSON(w, http.StatusOK, map[string]any{"result": "noop"}) |
| + // held on the no-op too, and this is not decoration: re-running the |
| + // same deploy (a CI retry, a repeated `carlos deploy`) against a |
| + // production app whose beta head is mid-bake is a NO-OP THAT IS STILL |
| + // HELD. A no-op answer without the field decodes as held:false and |
| + // sends the client off to watch for an adopt that is a day away — |
| + // exactly the false failure this field exists to prevent. Nothing is |
| + // announced here (no bump): the pointer did not move. |
| + writeJSON(w, http.StatusOK, map[string]any{ |
| + "result": "noop", "held": s.channelHeld(ctx, acct.Sqid, app, channel), |
| + }) |
| 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, "held": held, |
| + "result": "promoted", "from": res.From, "to": res.To, "noop": false, |
| + "held": s.channelHeld(ctx, acct.Sqid, app, channel), |
| }) |
| } |
| |
| +// channelHeld answers the CLI's next question without a second round trip: is |
| +// what this channel now carries 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 on the promote door: canary/* and edge have |
| +// zero-length windows, and stable never reaches it (the rung gate 403s it). |
| +// 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. |
| +func (s *Server) channelHeld(ctx context.Context, acct, app, channel string) bool { |
| + if release.HoldFor(release.DefaultHolds, channel) == 0 { |
| + return false |
| + } |
| + flag, err := release.GetProduction(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct, app) |
| + if err != nil { |
| + log.Printf("console: api promote production flag %s: %v", app, err) |
| + return true |
| + } |
| + return flag != nil && flag.Production |
| +} |
| + |
| // handleAPIRollback answers `carlos rollback`: the CLI's write door onto the |
| // same swap-to-previous-version primitive as the console's own rollback |
| // button (handleRollback, server.go). Below stable there is no rung gate, |