@intentius/chant 0.124.34 → 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 (169) 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 +23 -8
  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 +19 -1
  14. package/dist/cli/parse-args.d.ts.map +1 -1
  15. package/dist/cli/parse-args.js +17 -0
  16. package/dist/cli/registry.d.ts +14 -0
  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.d.ts +4 -1
  28. package/dist/components/promote.d.ts.map +1 -1
  29. package/dist/components/promote.js +6 -3
  30. package/dist/components/release-promote.d.ts +9 -0
  31. package/dist/components/release-promote.d.ts.map +1 -1
  32. package/dist/components/release-promote.js +15 -0
  33. package/dist/config.d.ts +2 -0
  34. package/dist/config.d.ts.map +1 -1
  35. package/dist/deep-observation.d.ts +11 -0
  36. package/dist/deep-observation.d.ts.map +1 -1
  37. package/dist/graph-declared.d.ts +5 -1
  38. package/dist/graph-declared.d.ts.map +1 -1
  39. package/dist/graph-declared.js +7 -2
  40. package/dist/graph-detail.d.ts.map +1 -1
  41. package/dist/graph-detail.js +30 -1
  42. package/dist/graph-ir.d.ts.map +1 -1
  43. package/dist/graph-ir.js +13 -2
  44. package/dist/lifecycle/__fixtures__/publish-fakes.d.ts.map +1 -1
  45. package/dist/lifecycle/__fixtures__/publish-fakes.js +5 -1
  46. package/dist/lifecycle/compose-refs.d.ts +52 -0
  47. package/dist/lifecycle/compose-refs.d.ts.map +1 -0
  48. package/dist/lifecycle/compose-refs.js +115 -0
  49. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  50. package/dist/lifecycle/deep-observe.js +3 -2
  51. package/dist/lifecycle/green-apply.d.ts +39 -0
  52. package/dist/lifecycle/green-apply.d.ts.map +1 -0
  53. package/dist/lifecycle/green-apply.js +51 -0
  54. package/dist/lifecycle/publish-declaration.d.ts +4 -0
  55. package/dist/lifecycle/publish-declaration.d.ts.map +1 -1
  56. package/dist/lifecycle/publish-declaration.js +15 -0
  57. package/dist/lifecycle/publish-npm.d.ts +7 -4
  58. package/dist/lifecycle/publish-npm.d.ts.map +1 -1
  59. package/dist/lifecycle/publish-npm.js +60 -13
  60. package/dist/lifecycle/release-compose.d.ts +91 -0
  61. package/dist/lifecycle/release-compose.d.ts.map +1 -0
  62. package/dist/lifecycle/release-compose.js +235 -0
  63. package/dist/lifecycle/release-cut.d.ts +8 -0
  64. package/dist/lifecycle/release-cut.d.ts.map +1 -1
  65. package/dist/lifecycle/release-cut.js +2 -0
  66. package/dist/lifecycle/release-publish.d.ts +2 -0
  67. package/dist/lifecycle/release-publish.d.ts.map +1 -1
  68. package/dist/lifecycle/release-publish.js +2 -1
  69. package/dist/lifecycle/release-store.d.ts +15 -0
  70. package/dist/lifecycle/release-store.d.ts.map +1 -1
  71. package/dist/lifecycle/release-store.js +5 -1
  72. package/dist/wave-runtime.mjs +225 -223
  73. package/dist/workspace/checks/ci-workflow.d.ts.map +1 -1
  74. package/dist/workspace/checks/ci-workflow.js +8 -1
  75. package/dist/workspace/ci-green-cli.d.ts.map +1 -1
  76. package/dist/workspace/ci-green-cli.js +5 -3
  77. package/dist/workspace/ci-green-workflow.d.ts +18 -2
  78. package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
  79. package/dist/workspace/ci-green-workflow.js +27 -5
  80. package/dist/workspace/ci-green.d.ts +31 -0
  81. package/dist/workspace/ci-green.d.ts.map +1 -1
  82. package/dist/workspace/ci-green.js +78 -4
  83. package/dist/workspace/ci-run.d.ts +6 -0
  84. package/dist/workspace/ci-run.d.ts.map +1 -1
  85. package/dist/workspace/ci-run.js +2 -1
  86. package/dist/workspace/ci-serve.d.ts +94 -13
  87. package/dist/workspace/ci-serve.d.ts.map +1 -1
  88. package/dist/workspace/ci-serve.js +248 -30
  89. package/dist/workspace/ci-workflow.d.ts +4 -4
  90. package/dist/workspace/ci-workflow.d.ts.map +1 -1
  91. package/dist/workspace/ci-workflow.js +2 -2
  92. package/dist/workspace/delivery-metrics.d.ts +3 -1
  93. package/dist/workspace/delivery-metrics.d.ts.map +1 -1
  94. package/dist/workspace/delivery-metrics.js +12 -3
  95. package/dist/workspace/member-commands.d.ts.map +1 -1
  96. package/dist/workspace/member-commands.js +4 -0
  97. package/dist/workspace/nested-graph.d.ts.map +1 -1
  98. package/dist/workspace/nested-graph.js +1 -0
  99. package/dist/workspace/release-candidates.d.ts +28 -0
  100. package/dist/workspace/release-candidates.d.ts.map +1 -1
  101. package/dist/workspace/release-candidates.js +38 -2
  102. package/dist/workspace/release-candidates.schema.json +51 -0
  103. package/dist/workspace/releases.schema.json +30 -0
  104. package/package.json +1 -1
  105. package/src/cli/handlers/graph.ts +2 -2
  106. package/src/cli/handlers/pr-green.test.ts +230 -0
  107. package/src/cli/handlers/pr.ts +148 -66
  108. package/src/cli/handlers/promote.test.ts +34 -0
  109. package/src/cli/handlers/promote.ts +22 -8
  110. package/src/cli/handlers/release-compose.e2e.test.ts +133 -0
  111. package/src/cli/handlers/release.ts +112 -10
  112. package/src/cli/handlers/run.ts +12 -1
  113. package/src/cli/main.ts +19 -1
  114. package/src/cli/parse-args.ts +13 -0
  115. package/src/cli/registry.ts +14 -0
  116. package/src/codegen/release-wiring.test.ts +56 -9
  117. package/src/components/auto-release.test.ts +22 -0
  118. package/src/components/auto-release.ts +9 -2
  119. package/src/components/green-cover.test.ts +170 -0
  120. package/src/components/green-cover.ts +186 -0
  121. package/src/components/pr-pipeline.test.ts +14 -4
  122. package/src/components/pr-pipeline.ts +56 -5
  123. package/src/components/promote.test.ts +7 -0
  124. package/src/components/promote.ts +9 -4
  125. package/src/components/release-promote.test.ts +24 -1
  126. package/src/components/release-promote.ts +15 -0
  127. package/src/deep-observation.ts +8 -0
  128. package/src/graph-declared.test.ts +18 -0
  129. package/src/graph-declared.ts +8 -1
  130. package/src/graph-detail.test.ts +68 -1
  131. package/src/graph-detail.ts +29 -1
  132. package/src/graph-ir.ts +12 -4
  133. package/src/lifecycle/__fixtures__/publish-fakes.ts +5 -1
  134. package/src/lifecycle/compose-refs.ts +125 -0
  135. package/src/lifecycle/deep-observe.test.ts +20 -0
  136. package/src/lifecycle/deep-observe.ts +4 -2
  137. package/src/lifecycle/green-apply.ts +71 -0
  138. package/src/lifecycle/publish-declaration.ts +15 -0
  139. package/src/lifecycle/publish-npm.ts +55 -13
  140. package/src/lifecycle/publish-targets.test.ts +45 -0
  141. package/src/lifecycle/release-compose.test.ts +125 -0
  142. package/src/lifecycle/release-compose.ts +312 -0
  143. package/src/lifecycle/release-cut.ts +6 -0
  144. package/src/lifecycle/release-publish.ts +4 -1
  145. package/src/lifecycle/release-store.ts +16 -1
  146. package/src/workspace/checks/ci-workflow.test.ts +12 -2
  147. package/src/workspace/checks/ci-workflow.ts +7 -1
  148. package/src/workspace/ci-green-cli.ts +5 -3
  149. package/src/workspace/ci-green-workflow.test.ts +52 -0
  150. package/src/workspace/ci-green-workflow.ts +34 -5
  151. package/src/workspace/ci-green.test.ts +33 -0
  152. package/src/workspace/ci-green.ts +85 -4
  153. package/src/workspace/ci-run.test.ts +18 -13
  154. package/src/workspace/ci-run.ts +8 -1
  155. package/src/workspace/ci-serve-land.e2e.test.ts +138 -0
  156. package/src/workspace/ci-serve-land.test.ts +176 -0
  157. package/src/workspace/ci-serve.test.ts +77 -43
  158. package/src/workspace/ci-serve.ts +268 -34
  159. package/src/workspace/ci-workflow.ts +4 -4
  160. package/src/workspace/delivery-metrics.ts +12 -3
  161. package/src/workspace/graph-contract.test.ts +3 -2
  162. package/src/workspace/member-commands.test.ts +11 -4
  163. package/src/workspace/member-commands.ts +3 -0
  164. package/src/workspace/nested-graph.test.ts +4 -0
  165. package/src/workspace/nested-graph.ts +1 -0
  166. package/src/workspace/release-candidates.schema.json +51 -0
  167. package/src/workspace/release-candidates.test.ts +30 -3
  168. package/src/workspace/release-candidates.ts +54 -2
  169. package/src/workspace/releases.schema.json +30 -0
