@intentius/chant 0.124.33 → 0.124.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (218) hide show
  1. package/dist/cli/handlers/graph.js +2 -2
  2. package/dist/cli/handlers/pr.d.ts.map +1 -1
  3. package/dist/cli/handlers/pr.js +147 -51
  4. package/dist/cli/handlers/promote.d.ts.map +1 -1
  5. package/dist/cli/handlers/promote.js +284 -81
  6. package/dist/cli/handlers/release.d.ts +12 -0
  7. package/dist/cli/handlers/release.d.ts.map +1 -1
  8. package/dist/cli/handlers/release.js +125 -10
  9. package/dist/cli/handlers/run.d.ts +3 -1
  10. package/dist/cli/handlers/run.d.ts.map +1 -1
  11. package/dist/cli/handlers/run.js +12 -2
  12. package/dist/cli/main.d.ts.map +1 -1
  13. package/dist/cli/main.js +37 -2
  14. package/dist/cli/parse-args.d.ts.map +1 -1
  15. package/dist/cli/parse-args.js +24 -0
  16. package/dist/cli/registry.d.ts +17 -1
  17. package/dist/cli/registry.d.ts.map +1 -1
  18. package/dist/components/auto-release.d.ts +8 -1
  19. package/dist/components/auto-release.d.ts.map +1 -1
  20. package/dist/components/auto-release.js +3 -2
  21. package/dist/components/green-cover.d.ts +77 -0
  22. package/dist/components/green-cover.d.ts.map +1 -0
  23. package/dist/components/green-cover.js +153 -0
  24. package/dist/components/pr-pipeline.d.ts +34 -0
  25. package/dist/components/pr-pipeline.d.ts.map +1 -1
  26. package/dist/components/pr-pipeline.js +39 -5
  27. package/dist/components/promote-rounds.d.ts +102 -0
  28. package/dist/components/promote-rounds.d.ts.map +1 -0
  29. package/dist/components/promote-rounds.js +174 -0
  30. package/dist/components/promote.d.ts +4 -1
  31. package/dist/components/promote.d.ts.map +1 -1
  32. package/dist/components/promote.js +6 -3
  33. package/dist/components/release-gate.d.ts +30 -0
  34. package/dist/components/release-gate.d.ts.map +1 -1
  35. package/dist/components/release-gate.js +92 -42
  36. package/dist/components/release-promote.d.ts +9 -0
  37. package/dist/components/release-promote.d.ts.map +1 -1
  38. package/dist/components/release-promote.js +15 -0
  39. package/dist/config.d.ts +2 -0
  40. package/dist/config.d.ts.map +1 -1
  41. package/dist/deep-observation.d.ts +11 -0
  42. package/dist/deep-observation.d.ts.map +1 -1
  43. package/dist/graph-declared.d.ts +9 -1
  44. package/dist/graph-declared.d.ts.map +1 -1
  45. package/dist/graph-declared.js +18 -3
  46. package/dist/graph-detail.d.ts.map +1 -1
  47. package/dist/graph-detail.js +30 -1
  48. package/dist/graph-ir.d.ts.map +1 -1
  49. package/dist/graph-ir.js +13 -2
  50. package/dist/lifecycle/__fixtures__/publish-fakes.d.ts.map +1 -1
  51. package/dist/lifecycle/__fixtures__/publish-fakes.js +5 -1
  52. package/dist/lifecycle/compose-refs.d.ts +52 -0
  53. package/dist/lifecycle/compose-refs.d.ts.map +1 -0
  54. package/dist/lifecycle/compose-refs.js +115 -0
  55. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  56. package/dist/lifecycle/deep-observe.js +3 -2
  57. package/dist/lifecycle/green-apply.d.ts +39 -0
  58. package/dist/lifecycle/green-apply.d.ts.map +1 -0
  59. package/dist/lifecycle/green-apply.js +51 -0
  60. package/dist/lifecycle/publish-declaration.d.ts +4 -0
  61. package/dist/lifecycle/publish-declaration.d.ts.map +1 -1
  62. package/dist/lifecycle/publish-declaration.js +15 -0
  63. package/dist/lifecycle/publish-npm.d.ts +7 -4
  64. package/dist/lifecycle/publish-npm.d.ts.map +1 -1
  65. package/dist/lifecycle/publish-npm.js +60 -13
  66. package/dist/lifecycle/release-compose.d.ts +91 -0
  67. package/dist/lifecycle/release-compose.d.ts.map +1 -0
  68. package/dist/lifecycle/release-compose.js +235 -0
  69. package/dist/lifecycle/release-cut.d.ts +8 -0
  70. package/dist/lifecycle/release-cut.d.ts.map +1 -1
  71. package/dist/lifecycle/release-cut.js +2 -0
  72. package/dist/lifecycle/release-publish.d.ts +2 -0
  73. package/dist/lifecycle/release-publish.d.ts.map +1 -1
  74. package/dist/lifecycle/release-publish.js +2 -1
  75. package/dist/lifecycle/release-store.d.ts +15 -0
  76. package/dist/lifecycle/release-store.d.ts.map +1 -1
  77. package/dist/lifecycle/release-store.js +5 -1
  78. package/dist/wave-runtime.mjs +226 -224
  79. package/dist/workspace/__fixtures__/contract-repo.d.ts.map +1 -1
  80. package/dist/workspace/__fixtures__/contract-repo.js +13 -2
  81. package/dist/workspace/check.schema.json +20 -2
  82. package/dist/workspace/checks/ci-workflow.d.ts +17 -0
  83. package/dist/workspace/checks/ci-workflow.d.ts.map +1 -0
  84. package/dist/workspace/checks/ci-workflow.js +130 -0
  85. package/dist/workspace/checks/security.d.ts +16 -1
  86. package/dist/workspace/checks/security.d.ts.map +1 -1
  87. package/dist/workspace/checks/security.js +4 -4
  88. package/dist/workspace/checks.d.ts +1 -1
  89. package/dist/workspace/checks.d.ts.map +1 -1
  90. package/dist/workspace/checks.js +4 -1
  91. package/dist/workspace/ci-green-cli.d.ts.map +1 -1
  92. package/dist/workspace/ci-green-cli.js +5 -3
  93. package/dist/workspace/ci-green-workflow.d.ts +18 -2
  94. package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
  95. package/dist/workspace/ci-green-workflow.js +27 -5
  96. package/dist/workspace/ci-green.d.ts +31 -0
  97. package/dist/workspace/ci-green.d.ts.map +1 -1
  98. package/dist/workspace/ci-green.js +78 -4
  99. package/dist/workspace/ci-init.d.ts +100 -0
  100. package/dist/workspace/ci-init.d.ts.map +1 -0
  101. package/dist/workspace/ci-init.js +434 -0
  102. package/dist/workspace/ci-run.d.ts +6 -0
  103. package/dist/workspace/ci-run.d.ts.map +1 -1
  104. package/dist/workspace/ci-run.js +2 -1
  105. package/dist/workspace/ci-serve.d.ts +94 -13
  106. package/dist/workspace/ci-serve.d.ts.map +1 -1
  107. package/dist/workspace/ci-serve.js +248 -30
  108. package/dist/workspace/ci-workflow.d.ts +91 -0
  109. package/dist/workspace/ci-workflow.d.ts.map +1 -0
  110. package/dist/workspace/ci-workflow.js +33 -0
  111. package/dist/workspace/declaration.d.ts +18 -0
  112. package/dist/workspace/declaration.d.ts.map +1 -1
  113. package/dist/workspace/declaration.js +9 -2
  114. package/dist/workspace/declaration.schema.json +28 -0
  115. package/dist/workspace/delivery-metrics.d.ts +3 -1
  116. package/dist/workspace/delivery-metrics.d.ts.map +1 -1
  117. package/dist/workspace/delivery-metrics.js +12 -3
  118. package/dist/workspace/init.js +2 -2
  119. package/dist/workspace/member-commands.d.ts.map +1 -1
  120. package/dist/workspace/member-commands.js +4 -0
  121. package/dist/workspace/nested-graph.d.ts.map +1 -1
  122. package/dist/workspace/nested-graph.js +1 -0
  123. package/dist/workspace/reason-codes.d.ts +9 -0
  124. package/dist/workspace/reason-codes.d.ts.map +1 -1
  125. package/dist/workspace/reason-codes.js +10 -0
  126. package/dist/workspace/release-candidates.d.ts +28 -0
  127. package/dist/workspace/release-candidates.d.ts.map +1 -1
  128. package/dist/workspace/release-candidates.js +38 -2
  129. package/dist/workspace/release-candidates.schema.json +51 -0
  130. package/dist/workspace/release-workflow.d.ts +31 -0
  131. package/dist/workspace/release-workflow.d.ts.map +1 -0
  132. package/dist/workspace/release-workflow.js +150 -0
  133. package/dist/workspace/releases.schema.json +30 -0
  134. package/package.json +1 -1
  135. package/src/cli/handlers/graph.ts +2 -2
  136. package/src/cli/handlers/pr-green.test.ts +230 -0
  137. package/src/cli/handlers/pr.ts +148 -66
  138. package/src/cli/handlers/promote.test.ts +34 -0
  139. package/src/cli/handlers/promote.ts +302 -91
  140. package/src/cli/handlers/release-compose.e2e.test.ts +133 -0
  141. package/src/cli/handlers/release.ts +112 -10
  142. package/src/cli/handlers/run.ts +12 -1
  143. package/src/cli/main.ts +37 -2
  144. package/src/cli/parse-args.ts +18 -0
  145. package/src/cli/registry.ts +17 -1
  146. package/src/codegen/release-wiring.test.ts +56 -9
  147. package/src/components/auto-release.test.ts +22 -0
  148. package/src/components/auto-release.ts +9 -2
  149. package/src/components/green-cover.test.ts +170 -0
  150. package/src/components/green-cover.ts +186 -0
  151. package/src/components/pr-pipeline.test.ts +14 -4
  152. package/src/components/pr-pipeline.ts +56 -5
  153. package/src/components/promote-rounds.test.ts +220 -0
  154. package/src/components/promote-rounds.ts +232 -0
  155. package/src/components/promote.test.ts +7 -0
  156. package/src/components/promote.ts +9 -4
  157. package/src/components/release-gate.test.ts +47 -0
  158. package/src/components/release-gate.ts +111 -46
  159. package/src/components/release-promote.test.ts +24 -1
  160. package/src/components/release-promote.ts +15 -0
  161. package/src/deep-observation.ts +8 -0
  162. package/src/graph-declared-exports.test.ts +40 -0
  163. package/src/graph-declared.test.ts +18 -0
  164. package/src/graph-declared.ts +17 -2
  165. package/src/graph-detail.test.ts +68 -1
  166. package/src/graph-detail.ts +29 -1
  167. package/src/graph-ir.ts +12 -4
  168. package/src/lifecycle/__fixtures__/publish-fakes.ts +5 -1
  169. package/src/lifecycle/compose-refs.ts +125 -0
  170. package/src/lifecycle/deep-observe.test.ts +20 -0
  171. package/src/lifecycle/deep-observe.ts +4 -2
  172. package/src/lifecycle/green-apply.ts +71 -0
  173. package/src/lifecycle/publish-declaration.ts +15 -0
  174. package/src/lifecycle/publish-npm.ts +55 -13
  175. package/src/lifecycle/publish-targets.test.ts +45 -0
  176. package/src/lifecycle/release-compose.test.ts +125 -0
  177. package/src/lifecycle/release-compose.ts +312 -0
  178. package/src/lifecycle/release-cut.ts +6 -0
  179. package/src/lifecycle/release-publish.ts +4 -1
  180. package/src/lifecycle/release-store.ts +16 -1
  181. package/src/workspace/__fixtures__/contract-repo.ts +11 -2
  182. package/src/workspace/check-contract.test.ts +3 -2
  183. package/src/workspace/check.schema.json +20 -2
  184. package/src/workspace/checks/ci-workflow.test.ts +160 -0
  185. package/src/workspace/checks/ci-workflow.ts +135 -0
  186. package/src/workspace/checks/security.ts +4 -4
  187. package/src/workspace/checks.ts +4 -1
  188. package/src/workspace/ci-green-cli.ts +5 -3
  189. package/src/workspace/ci-green-workflow.test.ts +52 -0
  190. package/src/workspace/ci-green-workflow.ts +34 -5
  191. package/src/workspace/ci-green.test.ts +33 -0
  192. package/src/workspace/ci-green.ts +85 -4
  193. package/src/workspace/ci-init.test.ts +159 -0
  194. package/src/workspace/ci-init.ts +445 -0
  195. package/src/workspace/ci-report-url.test.ts +6 -1
  196. package/src/workspace/ci-run.test.ts +18 -13
  197. package/src/workspace/ci-run.ts +8 -1
  198. package/src/workspace/ci-serve-land.e2e.test.ts +138 -0
  199. package/src/workspace/ci-serve-land.test.ts +176 -0
  200. package/src/workspace/ci-serve.test.ts +77 -43
  201. package/src/workspace/ci-serve.ts +268 -34
  202. package/src/workspace/ci-workflow.ts +71 -0
  203. package/src/workspace/declaration.schema.json +28 -0
  204. package/src/workspace/declaration.ts +32 -3
  205. package/src/workspace/delivery-metrics.ts +12 -3
  206. package/src/workspace/graph-contract.test.ts +3 -2
  207. package/src/workspace/init.ts +2 -2
  208. package/src/workspace/member-commands.test.ts +11 -4
  209. package/src/workspace/member-commands.ts +3 -0
  210. package/src/workspace/nested-graph.test.ts +4 -0
  211. package/src/workspace/nested-graph.ts +1 -0
  212. package/src/workspace/reason-codes.test.ts +2 -0
  213. package/src/workspace/reason-codes.ts +10 -0
  214. package/src/workspace/release-candidates.schema.json +51 -0
  215. package/src/workspace/release-candidates.test.ts +30 -3
  216. package/src/workspace/release-candidates.ts +54 -2
  217. package/src/workspace/release-workflow.ts +167 -0
  218. package/src/workspace/releases.schema.json +30 -0
