carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// `carlos pipeline` — release pipelines v2
2// (docs/superpowers/specs/2026-08-20-release-pipelines-v2-design.md): show
3// or shape an app's declared channel list, and see what an edit is waiting
4// on.
5//
6// API mode only, deliberately, like fleets and logs: a pipeline is
7// console-tracked policy (spec §2/§4), with nothing in the bucket for a
8// direct-mode CLI to read or write in its place. Every subcommand composes
9// its whole write client-side — GET the current list, mutate it, PUT the
10// whole thing back — because the console is the validator and the gate
11// (guardedEdit, member-count ceiling, shape checks all live server-side);
12// this file never second-guesses any of that, it only builds the list a
13// human meant and lets the console answer.
14package main
15
16import (
17 "context"
18 "errors"
19 "flag"
20 "fmt"
21 "io"
22 "log"
23 "strings"
24)
25
26const pipelineUsage = `usage: carlos pipeline <command>
27
28commands:
29 show show an app's channels and any pending changes
30 (this is also what a bare "carlos pipeline"
31 runs)
32 init --template edge-production|full-ladder
33 replace the pipeline with a starter template
34 set --channel <name> [--bake <dur>] [--passkey[=false]]
35 [--promote-approvals N] [--change-approvals N]
36 edit one existing channel's bake window or
37 rules — flags not given are left as they were
38 add --channel <name> [--after <name>] [--bake <dur>] [--passkey]
39 [--promote-approvals N] [--change-approvals N]
40 insert a new channel (default: appended at the
41 end of the list; --after inserts right after
42 that existing channel)
43 remove --channel <name> drop a channel from the pipeline
44
45Every command above also takes [--app <a>] [--account <sqid-or-name>]
46[--account-id <sqid>] [--console <origin>] — the same account/app resolution
47every other API-mode verb uses (defaults apply when a flag is omitted and
48only one candidate exists).
49
50carlos pipeline works through a logged-in console (carlos auth login) —
51there is no bucket-direct pipeline editor to fall back to; --bake, --passkey,
52--promote-approvals and --change-approvals set release.ChannelRules exactly as
53the console's own pipeline settings page does. promote and rollback are
54unaffected in direct mode.
55
56set/add/remove all read the CURRENT channel list first and PUT the whole
57list back — an edit that touches a channel the stored pipeline already
58guards with --change-approvals does not land from here: the console turns it
59into a proposal instead (0 confirmations; finish with the printed console
60URL), exactly as it does for a guarded promote or rollback.
61`
62
63// runPipeline dispatches `carlos pipeline`'s subcommands. Unlike RunEnv/
64// RunFleets/RunLedger, an unrecognized first word is not itself an error —
65// it falls through to runPipelineShow's own flag parsing, which is where
66// "show" lives as the DEFAULT (bare `carlos pipeline`, or `carlos pipeline
67// --app foo` with no subcommand word at all) — the same shape runInstances
68// (cli.go) gives its own bare-listing default.
69func runPipeline(args []string) {
70 if len(args) > 0 {
71 switch args[0] {
72 case "init":
73 runPipelineInit(args[1:])
74 return
75 case "set":
76 runPipelineSet(args[1:])
77 return
78 case "add":
79 runPipelineAdd(args[1:])
80 return
81 case "remove":
82 runPipelineRemove(args[1:])
83 return
84 case "show":
85 runPipelineShow(args[1:])
86 return
87 }
88 }
89 runPipelineShow(args)
90}
91
92// pipelineSignedInSession resolves API mode and fatals with a
93// pipeline-specific message when this shell is in direct bucket mode —
94// mirrors fleetsSignedInSession (fleets.go) exactly: a pipeline is
95// console-tracked policy with nothing in the bucket to fall back to, so
96// there is no second, bucket-direct implementation of any of this to keep
97// in sync.
98func pipelineSignedInSession(consoleFlag string) *apiSession {
99 sess, err := resolveMode(consoleFlag)
100 if err != nil {
101 log.Fatal(err)
102 }
103 if sess == nil {
104 log.Fatal("carlos pipeline works through a console, and this shell is in direct bucket mode\n" +
105 "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). A pipeline is console-tracked\n" +
106 "policy with no bucket-direct editor: unset the store env and run\n" +
107 "carlos auth login --console <url> instead. promote and rollback are unaffected.")
108 }
109 return sess
110}
111
112// pipelineFlagSet builds the --app/--account/--account-id/--console flags every
113// pipeline subcommand shares, with fs.Usage set to the shared doc block
114// (the "current style" this binary's more heavily-documented verbs use —
115// logs.go's runLogs is the model) so "-h" on any subcommand explains every
116// subcommand, not just the one typed.
117func pipelineFlagSet(name string) (fs *flag.FlagSet, app, acctRaw, acctIDRaw, consoleFlag *string) {
118 fs = flag.NewFlagSet(name, flag.ExitOnError)
119 app = appFlag(fs, "")
120 // API mode never opens the local registry — the third return
121 // (accountFlags' direct-mode resolver) has no use here and is dropped.
122 acctRaw, acctIDRaw, _ = accountFlags(fs)
123 consoleFlag = consoleOption(fs, "console origin to work through (default: the only logged-in one)")
124 fs.Usage = flagUsage(fs, pipelineUsage)
125 return fs, app, acctRaw, acctIDRaw, consoleFlag
126}
127
128// pipelineOrDefault is GetPipeline's (nil, nil) "no pipeline.json" case
129// turned into the same one-channel starter release.DefaultPipelineChannels()
130// gives the console's own settings page (pipelinepage.go) — so a fresh app's
131// `carlos pipeline show` reads the same "edge" starter row the console would
132// draft for it, and `set`/`add`/`remove` compose on top of that same
133// starting point rather than an empty list. Always returns a fresh slice:
134// callers mutate what they get back, and must never alias a cached response.
135func pipelineOrDefault(p *apiPipeline) []apiPipelineChannel {
136 if p == nil || len(p.Channels) == 0 {
137 return []apiPipelineChannel{{Name: "edge"}}
138 }
139 out := make([]apiPipelineChannel, len(p.Channels))
140 copy(out, p.Channels)
141 return out
142}
143
144// pipelineTemplate is the two starters spec §5 names, byte-for-byte what the
145// console's own settings page offers (edgeProductionTemplate/
146// fullLadderTemplate, internal/console/pipelinepage.go) — CONTROLLER RULING:
147// any template that sets a rule on a channel also sets change_approvals, so
148// a passkey nobody can strip with one bearer-token PUT.
149func pipelineTemplate(name string) ([]apiPipelineChannel, bool) {
150 switch name {
151 case "edge-production":
152 return []apiPipelineChannel{
153 {Name: "edge"},
154 {Name: "production", Bake: "24h", Passkey: true, ChangeApprovals: 1},
155 }, true
156 case "full-ladder":
157 return []apiPipelineChannel{
158 {Name: "edge"},
159 {Name: "beta", Bake: "24h"},
160 {Name: "stable", Bake: "72h", Passkey: true, ChangeApprovals: 1},
161 }, true
162 default:
163 return nil, false
164 }
165}
166
167// applyPipelinePut is set/add/remove/init's shared tail: PUT the composed
168// list, and tell a landed write, a proposal, and a genuine refusal apart —
169// the same errors.As check runPromote/runRollback use, so a
170// `carlos pipeline` edit that the console proposes instead of landing prints
171// the SAME "proposed" line those verbs print rather than the misleading
172// "unexpected response (status 202)" apiErrorFromBody would otherwise give
173// it.
174func applyPipelinePut(sess *apiSession, acct apiAcct, app string, channels []apiPipelineChannel) {
175 set, err := sess.pipelinePut(context.Background(), acct, app, channels)
176 if err != nil {
177 var prop *apiProposedError
178 if errors.As(err, &prop) {
179 printProposed(stdout, prop)
180 return
181 }
182 log.Fatal(err)
183 }
184 fmt.Fprintf(stdout, "%s the pipeline for %s\n", colorFor(stdout).ok("set"), app)
185 printPipelineChannels(stdout, set.Channels)
186}
187
188// printPipelineChannels is `show`'s multi-channel rendering, and every write
189// verb's confirmation echo: ORDER/BAKE/RULES, one row per channel, in
190// pipeline order — position IS the promotion order, which is why it is
191// spelled into the ORDER cell rather than left to be inferred from row
192// position alone (a table copied into a chat message still says "2 beta").
193func printPipelineChannels(w io.Writer, channels []apiPipelineChannel) {
194 t := newTable(w)
195 fmt.Fprintln(t, "ORDER\tBAKE\tRULES")
196 for i, c := range channels {
197 fmt.Fprintf(t, "%d %s\t%s\t%s\n", i+1, c.Name, pipelineBakeCell(c.Bake), pipelineRulesCell(c))
198 }
199 t.Flush()
200}
201
202func pipelineBakeCell(bake string) string {
203 if bake == "" {
204 return "-"
205 }
206 return bake
207}
208
209// pipelineRulesCell names a channel's ChannelRules in the same vocabulary
210// --promote-approvals/--change-approvals/--passkey use, so what a human reads
211// here is what they'd type to reproduce it.
212func pipelineRulesCell(c apiPipelineChannel) string {
213 var parts []string
214 if c.Passkey {
215 parts = append(parts, "passkey")
216 }
217 if c.PromoteApprovals > 0 {
218 parts = append(parts, fmt.Sprintf("promote:%d", c.PromoteApprovals))
219 }
220 if c.ChangeApprovals > 0 {
221 parts = append(parts, fmt.Sprintf("change:%d", c.ChangeApprovals))
222 }
223 if len(parts) == 0 {
224 return "-"
225 }
226 return strings.Join(parts, ", ")
227}
228
229// printPipelinePending renders `show`'s pending-changes section — nothing at
230// all when there is nothing pending, matching the console app page's own
231// card, which likewise only appears when PendingChangesFor returns rows.
232func printPipelinePending(w io.Writer, pending []apiPending) {
233 if len(pending) == 0 {
234 return
235 }
236 fmt.Fprintln(w)
237 fmt.Fprintln(w, colorFor(w).head("pending:"))
238 for _, pc := range pending {
239 fmt.Fprintf(w, " %s — %d/%d confirmed (id %s)\n", pc.Summary, pc.Confirmed, pc.Needed, pc.ID)
240 }
241}
242
243// runPipelineShow is `carlos pipeline` and `carlos pipeline show` alike —
244// see runPipeline's doc comment for why an unrecognized subcommand word also
245// lands here rather than being refused outright.
246func runPipelineShow(args []string) {
247 fs, app, acctRaw, acctIDRaw, consoleFlag := pipelineFlagSet("pipeline show")
248 fs.Parse(args)
249 if fs.NArg() != 0 {
250 log.Fatal(pipelineUsage)
251 }
252
253 sess := pipelineSignedInSession(*consoleFlag)
254 printUsing(stdout, sess)
255 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
256 app2 := resolvedApp(sess, acct, *app, pipelineUsage)
257
258 p, err := sess.pipelineGet(context.Background(), acct, app2)
259 if err != nil {
260 log.Fatal(err)
261 }
262 channels := pipelineOrDefault(p)
263 // A single-channel pipeline (the "no pipeline.json" default, or a real
264 // one-channel pipeline someone composed down to that) is not shown as a
265 // one-row ladder table — there is no order or rule to tabulate yet, and
266 // a table with one row reads as ceremony around nothing (Task 10
267 // controller ruling 5). The hint names the one command that grows it.
268 if len(channels) == 1 {
269 fmt.Fprintf(stdout, "%s — one channel, no pipeline set (carlos pipeline init --template edge-production|full-ladder adds more)\n",
270 channels[0].Name)
271 } else {
272 printPipelineChannels(stdout, channels)
273 }
274
275 pending, err := sess.pendingList(context.Background(), acct, app2)
276 if err != nil {
277 log.Fatal(err)
278 }
279 printPipelinePending(stdout, pending)
280}
281
282// runPipelineInit replaces the whole pipeline with one of the two starter
283// templates spec §5 names — the console-side equivalent of clicking a
284// template button on the settings page, over the API.
285func runPipelineInit(args []string) {
286 fs, app, acctRaw, acctIDRaw, consoleFlag := pipelineFlagSet("pipeline init")
287 template := fs.String("template", "", "starter pipeline: edge-production | full-ladder")
288 fs.Parse(args)
289
290 channels, ok := pipelineTemplate(*template)
291 if !ok {
292 log.Fatal("usage: carlos pipeline init --template edge-production|full-ladder [--app <a>] [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>]")
293 }
294
295 sess := pipelineSignedInSession(*consoleFlag)
296 printUsing(stdout, sess)
297 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
298 app2 := resolvedApp(sess, acct, *app, pipelineUsage)
299
300 applyPipelinePut(sess, acct, app2, channels)
301}
302
303// pipelinePolicyFlags are the four rule-shaping flags set and add share.
304// Registered once so their help text and names can never drift between the
305// two subcommands.
306func pipelinePolicyFlags(fs *flag.FlagSet) (bake *string, passkey *bool, promoteApprovals, changeApprovals *int) {
307 bake = fs.String("bake", "", "bake window before this channel is eligible to adopt (Go duration, e.g. 24h; \"\" clears it)")
308 passkey = fs.Bool("passkey", false, "require a console passkey to promote into or edit this channel; --passkey=false clears it")
309 promoteApprovals = fs.Int("promote-approvals", 0, "promotions into this channel need this many console confirmations (0 clears the rule)")
310 changeApprovals = fs.Int("change-approvals", 0, "pipeline edits that touch this channel need this many console confirmations (0 clears the rule)")
311 return bake, passkey, promoteApprovals, changeApprovals
312}
313
314// runPipelineSet edits ONE existing channel's bake window or rules. It GETs
315// the full current list, applies only the flags actually named on the
316// command line (fs.Visit — the same tri-state pattern runRoute's --hibernate/
317// --backing/--unit already use, cli.go) so an unmentioned flag never clobbers
318// a rule someone else set, and PUTs the whole list back.
319func runPipelineSet(args []string) {
320 fs, app, acctRaw, acctIDRaw, consoleFlag := pipelineFlagSet("pipeline set")
321 channel := channelFlag(fs, "channel to edit")
322 bake, passkey, promoteApprovals, changeApprovals := pipelinePolicyFlags(fs)
323 fs.Parse(args)
324 usage := "usage: carlos pipeline set --channel <name> [--app <a>] [--bake <dur>] [--passkey[=false]] " +
325 "[--promote-approvals N] [--change-approvals N] [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>]"
326 if *channel == "" {
327 log.Fatal(usage)
328 }
329 bakeSet, passkeySet, promoteSet, changeSet := false, false, false, false
330 fs.Visit(func(f *flag.Flag) {
331 switch f.Name {
332 case "bake":
333 bakeSet = true
334 case "passkey":
335 passkeySet = true
336 case "promote-approvals":
337 promoteSet = true
338 case "change-approvals":
339 changeSet = true
340 }
341 })
342
343 sess := pipelineSignedInSession(*consoleFlag)
344 printUsing(stdout, sess)
345 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
346 app2 := resolvedApp(sess, acct, *app, usage)
347
348 p, err := sess.pipelineGet(context.Background(), acct, app2)
349 if err != nil {
350 log.Fatal(err)
351 }
352 channels := pipelineOrDefault(p)
353 idx := -1
354 for i, c := range channels {
355 if c.Name == *channel {
356 idx = i
357 break
358 }
359 }
360 if idx == -1 {
361 log.Fatalf("pipeline set: %q is not a channel of this pipeline — carlos pipeline add --channel %s to create it", *channel, *channel)
362 }
363 if bakeSet {
364 channels[idx].Bake = *bake
365 }
366 if passkeySet {
367 channels[idx].Passkey = *passkey
368 }
369 if promoteSet {
370 channels[idx].PromoteApprovals = *promoteApprovals
371 }
372 if changeSet {
373 channels[idx].ChangeApprovals = *changeApprovals
374 }
375 applyPipelinePut(sess, acct, app2, channels)
376}
377
378// runPipelineAdd inserts a new channel. --after names an existing channel to
379// insert immediately behind; omitted, the new channel is appended at the
380// end (entering the pipeline as its newest, most-downstream rung — never
381// silently made the ENTRY channel, which --after edge would spell instead).
382func runPipelineAdd(args []string) {
383 fs, app, acctRaw, acctIDRaw, consoleFlag := pipelineFlagSet("pipeline add")
384 channel := channelFlag(fs, "channel to add")
385 after := fs.String("after", "", "insert immediately after this existing channel (default: append at the end)")
386 bake, passkey, promoteApprovals, changeApprovals := pipelinePolicyFlags(fs)
387 fs.Parse(args)
388 usage := "usage: carlos pipeline add --channel <name> [--after <name>] [--app <a>] [--bake <dur>] [--passkey] " +
389 "[--promote-approvals N] [--change-approvals N] [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>]"
390 if *channel == "" {
391 log.Fatal(usage)
392 }
393
394 sess := pipelineSignedInSession(*consoleFlag)
395 printUsing(stdout, sess)
396 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
397 app2 := resolvedApp(sess, acct, *app, usage)
398
399 p, err := sess.pipelineGet(context.Background(), acct, app2)
400 if err != nil {
401 log.Fatal(err)
402 }
403 channels := pipelineOrDefault(p)
404 for _, c := range channels {
405 if c.Name == *channel {
406 log.Fatalf("pipeline add: %q already exists in this pipeline — carlos pipeline set to edit it", *channel)
407 }
408 }
409 entry := apiPipelineChannel{
410 Name: *channel, Bake: *bake, Passkey: *passkey,
411 PromoteApprovals: *promoteApprovals, ChangeApprovals: *changeApprovals,
412 }
413 pos := len(channels)
414 if *after != "" {
415 pos = -1
416 for i, c := range channels {
417 if c.Name == *after {
418 pos = i + 1
419 break
420 }
421 }
422 if pos == -1 {
423 log.Fatalf("pipeline add: --after %q does not name an existing channel of this pipeline", *after)
424 }
425 }
426 next := make([]apiPipelineChannel, 0, len(channels)+1)
427 next = append(next, channels[:pos]...)
428 next = append(next, entry)
429 next = append(next, channels[pos:]...)
430 applyPipelinePut(sess, acct, app2, next)
431}
432
433// runPipelineRemove drops one channel from the pipeline. It never refuses on
434// emptying the list to zero channels itself — SetPipeline's own validation
435// (internal/release/pipeline.go) is where that shape check belongs, exactly
436// as it is the one place every other pipeline shape rule is enforced.
437func runPipelineRemove(args []string) {
438 fs, app, acctRaw, acctIDRaw, consoleFlag := pipelineFlagSet("pipeline remove")
439 channel := channelFlag(fs, "channel to remove")
440 fs.Parse(args)
441 usage := "usage: carlos pipeline remove --channel <name> [--app <a>] [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>]"
442 if *channel == "" {
443 log.Fatal(usage)
444 }
445
446 sess := pipelineSignedInSession(*consoleFlag)
447 printUsing(stdout, sess)
448 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
449 app2 := resolvedApp(sess, acct, *app, usage)
450
451 p, err := sess.pipelineGet(context.Background(), acct, app2)
452 if err != nil {
453 log.Fatal(err)
454 }
455 channels := pipelineOrDefault(p)
456 next := make([]apiPipelineChannel, 0, len(channels))
457 found := false
458 for _, c := range channels {
459 if c.Name == *channel {
460 found = true
461 continue
462 }
463 next = append(next, c)
464 }
465 if !found {
466 log.Fatalf("pipeline remove: %q is not a channel of this pipeline", *channel)
467 }
468 applyPipelinePut(sess, acct, app2, next)
469}
470