| 1 | // The channel ladder — the platform's frozen vocabulary for build maturity |
| 2 | // (pipelines spec §3). |
| 3 | // |
| 4 | // canary/<name> a specific change, shipped from a worktree or a branch. |
| 5 | // ↓ No gate in and no gate out: canary is where work happens. |
| 6 | // edge merged to main. Nothing is promoted ONTO edge — merging is |
| 7 | // ↓ what puts a build there. |
| 8 | // beta promoted from edge on sign-off. |
| 9 | // ↓ |
| 10 | // stable promoted from beta on sign-off, after the bake window. |
| 11 | // |
| 12 | // Ordering is enforced from edge upward only, and `carlos promote -hotfix` is |
| 13 | // the one bypass — an explicit, recorded one, because a bypass that is |
| 14 | // invisible is indistinguishable from a broken gate. |
| 15 | // |
| 16 | // This file is vocabulary and rules, nothing else: it touches no store, no |
| 17 | // pointer and no route. In particular, promotion moves a signed channel |
| 18 | // pointer and NEVER changes a route's environment — the ladder proves nothing |
| 19 | // if beta and stable can be wired to different glue. |
| 20 | package release |
| 21 | |
| 22 | import ( |
| 23 | "fmt" |
| 24 | "strings" |
| 25 | ) |
| 26 | |
| 27 | // The rungs. Frozen: a channel name outside this vocabulary (plus canary's |
| 28 | // nested names) is not a rung, and promotion onto it is not a promotion. |
| 29 | const ( |
| 30 | RungCanary = "canary" |
| 31 | RungEdge = "edge" |
| 32 | RungBeta = "beta" |
| 33 | RungStable = "stable" |
| 34 | ) |
| 35 | |
| 36 | // rungs is the ladder, bottom first — the order RungIndex reports. |
| 37 | var rungs = []string{RungCanary, RungEdge, RungBeta, RungStable} |
| 38 | |
| 39 | // RungIndex is a rung's height: 0 canary, 1 edge, 2 beta, 3 stable, -1 for |
| 40 | // anything that is not a rung name (including a channel name like |
| 41 | // "canary/x" — that is Rung's job). |
| 42 | func RungIndex(rung string) int { |
| 43 | for i, r := range rungs { |
| 44 | if r == rung { |
| 45 | return i |
| 46 | } |
| 47 | } |
| 48 | return -1 |
| 49 | } |
| 50 | |
| 51 | // Rung reports which rung a CHANNEL name belongs to: "stable" -> "stable", |
| 52 | // "canary/keymail" -> "canary". Only canary nests (a canary is a named |
| 53 | // change, so it needs a name); anything else is a single segment, and a name |
| 54 | // outside the vocabulary belongs to no rung at all. |
| 55 | func Rung(channel string) (string, bool) { |
| 56 | if !ValidChannel(channel) { |
| 57 | return "", false |
| 58 | } |
| 59 | name, _, nested := strings.Cut(channel, "/") |
| 60 | if nested && name != RungCanary { |
| 61 | return "", false |
| 62 | } |
| 63 | if RungIndex(name) < 0 { |
| 64 | return "", false |
| 65 | } |
| 66 | return name, true |
| 67 | } |
| 68 | |
| 69 | // PromotionAllowed reports whether a build may move onto rung `to`, given the |
| 70 | // rung it currently occupies (`from` — the highest, if several; "" when it is |
| 71 | // on none). nil means go ahead; every refusal names -hotfix, because the |
| 72 | // bypass has to be as discoverable as the gate. |
| 73 | // |
| 74 | // Legal: edge -> beta, beta -> stable, and a build that is on no rung yet |
| 75 | // entering at edge. Canary is unconditional in both directions of the |
| 76 | // argument list — production runs on canary/* channels, and nothing about |
| 77 | // maturity may stop a canary shipping. |
| 78 | func PromotionAllowed(from, to string) error { |
| 79 | if RungIndex(to) < 0 { |
| 80 | return fmt.Errorf("%q is not a rung (%s) — use --hotfix to point a channel outside the ladder", |
| 81 | to, strings.Join(rungs, ", ")) |
| 82 | } |
| 83 | switch { |
| 84 | case to == RungCanary: |
| 85 | // No gate in: a canary is proven BEFORE it is a candidate. |
| 86 | return nil |
| 87 | case to == RungEdge && from == "": |
| 88 | // A fresh build entering the ladder — the merge that produced it is |
| 89 | // the gate, and this is what an automatic edge promotion does. |
| 90 | return nil |
| 91 | case to == RungBeta && from == RungEdge: |
| 92 | return nil |
| 93 | case to == RungStable && from == RungBeta: |
| 94 | return nil |
| 95 | } |
| 96 | if to == RungEdge { |
| 97 | return fmt.Errorf("nothing is promoted onto edge — merging to main is what puts a build there, and this one is on %s; use --hotfix to override", |
| 98 | describeRung(from)) |
| 99 | } |
| 100 | return fmt.Errorf("%s is entered from %s only, and this build is on %s — use --hotfix to bypass the ladder", |
| 101 | to, rungs[RungIndex(to)-1], describeRung(from)) |
| 102 | } |
| 103 | |
| 104 | // describeRung names a rung for a human, including the "on nothing yet" case. |
| 105 | func describeRung(rung string) string { |
| 106 | if rung == "" { |
| 107 | return "no rung yet" |
| 108 | } |
| 109 | return rung |
| 110 | } |
| 111 | |