@@ -0,0 +1,133 @@
1
+ /**
2
+ * `chant release compose` end to end (#4168 4.3, mode b): a chosen trunk
3
+ * change composed onto a released commit while trunk carries unready work,
4
+ * a conflict that stops with nothing written, the tick judging the pushed
5
+ * compose branch, and a cut of the composed commit that records its base,
6
+ * its sources and a publish channel. A temp repo with a temp bare remote;
7
+ * the tick's forge is a fake that answers check runs. No key or signer.
8
+ */
9
+
10
+ import { describe, test, expect } from "vitest";
11
+ import { spawnSync } from "node:child_process";
12
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { pathToFileURL } from "node:url";
16
+ import { REPO } from "../../workspace/__fixtures__/contract-repo";
17
+ import { ciTick } from "../../workspace/ci-green";
18
+ import type { CheckRun, CiForge } from "../../workspace/ci-green-forge";
19
+
20
+ const CHANT = [process.execPath, "--import", pathToFileURL(join(REPO, "node_modules", "tsx", "dist", "loader.mjs")).href, join(REPO, "packages", "core", "src", "cli", "main.ts")];
21
+ const CI_VARS = ["CI", "GITHUB_ACTIONS", "GITHUB_RUN_ID", "GITHUB_SHA", "GITHUB_ACTOR", "GITLAB_CI", "CI_PIPELINE_ID", "CHANT_ENV", "CHANT_RELEASE_LABEL"];
22
+
23
+ function chant(cwd: string, args: string[]): { code: number; stdout: string; stderr: string } {
24
+ const env: NodeJS.ProcessEnv = { ...process.env, USER: "tester" };
25
+ for (const k of CI_VARS) delete env[k];
26
+ const r = spawnSync(CHANT[0], [...CHANT.slice(1), ...args], { cwd, encoding: "utf-8", env, timeout: 120_000 });
27
+ return { code: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
28
+ }
29
+
30
+ function git(cwd: string, ...args: string[]): string {
31
+ const r = spawnSync("git", args, { cwd, encoding: "utf-8" });
32
+ if (r.status !== 0) throw new Error(r.stderr);
33
+ return r.stdout.trim();
34
+ }
35
+
36
+ const BUILD = `mkdir -p dist && printf '{"env":"%s","marker":"%s","fix":"%s"}' "$CHANT_ENV" "$(cat marker.txt)" "$(cat fix.txt 2>/dev/null)" > dist/api.template.json`;
37
+ const PASSING: CheckRun[] = [{ id: 1, name: "test", status: "completed", conclusion: "success", url: null } as unknown as CheckRun];
38
+
39
+ describe("chant release compose", () => {
40
+ test(
41
+ "composes a fix onto a released commit, stops on a conflict, and cuts the composed commit once the tick has judged it green",
42
+ async () => {
43
+ const root = mkdtempSync(join(tmpdir(), "chant-release-compose-"));
44
+ try {
45
+ const dir = join(root, "work");
46
+ const remote = join(root, "origin.git");
47
+ git(root, "init", "-q", "--bare", "-b", "main", remote);
48
+ git(root, "init", "-q", "-b", "main", dir);
49
+ git(dir, "config", "user.email", "t@chant.dev");
50
+ git(dir, "config", "user.name", "T");
51
+ writeFileSync(
52
+ join(dir, "chant.workspace.json"),
53
+ JSON.stringify({ name: "w", schema: 1, members: [{ name: "api", dir: ".", kind: "chant" }], ci: { green: { branch: "main", phases: { test: ["test"] }, require: ["test"] } } }, null, 2),
54
+ );
55
+ writeFileSync(join(dir, "chant.config.ts"), `export default { environments: ["dev", "prod"] };\n`);
56
+ writeFileSync(
57
+ join(dir, "api.component.ts"),
58
+ `export const api = { name: "api", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "cfn-deploy", template: "dist/api.template.json" }] }] };\n`,
59
+ );
60
+ writeFileSync(join(dir, ".gitignore"), "dist/\n");
61
+ writeFileSync(join(dir, "marker.txt"), "one");
62
+ git(dir, "add", ".");
63
+ git(dir, "commit", "-q", "-m", "one (#11)");
64
+ const one = git(dir, "rev-parse", "HEAD");
65
+ // Trunk moves on: unready work, a fix, and work on top of the unready work.
66
+ writeFileSync(join(dir, "marker.txt"), "two");
67
+ git(dir, "commit", "-q", "-am", "unready (#12)");
68
+ writeFileSync(join(dir, "fix.txt"), "fixed");
69
+ git(dir, "add", "fix.txt");
70
+ git(dir, "commit", "-q", "-m", "the fix (#13)\n\nChant-Record: work:fix-1");
71
+ const fix = git(dir, "rev-parse", "HEAD");
72
+ writeFileSync(join(dir, "marker.txt"), "four");
73
+ git(dir, "commit", "-q", "-am", "more on unready (#14)");
74
+ git(dir, "remote", "add", "origin", remote);
75
+ git(dir, "push", "-q", "origin", "main");
76
+ git(dir, "fetch", "-q", "origin");
77
+
78
+ // A change that needs the unready one stops, naming it and the file, and writes nothing.
79
+ const conflict = chant(dir, ["release", "compose", "bad", "--base", one, "--pr", "14", "--yes"]);
80
+ expect(conflict.code).toBe(1);
81
+ expect(conflict.stderr).toContain("[compose-conflict]");
82
+ expect(conflict.stderr).toContain("marker.txt");
83
+ expect(spawnSync("git", ["rev-parse", "--verify", "--quiet", "refs/heads/chant/compose/bad"], { cwd: remote }).status).not.toBe(0);
84
+
85
+ const dry = chant(dir, ["release", "compose", "hotfix", "--base", one, "--work", "fix-1", "--json"]);
86
+ expect(dry.code, dry.stderr).toBe(0);
87
+ expect(JSON.parse(dry.stdout)).toMatchObject({ dryRun: true, written: false, pushed: false, picked: [{ commit: fix, pr: 13, work: ["fix-1"] }] });
88
+
89
+ const composed = chant(dir, ["release", "compose", "hotfix", "--base", one, "--pr", "13", "--yes", "--json"]);
90
+ expect(composed.code, composed.stderr).toBe(0);
91
+ const tip = JSON.parse(composed.stdout).tip as string;
92
+ expect(git(remote, "rev-parse", "refs/heads/chant/compose/hotfix")).toBe(tip);
93
+ expect(git(dir, "show", "-s", "--format=%(trailers:key=Chant-Composed-From,valueonly)", tip)).toBe(fix);
94
+
95
+ // Not green yet, so no cut.
96
+ const early = chant(dir, ["release", "cut", "v1.0.1", "--at", tip, "--build", BUILD]);
97
+ expect(early.code).toBe(1);
98
+ expect(early.stderr).toContain("[release-commit-not-green]");
99
+
100
+ // The tick walks the compose branch and tags the composed commit green.
101
+ const forge: CiForge = { kind: "github", checkRuns: async () => PASSING };
102
+ const tick = await ciTick({ cwd: dir, forge, now: new Date() });
103
+ expect(tick.composed.map((b) => b.branch)).toEqual(["chant/compose/hotfix"]);
104
+ expect(tick.made).toContain(`ci/green/${tip}`);
105
+
106
+ const cut = chant(dir, ["release", "cut", "v1.0.1", "--at", tip, "--build", BUILD, "--channel", "hotfix", "--yes", "--json"]);
107
+ expect(cut.code, cut.stderr).toBe(0);
108
+ const result = JSON.parse(cut.stdout);
109
+ expect(result.release.commit).toBe(tip);
110
+ expect(result.composed).toEqual({ branch: "chant/compose/hotfix", base: one, sources: [fix] });
111
+ expect(result.cut).toMatchObject({ label: "v1.0.1", channel: "hotfix", composed: { base: one, sources: [fix] }, signature: null });
112
+
113
+ // The release carries the fix on the released marker, not trunk's unready one.
114
+ const out = join(root, "export");
115
+ expect(chant(dir, ["release", "export", "v1.0.1", "--env", "prod", "-o", out]).code).toBe(0);
116
+ expect(JSON.parse(spawnSync("cat", [join(out, "dist", "api.template.json")], { encoding: "utf-8" }).stdout)).toEqual({ env: "prod", marker: "one", fix: "fixed" });
117
+
118
+ // A commit that is on neither trunk nor a compose branch is still refused.
119
+ git(dir, "checkout", "-q", "-b", "side", one);
120
+ writeFileSync(join(dir, "marker.txt"), "side");
121
+ git(dir, "commit", "-q", "-am", "side");
122
+ const side = git(dir, "rev-parse", "HEAD");
123
+ git(dir, "checkout", "-q", "main");
124
+ const refused = chant(dir, ["release", "cut", "v9", "--at", side, "--build", BUILD]);
125
+ expect(refused.code).toBe(1);
126
+ expect(refused.stderr).toContain("[release-commit-not-on-trunk]");
127
+ } finally {
128
+ rmSync(root, { recursive: true, force: true });
129
+ }
130
+ },
131
+ 300_000,
132
+ );
133
+ });
@@ -20,6 +20,11 @@
20
20
  * release candidates [--env <env>] [--json]
21
21
  * the green commits of trunk a release can be cut from, each with what it
22
22
  * would ship, and what each environment runs (#4168 4.2)
23
+ * release compose <name> [--base <label|sha>] (--pr <n> | --work <id>)... [--yes] [--json]
24
+ * pick chosen trunk changes onto a base, each with a Chant-Composed-From
25
+ * trailer, on the pushed branch chant/compose/<name> (#4168 4.3); a dry
26
+ * run unless --yes. `release cut` takes a green commit of that branch,
27
+ * and `--channel <tag>` makes its publish move that tag, not latest
23
28
  * release list [--json]
24
29
  * every release of this member with its targets, its cuts and where it runs;
25
30
  * `--json` prints the read contract's `releases` document
@@ -42,7 +47,7 @@ import { parseParamFlags } from "../build-params-cli";
42
47
  import type { CommandContext } from "../registry";
43
48
 
44
49
  const USAGE =
45
- "chant release cut [<label>] [--at <sha>] [--yes] [--build <command>] [--param <k=v>] [--params-file <file>] [--src <dir>] [-o <file>] [--lexicon <name>] [--actor <name>] [--sign [<key file>]] [--json] | release publish <label|digest> [--yes] [--allow-unsigned] [--require-provenance] [--json] | release verify <label|digest> [--json] | release candidates [--env <env>] [--json] | release list [--json] | release export <label|digest> --env <env> [-o <dir>] [--json]";
50
+ "chant release cut [<label>] [--at <sha>] [--yes] [--build <command>] [--param <k=v>] [--params-file <file>] [--src <dir>] [-o <file>] [--lexicon <name>] [--actor <name>] [--sign [<key file>]] [--channel <tag>] [--json] | release publish <label|digest> [--yes] [--allow-unsigned] [--require-provenance] [--json] | release verify <label|digest> [--json] | release candidates [--env <env>] [--json] | release compose <name> [--base <label|sha>] (--pr <n> | --work <id>)... [--yes] [--json] | release list [--json] | release export <label|digest> --env <env> [-o <dir>] [--json]";
46
51
 
47
52
  /** The build inputs `release cut` and `release compile` share. */
48
53
  function compileInputs(
@@ -155,6 +160,7 @@ export async function runReleaseCut(ctx: CommandContext): Promise<number> {
155
160
  let trunk: import("../../lifecycle/release-workstation").Trunk | null = null;
156
161
  let signers: readonly import("../../workspace/trust/trust-files").Signer[] = [];
157
162
  let member: string | null = null;
163
+ let composed: { branch: string; base: string; sources: string[] } | undefined;
158
164
  if (args.at !== undefined && !top) {
159
165
  return fail("not-a-git-repository", "--at names a commit, and this directory is not in a git repository");
160
166
  }
@@ -169,7 +175,18 @@ export async function runReleaseCut(ctx: CommandContext): Promise<number> {
169
175
  const { releaseRuleFrom } = await import("../../workspace/identity");
170
176
  const source = scopeSource(cwd);
171
177
  trunk = ws.resolveTrunk(top, source.declaration?.ci?.green?.branch ?? null);
172
- ws.checkOnTrunk(top, commit, trunk);
178
+ try {
179
+ ws.checkOnTrunk(top, commit, trunk);
180
+ } catch (err) {
181
+ // #4168 4.3: a commit of a pushed compose branch is cut too, and the cut records what it carries.
182
+ if (!(err instanceof ws.WorkstationCheckError) || err.code !== "release-commit-not-on-trunk") throw err;
183
+ const refs = await import("../../lifecycle/compose-refs");
184
+ const on = refs.composeBranchContaining(top, commit, trunk.remote);
185
+ const trunkRef = `refs/remotes/${trunk.remote}/${trunk.branch}`;
186
+ const composition = on ? refs.compositionOnTrunk(top, commit, trunkRef, on.branch) : null;
187
+ if (!on || !composition) throw err;
188
+ composed = { branch: on.branch, base: composition.base, sources: composition.sources };
189
+ }
173
190
  signers = releaseRuleFrom(source).rule.signers.signers;
174
191
  const { resolveMemberLedger } = await import("../../lifecycle/member-ledger");
175
192
  const ledger = await resolveMemberLedger(cwd);
@@ -206,7 +223,15 @@ export async function runReleaseCut(ctx: CommandContext): Promise<number> {
206
223
  if (args.at !== undefined && top && commit) checkout = at.checkoutCommit(top, cwd, commit);
207
224
  const cutCwd = checkout?.memberDir ?? cwd;
208
225
  const compiler = spawnTargetCompiler(compileInputs(ctx, params, cutCwd, label));
209
- const request = { cwd: cutCwd, compiler, label, actor, ...(args.sign !== undefined ? { sign: args.sign } : {}) };
226
+ const request = {
227
+ cwd: cutCwd,
228
+ compiler,
229
+ label,
230
+ actor,
231
+ ...(args.sign !== undefined ? { sign: args.sign } : {}),
232
+ ...(composed ? { composed } : {}),
233
+ ...(args.channel ? { channel: args.channel } : {}),
234
+ };
210
235
 
211
236
  if (dryRun) {
212
237
  const plan = await planRelease(request);
@@ -233,6 +258,8 @@ export async function runReleaseCut(ctx: CommandContext): Promise<number> {
233
258
  actorSource: who.source,
234
259
  run: ci ? { runId: ci.runId, runOrigin: ci.runOrigin } : { runId: null, runOrigin: { forge: "local" as const } },
235
260
  trunk,
261
+ composed: composed ?? null,
262
+ channel: args.channel ?? null,
236
263
  at: args.at !== undefined ? { ref: args.at, commit } : null,
237
264
  sign: args.sign !== undefined ? { key: publicKey !== null ? publicKey.split(" ")[0] : null, readable: publicKey !== null } : null,
238
265
  tag: tag ? { name: tag.name, exists: tag.state === "present" } : null,
@@ -244,7 +271,13 @@ export async function runReleaseCut(ctx: CommandContext): Promise<number> {
244
271
  return 0;
245
272
  }
246
273
  const name = plan.label ? formatBold(plan.label) : "an unlabelled release";
247
- console.log(`Dry run: cut ${name} from commit ${plan.commit.slice(0, 12)}${trunk ? ` (on ${trunk.remote}/${trunk.branch}, green)` : ""}`);
274
+ const where = composed
275
+ ? ` (on ${trunk!.remote}/${composed.branch}, composed on ${composed.base.slice(0, 12)} from ${composed.sources.length} trunk commit(s), green)`
276
+ : trunk
277
+ ? ` (on ${trunk.remote}/${trunk.branch}, green)`
278
+ : "";
279
+ console.log(`Dry run: cut ${name} from commit ${plan.commit.slice(0, 12)}${where}`);
280
+ if (args.channel) console.log(` channel ${args.channel}: a publish moves ${args.channel}, not latest`);
248
281
  if (plan.alreadyCut) console.log(` already cut as ${plan.alreadyCut}: the cut would record nothing new`);
249
282
  console.log(` actor ${actor} (from ${describeActorSource(who.source)})`);
250
283
  console.log(` run ${ci ? `${ci.runId} (${ci.runOrigin.forge})` : "minted on this machine at the cut (local)"}`);
@@ -299,7 +332,7 @@ export async function runReleaseCut(ctx: CommandContext): Promise<number> {
299
332
  if (args.json) {
300
333
  console.log(
301
334
  JSON.stringify(
302
- { dryRun: false, release: result.release, label: result.label, cut: result.cut, stored: result.stored, reused: result.reused, actor, actorSource: who.source, tag: tagged, next },
335
+ { dryRun: false, release: result.release, label: result.label, cut: result.cut, stored: result.stored, reused: result.reused, actor, actorSource: who.source, tag: tagged, composed: composed ?? null, channel: args.channel ?? null, next },
303
336
  null,
304
337
  2,
305
338
  ),
@@ -308,7 +341,8 @@ export async function runReleaseCut(ctx: CommandContext): Promise<number> {
308
341
  const name = result.label ? formatBold(result.label) : "unlabelled release";
309
342
  if (result.cut) console.error(formatSuccess(`Cut ${name}: ${result.release.releaseDigest}`));
310
343
  else console.error(formatSuccess(`Already cut${result.label ? ` as ${formatBold(result.label)}` : ""}: ${result.release.releaseDigest}`));
311
- console.error(` commit ${result.release.commit}`);
344
+ console.error(` commit ${result.release.commit}${composed ? `, composed on ${composed.base.slice(0, 12)} on ${composed.branch}` : ""}`);
345
+ if (args.channel) console.error(` channel ${args.channel}`);
312
346
  if (result.cut) console.error(` ${result.cut.signature ? `signed by ${result.cut.actor}` : `not signed; actor ${result.cut.actor}`}, run ${result.cut.runId}`);
313
347
  console.error(` artifacts ${result.release.artifactDigests.length}, targets ${result.release.targets.map((t) => `${t.env} (${t.templates.length} template${t.templates.length === 1 ? "" : "s"})`).join(", ")}`);
314
348
  for (const r of result.reused) console.error(` ${r.component}: artifacts unchanged, carried from ${r.from}`);
@@ -386,7 +420,8 @@ export async function runReleasePublish(ctx: CommandContext): Promise<number> {
386
420
  // The warnings go to stderr with --json too, before any target runs.
387
421
  if (signatureWarning) console.error(formatWarning({ message: signatureWarning }));
388
422
  if (warning) console.error(formatWarning({ message: warning }));
389
- const pctx = publishContext(prepared, { cwd, log: (line) => (args.json ? undefined : console.error(` ${line}`)) });
423
+ // #4168 4.3: a release cut with --channel moves that tag, not latest.
424
+ const pctx = publishContext(prepared, { cwd, tag: prepared.channel, log: (line) => (args.json ? undefined : console.error(` ${line}`)) });
390
425
 
391
426
  // Every target is planned before any publishes, so a missing file or credential stops the publish with nothing sent.
392
427
  const progress = publishProgress(await readPublishRecords(release.releaseDigest, { cwd }).catch(() => []));
@@ -539,9 +574,14 @@ export async function runReleaseCandidates(ctx: CommandContext): Promise<number>
539
574
  for (const c of doc.candidates) {
540
575
  const tags = [...(c.newFor.length > 0 ? [`new for ${c.newFor.join(", ")}`] : []), ...(c.cut.length > 0 ? [`cut as ${c.cut.join(", ")}`] : [])];
541
576
  console.log(`${c.commit.slice(0, 12)} ${c.subject}${tags.length > 0 ? ` (${tags.join("; ")})` : ""}`);
542
- for (const ch of c.changes) {
543
- const links = [...(ch.pr !== null ? [`#${ch.pr}`] : []), ...ch.work.map((w) => `work:${w}`)];
544
- console.log(` ${ch.commit.slice(0, 12)} ${ch.subject}${links.length > 0 ? ` [${links.join(", ")}]` : ""}`);
577
+ // #4168 4.4: by work item, then pull request, then commit.
578
+ const subjectOf = new Map(c.changes.map((ch) => [ch.commit, ch.subject]));
579
+ for (const g of c.groups) {
580
+ console.log(` ${g.work !== null ? `work:${g.work}` : "no work item"}`);
581
+ for (const p of g.pullRequests) {
582
+ console.log(` ${p.pr !== null ? `#${p.pr}` : "no pull request"}`);
583
+ for (const sha of p.commits) console.log(` ${sha.slice(0, 12)} ${subjectOf.get(sha) ?? ""}`);
584
+ }
545
585
  }
546
586
  for (const f of c.flags) console.log(` flag ${f.id} (${f.member} ${f.param}) changed`);
547
587
  }
@@ -661,3 +701,65 @@ export async function runReleaseUnknown(ctx: CommandContext): Promise<number> {
661
701
  console.error(formatError({ message: `Unknown release subcommand: ${ctx.args.path}`, hint: USAGE }));
662
702
  return 1;
663
703
  }
704
+
705
+ /**
706
+ * `release compose <name> [--base <label|sha>] (--pr <n> | --work <id>)... [--yes] [--json]`
707
+ * (#4168 4.3): pick the chosen trunk changes onto a base, on
708
+ * `chant/compose/<name>` (../../lifecycle/release-compose.ts). A dry run
709
+ * unless `--yes`, which moves the branch and pushes it so CI builds it.
710
+ */
711
+ export async function runReleaseCompose(ctx: CommandContext): Promise<number> {
712
+ const { args } = ctx;
713
+ const name = args.extraPositional;
714
+ if (!name) {
715
+ console.error(formatError({ message: "release compose needs a name for the compose branch", hint: USAGE }));
716
+ return 1;
717
+ }
718
+ const cwd = resolve(".");
719
+ const { composeRelease, ComposeError } = await import("../../lifecycle/release-compose");
720
+ const ws = await import("../../lifecycle/release-workstation");
721
+ const { scopeSource } = await import("../../workspace/write-scope");
722
+ const top = (() => {
723
+ try {
724
+ return execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
725
+ } catch {
726
+ return null;
727
+ }
728
+ })();
729
+ if (!top) {
730
+ console.error(formatError({ message: "a compose picks commits, and this directory is not in a git repository [not-a-git-repository]" }));
731
+ return 1;
732
+ }
733
+ const trunk = ws.resolveTrunk(top, scopeSource(cwd).declaration?.ci?.green?.branch ?? null);
734
+ try {
735
+ const r = await composeRelease({
736
+ cwd,
737
+ name,
738
+ ...(args.base !== undefined ? { base: args.base } : {}),
739
+ prs: args.prs ?? [],
740
+ works: args.works ?? [],
741
+ yes: args.yes === true,
742
+ trunk,
743
+ });
744
+ if (args.json) {
745
+ console.log(JSON.stringify({ dryRun: args.yes !== true, ...r, next: r.written || r.pushed ? [`chant release cut <label> --at ${r.tip}`] : [sameWithYes(process.argv.slice(2))] }, null, 2));
746
+ return 0;
747
+ }
748
+ const verb = args.yes === true ? "Composed" : "Dry run: would compose";
749
+ console.log(`${verb} ${formatBold(r.branch)} on ${r.base.slice(0, 12)}${r.from ? `, after ${r.from.slice(0, 12)}` : ""}`);
750
+ for (const s of r.picked) console.log(` pick ${s.commit.slice(0, 12)} ${s.subject} [${s.chosenBy.join(", ")}]`);
751
+ for (const s of r.already) console.log(` carried already ${s.commit.slice(0, 12)} ${s.subject}`);
752
+ if (r.picked.length === 0) console.log(" nothing new to pick");
753
+ console.log(` tip ${r.tip.slice(0, 12)}${r.pushed ? `, pushed to ${trunk.remote}` : ""}`);
754
+ if (args.yes !== true) console.error(`Dry run: no branch was moved or pushed. Run again with --yes: ${sameWithYes(process.argv.slice(2))}`);
755
+ else console.error(`Next: once CI tags ${r.tip.slice(0, 12)} green, cut it: chant release cut <label> --at ${r.tip} --channel <channel>`);
756
+ return 0;
757
+ } catch (err) {
758
+ if (err instanceof ComposeError) {
759
+ if (args.json) console.log(JSON.stringify({ error: { code: err.code, message: err.message, ...(err.conflict ? { conflict: err.conflict } : {}) } }, null, 2));
760
+ console.error(formatError({ message: `${err.message} [${err.code}]`, ...(err.fix ? { hint: err.fix } : {}) }));
761
+ return 1;
762
+ }
763
+ throw err;
764
+ }
765
+ }
@@ -933,6 +933,8 @@ export async function recordAutoReleasesForRun(
933
933
  disabled: boolean,
934
934
  runOrigin: RunOrigin = { forge: "local" },
935
935
  releaseDigest?: string,
936
+ /** #4222: the digest of the templates the release records for a component in `env`, for one that publishes no artifact. */
937
+ templatesDigestOf?: (component: string) => string | null,
936
938
  ): Promise<ReleaseRecord[]> {
937
939
  const recorded: ReleaseRecord[] = [];
938
940
  for (const componentResult of results) {
@@ -951,6 +953,7 @@ export async function recordAutoReleasesForRun(
951
953
  ...(gateAdmission(componentResult) ? { admission: gateAdmission(componentResult) } : {}),
952
954
  // #4043: the release the deploy was made under, when `--release` named one.
953
955
  ...(releaseDigest !== undefined ? { releaseDigest } : {}),
956
+ ...(releaseDigest !== undefined && templatesDigestOf?.(componentResult.component) ? { templatesDigest: templatesDigestOf(componentResult.component)! } : {}),
954
957
  },
955
958
  { disabled },
956
959
  );
@@ -1083,6 +1086,13 @@ export async function resolveRunRelease(ref: string, env: string, cwd: string) {
1083
1086
  }
1084
1087
 
1085
1088
  /** A rebuilt image need not digest as the release's own did; say so, as promote does. Never fails the run. */
1089
+ /** #4222: a component's templates digest in the release's target for `env`, or null when the release has no such target. */
1090
+ async function releaseTemplatesDigestOf(release: import("../../lifecycle/release-store").Release, env: string): Promise<(component: string) => string | null> {
1091
+ const { targetFor, releaseTemplatesDigest } = await import("../../components/release-promote");
1092
+ const found = targetFor(release, env);
1093
+ return (component) => ("target" in found ? releaseTemplatesDigest(found.target, component) : null);
1094
+ }
1095
+
1086
1096
  async function warnOnArtifactMismatch(release: import("../../lifecycle/release-store").Release, recorded: readonly ReleaseRecord[]): Promise<void> {
1087
1097
  try {
1088
1098
  const { releaseArtifactMismatches } = await import("../../components/release-promote");
@@ -1270,7 +1280,8 @@ async function runComponentSelection(ctx: CommandContext): Promise<number> {
1270
1280
  if (result.success && result.run) {
1271
1281
  // #3955: a run that records nothing has no release to report either.
1272
1282
  const disabled = resolveAutoReleaseDisabled(config, ctx.args.noReleaseRecord) || ledgerRecordingOff();
1273
- const recorded = await recordAutoReleasesForRun(result.run.results, env, release.runId, disabled, release.runOrigin, underRelease?.releaseDigest);
1283
+ const templatesDigestOf = underRelease ? await releaseTemplatesDigestOf(underRelease.release, env) : undefined;
1284
+ const recorded = await recordAutoReleasesForRun(result.run.results, env, release.runId, disabled, release.runOrigin, underRelease?.releaseDigest, templatesDigestOf);
1274
1285
  if (underRelease) await warnOnArtifactMismatch(underRelease.release, recorded);
1275
1286
  // `--digest-file` (#2602): the releases this run recorded, one
1276
1287
  // `<component>=<digest>` line each, the form `chant components promote
package/src/cli/main.ts CHANGED
@@ -342,6 +342,12 @@ CI:
342
342
  key. --trust commits its runner entry on a branch of its
343
343
  own and pushes it for review; the target branch is never
344
344
  written
345
+ ci init --workflow trunk-curated|trunk-curated+apply-on-merge
346
+ [--forge github|gitlab|forgejo] [--security <group>]... [--yes]
347
+ Set the workspace up for a prescribed delivery workflow:
348
+ print what it would write as a diff, then write it on
349
+ --yes or a yes at the prompt and run the generators. A
350
+ rerun proposes only what workspace check still reports
345
351
  ci workflow [--workflow <name>]... [--output <file>] Write
346
352
  [--chant <command>] [--install <command>]
347
353
  [--token-secret <NAME> | --app-id-var <VAR> --app-key-secret <NAME>]
@@ -362,7 +368,7 @@ CI:
362
368
  Releases:
363
369
  release cut [<label>] [--at <sha>] [--yes] [--build <command>] [--param <k=v>]
364
370
  [--params-file <file>] [--src <dir>] [-o <file>] [--lexicon <name>]
365
- [--actor <name>] [--sign [<key file>]] [--json]
371
+ [--actor <name>] [--sign [<key file>]] [--channel <tag>] [--json]
366
372
  A dry run of a cut of this member from HEAD, which
367
373
  must be clean, on trunk and pushed, and carry a
368
374
  ci/green tag: compile every environment in the
@@ -383,6 +389,18 @@ Releases:
383
389
  trunk from a temporary worktree instead of HEAD, so
384
390
  the checkout need not be clean. The label reaches
385
391
  every compile and build as CHANT_RELEASE_LABEL.
392
+ A green commit of a pushed chant/compose/<name>
393
+ branch is cut too, recording its base and sources.
394
+ --channel <tag> records the tag a publish moves in
395
+ place of latest.
396
+ release compose <name> [--base <label|sha>] (--pr <n> | --work <id>)...
397
+ [--yes] [--json]
398
+ Cherry-pick the chosen trunk changes, in trunk order,
399
+ onto the base, each with a Chant-Composed-From
400
+ trailer, on chant/compose/<name>. Appends to a branch
401
+ that exists; stops on a conflict, naming the change
402
+ and files, and writes nothing. A dry run unless --yes,
403
+ which moves the branch and pushes it.
386
404
  release candidates [--env <env>] [--json]
387
405
  The green commits of trunk a release can be cut from,
388
406
  newest first, each with the commits, work items, pull
@@ -405,6 +423,11 @@ Releases:
405
423
  release verify <label|digest> [--json]
406
424
  Check, offline, that the release was signed by a key
407
425
  .chant/allowed_signers lists at the release's commit.
426
+ release workflow [--forge github|gitlab|forgejo] [--install <command>]
427
+ [--build <command>] [--chant <command>] [--output <file>]
428
+ Write the publish workflow, run on a release/** tag.
429
+ Under supply-chain it passes --require-provenance, and
430
+ under verified-releases it runs release verify first
408
431
  release list [--json] Every release of this member with its targets, its
409
432
  cuts and the environments that run it.
410
433
  release export <label|digest> --env <env> [-o <dir>] [--json]
@@ -999,6 +1022,10 @@ Component release ledger + status:
999
1022
  planned after the one before applied, a moved
1000
1023
  wave waiting at <name>-wave-<k>, with --wave <k>
1001
1024
  for one wave per CI job;
1025
+ --green: apply the green commit at HEAD, measured
1026
+ from the one last applied to --env, under the
1027
+ approved plans of the merges since, else at gate
1028
+ op green-<sha>;
1002
1029
  --events-json, both commands: the run-event
1003
1030
  stream on stdout; --report-url <s3 or file URL>,
1004
1031
  both commands: copy the report, the change set
@@ -1022,7 +1049,11 @@ Component release ledger + status:
1022
1049
  [--component <name> [--digest <sha256:...>]];
1023
1050
  --from defaults to the environment before --to
1024
1051
  in the config's environments; --dry-run prints
1025
- the plan; the target's gates still apply)
1052
+ the plan; the target's gates still apply. Under
1053
+ --release a dependent plans against its
1054
+ upstream's planned outputs and plans again once
1055
+ it applies; --wave-gate <gate> checks each wave
1056
+ at <gate>-wave-<k>; a stop writes --resume <file>)
1026
1057
  components rollback <env> Redeploy an earlier release of an environment
1027
1058
  from its recorded digest (--component <name>
1028
1059
  [--digest <sha256:...>]; defaults to the release
@@ -1437,6 +1468,7 @@ export const commandRegistry: CommandDef[] = [
1437
1468
  { name: "ci list", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/ci-green-cli")).runCiList(ctx) },
1438
1469
  { name: "ci tick", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/ci-green-cli")).runCiTick(ctx) },
1439
1470
  { name: "ci workflow", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/ci-green-cli")).runCiWorkflow(ctx) },
1471
+ { name: "ci init", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/ci-init")).runCiInit(ctx) },
1440
1472
  // #4094: one pull-request job as a pipeline Op in a fresh checkout, the chant runner's first slice.
1441
1473
  { name: "ci run", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/ci-run")).runCiRun(ctx) },
1442
1474
  // #4098: the run index at the report URL.
@@ -1450,6 +1482,9 @@ export const commandRegistry: CommandDef[] = [
1450
1482
  { name: "release publish", runsNoConfig: true, handler: async (ctx) => (await import("./handlers/release")).runReleasePublish(ctx) },
1451
1483
  // #4049: check a release's signature offline.
1452
1484
  { name: "release verify", runsNoConfig: true, handler: async (ctx) => (await import("./handlers/release")).runReleaseVerify(ctx) },
1485
+ { name: "release workflow", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/release-workflow")).runReleaseWorkflow(ctx) },
1486
+ // #4168 4.3: mode (b), chosen changes composed onto a base on chant/compose/<name>.
1487
+ { name: "release compose", runsNoConfig: true, handler: async (ctx) => (await import("./handlers/release")).runReleaseCompose(ctx) },
1453
1488
  { name: "release candidates", runsNoConfig: true, handler: async (ctx) => (await import("./handlers/release")).runReleaseCandidates(ctx) },
1454
1489
  { name: "release list", runsNoConfig: true, handler: async (ctx) => (await import("./handlers/release")).runReleaseList(ctx) },
1455
1490
  { name: "release export", runsNoConfig: true, handler: async (ctx) => (await import("./handlers/release")).runReleaseExport(ctx) },
@@ -385,6 +385,12 @@ export function parseArgs(args: string[]): ParsedArgs {
385
385
  const pr = Number(raw);
386
386
  if (!Number.isInteger(pr) || pr < 1) throw new Error(`--pr needs a pull or merge request number: got "${raw ?? ""}"`);
387
387
  result.pr = pr;
388
+ // `chant release compose --pr <n>...` (#4168 4.3) reads every one.
389
+ (result.prs ??= []).push(pr);
390
+ } else if (arg === "--channel") {
391
+ // `chant release cut --channel <tag>` (#4168 4.3): the tag a publish moves in place of latest.
392
+ result.channel = args[++i];
393
+ if (!result.channel || !/^[A-Za-z][A-Za-z0-9._-]*$/.test(result.channel)) throw new Error(`--channel needs a channel name, such as next or hotfix: got "${result.channel ?? ""}"`);
388
394
  } else if (arg === "--forge") {
389
395
  result.forge = args[++i];
390
396
  if (!result.forge || result.forge.startsWith("-")) throw new Error("--forge needs a forge: --forge github|gitlab|forgejo");
@@ -410,6 +416,9 @@ export function parseArgs(args: string[]): ParsedArgs {
410
416
  result.requireProvenance = true;
411
417
  } else if (arg === "--pr-loop") {
412
418
  result.prLoop = true;
419
+ } else if (arg === "--green") {
420
+ // `chant components pr-apply --green` (#4168 step 9): apply the green commit at HEAD.
421
+ result.green = true;
413
422
  } else if (arg === "--apply-on-merge") {
414
423
  // `chant build --pr-loop --apply-on-merge` and `chant ci serve --apply-on-merge` (#4168): opt into applying on merge.
415
424
  result.applyOnMerge = true;
@@ -421,6 +430,9 @@ export function parseArgs(args: string[]): ParsedArgs {
421
430
  const line = args[++i];
422
431
  if (line === undefined || line === "") throw new Error("--build-command needs a shell line: --build-command '<line>'");
423
432
  result.ciBuildCommand = line;
433
+ } else if (arg === "--land") {
434
+ // `chant ci serve --land` (#4168 4.4): fast-forward the target to a chant/land/<name> branch once its pre-merge phases pass.
435
+ result.ciLand = true;
424
436
  } else if (arg === "--pr-build-command") {
425
437
  const line = args[++i];
426
438
  if (line === undefined || line === "") throw new Error("--pr-build-command needs a shell line: --pr-build-command '<line>'");
@@ -540,6 +552,11 @@ export function parseArgs(args: string[]): ParsedArgs {
540
552
  // `chant workspace wip --branch <branch>` (#3172)
541
553
  result.branch = args[++i];
542
554
  if (!result.branch || result.branch.startsWith("-")) throw new Error("--branch needs a branch name: --branch <branch>");
555
+ } else if (arg === "--security") {
556
+ // `chant ci init --security <group>`, repeatable (#4168 step 6): a security group to turn on.
557
+ const group = args[++i];
558
+ if (!group || group.startsWith("-")) throw new Error("--security needs a group: --security verified-releases|supply-chain|who-ran-it|review-rules|isolated-runners");
559
+ result.securityGroups = [...(result.securityGroups ?? []), group];
543
560
  } else if (arg === "--workflow") {
544
561
  // `chant ci workflow --workflow <name>`, repeatable (#3573)
545
562
  const name = args[++i];
@@ -817,6 +834,7 @@ export function parseArgs(args: string[]): ParsedArgs {
817
834
  const value = args[++i];
818
835
  if (!value || value.startsWith("-")) throw new Error("--work needs a work item id: --work <id>");
819
836
  result.work = value;
837
+ (result.works ??= []).push(value);
820
838
  } else if (arg === "--holder" || arg === "--ttl" || arg === "--token" || arg === "--outcome") {
821
839
  // `chant workspace work claim|renew|release <id>` (#2732)
822
840
  const value = args[++i];
@@ -302,6 +302,10 @@ export interface ParsedArgs {
302
302
  * pr-apply finds it from the commit through `--forge` when omitted.
303
303
  */
304
304
  pr?: number;
305
+ /** Every `--pr <n>`, in order: `chant release compose` (#4168 4.3) takes several. */
306
+ prs?: number[];
307
+ /** `chant release cut --channel <tag>` (#4168 4.3): the tag a publish of the release moves in place of `latest`. */
308
+ channel?: string;
305
309
  /**
306
310
  * `chant components pr-plan|pr-apply --forge github|gitlab|forgejo` (#3183)
307
311
  * — post the note and the commit status on this forge, with the client
@@ -335,10 +339,18 @@ export interface ParsedArgs {
335
339
  * #4168, in place of recording it. Opt-in: a merge deploys nothing without it.
336
340
  */
337
341
  applyOnMerge?: boolean;
342
+ /**
343
+ * `chant components pr-apply --green` (#4168 step 9): apply the green commit
344
+ * at HEAD, measured from the one last applied to the environment, under the
345
+ * approved plans of the merges between or a gate of its own.
346
+ */
347
+ green?: boolean;
338
348
  /** `chant build --pr-loop --pr-build` (#4088) — build and lint before pr-plan and pr-apply. */
339
349
  prBuild?: boolean;
340
350
  /** `chant ci serve --build-command <line>` (#4169) — the line that builds a commit a `ci/build/<sha>` tag asks for, in a worktree of it. */
341
351
  ciBuildCommand?: string;
352
+ /** `chant ci serve --land` (#4168 4.4) — fast-forward the target branch to each pushed `chant/land/<name>` branch whose `ci.green.preMerge` phases pass on it. */
353
+ ciLand?: boolean;
342
354
  /** `chant build --pr-loop --pr-build-command <line>` (#4088) — the build line to run in place of `chant build --env <env>`. */
343
355
  prBuildCommand?: string;
344
356
  /**
@@ -467,8 +479,10 @@ export interface ParsedArgs {
467
479
  label?: string;
468
480
  /** `chant workspace wip --branch <branch>` (#3172): list one branch's snapshots. */
469
481
  branch?: string;
470
- /** `chant ci workflow --workflow <name>`, repeatable (#3573): a workflow whose completion triggers a tick, beside those the scan finds. */
482
+ /** `chant ci workflow --workflow <name>`, repeatable (#3573): a workflow whose completion triggers a tick, beside those the scan finds. Also `chant ci init --workflow <name>` (#4168 step 6), the prescribed workflow to set up. */
471
483
  ciWorkflows?: string[];
484
+ /** `chant ci init --security <group>`, repeatable (#4168 step 6): a security group to turn on. */
485
+ securityGroups?: string[];
472
486
  /** `chant ci run plan|apply --commit <sha>` (#4094): the commit the job runs at. Default `HEAD`. */
473
487
  commit?: string;
474
488
  /** `chant ci run --pass-env <NAME>`, repeatable (#4094): a variable of the runner's environment the job gets, beside the few it always gets. */
@@ -699,6 +713,8 @@ export interface ParsedArgs {
699
713
  holder?: string;
700
714
  /** `chant run <op> --work <id>` (#2748): the work item an Op with a work lease runs under. `chant workspace check --changes --work <id>` (#2773): the work item in hand. */
701
715
  work?: string;
716
+ /** Every `--work <id>`, in order: `chant release compose` (#4168 4.3) takes several. */
717
+ works?: string[];
702
718
  /** `chant workspace work claim|renew <id> --ttl <seconds|duration>` (#2732): how long the lease lasts unless renewed. */
703
719
  ttl?: string;
704
720
  /** `chant workspace work renew|release <id> --token <token>` (#2732): the fencing token the caller holds. */