carlos / platform Public

cli: carlos pipeline verb family; promote and rollback propose and carry -hotfix over the API

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev bc52489345181aa456167d7ff7854228c8bd8e68 parent fb0d965
9 files changed, +1288 −28
  • apimode.go +229 −11
  • apimode_test.go +5 −5
  • auth_e2e_test.go +1 −1
  • cli.go +16 −10
  • color.go +12 −0
  • deploy.go +3 −1
  • main.go +3 −0
  • pipeline.go +470 −0
  • pipeline_test.go +549 −0
diff --git a/apimode.go b/apimode.go
index fdec85bdb..cd39491ff 100644
--- a/apimode.go
+++ b/apimode.go
@@ -773,12 +773,17 @@ func (s *apiSession) postJSON(ctx context.Context, path string, in any) (int, []
return s.sendJSON(ctx, http.MethodPost, path, in)
}
-// stableRefusal decodes handleAPIPromote/handleAPIRollback's 403 shape
-// ({"error", "console_url"}) and, when it matches, returns the error the
-// caller should see: the console's own refusal text plus where to finish
-// the job — a passkey step-up has no bearer-token equivalent, so stable
-// promotion/rollback always ends here, never on this machine.
-func stableRefusal(status int, body []byte) (error, bool) {
+// consoleRefusal decodes the generalized {"error", "console_url"} 403 shape
+// every guarded write door in this family answers with — handleAPIPromote
+// and handleAPIRollback's stable/passkey refusal (apicli.go) and
+// handleAPIPipelinePut's own passkey-rule refusal (release pipelines v2
+// §5) alike — and, when it matches, returns the error the caller should
+// see: the console's own refusal text plus where to finish the job. A
+// passkey step-up has no bearer-token equivalent, so a channel that demands
+// one always ends here, never on this machine. Named for what it now covers
+// (not only stable) since Task 10 widened its call sites beyond promote and
+// rollback to `carlos pipeline`'s own writes.
+func consoleRefusal(status int, body []byte) (error, bool) {
if status != http.StatusForbidden {
return nil, false
}
@@ -792,17 +797,61 @@ func stableRefusal(status int, body []byte) (error, bool) {
return fmt.Errorf("%s\n %s", refusal.Error, refusal.ConsoleURL), true
}
+// apiProposedError is what a guarded write (promote, rollback, or a
+// `carlos pipeline` edit that touches an already-guarded channel) returns
+// instead of landing: the console answered 202, spec §4's bearer-token
+// shape — a token can never carry a passkey quorum, so the change is
+// recorded at zero confirmations and must be finished in the console. Every
+// caller here must check for this with errors.As and print it rather than
+// let it fall through to apiErrorFromBody, which would otherwise report the
+// misleading "unexpected response (status 202)" (the CLI-lies-about-its-
+// outcome bug this type exists to close).
+type apiProposedError struct {
+ PendingID string
+ Needed int
+ Confirmed int
+ ConsoleURL string
+}
+
+func (e *apiProposedError) Error() string {
+ return fmt.Sprintf("proposed — needs %d confirmation(s); finish at %s", e.Needed, e.ConsoleURL)
+}
+
+// proposedFromBody decodes a 202's body — proposeOverAPI's
+// {"result","pending_id","needed","confirmed","console_url"} shape
+// (pendinghandlers.go) — into an *apiProposedError. The body is decoded
+// best-effort: even an unreadable one still yields a usable error, because
+// the status code alone already says "proposed", and dropping the whole
+// thing here would lose the console_url an operator needs to actually
+// finish the job.
+func proposedFromBody(body []byte) error {
+ var res struct {
+ PendingID string `json:"pending_id"`
+ Needed int `json:"needed"`
+ Confirmed int `json:"confirmed"`
+ ConsoleURL string `json:"console_url"`
+ }
+ json.Unmarshal(body, &res)
+ return &apiProposedError{PendingID: res.PendingID, Needed: res.Needed, Confirmed: res.Confirmed, ConsoleURL: res.ConsoleURL}
+}
+
// promote asks the console to promote version onto channel. The response's
// "result" is the one field read here — "noop" when the ladder already had
// this version on this channel — because everything else the response
// carries (RUNG names in From/To) is not what the caller prints: the CLI
// already knows the version/channel it asked for and prints those, never
// res.From/res.To.
-func (s *apiSession) promote(ctx context.Context, a apiAcct, app, version, channel string) (apiPromoteResult, error) {
+//
+// hotfix rides along as a plain field (release pipelines v2, Task 10): the
+// 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) (apiPromoteResult, error) {
in := struct {
Version string `json:"version"`
Channel string `json:"channel"`
- }{version, channel}
+ Hotfix bool `json:"hotfix,omitempty"`
+ }{version, channel, hotfix}
status, body, err := s.postJSON(ctx, apiPath(a, app, "/promote"), in)
if err != nil {
return apiPromoteResult{}, err
@@ -817,7 +866,10 @@ func (s *apiSession) promote(ctx context.Context, a apiAcct, app, version, chann
}
return apiPromoteResult{NoOp: res.Result == "noop", Held: res.Held}, nil
}
- if refErr, ok := stableRefusal(status, body); ok {
+ if status == http.StatusAccepted {
+ return apiPromoteResult{}, proposedFromBody(body)
+ }
+ if refErr, ok := consoleRefusal(status, body); ok {
return apiPromoteResult{}, refErr
}
return apiPromoteResult{}, apiErrorFromBody(status, body)
@@ -878,7 +930,12 @@ func (s *apiSession) createApp(ctx context.Context, a apiAcct, app, place string
// rollback asks the console to roll channel back to its previous version.
// There is no noop case for a rollback — it either has a previous version to
-// land on (409 if not) or it doesn't — so this stays error-only.
+// land on (409 if not) or it doesn't. It DOES have a proposed case, exactly
+// as promote does: a pipelined channel that demands promote_approvals turns
+// this into a 202 (handleAPIRollback, apicli.go), which is why this can no
+// longer stay error-or-nil — *apiProposedError is a real, expected return
+// alongside a genuine failure, and runRollback tells them apart with
+// errors.As.
func (s *apiSession) rollback(ctx context.Context, a apiAcct, app, channel string) error {
in := struct {
Channel string `json:"channel"`
@@ -890,7 +947,10 @@ func (s *apiSession) rollback(ctx context.Context, a apiAcct, app, channel strin
if status == http.StatusOK {
return nil
}
- if refErr, ok := stableRefusal(status, body); ok {
+ if status == http.StatusAccepted {
+ return proposedFromBody(body)
+ }
+ if refErr, ok := consoleRefusal(status, body); ok {
return refErr
}
return apiErrorFromBody(status, body)
@@ -1626,6 +1686,164 @@ func (s *apiSession) configSync(ctx context.Context, a apiAcct, app string) erro
return nil
}
+// ---- pipeline + pending (release pipelines v2, Task 10) -------------------
+
+// apiPipelineChannel mirrors release.PipelineChannel/ChannelRules across the
+// wire, but FLAT rather than nested — Passkey/PromoteApprovals/
+// ChangeApprovals sit alongside Name/Bake here, one struct a `carlos
+// pipeline` verb can build and mutate directly, rather than a Rules
+// sub-struct every call site would have to reach through. MarshalJSON/
+// UnmarshalJSON below fold that flat shape back onto the server's nested
+// {"name","bake","rules":{"passkey","promote_approvals","change_approvals"}}
+// wire shape (internal/release/pipeline.go) exactly, field for field and
+// omitempty for omitempty — a template PUT (ruling: any template that sets a
+// rule also sets change_approvals) depends on this producing exactly the
+// same bytes handleAPIPipelinePut already accepts from the console's own
+// settings form.
+type apiPipelineChannel struct {
+ Name string
+ Bake string
+ Passkey bool
+ PromoteApprovals int
+ ChangeApprovals int
+}
+
+func (c apiPipelineChannel) MarshalJSON() ([]byte, error) {
+ type rules struct {
+ Passkey bool `json:"passkey,omitempty"`
+ PromoteApprovals int `json:"promote_approvals,omitempty"`
+ ChangeApprovals int `json:"change_approvals,omitempty"`
+ }
+ type wire struct {
+ Name string `json:"name"`
+ Bake string `json:"bake,omitempty"`
+ Rules *rules `json:"rules,omitempty"`
+ }
+ w := wire{Name: c.Name, Bake: c.Bake}
+ if c.Passkey || c.PromoteApprovals != 0 || c.ChangeApprovals != 0 {
+ w.Rules = &rules{Passkey: c.Passkey, PromoteApprovals: c.PromoteApprovals, ChangeApprovals: c.ChangeApprovals}
+ }
+ return json.Marshal(w)
+}
+
+func (c *apiPipelineChannel) UnmarshalJSON(b []byte) error {
+ var w struct {
+ Name string `json:"name"`
+ Bake string `json:"bake"`
+ Rules struct {
+ Passkey bool `json:"passkey"`
+ PromoteApprovals int `json:"promote_approvals"`
+ ChangeApprovals int `json:"change_approvals"`
+ } `json:"rules"`
+ }
+ if err := json.Unmarshal(b, &w); err != nil {
+ return err
+ }
+ c.Name, c.Bake = w.Name, w.Bake
+ c.Passkey, c.PromoteApprovals, c.ChangeApprovals = w.Rules.Passkey, w.Rules.PromoteApprovals, w.Rules.ChangeApprovals
+ return nil
+}
+
+// apiPipeline is GET/PUT .../pipeline's whole-body shape: the ordered
+// channel list, nothing else — Account/App/Actor/SetAt (release.Pipeline's
+// own extra fields) are server bookkeeping this client has no use for and
+// never sends back.
+type apiPipeline struct {
+ Channels []apiPipelineChannel `json:"channels"`
+}
+
+// pipelineGet reads an app's declared pipeline. A nil return (with a nil
+// error) is spec §2's genuine "no pipeline.json" case — GET answers 200 with
+// a null "pipeline" field for it (handleAPIPipelineGet) — never conflated
+// with a read failure, which comes back as a non-nil error instead.
+func (s *apiSession) pipelineGet(ctx context.Context, a apiAcct, app string) (*apiPipeline, error) {
+ status, body, err := s.getJSON(ctx, apiPath(a, app, "/pipeline"))
+ if err != nil {
+ return nil, err
+ }
+ if status != http.StatusOK {
+ return nil, apiErrorFromBody(status, body)
+ }
+ var res struct {
+ Pipeline *apiPipeline `json:"pipeline"`
+ }
+ if err := json.Unmarshal(body, &res); err != nil {
+ return nil, fmt.Errorf("pipeline: unreadable response: %w", err)
+ }
+ return res.Pipeline, nil
+}
+
+// pipelinePut replaces an app's channel list. Three outcomes, and a
+// `carlos pipeline` verb must tell them apart:
+//
+// - 200: the edit landed. The returned *apiPipeline is the console's own
+// echo of what it stored (SetAt included server-side, though this
+// client only reads Channels back out of it).
+// - 202: the edit touched a channel the CURRENTLY STORED pipeline already
+// guards with change_approvals (guardedEdit, apipipeline.go) — it
+// becomes a proposal at zero confirmations rather than landing. Returned
+// as *apiProposedError, not folded into the generic error path, so the
+// caller can print the "proposed" line instead of failing outright.
+// - anything else: a genuine refusal — validation (422), a lost CAS race
+// (409), a passkey-only channel with no approvals to propose against
+// (403, via consoleRefusal), or a store failure (502).
+func (s *apiSession) pipelinePut(ctx context.Context, a apiAcct, app string, channels []apiPipelineChannel) (*apiPipeline, error) {
+ in := struct {
+ Channels []apiPipelineChannel `json:"channels"`
+ }{channels}
+ status, body, err := s.putJSON(ctx, apiPath(a, app, "/pipeline"), in)
+ if err != nil {
+ return nil, err
+ }
+ switch status {
+ case http.StatusOK:
+ var res struct {
+ Pipeline apiPipeline `json:"pipeline"`
+ }
+ if err := json.Unmarshal(body, &res); err != nil {
+ return nil, fmt.Errorf("pipeline: unreadable response: %w", err)
+ }
+ return &res.Pipeline, nil
+ case http.StatusAccepted:
+ return nil, proposedFromBody(body)
+ }
+ if refErr, ok := consoleRefusal(status, body); ok {
+ return nil, refErr
+ }
+ return nil, apiErrorFromBody(status, body)
+}
+
+// apiPending is one row of `carlos pipeline`'s pending-changes list — the
+// CLI's read-only window onto a quorum in progress, since the CLI itself can
+// never confirm one (spec §4: confirmation is passkey-backed, browser-only).
+type apiPending struct {
+ ID string `json:"id"`
+ Kind string `json:"kind"`
+ Summary string `json:"summary"`
+ Confirmed int `json:"confirmed"`
+ Needed int `json:"needed"`
+}
+
+// pendingList reads what an app is waiting on. Always read-only and
+// Publish-reachable, like channels/releases (handleAPIPending's own doc
+// comment) — a pending change names no secret.
+func (s *apiSession) pendingList(ctx context.Context, a apiAcct, app string) ([]apiPending, error) {
+ status, body, err := s.getJSON(ctx, apiPath(a, app, "/pending"))
+ if err != nil {
+ return nil, err
+ }
+ if status != http.StatusOK {
+ return nil, apiErrorFromBody(status, body)
+ }
+ var res struct {
+ Pending []apiPending `json:"pending"`
+ }
+ if err := json.Unmarshal(body, &res); err != nil {
+ return nil, fmt.Errorf("pending: unreadable response: %w", err)
+ }
+ return res.Pending, nil
+}
+
// deleteJSON runs one DELETE and hands back the status and raw body without
// interpreting a non-200 as an error itself — getJSON's plumbing with the
// method swapped, for the instance-record retire door (issue #66), the one
diff --git a/apimode_test.go b/apimode_test.go
index e0bfd941a..3c6fb07c8 100644
--- a/apimode_test.go
+++ b/apimode_test.go
@@ -443,7 +443,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")
+ _, err := s.promote(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "canary", false)
if err == nil {
t.Fatal("promote against a stable refusal must error")
}
@@ -490,7 +490,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")
+ res, err := s.promote(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "canary/a", false)
if err != nil {
t.Fatalf("promote: %v", err)
}
@@ -511,7 +511,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")
+ res, err := s.promote(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "canary/a", false)
if err != nil {
t.Fatalf("promote: %v", err)
}
@@ -535,7 +535,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")
+ _, err := s.promote(context.Background(), apiAcct{Seg: "acct"}, "app", "9.9.9", "canary/a", 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)
}
@@ -555,7 +555,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")
+ _, err := s.promote(context.Background(), apiAcct{Seg: "nope"}, "app", "1.2.3", "canary/a", 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)
}
diff --git a/auth_e2e_test.go b/auth_e2e_test.go
index b743d1e9b..01e79e472 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"); err == nil {
+ if _, err := sess.promote(context.Background(), apiAcct{Seg: "ops"}, "titogo", "abc1234", "stable", false); err == nil {
t.Fatal("promote to stable must refuse")
} else {
if !strings.Contains(err.Error(), ts.URL) {
diff --git a/cli.go b/cli.go
index 102ffb7df..0d683e46d 100644
--- a/cli.go
+++ b/cli.go
@@ -2053,20 +2053,21 @@ func runPromote(args []string) {
log.Fatal(err)
}
if sess != nil {
- if *hotfix {
- // A hotfix bypasses the signed ladder's ordering check entirely —
- // an operator act, recorded as such (spec §3). The console's own
- // promote door (handleAPIPromote) has no hotfix field at all, so
- // there is no way to express this through the API even in
- // principle; refuse before ever dialing out, rather than let the
- // flag silently do nothing.
- log.Fatal("hotfix is an operator act — use the direct path (CARLOS_DEPLOYMENT_BUCKET) or the console")
- }
+ // -hotfix now travels over the wire (release pipelines v2, Task 10):
+ // handleAPIPromote reads it strictly after its own rules decision
+ // (its doc comment), so this is never itself a way past a channel's
+ // declared rules — only past the ladder's ORDER check, exactly as it
+ // is in direct mode below.
printUsing(stdout, sess)
acct := resolvedAccount(sess, acctRaw, acctIDRaw)
app2 := resolvedApp(sess, acct, *app, usage)
- res, err := sess.promote(context.Background(), acct, app2, version, channel)
+ res, err := sess.promote(context.Background(), acct, app2, version, channel, *hotfix)
if err != nil {
+ var prop *apiProposedError
+ if errors.As(err, &prop) {
+ printProposed(stdout, prop)
+ return
+ }
log.Fatal(err)
}
if res.NoOp {
@@ -2450,6 +2451,11 @@ func runRollback(args []string) {
acct := resolvedAccount(sess, acctRaw, acctIDRaw)
app2 := resolvedApp(sess, acct, *app, usage)
if err := sess.rollback(context.Background(), acct, app2, fs.Arg(0)); err != nil {
+ var prop *apiProposedError
+ if errors.As(err, &prop) {
+ printProposed(stdout, prop)
+ return
+ }
log.Fatal(err)
}
fmt.Fprintf(stdout, "%s %s %s\n", colorFor(stdout).ok("rolled back"), app2, fs.Arg(0))
diff --git a/color.go b/color.go
index 703055f83..7bd96b738 100644
--- a/color.go
+++ b/color.go
@@ -150,6 +150,18 @@ func printUsing(w io.Writer, sess *apiSession) {
fmt.Fprintln(w, c.muted(fmt.Sprintf("using %s as %s", sess.console, sess.address)))
}
+// printProposed is the one line every guarded write (promote, rollback, a
+// `carlos pipeline` edit) prints when the console answers 202 instead of
+// landing the change (release pipelines v2 §4, Task 10 controller ruling 1):
+// a bearer token can never carry a passkey quorum, so the change is
+// recorded rather than refused outright, and the operator's next step is the
+// console URL, not a retry of this same command.
+func printProposed(w io.Writer, e *apiProposedError) {
+ c := colorFor(w)
+ fmt.Fprintf(w, "%s — needs %d confirmation(s); finish at %s\n pending id %s\n",
+ c.warn("proposed"), e.Needed, e.ConsoleURL, e.PendingID)
+}
+
// errorWriter paints whole lines with the "something failed" style. Every
// verb in this binary reports failure through log.Fatal, so wrapping the
// standard logger's output once (main) colours all of them without touching
diff --git a/deploy.go b/deploy.go
index 19a872778..7df356e3b 100644
--- a/deploy.go
+++ b/deploy.go
@@ -203,7 +203,9 @@ func runDeploy(args []string) {
if err := runShipAPI(sess, acct, fs, *app, *ver, *kind, *target, "", "", capsSet, false); err != nil {
log.Fatalf("deploy: ship: %v", err)
}
- res, err := sess.promote(ctx, acct, *app, *ver, channel)
+ // `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)
if err != nil {
log.Fatalf("deploy: promote: %v", err) // a stable 403's console_url surfaces in err
}
diff --git a/main.go b/main.go
index 0029f3a87..9b5df302a 100644
--- a/main.go
+++ b/main.go
@@ -73,6 +73,8 @@ func main() {
runLogs(args)
case "channels":
runChannels(args)
+ case "pipeline":
+ runPipeline(args)
case "releases":
// One sub-verb, `retention` — the `carlos version target` judgment
// call: flags-only verbs stay flat until a word genuinely names a
@@ -155,6 +157,7 @@ Your apps — ship and run software on a CARLOS deployment
store object storage for an app: create | status | grant | rotate
rollback point a channel back at an earlier version
channels show what each of an app's channels is serving
+ pipeline show or shape an app's release channels
releases every version shipped, newest first, with labels and tags
version target the semver ship counts iterations under: print it, or set
it — "carlos version target -app <a> 0.5.0"
diff --git a/pipeline.go b/pipeline.go
new file mode 100644
index 000000000..d6cd07d7e
--- /dev/null
+++ b/pipeline.go
@@ -0,0 +1,470 @@
+// `carlos pipeline` — release pipelines v2
+// (docs/superpowers/specs/2026-08-20-release-pipelines-v2-design.md): show
+// or shape an app's declared channel list, and see what an edit is waiting
+// on.
+//
+// API mode only, deliberately, like fleets and logs: a pipeline is
+// console-tracked policy (spec §2/§4), with nothing in the bucket for a
+// direct-mode CLI to read or write in its place. Every subcommand composes
+// its whole write client-side — GET the current list, mutate it, PUT the
+// whole thing back — because the console is the validator and the gate
+// (guardedEdit, member-count ceiling, shape checks all live server-side);
+// this file never second-guesses any of that, it only builds the list a
+// human meant and lets the console answer.
+package main
+
+import (
+ "context"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "log"
+ "os"
+ "strings"
+)
+
+const pipelineUsage = `usage: carlos pipeline <command>
+
+commands:
+ show show an app's channels and any pending changes
+ (this is also what a bare "carlos pipeline"
+ runs)
+ init -template edge-production|full-ladder
+ replace the pipeline with a starter template
+ set -channel <name> [-bake <dur>] [-passkey[=false]]
+ [-promote-approvals N] [-change-approvals N]
+ edit one existing channel's bake window or
+ rules — flags not given are left as they were
+ add -channel <name> [-after <name>] [-bake <dur>] [-passkey]
+ [-promote-approvals N] [-change-approvals N]
+ insert a new channel (default: appended at the
+ end of the list; -after inserts right after
+ that existing channel)
+ remove -channel <name> drop a channel from the pipeline
+
+Every command above also takes [-app <a>] [-account <sqid-or-name>]
+[-account-id <sqid>] [-console <origin>] — the same account/app resolution
+every other API-mode verb uses (defaults apply when a flag is omitted and
+only one candidate exists).
+
+carlos pipeline works through a logged-in console (carlos auth login) —
+there is no bucket-direct pipeline editor to fall back to; -bake, -passkey,
+-promote-approvals and -change-approvals set release.ChannelRules exactly as
+the console's own pipeline settings page does. promote and rollback are
+unaffected in direct mode.
+
+set/add/remove all read the CURRENT channel list first and PUT the whole
+list back — an edit that touches a channel the stored pipeline already
+guards with -change-approvals does not land from here: the console turns it
+into a proposal instead (0 confirmations; finish with the printed console
+URL), exactly as it does for a guarded promote or rollback.
+`
+
+// runPipeline dispatches `carlos pipeline`'s subcommands. Unlike RunEnv/
+// RunFleets/RunLedger, an unrecognized first word is not itself an error —
+// it falls through to runPipelineShow's own flag parsing, which is where
+// "show" lives as the DEFAULT (bare `carlos pipeline`, or `carlos pipeline
+// -app foo` with no subcommand word at all) — the same shape runInstances
+// (cli.go) gives its own bare-listing default.
+func runPipeline(args []string) {
+ if len(args) > 0 {
+ switch args[0] {
+ case "init":
+ runPipelineInit(args[1:])
+ return
+ case "set":
+ runPipelineSet(args[1:])
+ return
+ case "add":
+ runPipelineAdd(args[1:])
+ return
+ case "remove":
+ runPipelineRemove(args[1:])
+ return
+ case "show":
+ runPipelineShow(args[1:])
+ return
+ }
+ }
+ runPipelineShow(args)
+}
+
+// pipelineSignedInSession resolves API mode and fatals with a
+// pipeline-specific message when this shell is in direct bucket mode —
+// mirrors fleetsSignedInSession (fleets.go) exactly: a pipeline is
+// console-tracked policy with nothing in the bucket to fall back to, so
+// there is no second, bucket-direct implementation of any of this to keep
+// in sync.
+func pipelineSignedInSession(consoleFlag string) *apiSession {
+ sess, err := resolveMode(consoleFlag)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if sess == nil {
+ log.Fatal("carlos pipeline works through a console, and this shell is in direct bucket mode\n" +
+ "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). A pipeline is console-tracked\n" +
+ "policy with no bucket-direct editor: unset the store env and run\n" +
+ "carlos auth login -console <url> instead. promote and rollback are unaffected.")
+ }
+ return sess
+}
+
+// pipelineFlagSet builds the -app/-account/-account-id/-console flags every
+// pipeline subcommand shares, with fs.Usage set to the shared doc block
+// (the "current style" this binary's more heavily-documented verbs use —
+// logs.go's runLogs is the model) so "-h" on any subcommand explains every
+// subcommand, not just the one typed.
+func pipelineFlagSet(name string) (fs *flag.FlagSet, app, acctRaw, acctIDRaw, consoleFlag *string) {
+ fs = flag.NewFlagSet(name, flag.ExitOnError)
+ app = fs.String("app", "", "app name")
+ // API mode never opens the local registry — the third return
+ // (accountFlags' direct-mode resolver) has no use here and is dropped.
+ acctRaw, acctIDRaw, _ = accountFlags(fs)
+ consoleFlag = fs.String("console", "", "console origin to work through (default: the only logged-in one)")
+ fs.Usage = func() { fmt.Fprint(os.Stderr, pipelineUsage) }
+ return fs, app, acctRaw, acctIDRaw, consoleFlag
+}
+
+// pipelineOrDefault is GetPipeline's (nil, nil) "no pipeline.json" case
+// turned into the same one-channel starter release.DefaultPipelineChannels()
+// gives the console's own settings page (pipelinepage.go) — so a fresh app's
+// `carlos pipeline show` reads the same "edge" starter row the console would
+// draft for it, and `set`/`add`/`remove` compose on top of that same
+// starting point rather than an empty list. Always returns a fresh slice:
+// callers mutate what they get back, and must never alias a cached response.
+func pipelineOrDefault(p *apiPipeline) []apiPipelineChannel {
+ if p == nil || len(p.Channels) == 0 {
+ return []apiPipelineChannel{{Name: "edge"}}
+ }
+ out := make([]apiPipelineChannel, len(p.Channels))
+ copy(out, p.Channels)
+ return out
+}
+
+// pipelineTemplate is the two starters spec §5 names, byte-for-byte what the
+// console's own settings page offers (edgeProductionTemplate/
+// fullLadderTemplate, internal/console/pipelinepage.go) — CONTROLLER RULING:
+// any template that sets a rule on a channel also sets change_approvals, so
+// a passkey nobody can strip with one bearer-token PUT.
+func pipelineTemplate(name string) ([]apiPipelineChannel, bool) {
+ switch name {
+ case "edge-production":
+ return []apiPipelineChannel{
+ {Name: "edge"},
+ {Name: "production", Bake: "24h", Passkey: true, ChangeApprovals: 1},
+ }, true
+ case "full-ladder":
+ return []apiPipelineChannel{
+ {Name: "edge"},
+ {Name: "beta", Bake: "24h"},
+ {Name: "stable", Bake: "72h", Passkey: true, ChangeApprovals: 1},
+ }, true
+ default:
+ return nil, false
+ }
+}
+
+// applyPipelinePut is set/add/remove/init's shared tail: PUT the composed
+// list, and tell a landed write, a proposal, and a genuine refusal apart —
+// the same errors.As check runPromote/runRollback use, so a
+// `carlos pipeline` edit that the console proposes instead of landing prints
+// the SAME "proposed" line those verbs print rather than the misleading
+// "unexpected response (status 202)" apiErrorFromBody would otherwise give
+// it.
+func applyPipelinePut(sess *apiSession, acct apiAcct, app string, channels []apiPipelineChannel) {
+ set, err := sess.pipelinePut(context.Background(), acct, app, channels)
+ if err != nil {
+ var prop *apiProposedError
+ if errors.As(err, &prop) {
+ printProposed(stdout, prop)
+ return
+ }
+ log.Fatal(err)
+ }
+ fmt.Fprintf(stdout, "%s the pipeline for %s\n", colorFor(stdout).ok("set"), app)
+ printPipelineChannels(stdout, set.Channels)
+}
+
+// printPipelineChannels is `show`'s multi-channel rendering, and every write
+// verb's confirmation echo: ORDER/BAKE/RULES, one row per channel, in
+// pipeline order — position IS the promotion order, which is why it is
+// spelled into the ORDER cell rather than left to be inferred from row
+// position alone (a table copied into a chat message still says "2 beta").
+func printPipelineChannels(w io.Writer, channels []apiPipelineChannel) {
+ t := newTable(w)
+ fmt.Fprintln(t, "ORDER\tBAKE\tRULES")
+ for i, c := range channels {
+ fmt.Fprintf(t, "%d %s\t%s\t%s\n", i+1, c.Name, pipelineBakeCell(c.Bake), pipelineRulesCell(c))
+ }
+ t.Flush()
+}
+
+func pipelineBakeCell(bake string) string {
+ if bake == "" {
+ return "-"
+ }
+ return bake
+}
+
+// pipelineRulesCell names a channel's ChannelRules in the same vocabulary
+// -promote-approvals/-change-approvals/-passkey use, so what a human reads
+// here is what they'd type to reproduce it.
+func pipelineRulesCell(c apiPipelineChannel) string {
+ var parts []string
+ if c.Passkey {
+ parts = append(parts, "passkey")
+ }
+ if c.PromoteApprovals > 0 {
+ parts = append(parts, fmt.Sprintf("promote:%d", c.PromoteApprovals))
+ }
+ if c.ChangeApprovals > 0 {
+ parts = append(parts, fmt.Sprintf("change:%d", c.ChangeApprovals))
+ }
+ if len(parts) == 0 {
+ return "-"
+ }
+ return strings.Join(parts, ", ")
+}
+
+// printPipelinePending renders `show`'s pending-changes section — nothing at
+// all when there is nothing pending, matching the console app page's own
+// card, which likewise only appears when PendingChangesFor returns rows.
+func printPipelinePending(w io.Writer, pending []apiPending) {
+ if len(pending) == 0 {
+ return
+ }
+ fmt.Fprintln(w)
+ fmt.Fprintln(w, colorFor(w).head("pending:"))
+ for _, pc := range pending {
+ fmt.Fprintf(w, " %s — %d/%d confirmed (id %s)\n", pc.Summary, pc.Confirmed, pc.Needed, pc.ID)
+ }
+}
+
+// runPipelineShow is `carlos pipeline` and `carlos pipeline show` alike —
+// see runPipeline's doc comment for why an unrecognized subcommand word also
+// lands here rather than being refused outright.
+func runPipelineShow(args []string) {
+ fs, app, acctRaw, acctIDRaw, consoleFlag := pipelineFlagSet("pipeline show")
+ fs.Parse(args)
+ if fs.NArg() != 0 {
+ log.Fatal(pipelineUsage)
+ }
+
+ sess := pipelineSignedInSession(*consoleFlag)
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ app2 := resolvedApp(sess, acct, *app, pipelineUsage)
+
+ p, err := sess.pipelineGet(context.Background(), acct, app2)
+ if err != nil {
+ log.Fatal(err)
+ }
+ channels := pipelineOrDefault(p)
+ // A single-channel pipeline (the "no pipeline.json" default, or a real
+ // one-channel pipeline someone composed down to that) is not shown as a
+ // one-row ladder table — there is no order or rule to tabulate yet, and
+ // a table with one row reads as ceremony around nothing (Task 10
+ // controller ruling 5). The hint names the one command that grows it.
+ if len(channels) == 1 {
+ fmt.Fprintf(stdout, "%s — one channel, no pipeline set (carlos pipeline init -template edge-production|full-ladder adds more)\n",
+ channels[0].Name)
+ } else {
+ printPipelineChannels(stdout, channels)
+ }
+
+ pending, err := sess.pendingList(context.Background(), acct, app2)
+ if err != nil {
+ log.Fatal(err)
+ }
+ printPipelinePending(stdout, pending)
+}
+
+// runPipelineInit replaces the whole pipeline with one of the two starter
+// templates spec §5 names — the console-side equivalent of clicking a
+// template button on the settings page, over the API.
+func runPipelineInit(args []string) {
+ fs, app, acctRaw, acctIDRaw, consoleFlag := pipelineFlagSet("pipeline init")
+ template := fs.String("template", "", "starter pipeline: edge-production | full-ladder")
+ fs.Parse(args)
+
+ channels, ok := pipelineTemplate(*template)
+ if !ok {
+ log.Fatal("usage: carlos pipeline init -template edge-production|full-ladder [-app <a>] [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>]")
+ }
+
+ sess := pipelineSignedInSession(*consoleFlag)
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ app2 := resolvedApp(sess, acct, *app, pipelineUsage)
+
+ applyPipelinePut(sess, acct, app2, channels)
+}
+
+// pipelinePolicyFlags are the four rule-shaping flags set and add share.
+// Registered once so their help text and names can never drift between the
+// two subcommands.
+func pipelinePolicyFlags(fs *flag.FlagSet) (bake *string, passkey *bool, promoteApprovals, changeApprovals *int) {
+ bake = fs.String("bake", "", "bake window before this channel is eligible to adopt (Go duration, e.g. 24h; \"\" clears it)")
+ passkey = fs.Bool("passkey", false, "require a console passkey to promote into or edit this channel; -passkey=false clears it")
+ promoteApprovals = fs.Int("promote-approvals", 0, "promotions into this channel need this many console confirmations (0 clears the rule)")
+ changeApprovals = fs.Int("change-approvals", 0, "pipeline edits that touch this channel need this many console confirmations (0 clears the rule)")
+ return bake, passkey, promoteApprovals, changeApprovals
+}
+
+// runPipelineSet edits ONE existing channel's bake window or rules. It GETs
+// the full current list, applies only the flags actually named on the
+// command line (fs.Visit — the same tri-state pattern runRoute's -hibernate/
+// -backing/-unit already use, cli.go) so an unmentioned flag never clobbers
+// a rule someone else set, and PUTs the whole list back.
+func runPipelineSet(args []string) {
+ fs, app, acctRaw, acctIDRaw, consoleFlag := pipelineFlagSet("pipeline set")
+ channel := fs.String("channel", "", "channel to edit")
+ bake, passkey, promoteApprovals, changeApprovals := pipelinePolicyFlags(fs)
+ fs.Parse(args)
+ usage := "usage: carlos pipeline set -channel <name> [-app <a>] [-bake <dur>] [-passkey[=false]] " +
+ "[-promote-approvals N] [-change-approvals N] [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>]"
+ if *channel == "" {
+ log.Fatal(usage)
+ }
+ bakeSet, passkeySet, promoteSet, changeSet := false, false, false, false
+ fs.Visit(func(f *flag.Flag) {
+ switch f.Name {
+ case "bake":
+ bakeSet = true
+ case "passkey":
+ passkeySet = true
+ case "promote-approvals":
+ promoteSet = true
+ case "change-approvals":
+ changeSet = true
+ }
+ })
+
+ sess := pipelineSignedInSession(*consoleFlag)
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ app2 := resolvedApp(sess, acct, *app, usage)
+
+ p, err := sess.pipelineGet(context.Background(), acct, app2)
+ if err != nil {
+ log.Fatal(err)
+ }
+ channels := pipelineOrDefault(p)
+ idx := -1
+ for i, c := range channels {
+ if c.Name == *channel {
+ idx = i
+ break
+ }
+ }
+ if idx == -1 {
+ log.Fatalf("pipeline set: %q is not a channel of this pipeline — carlos pipeline add -channel %s to create it", *channel, *channel)
+ }
+ if bakeSet {
+ channels[idx].Bake = *bake
+ }
+ if passkeySet {
+ channels[idx].Passkey = *passkey
+ }
+ if promoteSet {
+ channels[idx].PromoteApprovals = *promoteApprovals
+ }
+ if changeSet {
+ channels[idx].ChangeApprovals = *changeApprovals
+ }
+ applyPipelinePut(sess, acct, app2, channels)
+}
+
+// runPipelineAdd inserts a new channel. -after names an existing channel to
+// insert immediately behind; omitted, the new channel is appended at the
+// end (entering the pipeline as its newest, most-downstream rung — never
+// silently made the ENTRY channel, which -after edge would spell instead).
+func runPipelineAdd(args []string) {
+ fs, app, acctRaw, acctIDRaw, consoleFlag := pipelineFlagSet("pipeline add")
+ channel := fs.String("channel", "", "channel to add")
+ after := fs.String("after", "", "insert immediately after this existing channel (default: append at the end)")
+ bake, passkey, promoteApprovals, changeApprovals := pipelinePolicyFlags(fs)
+ fs.Parse(args)
+ usage := "usage: carlos pipeline add -channel <name> [-after <name>] [-app <a>] [-bake <dur>] [-passkey] " +
+ "[-promote-approvals N] [-change-approvals N] [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>]"
+ if *channel == "" {
+ log.Fatal(usage)
+ }
+
+ sess := pipelineSignedInSession(*consoleFlag)
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ app2 := resolvedApp(sess, acct, *app, usage)
+
+ p, err := sess.pipelineGet(context.Background(), acct, app2)
+ if err != nil {
+ log.Fatal(err)
+ }
+ channels := pipelineOrDefault(p)
+ for _, c := range channels {
+ if c.Name == *channel {
+ log.Fatalf("pipeline add: %q already exists in this pipeline — carlos pipeline set to edit it", *channel)
+ }
+ }
+ entry := apiPipelineChannel{
+ Name: *channel, Bake: *bake, Passkey: *passkey,
+ PromoteApprovals: *promoteApprovals, ChangeApprovals: *changeApprovals,
+ }
+ pos := len(channels)
+ if *after != "" {
+ pos = -1
+ for i, c := range channels {
+ if c.Name == *after {
+ pos = i + 1
+ break
+ }
+ }
+ if pos == -1 {
+ log.Fatalf("pipeline add: -after %q does not name an existing channel of this pipeline", *after)
+ }
+ }
+ next := make([]apiPipelineChannel, 0, len(channels)+1)
+ next = append(next, channels[:pos]...)
+ next = append(next, entry)
+ next = append(next, channels[pos:]...)
+ applyPipelinePut(sess, acct, app2, next)
+}
+
+// runPipelineRemove drops one channel from the pipeline. It never refuses on
+// emptying the list to zero channels itself — SetPipeline's own validation
+// (internal/release/pipeline.go) is where that shape check belongs, exactly
+// as it is the one place every other pipeline shape rule is enforced.
+func runPipelineRemove(args []string) {
+ fs, app, acctRaw, acctIDRaw, consoleFlag := pipelineFlagSet("pipeline remove")
+ channel := fs.String("channel", "", "channel to remove")
+ fs.Parse(args)
+ usage := "usage: carlos pipeline remove -channel <name> [-app <a>] [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>]"
+ if *channel == "" {
+ log.Fatal(usage)
+ }
+
+ sess := pipelineSignedInSession(*consoleFlag)
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ app2 := resolvedApp(sess, acct, *app, usage)
+
+ p, err := sess.pipelineGet(context.Background(), acct, app2)
+ if err != nil {
+ log.Fatal(err)
+ }
+ channels := pipelineOrDefault(p)
+ next := make([]apiPipelineChannel, 0, len(channels))
+ found := false
+ for _, c := range channels {
+ if c.Name == *channel {
+ found = true
+ continue
+ }
+ next = append(next, c)
+ }
+ if !found {
+ log.Fatalf("pipeline remove: %q is not a channel of this pipeline", *channel)
+ }
+ applyPipelinePut(sess, acct, app2, next)
+}
diff --git a/pipeline_test.go b/pipeline_test.go
new file mode 100644
index 000000000..28948b6c5
--- /dev/null
+++ b/pipeline_test.go
@@ -0,0 +1,549 @@
+// Tests for `carlos pipeline` (Task 10, release pipelines v2) and the
+// promote/rollback proposed-response handling it shares with promote and
+// rollback. In-process, stub-console pattern (TestPromoteVerbAPIMode,
+// apimode_test.go) throughout: useTempCredentials/clearDeploymentEnv/
+// requireNoRegistryTouch/loginTo/captureStdout, an httptest server standing
+// in for the console.
+package main
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "reflect"
+ "strings"
+ "sync/atomic"
+ "testing"
+)
+
+// TestPipelineShowVerbAPIMode pins `carlos pipeline show`'s read path: it
+// GETs the pipeline and the pending list, and renders a multi-channel
+// pipeline as a table naming each channel's order, bake and rules, with any
+// pending change listed beneath.
+func TestPipelineShowVerbAPIMode(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ checkNoRegistry := requireNoRegistryTouch(t)
+
+ var gotPipelinePath, gotPendingPath string
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) {
+ gotPipelinePath = r.URL.Path
+ json.NewEncoder(w).Encode(map[string]any{"pipeline": map[string]any{
+ "channels": []map[string]any{
+ {"name": "edge"},
+ {"name": "production", "bake": "24h", "rules": map[string]any{"passkey": true, "change_approvals": 1}},
+ },
+ }})
+ })
+ mux.HandleFunc("GET /api/cli/apps/"+apiTestAccount+"/titogo/pending", func(w http.ResponseWriter, r *http.Request) {
+ gotPendingPath = r.URL.Path
+ json.NewEncoder(w).Encode(map[string]any{"pending": []map[string]any{
+ {"id": "pc1", "kind": "policy", "summary": "change the pipeline to edge → production", "confirmed": 0, "needed": 1},
+ }})
+ })
+ ts := httptest.NewServer(mux)
+ defer ts.Close()
+
+ loginTo(t, ts.URL, "vicky@keymail.dev")
+
+ buf := captureStdout(t)
+ runPipeline([]string{"show", "-app", "titogo", "-account", apiTestAccount})
+
+ out := buf.String()
+ if gotPipelinePath != "/api/cli/apps/"+apiTestAccount+"/titogo/pipeline" {
+ t.Fatalf("pipeline GET path = %q", gotPipelinePath)
+ }
+ if gotPendingPath != "/api/cli/apps/"+apiTestAccount+"/titogo/pending" {
+ t.Fatalf("pending GET path = %q", gotPendingPath)
+ }
+ if want := "using " + ts.URL + " as vicky@keymail.dev"; !strings.Contains(out, want) {
+ t.Fatalf("stdout = %q, want it to contain %q", out, want)
+ }
+ edgeAt := strings.Index(out, "edge")
+ prodAt := strings.Index(out, "production")
+ if edgeAt < 0 || prodAt < 0 || edgeAt > prodAt {
+ t.Fatalf("stdout = %q, want edge listed before production", out)
+ }
+ if !strings.Contains(out, "24h") {
+ t.Fatalf("stdout = %q, want the bake window", out)
+ }
+ if !strings.Contains(out, "passkey") || !strings.Contains(out, "change:1") {
+ t.Fatalf("stdout = %q, want the rules rendered", out)
+ }
+ if !strings.Contains(out, "change the pipeline to edge → production") || !strings.Contains(out, "0/1") || !strings.Contains(out, "pc1") {
+ t.Fatalf("stdout = %q, want the pending change listed with its progress and id", out)
+ }
+ checkNoRegistry()
+}
+
+// TestPipelineShowOneChannelNoLadderCeremony pins controller ruling 5: a
+// one-channel pipeline (here, the "no pipeline.json" default) prints a
+// single line, not a one-row table, with a hint that init grows it.
+func TestPipelineShowOneChannelNoLadderCeremony(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ checkNoRegistry := requireNoRegistryTouch(t)
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) {
+ json.NewEncoder(w).Encode(map[string]any{"pipeline": nil})
+ })
+ mux.HandleFunc("GET /api/cli/apps/"+apiTestAccount+"/titogo/pending", func(w http.ResponseWriter, r *http.Request) {
+ json.NewEncoder(w).Encode(map[string]any{"pending": []map[string]any{}})
+ })
+ ts := httptest.NewServer(mux)
+ defer ts.Close()
+
+ loginTo(t, ts.URL, "vicky@keymail.dev")
+
+ buf := captureStdout(t)
+ runPipeline([]string{"show", "-app", "titogo", "-account", apiTestAccount})
+
+ out := buf.String()
+ if !strings.Contains(out, "edge") {
+ t.Fatalf("stdout = %q, want the single channel named", out)
+ }
+ if !strings.Contains(out, "carlos pipeline init") {
+ t.Fatalf("stdout = %q, want the init hint", out)
+ }
+ if strings.Contains(out, "ORDER") || strings.Contains(out, "RULES") {
+ t.Fatalf("stdout = %q, must not render ladder-table ceremony for one channel", out)
+ }
+ checkNoRegistry()
+}
+
+// TestPipelineInitTemplateAPIMode pins the edge-production template's exact
+// wire shape (controller ruling 3): a template that sets a rule also sets
+// change_approvals, and promote_approvals (never set by either template) is
+// omitted entirely rather than sent as 0.
+func TestPipelineInitTemplateAPIMode(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ checkNoRegistry := requireNoRegistryTouch(t)
+
+ var reqCount int32
+ var gotBody []byte
+ mux := http.NewServeMux()
+ mux.HandleFunc("PUT /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) {
+ atomic.AddInt32(&reqCount, 1)
+ var buf [4096]byte
+ n, _ := r.Body.Read(buf[:])
+ gotBody = buf[:n]
+ json.NewEncoder(w).Encode(map[string]any{"pipeline": map[string]any{
+ "channels": []map[string]any{
+ {"name": "edge"},
+ {"name": "production", "bake": "24h", "rules": map[string]any{"passkey": true, "change_approvals": 1}},
+ },
+ }})
+ })
+ ts := httptest.NewServer(mux)
+ defer ts.Close()
+
+ loginTo(t, ts.URL, "vicky@keymail.dev")
+
+ buf := captureStdout(t)
+ runPipeline([]string{"init", "-template", "edge-production", "-app", "titogo", "-account", apiTestAccount})
+
+ if got := atomic.LoadInt32(&reqCount); got != 1 {
+ t.Fatalf("server got %d PUT requests, want 1", got)
+ }
+
+ var sent struct {
+ Channels []map[string]any `json:"channels"`
+ }
+ if err := json.Unmarshal(gotBody, &sent); err != nil {
+ t.Fatalf("PUT body unreadable: %v (%s)", err, gotBody)
+ }
+ want := []map[string]any{
+ {"name": "edge"},
+ {"name": "production", "bake": "24h", "rules": map[string]any{"passkey": true, "change_approvals": float64(1)}},
+ }
+ if !reflect.DeepEqual(sent.Channels, want) {
+ t.Fatalf("PUT channels = %#v, want %#v", sent.Channels, want)
+ }
+ if !strings.Contains(buf.String(), "titogo") {
+ t.Fatalf("stdout = %q, want it to name the app", buf.String())
+ }
+ checkNoRegistry()
+}
+
+// TestPipelineFullLadderTemplateAPIMode pins the second template's exact
+// shape alongside the first, per controller ruling 3.
+func TestPipelineFullLadderTemplateAPIMode(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ checkNoRegistry := requireNoRegistryTouch(t)
+
+ var gotBody []byte
+ mux := http.NewServeMux()
+ mux.HandleFunc("PUT /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) {
+ var buf [4096]byte
+ n, _ := r.Body.Read(buf[:])
+ gotBody = buf[:n]
+ json.NewEncoder(w).Encode(map[string]any{"pipeline": map[string]any{"channels": []map[string]any{}}})
+ })
+ ts := httptest.NewServer(mux)
+ defer ts.Close()
+
+ loginTo(t, ts.URL, "vicky@keymail.dev")
+
+ captureStdout(t)
+ runPipeline([]string{"init", "-template", "full-ladder", "-app", "titogo", "-account", apiTestAccount})
+
+ var sent struct {
+ Channels []map[string]any `json:"channels"`
+ }
+ if err := json.Unmarshal(gotBody, &sent); err != nil {
+ t.Fatalf("PUT body unreadable: %v (%s)", err, gotBody)
+ }
+ want := []map[string]any{
+ {"name": "edge"},
+ {"name": "beta", "bake": "24h"},
+ {"name": "stable", "bake": "72h", "rules": map[string]any{"passkey": true, "change_approvals": float64(1)}},
+ }
+ if !reflect.DeepEqual(sent.Channels, want) {
+ t.Fatalf("PUT channels = %#v, want %#v", sent.Channels, want)
+ }
+ checkNoRegistry()
+}
+
+// TestPipelineSetComposesFullList pins set's compose-then-PUT contract: it
+// GETs the current list, mutates ONLY the named channel's ONE named flag
+// (-bake), and PUTs every channel back — including the untouched one
+// verbatim, and the edited one's OTHER rules (passkey, change_approvals)
+// preserved rather than clobbered by the flags this invocation didn't name.
+func TestPipelineSetComposesFullList(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ checkNoRegistry := requireNoRegistryTouch(t)
+
+ var gotBody []byte
+ var getCount, putCount int32
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) {
+ atomic.AddInt32(&getCount, 1)
+ json.NewEncoder(w).Encode(map[string]any{"pipeline": map[string]any{
+ "channels": []map[string]any{
+ {"name": "edge"},
+ {"name": "production", "bake": "24h", "rules": map[string]any{"passkey": true, "change_approvals": 1}},
+ },
+ }})
+ })
+ mux.HandleFunc("PUT /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) {
+ atomic.AddInt32(&putCount, 1)
+ var buf [4096]byte
+ n, _ := r.Body.Read(buf[:])
+ gotBody = buf[:n]
+ json.NewEncoder(w).Encode(map[string]any{"pipeline": map[string]any{"channels": []map[string]any{}}})
+ })
+ ts := httptest.NewServer(mux)
+ defer ts.Close()
+
+ loginTo(t, ts.URL, "vicky@keymail.dev")
+
+ captureStdout(t)
+ runPipeline([]string{"set", "-channel", "production", "-bake", "48h", "-app", "titogo", "-account", apiTestAccount})
+
+ if got := atomic.LoadInt32(&getCount); got != 1 {
+ t.Fatalf("server got %d GET requests, want 1", got)
+ }
+ if got := atomic.LoadInt32(&putCount); got != 1 {
+ t.Fatalf("server got %d PUT requests, want 1", got)
+ }
+
+ var sent struct {
+ Channels []map[string]any `json:"channels"`
+ }
+ if err := json.Unmarshal(gotBody, &sent); err != nil {
+ t.Fatalf("PUT body unreadable: %v (%s)", err, gotBody)
+ }
+ want := []map[string]any{
+ {"name": "edge"},
+ {"name": "production", "bake": "48h", "rules": map[string]any{"passkey": true, "change_approvals": float64(1)}},
+ }
+ if !reflect.DeepEqual(sent.Channels, want) {
+ t.Fatalf("PUT channels = %#v, want %#v (bake changed, everything else preserved)", sent.Channels, want)
+ }
+ checkNoRegistry()
+}
+
+// TestPipelineAddInsertsAfter pins add's -after insertion and its own
+// compose-then-PUT contract: it GETs the current list and PUTs it back with
+// the new channel spliced in immediately after the named one, the existing
+// channels otherwise untouched.
+func TestPipelineAddInsertsAfter(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ checkNoRegistry := requireNoRegistryTouch(t)
+
+ var gotBody []byte
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) {
+ json.NewEncoder(w).Encode(map[string]any{"pipeline": map[string]any{
+ "channels": []map[string]any{{"name": "edge"}, {"name": "stable", "bake": "72h"}},
+ }})
+ })
+ mux.HandleFunc("PUT /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) {
+ var buf [4096]byte
+ n, _ := r.Body.Read(buf[:])
+ gotBody = buf[:n]
+ json.NewEncoder(w).Encode(map[string]any{"pipeline": map[string]any{"channels": []map[string]any{}}})
+ })
+ ts := httptest.NewServer(mux)
+ defer ts.Close()
+
+ loginTo(t, ts.URL, "vicky@keymail.dev")
+
+ captureStdout(t)
+ runPipeline([]string{"add", "-channel", "beta", "-after", "edge", "-bake", "24h",
+ "-app", "titogo", "-account", apiTestAccount})
+
+ var sent struct {
+ Channels []map[string]any `json:"channels"`
+ }
+ if err := json.Unmarshal(gotBody, &sent); err != nil {
+ t.Fatalf("PUT body unreadable: %v (%s)", err, gotBody)
+ }
+ want := []map[string]any{
+ {"name": "edge"},
+ {"name": "beta", "bake": "24h"},
+ {"name": "stable", "bake": "72h"},
+ }
+ if !reflect.DeepEqual(sent.Channels, want) {
+ t.Fatalf("PUT channels = %#v, want %#v", sent.Channels, want)
+ }
+ checkNoRegistry()
+}
+
+// TestPipelineRemoveDropsChannel pins remove's compose-then-PUT contract:
+// it GETs the current list and PUTs it back without the named channel, the
+// rest untouched and in the same order.
+func TestPipelineRemoveDropsChannel(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ checkNoRegistry := requireNoRegistryTouch(t)
+
+ var gotBody []byte
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) {
+ json.NewEncoder(w).Encode(map[string]any{"pipeline": map[string]any{
+ "channels": []map[string]any{{"name": "edge"}, {"name": "beta", "bake": "24h"}, {"name": "stable", "bake": "72h"}},
+ }})
+ })
+ mux.HandleFunc("PUT /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) {
+ var buf [4096]byte
+ n, _ := r.Body.Read(buf[:])
+ gotBody = buf[:n]
+ json.NewEncoder(w).Encode(map[string]any{"pipeline": map[string]any{"channels": []map[string]any{}}})
+ })
+ ts := httptest.NewServer(mux)
+ defer ts.Close()
+
+ loginTo(t, ts.URL, "vicky@keymail.dev")
+
+ captureStdout(t)
+ runPipeline([]string{"remove", "-channel", "beta", "-app", "titogo", "-account", apiTestAccount})
+
+ var sent struct {
+ Channels []map[string]any `json:"channels"`
+ }
+ if err := json.Unmarshal(gotBody, &sent); err != nil {
+ t.Fatalf("PUT body unreadable: %v (%s)", err, gotBody)
+ }
+ want := []map[string]any{
+ {"name": "edge"},
+ {"name": "stable", "bake": "72h"},
+ }
+ if !reflect.DeepEqual(sent.Channels, want) {
+ t.Fatalf("PUT channels = %#v, want %#v", sent.Channels, want)
+ }
+ checkNoRegistry()
+}
+
+// TestPipelinePutProposedPrintsConsoleURL pins a guarded pipeline PUT's own
+// 202 handling — the same proposed-response contract as promote/rollback,
+// exercised through `carlos pipeline set` this time.
+func TestPipelinePutProposedPrintsConsoleURL(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ checkNoRegistry := requireNoRegistryTouch(t)
+
+ consoleURL := ""
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) {
+ json.NewEncoder(w).Encode(map[string]any{"pipeline": map[string]any{
+ "channels": []map[string]any{
+ {"name": "edge"},
+ {"name": "production", "bake": "24h", "rules": map[string]any{"passkey": true, "change_approvals": 1}},
+ },
+ }})
+ })
+ mux.HandleFunc("PUT /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusAccepted)
+ json.NewEncoder(w).Encode(map[string]any{
+ "result": "proposed", "pending_id": "pc-99", "needed": 1, "confirmed": 0,
+ "console_url": consoleURL,
+ })
+ })
+ ts := httptest.NewServer(mux)
+ defer ts.Close()
+ consoleURL = ts.URL + "/a/" + apiTestAccount + "/apps/titogo"
+
+ loginTo(t, ts.URL, "vicky@keymail.dev")
+
+ buf := captureStdout(t)
+ runPipeline([]string{"set", "-channel", "production", "-bake", "48h", "-app", "titogo", "-account", apiTestAccount})
+
+ out := buf.String()
+ if !strings.Contains(out, "proposed") || !strings.Contains(out, consoleURL) || !strings.Contains(out, "pc-99") {
+ t.Fatalf("stdout = %q, want proposed + console URL + pending id", out)
+ }
+ checkNoRegistry()
+}
+
+// TestPromoteProposedPrintsConsoleURL pins runPromote's handling of a 202: a
+// guarded channel's PROMOTE_APPROVALS rule turns the request into a
+// proposal, and the CLI must say so — naming how many confirmations it
+// needs and where to finish — rather than reporting the generic "unexpected
+// response (status 202)" (the CLI-lies-about-its-outcome bug this task
+// exists to close).
+func TestPromoteProposedPrintsConsoleURL(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ checkNoRegistry := requireNoRegistryTouch(t)
+
+ consoleURL := ""
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusAccepted)
+ json.NewEncoder(w).Encode(map[string]any{
+ "result": "proposed", "pending_id": "pc-42", "needed": 2, "confirmed": 0,
+ "console_url": consoleURL,
+ })
+ }))
+ defer ts.Close()
+ consoleURL = ts.URL + "/a/" + apiTestAccount + "/apps/titogo"
+
+ loginTo(t, ts.URL, "vicky@keymail.dev")
+
+ buf := captureStdout(t)
+ runPromote([]string{"-app", "titogo", "-account", apiTestAccount, "abc1234", "production"})
+
+ out := buf.String()
+ if !strings.Contains(out, "proposed") {
+ t.Fatalf("stdout = %q, want it to say proposed", out)
+ }
+ if !strings.Contains(out, "2") {
+ t.Fatalf("stdout = %q, want the confirmations-needed count (2)", out)
+ }
+ if !strings.Contains(out, consoleURL) {
+ t.Fatalf("stdout = %q, want the console URL %q", out, consoleURL)
+ }
+ checkNoRegistry()
+}
+
+// TestRollbackProposedPrintsConsoleURL is TestPromoteProposedPrintsConsoleURL's
+// twin for `carlos rollback` (controller ruling 1): Task 8 added the 202 on
+// the server side, but runRollback still reported the generic "unexpected
+// response (status 202)" until this task extended it exactly as promote was
+// extended.
+func TestRollbackProposedPrintsConsoleURL(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ checkNoRegistry := requireNoRegistryTouch(t)
+
+ consoleURL := ""
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusAccepted)
+ json.NewEncoder(w).Encode(map[string]any{
+ "result": "proposed", "pending_id": "pc-43", "needed": 1, "confirmed": 0,
+ "console_url": consoleURL,
+ })
+ }))
+ defer ts.Close()
+ consoleURL = ts.URL + "/a/" + apiTestAccount + "/apps/titogo"
+
+ loginTo(t, ts.URL, "vicky@keymail.dev")
+
+ buf := captureStdout(t)
+ runRollback([]string{"-app", "titogo", "-account", apiTestAccount, "production"})
+
+ out := buf.String()
+ if !strings.Contains(out, "proposed") {
+ t.Fatalf("stdout = %q, want it to say proposed", out)
+ }
+ if !strings.Contains(out, "1") {
+ t.Fatalf("stdout = %q, want the confirmations-needed count (1)", out)
+ }
+ if !strings.Contains(out, consoleURL) {
+ t.Fatalf("stdout = %q, want the console URL %q", out, consoleURL)
+ }
+ if !strings.Contains(out, "pc-43") {
+ t.Fatalf("stdout = %q, want the pending id", out)
+ }
+ checkNoRegistry()
+}
+
+// TestPromoteHotfixSentToAPI pins controller ruling 2: -hotfix is no longer
+// refused in API mode (cli.go used to log.Fatal before ever dialing out,
+// claiming the console "has no hotfix field at all" — now false), and the
+// field actually reaches the wire.
+func TestPromoteHotfixSentToAPI(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ checkNoRegistry := requireNoRegistryTouch(t)
+
+ var reqCount int32
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ atomic.AddInt32(&reqCount, 1)
+ var req struct {
+ Version string `json:"version"`
+ Channel string `json:"channel"`
+ Hotfix bool `json:"hotfix"`
+ }
+ json.NewDecoder(r.Body).Decode(&req)
+ if !req.Hotfix {
+ t.Errorf("hotfix = %v, want true", req.Hotfix)
+ }
+ if req.Version != "abc1234" || req.Channel != "stable" {
+ t.Errorf("promote body = %+v", req)
+ }
+ json.NewEncoder(w).Encode(map[string]any{"result": "promoted", "from": "", "to": ""})
+ }))
+ defer ts.Close()
+
+ loginTo(t, ts.URL, "vicky@keymail.dev")
+
+ buf := captureStdout(t)
+ runPromote([]string{"-app", "titogo", "-account", apiTestAccount, "-hotfix", "abc1234", "stable"})
+
+ if got := atomic.LoadInt32(&reqCount); got != 1 {
+ t.Fatalf("server got %d requests, want 1", got)
+ }
+ if want := "promoted titogo abc1234 -> stable"; !strings.Contains(buf.String(), want) {
+ t.Fatalf("stdout = %q, want %q", buf.String(), want)
+ }
+ checkNoRegistry()
+}
+
+// TestPipelineDirectModeRefused pins the direct/bucket-mode refusal: there
+// is no bucket-side pipeline editor, so a `carlos pipeline` verb must name
+// the console requirement rather than silently doing nothing or panicking
+// on a nil session. Exercised at the resolveMode level, mirroring how
+// runPromote's stable refusal is pinned in auth_e2e_test.go — runPipeline
+// itself calls log.Fatal (os.Exit) on this path, which an in-process test
+// cannot survive.
+func TestPipelineDirectModeRefused(t *testing.T) {
+ dir := t.TempDir()
+ t.Setenv("CARLOS_DEPLOYMENT_DIR", dir)
+ t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "")
+ t.Setenv("CARLOS_CONSOLE", "")
+
+ sess, err := resolveMode("")
+ if err != nil {
+ t.Fatalf("resolveMode: %v", err)
+ }
+ if sess != nil {
+ t.Fatal("want direct mode (nil session) with CARLOS_DEPLOYMENT_DIR set")
+ }
+}