@@ -21,6 +21,7 @@
21
21
  * ran from it, through the release gate `promote --release` binds (#4045).
22
22
  */
23
23
 
24
+ import { ENV_VAR } from "../../env";
24
25
  import { dirname, join, resolve } from "node:path";
25
26
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
26
27
  import { loadChantConfig, type ChantConfig } from "../../config";
@@ -116,9 +117,16 @@ function resolveActor(ctx: CommandContext): string | undefined {
116
117
  return ctx.args.actor ?? process.env.GITHUB_ACTOR ?? process.env.GITLAB_USER_LOGIN ?? process.env.USER;
117
118
  }
118
119
 
119
- /** Config, params and the declared components, or the exit code of a refusal already printed. */
120
- async function loadProject(ctx: CommandContext, selector: string) {
120
+ /**
121
+ * Config, params and the declared components, or the exit code of a refusal
122
+ * already printed. `env` is the environment being deployed: it is set as
123
+ * `CHANT_ENV` before the components load, as `--env` sets it for `chant run`
124
+ * (../main.ts), so a component that reads it (a stack name per environment)
125
+ * deploys to that environment and not to the one it was promoted from (#4222).
126
+ */
127
+ async function loadProject(ctx: CommandContext, selector: string, env: string) {
121
128
  const { args } = ctx;
129
+ process.env[ENV_VAR] = env;
122
130
  const projectPath = resolve(".");
123
131
  const { config } = await loadChantConfig(projectPath).catch(() => ({ config: {} as ChantConfig }));
124
132
  const paramsResolution = resolveCliBuildParams(config.buildParams, {
@@ -397,17 +405,22 @@ async function deployPlan(
397
405
  const prepared: DriverComponent[] = [];
398
406
  const removed = new Map<string, string[]>();
399
407
  const refusals: string[] = [];
408
+ // #4222: a component with no artifact, deployed from the templates the release records, and its record's digest.
409
+ const templateDigests = new Map<string, string>();
410
+ const { releaseTemplatesDigest } = await import("../../components/release-promote");
400
411
  for (const item of plan.items) {
412
+ const templates = under ? releaseTemplatesDigest(under.target, item.component) : null;
401
413
  // A rollback and a redeploy deploy a release this environment already
402
414
  // received, so neither runs the publish step.
403
415
  const result = withoutBuildSteps(
404
416
  byName.get(item.component)!,
405
- verb === "promote" ? {} : { pinDigest: item.digest, verb: `a ${verb}` },
417
+ { ...(verb === "promote" ? {} : { pinDigest: item.digest, verb: `a ${verb}` }), templatesPinned: templates !== null },
406
418
  );
407
419
  if ("error" in result) refusals.push(result.error);
408
420
  else {
409
421
  prepared.push(result.component);
410
422
  removed.set(item.component, result.removed);
423
+ if (result.templateOnly && templates !== null) templateDigests.set(item.component, templates);
411
424
  }
412
425
  }
413
426
  if (refusals.length > 0) {
@@ -647,7 +660,8 @@ async function deployPlan(
647
660
  const approver = gateApprover(result);
648
661
  const admission = gateAdmission(result);
649
662
  const input = record(item, plan.to, { runId, ...(runOrigin ? { runOrigin } : {}), actor: actor!, timestamp, ...(approver ? { approver } : {}), ...(admission ? { admission } : {}) });
650
- const { record: written } = await appendReleaseRecord(under ? { ...input, releaseDigest: under.release.releaseDigest } : input);
663
+ const templates = templateDigests.get(item.component);
664
+ const { record: written } = await appendReleaseRecord(under ? { ...input, ...(templates ? { digest: templates } : {}), releaseDigest: under.release.releaseDigest } : input);
651
665
  recorded.push(written);
652
666
  }
653
667
  if (outcome) await appendDeployOutcome(outcome);
@@ -872,7 +886,7 @@ export async function runComponentsPromote(ctx: CommandContext): Promise<number>
872
886
  const who = actorOrRefuse(ctx);
873
887
  if (typeof who === "number") return who;
874
888
 
875
- const project = await loadProject(ctx, args.component ?? "all");
889
+ const project = await loadProject(ctx, args.component ?? "all", to);
876
890
  if (typeof project === "number") return project;
877
891
 
878
892
  // The member's `environments` array is its promotion order (#4030). It
@@ -958,7 +972,7 @@ export async function runComponentsRollback(ctx: CommandContext): Promise<number
958
972
  if (typeof who === "number") return who;
959
973
  if (args.release) return runReleaseRollback(ctx, env, args.release, who.actor);
960
974
 
961
- const project = await loadProject(ctx, args.component!);
975
+ const project = await loadProject(ctx, args.component!, env);
962
976
  if (typeof project === "number") return project;
963
977
 
964
978
  await fetchLifecycle().catch(() => false);
@@ -988,7 +1002,7 @@ export async function runComponentsRollback(ctx: CommandContext): Promise<number
988
1002
  * runs now, as `promote --release` binds them (#4045).
989
1003
  */
990
1004
  async function runReleaseRollback(ctx: CommandContext, env: string, ref: string, actor: string | undefined): Promise<number> {
991
- const project = await loadProject(ctx, "all");
1005
+ const project = await loadProject(ctx, "all", env);
992
1006
  if (typeof project === "number") return project;
993
1007
  await fetchLifecycle().catch(() => false);
994
1008
  const records = await readLedger(env);
@@ -1025,7 +1039,7 @@ export async function runComponentsRedeploy(ctx: CommandContext): Promise<number
1025
1039
  const who = actorOrRefuse(ctx);
1026
1040
  if (typeof who === "number") return who;
1027
1041
 
1028
- const project = await loadProject(ctx, args.component);
1042
+ const project = await loadProject(ctx, args.component, env);
1029
1043
  if (typeof project === "number") return project;
1030
1044
 
1031
1045
  await fetchLifecycle().catch(() => false);
@@ -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
@@ -368,7 +368,7 @@ CI:
368
368
  Releases:
369
369
  release cut [<label>] [--at <sha>] [--yes] [--build <command>] [--param <k=v>]
370
370
  [--params-file <file>] [--src <dir>] [-o <file>] [--lexicon <name>]
371
- [--actor <name>] [--sign [<key file>]] [--json]
371
+ [--actor <name>] [--sign [<key file>]] [--channel <tag>] [--json]
372
372
  A dry run of a cut of this member from HEAD, which
373
373
  must be clean, on trunk and pushed, and carry a
374
374
  ci/green tag: compile every environment in the
@@ -389,6 +389,18 @@ Releases:
389
389
  trunk from a temporary worktree instead of HEAD, so
390
390
  the checkout need not be clean. The label reaches
391
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.
392
404
  release candidates [--env <env>] [--json]
393
405
  The green commits of trunk a release can be cut from,
394
406
  newest first, each with the commits, work items, pull
@@ -1010,6 +1022,10 @@ Component release ledger + status:
1010
1022
  planned after the one before applied, a moved
1011
1023
  wave waiting at <name>-wave-<k>, with --wave <k>
1012
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>;
1013
1029
  --events-json, both commands: the run-event
1014
1030
  stream on stdout; --report-url <s3 or file URL>,
1015
1031
  both commands: copy the report, the change set
@@ -1467,6 +1483,8 @@ export const commandRegistry: CommandDef[] = [
1467
1483
  // #4049: check a release's signature offline.
1468
1484
  { name: "release verify", runsNoConfig: true, handler: async (ctx) => (await import("./handlers/release")).runReleaseVerify(ctx) },
1469
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) },
1470
1488
  { name: "release candidates", runsNoConfig: true, handler: async (ctx) => (await import("./handlers/release")).runReleaseCandidates(ctx) },
1471
1489
  { name: "release list", runsNoConfig: true, handler: async (ctx) => (await import("./handlers/release")).runReleaseList(ctx) },
1472
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>'");
@@ -822,6 +834,7 @@ export function parseArgs(args: string[]): ParsedArgs {
822
834
  const value = args[++i];
823
835
  if (!value || value.startsWith("-")) throw new Error("--work needs a work item id: --work <id>");
824
836
  result.work = value;
837
+ (result.works ??= []).push(value);
825
838
  } else if (arg === "--holder" || arg === "--ttl" || arg === "--token" || arg === "--outcome") {
826
839
  // `chant workspace work claim|renew|release <id>` (#2732)
827
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
  /**
@@ -701,6 +713,8 @@ export interface ParsedArgs {
701
713
  holder?: string;
702
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. */
703
715
  work?: string;
716
+ /** Every `--work <id>`, in order: `chant release compose` (#4168 4.3) takes several. */
717
+ works?: string[];
704
718
  /** `chant workspace work claim|renew <id> --ttl <seconds|duration>` (#2732): how long the lease lasts unless renewed. */
705
719
  ttl?: string;
706
720
  /** `chant workspace work renew|release <id> --token <token>` (#2732): the fencing token the caller holds. */