@intentius/chant 0.124.34 → 0.124.36

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 (171) 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 +3 -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 +5 -0
  45. package/dist/lifecycle/__fixtures__/publish-fakes.d.ts.map +1 -1
  46. package/dist/lifecycle/__fixtures__/publish-fakes.js +20 -2
  47. package/dist/lifecycle/compose-refs.d.ts +52 -0
  48. package/dist/lifecycle/compose-refs.d.ts.map +1 -0
  49. package/dist/lifecycle/compose-refs.js +115 -0
  50. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  51. package/dist/lifecycle/deep-observe.js +3 -2
  52. package/dist/lifecycle/green-apply.d.ts +39 -0
  53. package/dist/lifecycle/green-apply.d.ts.map +1 -0
  54. package/dist/lifecycle/green-apply.js +51 -0
  55. package/dist/lifecycle/publish-declaration.d.ts +6 -0
  56. package/dist/lifecycle/publish-declaration.d.ts.map +1 -1
  57. package/dist/lifecycle/publish-declaration.js +21 -0
  58. package/dist/lifecycle/publish-npm.d.ts +11 -4
  59. package/dist/lifecycle/publish-npm.d.ts.map +1 -1
  60. package/dist/lifecycle/publish-npm.js +104 -14
  61. package/dist/lifecycle/release-compose.d.ts +91 -0
  62. package/dist/lifecycle/release-compose.d.ts.map +1 -0
  63. package/dist/lifecycle/release-compose.js +235 -0
  64. package/dist/lifecycle/release-cut.d.ts +8 -0
  65. package/dist/lifecycle/release-cut.d.ts.map +1 -1
  66. package/dist/lifecycle/release-cut.js +2 -0
  67. package/dist/lifecycle/release-publish.d.ts +6 -0
  68. package/dist/lifecycle/release-publish.d.ts.map +1 -1
  69. package/dist/lifecycle/release-publish.js +2 -1
  70. package/dist/lifecycle/release-store.d.ts +15 -0
  71. package/dist/lifecycle/release-store.d.ts.map +1 -1
  72. package/dist/lifecycle/release-store.js +5 -1
  73. package/dist/wave-runtime.mjs +225 -223
  74. package/dist/workspace/checks/ci-workflow.d.ts.map +1 -1
  75. package/dist/workspace/checks/ci-workflow.js +8 -1
  76. package/dist/workspace/ci-green-cli.d.ts.map +1 -1
  77. package/dist/workspace/ci-green-cli.js +5 -3
  78. package/dist/workspace/ci-green-workflow.d.ts +18 -2
  79. package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
  80. package/dist/workspace/ci-green-workflow.js +27 -5
  81. package/dist/workspace/ci-green.d.ts +31 -0
  82. package/dist/workspace/ci-green.d.ts.map +1 -1
  83. package/dist/workspace/ci-green.js +78 -4
  84. package/dist/workspace/ci-run.d.ts +6 -0
  85. package/dist/workspace/ci-run.d.ts.map +1 -1
  86. package/dist/workspace/ci-run.js +2 -1
  87. package/dist/workspace/ci-serve.d.ts +94 -13
  88. package/dist/workspace/ci-serve.d.ts.map +1 -1
  89. package/dist/workspace/ci-serve.js +248 -30
  90. package/dist/workspace/ci-workflow.d.ts +4 -4
  91. package/dist/workspace/ci-workflow.d.ts.map +1 -1
  92. package/dist/workspace/ci-workflow.js +2 -2
  93. package/dist/workspace/delivery-metrics.d.ts +3 -1
  94. package/dist/workspace/delivery-metrics.d.ts.map +1 -1
  95. package/dist/workspace/delivery-metrics.js +12 -3
  96. package/dist/workspace/member-commands.d.ts.map +1 -1
  97. package/dist/workspace/member-commands.js +4 -0
  98. package/dist/workspace/nested-graph.d.ts.map +1 -1
  99. package/dist/workspace/nested-graph.js +1 -0
  100. package/dist/workspace/release-candidates.d.ts +28 -0
  101. package/dist/workspace/release-candidates.d.ts.map +1 -1
  102. package/dist/workspace/release-candidates.js +38 -2
  103. package/dist/workspace/release-candidates.schema.json +51 -0
  104. package/dist/workspace/releases.schema.json +30 -0
  105. package/package.json +1 -1
  106. package/src/cli/handlers/graph.ts +2 -2
  107. package/src/cli/handlers/pr-green.test.ts +230 -0
  108. package/src/cli/handlers/pr.ts +148 -66
  109. package/src/cli/handlers/promote.test.ts +34 -0
  110. package/src/cli/handlers/promote.ts +22 -8
  111. package/src/cli/handlers/release-compose.e2e.test.ts +133 -0
  112. package/src/cli/handlers/release-publish-targets.e2e.test.ts +4 -1
  113. package/src/cli/handlers/release.ts +112 -10
  114. package/src/cli/handlers/run.ts +12 -1
  115. package/src/cli/main.ts +19 -1
  116. package/src/cli/parse-args.ts +13 -0
  117. package/src/cli/registry.ts +14 -0
  118. package/src/codegen/release-wiring.test.ts +56 -9
  119. package/src/components/auto-release.test.ts +22 -0
  120. package/src/components/auto-release.ts +9 -2
  121. package/src/components/green-cover.test.ts +170 -0
  122. package/src/components/green-cover.ts +186 -0
  123. package/src/components/pr-pipeline.test.ts +14 -4
  124. package/src/components/pr-pipeline.ts +56 -5
  125. package/src/components/promote.test.ts +7 -0
  126. package/src/components/promote.ts +9 -4
  127. package/src/components/release-promote.test.ts +24 -1
  128. package/src/components/release-promote.ts +15 -0
  129. package/src/deep-observation.ts +8 -0
  130. package/src/graph-declared.test.ts +18 -0
  131. package/src/graph-declared.ts +8 -1
  132. package/src/graph-detail.test.ts +68 -1
  133. package/src/graph-detail.ts +29 -1
  134. package/src/graph-ir.ts +12 -4
  135. package/src/lifecycle/__fixtures__/publish-fakes.ts +20 -2
  136. package/src/lifecycle/compose-refs.ts +125 -0
  137. package/src/lifecycle/deep-observe.test.ts +20 -0
  138. package/src/lifecycle/deep-observe.ts +4 -2
  139. package/src/lifecycle/green-apply.ts +71 -0
  140. package/src/lifecycle/publish-declaration.ts +21 -0
  141. package/src/lifecycle/publish-npm.ts +100 -14
  142. package/src/lifecycle/publish-targets.test.ts +108 -0
  143. package/src/lifecycle/release-compose.test.ts +125 -0
  144. package/src/lifecycle/release-compose.ts +312 -0
  145. package/src/lifecycle/release-cut.ts +6 -0
  146. package/src/lifecycle/release-publish.ts +8 -1
  147. package/src/lifecycle/release-store.ts +16 -1
  148. package/src/workspace/checks/ci-workflow.test.ts +12 -2
  149. package/src/workspace/checks/ci-workflow.ts +7 -1
  150. package/src/workspace/ci-green-cli.ts +5 -3
  151. package/src/workspace/ci-green-workflow.test.ts +52 -0
  152. package/src/workspace/ci-green-workflow.ts +34 -5
  153. package/src/workspace/ci-green.test.ts +33 -0
  154. package/src/workspace/ci-green.ts +85 -4
  155. package/src/workspace/ci-run.test.ts +18 -13
  156. package/src/workspace/ci-run.ts +8 -1
  157. package/src/workspace/ci-serve-land.e2e.test.ts +138 -0
  158. package/src/workspace/ci-serve-land.test.ts +176 -0
  159. package/src/workspace/ci-serve.test.ts +77 -43
  160. package/src/workspace/ci-serve.ts +268 -34
  161. package/src/workspace/ci-workflow.ts +4 -4
  162. package/src/workspace/delivery-metrics.ts +12 -3
  163. package/src/workspace/graph-contract.test.ts +3 -2
  164. package/src/workspace/member-commands.test.ts +11 -4
  165. package/src/workspace/member-commands.ts +3 -0
  166. package/src/workspace/nested-graph.test.ts +4 -0
  167. package/src/workspace/nested-graph.ts +1 -0
  168. package/src/workspace/release-candidates.schema.json +51 -0
  169. package/src/workspace/release-candidates.test.ts +30 -3
  170. package/src/workspace/release-candidates.ts +54 -2
  171. package/src/workspace/releases.schema.json +30 -0
@@ -0,0 +1,125 @@
1
+ /**
2
+ * `release compose` (#4168 4.3): chosen trunk commits cherry-picked onto a
3
+ * base, each with a `Chant-Composed-From` trailer, on a pushed
4
+ * `chant/compose/<name>` branch, appended to and never rewritten.
5
+ */
6
+
7
+ import { describe, test, expect } from "vitest";
8
+ import { withTestDir } from "@intentius/chant-test-utils";
9
+ import { spawnSync } from "node:child_process";
10
+ import { mkdirSync, writeFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { composeRelease, ComposeError } from "./release-compose";
13
+ import { composeBranchContaining, composedFrom, composedIncluding, compositionOf } from "./compose-refs";
14
+
15
+ function git(dir: string, ...args: string[]): string {
16
+ const r = spawnSync("git", args, { cwd: dir, encoding: "utf-8" });
17
+ if (r.status !== 0) throw new Error(r.stderr);
18
+ return r.stdout.trim();
19
+ }
20
+
21
+ /** A clone of a bare remote with trunk: base, then #1 (a.txt), #2 (b.txt, work w-2), #3 (a.txt again). */
22
+ function trunk(dir: string): { clone: string; remote: string; base: string; c1: string; c2: string; c3: string } {
23
+ const remote = join(dir, "remote.git");
24
+ const clone = join(dir, "clone");
25
+ git(dir, "init", "-q", "--bare", "-b", "main", remote);
26
+ mkdirSync(clone);
27
+ for (const args of [["init", "-q", "-b", "main"], ["config", "user.email", "t@chant.dev"], ["config", "user.name", "T"], ["remote", "add", "origin", remote]]) git(clone, ...args);
28
+ const commit = (file: string, text: string, message: string): string => {
29
+ writeFileSync(join(clone, file), text);
30
+ git(clone, "add", file);
31
+ git(clone, "commit", "-q", "-m", message);
32
+ return git(clone, "rev-parse", "HEAD");
33
+ };
34
+ const base = commit("a.txt", "a0\n", "base");
35
+ const c1 = commit("a.txt", "a1\n", "fix a (#1)");
36
+ const c2 = commit("b.txt", "b\n", "add b (#2)\n\nChant-Record: work:w-2");
37
+ const c3 = commit("a.txt", "a3\n", "rework a (#3)");
38
+ git(clone, "push", "-q", "origin", "main");
39
+ git(clone, "fetch", "-q", "origin");
40
+ return { clone, remote, base, c1, c2, c3 };
41
+ }
42
+
43
+ const TRUNK = { remote: "origin", branch: "main" };
44
+
45
+ describe("composeRelease", () => {
46
+ test("picks the chosen changes in trunk order onto the base, with trailers, and pushes the branch", async () => {
47
+ await withTestDir(async (dir) => {
48
+ const t = trunk(dir);
49
+ const dry = await composeRelease({ cwd: t.clone, name: "hotfix", base: t.base, prs: [], works: ["w-2"], yes: false, trunk: TRUNK });
50
+ expect(dry.written).toBe(false);
51
+ expect(dry.picked.map((s) => s.commit)).toEqual([t.c2]);
52
+ expect(git(t.clone, "branch", "--list", "chant/compose/hotfix")).toBe("");
53
+
54
+ const r = await composeRelease({ cwd: t.clone, name: "hotfix", base: t.base, prs: [2, 1], works: [], yes: true, trunk: TRUNK });
55
+ expect(r.picked.map((s) => s.commit)).toEqual([t.c1, t.c2]);
56
+ expect(r.picked[1].work).toEqual(["w-2"]);
57
+ expect(r.written).toBe(true);
58
+ expect(r.pushed).toBe(true);
59
+ expect(git(t.remote, "rev-parse", "refs/heads/chant/compose/hotfix")).toBe(r.tip);
60
+ expect(composedFrom(t.clone, r.tip)).toEqual([t.c2]);
61
+ expect(compositionOf(t.clone, r.tip)).toEqual({ branch: null, base: t.base, sources: [t.c1, t.c2] });
62
+ expect(composeBranchContaining(t.clone, r.tip)?.branch).toBe("chant/compose/hotfix");
63
+ expect(composedIncluding(t.clone, t.c1)).toEqual([{ branch: "chant/compose/hotfix", commit: git(t.clone, "rev-parse", `${r.tip}^`) }]);
64
+ expect(git(t.clone, "show", `${r.tip}:a.txt`)).toBe("a1");
65
+ });
66
+ });
67
+
68
+ test("appends to the branch, skips what it carries, and refuses another base", async () => {
69
+ await withTestDir(async (dir) => {
70
+ const t = trunk(dir);
71
+ const first = await composeRelease({ cwd: t.clone, name: "x", base: t.base, prs: [1], works: [], yes: true, trunk: TRUNK });
72
+ const again = await composeRelease({ cwd: t.clone, name: "x", prs: [1, 2], works: [], yes: true, trunk: TRUNK });
73
+ expect(again.from).toBe(first.tip);
74
+ expect(again.already.map((s) => s.commit)).toEqual([t.c1]);
75
+ expect(again.picked.map((s) => s.commit)).toEqual([t.c2]);
76
+ expect(git(t.clone, "rev-parse", `${again.tip}^`)).toBe(first.tip);
77
+ await expect(composeRelease({ cwd: t.clone, name: "x", base: t.c1, prs: [3], works: [], yes: true, trunk: TRUNK })).rejects.toMatchObject({ code: "compose-base-differs" });
78
+ });
79
+ });
80
+
81
+ test("stops on a conflict, names the change and the file, and writes nothing", async () => {
82
+ await withTestDir(async (dir) => {
83
+ const t = trunk(dir);
84
+ const err = await composeRelease({ cwd: t.clone, name: "y", base: t.base, prs: [3], works: [], yes: true, trunk: TRUNK }).catch((e: unknown) => e);
85
+ expect(err).toBeInstanceOf(ComposeError);
86
+ expect((err as ComposeError).code).toBe("compose-conflict");
87
+ expect((err as ComposeError).conflict).toMatchObject({ commit: t.c3, files: ["a.txt"] });
88
+ expect((err as ComposeError).message).toContain("#3");
89
+ expect(git(t.clone, "branch", "--list", "chant/compose/y")).toBe("");
90
+ expect(spawnSync("git", ["rev-parse", "--verify", "--quiet", "refs/heads/chant/compose/y"], { cwd: t.remote }).status).not.toBe(0);
91
+ expect(git(t.clone, "worktree", "list").split("\n")).toHaveLength(1);
92
+ });
93
+ });
94
+
95
+ test("refuses a change trunk does not have, a bad name, and no base for a new branch", async () => {
96
+ await withTestDir(async (dir) => {
97
+ const t = trunk(dir);
98
+ await expect(composeRelease({ cwd: t.clone, name: "z", base: t.base, prs: [9], works: [], yes: false, trunk: TRUNK })).rejects.toMatchObject({ code: "compose-source-unknown" });
99
+ await expect(composeRelease({ cwd: t.clone, name: "../z", base: t.base, prs: [1], works: [], yes: false, trunk: TRUNK })).rejects.toMatchObject({ code: "compose-name-invalid" });
100
+ await expect(composeRelease({ cwd: t.clone, name: "z", prs: [1], works: [], yes: false, trunk: TRUNK })).rejects.toMatchObject({ code: "compose-base-unknown" });
101
+ await expect(composeRelease({ cwd: t.clone, name: "z", base: t.base, prs: [], works: [], yes: false, trunk: TRUNK })).rejects.toMatchObject({ code: "compose-nothing-chosen" });
102
+ });
103
+ });
104
+ });
105
+
106
+ describe("composeRelease after a hand-made commit", () => {
107
+ test("keeps the branch's base when its tip carries no trailer", async () => {
108
+ await withTestDir(async (dir) => {
109
+ const t = trunk(dir);
110
+ const first = await composeRelease({ cwd: t.clone, name: "h", base: t.base, prs: [1], works: [], yes: true, trunk: TRUNK });
111
+ // A person resolves something on the branch and pushes it.
112
+ git(t.clone, "checkout", "-q", "chant/compose/h");
113
+ writeFileSync(join(t.clone, "c.txt"), "by hand\n");
114
+ git(t.clone, "add", "c.txt");
115
+ git(t.clone, "commit", "-q", "-m", "by hand");
116
+ git(t.clone, "push", "-q", "origin", "chant/compose/h");
117
+ git(t.clone, "checkout", "-q", "main");
118
+ const next = await composeRelease({ cwd: t.clone, name: "h", base: t.base, prs: [1, 2], works: [], yes: true, trunk: TRUNK });
119
+ expect(next.base).toBe(t.base);
120
+ expect(next.already.map((s) => s.commit)).toEqual([t.c1]);
121
+ expect(next.picked.map((s) => s.commit)).toEqual([t.c2]);
122
+ expect(git(t.clone, "rev-parse", `${next.tip}~2`)).toBe(first.tip);
123
+ });
124
+ });
125
+ });
@@ -0,0 +1,312 @@
1
+ /**
2
+ * `chant release compose` (#4168 4.3, mode b): a release of chosen changes
3
+ * on a base, when trunk carries work that is not ready to ship.
4
+ *
5
+ * The changes are named by pull request (`--pr <n>`) or work item
6
+ * (`--work <id>`) and found on trunk's first-parent line after the base:
7
+ * a pull request by the merge record `_merges/<sha>.json` on
8
+ * `chant/lifecycle` (./merge-record.ts) or the squash subject `... (#n)`,
9
+ * a work item by the commit's `Chant-Record: work:<id>` trailers or a merge
10
+ * record that names it. Each commit is cherry-picked in trunk order onto the
11
+ * base, in a temporary detached worktree, with a `Chant-Composed-From: <sha>`
12
+ * trailer, and the result goes on the branch `chant/compose/<name>`, pushed
13
+ * so the forge's CI builds it (decision 6).
14
+ *
15
+ * It only appends. A branch that exists is composed onto from its tip, with
16
+ * the base it already has; a change it already carries is skipped. On a
17
+ * conflict it stops, names the change and the files, and writes nothing: a
18
+ * person resolves it in a pull request to the compose branch.
19
+ *
20
+ * Without `yes` it is a dry run: the picks are made in the temporary
21
+ * worktree, so a conflict shows now, and no ref is moved or pushed.
22
+ */
23
+
24
+ import { execFileSync, spawnSync } from "node:child_process";
25
+ import { mkdtempSync, realpathSync, rmSync } from "node:fs";
26
+ import { tmpdir } from "node:os";
27
+ import { join } from "node:path";
28
+ import { COMPOSE_BRANCH_PREFIX, COMPOSED_FROM_TRAILER, compositionOnTrunk } from "./compose-refs";
29
+
30
+ export type ComposeCode =
31
+ | "not-a-git-repository"
32
+ | "compose-name-invalid"
33
+ | "compose-nothing-chosen"
34
+ | "compose-base-unknown"
35
+ | "compose-base-differs"
36
+ | "compose-branch-diverged"
37
+ | "compose-source-unknown"
38
+ | "compose-conflict"
39
+ | "compose-push-refused";
40
+
41
+ export class ComposeError extends Error {
42
+ constructor(
43
+ readonly code: ComposeCode,
44
+ message: string,
45
+ readonly fix?: string,
46
+ /** With `compose-conflict`: the change that did not apply and the files it conflicts in. */
47
+ readonly conflict?: { commit: string; subject: string; files: string[] },
48
+ ) {
49
+ super(message);
50
+ this.name = "ComposeError";
51
+ }
52
+ }
53
+
54
+ export interface ComposeRequest {
55
+ cwd: string;
56
+ name: string;
57
+ /** A release label or digest, or a commit. Required when the branch does not exist yet. */
58
+ base?: string;
59
+ prs: readonly number[];
60
+ works: readonly string[];
61
+ /** Move the branch and push it. Otherwise a dry run. */
62
+ yes: boolean;
63
+ /** The trunk branch, `ci.green.branch` as the cut resolves it. */
64
+ trunk: { remote: string; branch: string };
65
+ /** Push the branch to the trunk's remote with `yes`. Default true. */
66
+ push?: boolean;
67
+ }
68
+
69
+ /** One trunk commit a compose picks, and why. */
70
+ export interface ComposeSource {
71
+ commit: string;
72
+ subject: string;
73
+ pr: number | null;
74
+ work: string[];
75
+ /** The `--pr` and `--work` values that chose it. */
76
+ chosenBy: string[];
77
+ }
78
+
79
+ export interface ComposeResult {
80
+ name: string;
81
+ branch: string;
82
+ base: string;
83
+ /** The branch's tip before the compose, or null when it is new. */
84
+ from: string | null;
85
+ /** The commits picked now, in trunk order. */
86
+ picked: ComposeSource[];
87
+ /** Chosen commits the branch already carries. */
88
+ already: ComposeSource[];
89
+ /** The branch's tip after the picks. Under a dry run, the tip it would have. */
90
+ tip: string;
91
+ /** Whether the branch was moved here. False under a dry run. */
92
+ written: boolean;
93
+ /** Whether it was pushed. */
94
+ pushed: boolean;
95
+ }
96
+
97
+ const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
98
+ const PR_IN_SUBJECT = /\(#(\d+)\)\s*$/;
99
+
100
+ function git(cwd: string, args: string[], env?: NodeJS.ProcessEnv): string {
101
+ return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 64 * 1024 * 1024, ...(env ? { env } : {}) }).trim();
102
+ }
103
+
104
+ function tryGit(cwd: string, args: string[]): string | null {
105
+ try {
106
+ return git(cwd, args);
107
+ } catch {
108
+ return null;
109
+ }
110
+ }
111
+
112
+ const isAncestor = (top: string, a: string, b: string): boolean => spawnSync("git", ["merge-base", "--is-ancestor", a, b], { cwd: top, stdio: "ignore" }).status === 0;
113
+
114
+ /** The branch a compose name lives on. */
115
+ export function composeBranchName(name: string): string {
116
+ return `${COMPOSE_BRANCH_PREFIX}${name}`;
117
+ }
118
+
119
+ async function resolveBase(top: string, cwd: string, ref: string): Promise<string> {
120
+ const { resolveRelease } = await import("./release-store");
121
+ const found = await resolveRelease(ref, { cwd }).catch(() => null);
122
+ if (found) return found.release.commit;
123
+ const sha = tryGit(top, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
124
+ if (!sha) {
125
+ throw new ComposeError("compose-base-unknown", `--base ${ref} names no release of this member and no commit this clone has`, "Name a release label (chant release list) or a commit, after fetching it.");
126
+ }
127
+ return sha;
128
+ }
129
+
130
+ /** The merge record of `sha`, as stored, or null. */
131
+ async function mergeRecordOf(cwd: string, sha: string): Promise<Record<string, unknown> | null> {
132
+ const { readBlobFromPath } = await import("./git");
133
+ const text = await readBlobFromPath("_merges", `${sha}.json`, { cwd }).catch(() => null);
134
+ if (text === null) return null;
135
+ try {
136
+ const parsed = JSON.parse(text) as unknown;
137
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : null;
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
142
+
143
+ /** The pull request and work items a merge record names. */
144
+ function fromMergeRecord(merge: Record<string, unknown> | null): { pr: number | null; work: string[] } {
145
+ if (!merge) return { pr: null, work: [] };
146
+ const source = merge.source as { kind?: unknown; pr?: unknown; records?: unknown } | undefined;
147
+ const pr = source?.kind === "pull-request" && typeof source.pr === "number" ? source.pr : typeof merge.pr === "number" ? merge.pr : null;
148
+ const work =
149
+ source?.kind === "work-item" && Array.isArray(source.records)
150
+ ? source.records.filter((r): r is { kind: string; id: string } => !!r && typeof r === "object" && (r as { kind?: unknown }).kind === "work").map((r) => r.id)
151
+ : [];
152
+ return { pr, work };
153
+ }
154
+
155
+ /**
156
+ * Compose the chosen changes onto the base, on `chant/compose/<name>`.
157
+ * Throws {@link ComposeError} before any ref moves.
158
+ */
159
+ export async function composeRelease(req: ComposeRequest): Promise<ComposeResult> {
160
+ const top = tryGit(req.cwd, ["rev-parse", "--show-toplevel"]);
161
+ if (!top) throw new ComposeError("not-a-git-repository", "a compose picks commits, and this directory is not in a git repository");
162
+ if (!NAME.test(req.name)) {
163
+ throw new ComposeError("compose-name-invalid", `"${req.name}" can't name a compose branch: use letters, digits, dots, dashes and underscores, starting with a letter or digit`);
164
+ }
165
+ if (req.prs.length === 0 && req.works.length === 0) {
166
+ throw new ComposeError("compose-nothing-chosen", "a compose needs the changes it carries", "Name them: --pr <n> or --work <id>, each as often as needed.");
167
+ }
168
+ const { remote } = req.trunk;
169
+ const branch = composeBranchName(req.name);
170
+ const remoteRef = `refs/remotes/${remote}/${branch}`;
171
+ const localRef = `refs/heads/${branch}`;
172
+ // The branch as the remote has it, when the remote is reachable; a new branch is absent there.
173
+ if (tryGit(top, ["remote", "get-url", remote]) !== null) tryGit(top, ["fetch", "--quiet", "--no-tags", remote, `+${localRef}:${remoteRef}`]);
174
+ const remoteTip = tryGit(top, ["rev-parse", "--verify", "--quiet", `${remoteRef}^{commit}`]);
175
+ const localTip = tryGit(top, ["rev-parse", "--verify", "--quiet", `${localRef}^{commit}`]);
176
+ if (remoteTip && localTip && !isAncestor(top, localTip, remoteTip) && !isAncestor(top, remoteTip, localTip)) {
177
+ throw new ComposeError(
178
+ "compose-branch-diverged",
179
+ `${branch} here and on ${remote} have diverged, and a compose only appends`,
180
+ `Keep the remote's: git branch -f ${branch} ${remote}/${branch}`,
181
+ );
182
+ }
183
+ const from = remoteTip && localTip ? (isAncestor(top, localTip, remoteTip) ? remoteTip : localTip) : (remoteTip ?? localTip);
184
+ const trunkRef =
185
+ tryGit(top, ["rev-parse", "--verify", "--quiet", `refs/remotes/${remote}/${req.trunk.branch}^{commit}`]) ??
186
+ tryGit(top, ["rev-parse", "--verify", "--quiet", `refs/heads/${req.trunk.branch}^{commit}`]);
187
+ if (!trunkRef) throw new ComposeError("compose-source-unknown", `this clone has neither ${remote}/${req.trunk.branch} nor ${req.trunk.branch}, so there is no trunk to pick from`);
188
+ // What the branch carries: back to trunk, past a commit a person made on it, such as a resolved conflict.
189
+ const existing = from ? compositionOnTrunk(top, from, trunkRef, branch) : null;
190
+
191
+ let base: string;
192
+ if (from) {
193
+ base = existing?.base ?? from;
194
+ if (req.base !== undefined) {
195
+ const asked = await resolveBase(top, req.cwd, req.base);
196
+ if (asked !== base) {
197
+ throw new ComposeError(
198
+ "compose-base-differs",
199
+ `${branch} is composed on ${base.slice(0, 12)}, and --base names ${asked.slice(0, 12)}; a compose only appends to its branch`,
200
+ `Drop --base to add to ${branch}, or compose under another name.`,
201
+ );
202
+ }
203
+ }
204
+ } else {
205
+ if (req.base === undefined) throw new ComposeError("compose-base-unknown", `${branch} does not exist yet, so the compose needs --base`, "Name the release or commit to compose on: --base <label|sha>");
206
+ base = await resolveBase(top, req.cwd, req.base);
207
+ }
208
+
209
+ // Trunk's first-parent commits after the base, oldest first.
210
+ const line = (tryGit(top, ["rev-list", "--first-parent", "--reverse", trunkRef, `^${base}`]) ?? "").split("\n").filter(Boolean);
211
+ const { commitDetails } = await import("../workspace/intent");
212
+ const { recordTrailers } = await import("../workspace/release-candidates");
213
+ const details = commitDetails(top, line);
214
+
215
+ const chosen = new Map<string, ComposeSource>();
216
+ const choose = (sha: string, by: string, pr: number | null, work: string[]) => {
217
+ const entry = chosen.get(sha) ?? { commit: sha, subject: details.get(sha)?.subject ?? "", pr, work, chosenBy: [] };
218
+ if (!entry.chosenBy.includes(by)) entry.chosenBy.push(by);
219
+ chosen.set(sha, entry);
220
+ };
221
+ const unmatched = new Set<string>([...req.prs.map((n) => `--pr ${n}`), ...req.works.map((w) => `--work ${w}`)]);
222
+ for (const sha of line) {
223
+ const d = details.get(sha);
224
+ const merge = fromMergeRecord(await mergeRecordOf(req.cwd, sha));
225
+ const subjectPr = PR_IN_SUBJECT.exec(d?.subject ?? "");
226
+ const pr = merge.pr ?? (subjectPr ? Number(subjectPr[1]) : null);
227
+ const work = [...new Set([...recordTrailers(d?.trailers ?? {}).work, ...merge.work])];
228
+ for (const n of req.prs) {
229
+ if (pr === n) {
230
+ choose(sha, `--pr ${n}`, pr, work);
231
+ unmatched.delete(`--pr ${n}`);
232
+ }
233
+ }
234
+ for (const w of req.works) {
235
+ if (work.includes(w)) {
236
+ choose(sha, `--work ${w}`, pr, work);
237
+ unmatched.delete(`--work ${w}`);
238
+ }
239
+ }
240
+ }
241
+ if (unmatched.size > 0) {
242
+ throw new ComposeError(
243
+ "compose-source-unknown",
244
+ `${[...unmatched].join(", ")} names no commit on ${remote}/${req.trunk.branch} after ${base.slice(0, 12)}`,
245
+ "A pull request is found by its merge record or its squash subject (#n), and a work item by a Chant-Record: work:<id> trailer. Fetch trunk and chant/lifecycle if it merged elsewhere.",
246
+ );
247
+ }
248
+
249
+ // Trunk order, and what the branch already carries.
250
+ const carried = new Set(existing?.sources ?? []);
251
+ const ordered = line.filter((sha) => chosen.has(sha)).map((sha) => chosen.get(sha)!);
252
+ const already = ordered.filter((s) => carried.has(s.commit));
253
+ const picked = ordered.filter((s) => !carried.has(s.commit));
254
+
255
+ const tip = picked.length === 0 ? (from ?? base) : pickOnto(top, from ?? base, picked);
256
+ let written = false;
257
+ let pushed = false;
258
+ if (req.yes && picked.length > 0) {
259
+ git(top, ["update-ref", "-m", `chant release compose ${req.name}`, localRef, tip, localTip ?? ""]);
260
+ written = true;
261
+ }
262
+ if (req.yes && (req.push ?? true) && tryGit(top, ["remote", "get-url", remote]) !== null && tip !== remoteTip) {
263
+ try {
264
+ git(top, ["push", "--quiet", remote, `${tip}:${localRef}`]);
265
+ tryGit(top, ["update-ref", remoteRef, tip]);
266
+ pushed = true;
267
+ } catch (err) {
268
+ const stderr = (err as { stderr?: Buffer | string }).stderr?.toString().trim() ?? String(err);
269
+ throw new ComposeError("compose-push-refused", `${branch} was composed here at ${tip.slice(0, 12)} and ${remote} refused the push: ${stderr}`, `Push it by hand once the remote allows it: git push ${remote} ${branch}`);
270
+ }
271
+ }
272
+ return { name: req.name, branch, base, from, picked, already, tip, written, pushed };
273
+ }
274
+
275
+ /**
276
+ * Cherry-pick `sources` in order onto `start` in a temporary detached
277
+ * worktree, each with its trailer, and return the last commit. Throws
278
+ * `compose-conflict` naming the change and its files when one does not
279
+ * apply. The worktree is removed either way.
280
+ */
281
+ function pickOnto(top: string, start: string, sources: readonly ComposeSource[]): string {
282
+ const realTop = realpathSync(top);
283
+ const dir = mkdtempSync(join(tmpdir(), "chant-compose-"));
284
+ git(realTop, ["worktree", "add", "--detach", "--quiet", dir, start]);
285
+ try {
286
+ for (const s of sources) {
287
+ const parents = (tryGit(dir, ["rev-list", "--parents", "-n", "1", s.commit]) ?? "").split(" ").length - 1;
288
+ const args = ["cherry-pick", "--allow-empty", "--keep-redundant-commits", ...(parents > 1 ? ["-m", "1"] : []), s.commit];
289
+ const run = spawnSync("git", args, { cwd: dir, encoding: "utf-8" });
290
+ if (run.status !== 0) {
291
+ const files = (tryGit(dir, ["diff", "--name-only", "--diff-filter=U"]) ?? "").split("\n").filter(Boolean);
292
+ tryGit(dir, ["cherry-pick", "--abort"]);
293
+ const what = [s.pr !== null ? `#${s.pr}` : null, ...s.work.map((w) => `work:${w}`)].filter(Boolean).join(", ");
294
+ throw new ComposeError(
295
+ "compose-conflict",
296
+ `${s.commit.slice(0, 12)} (${s.subject}${what ? `; ${what}` : ""}) does not apply on the composed commits${files.length > 0 ? `: it conflicts in ${files.join(", ")}` : `: ${(run.stderr || run.stdout).trim()}`}. Nothing was written`,
297
+ "Resolve it by hand in a pull request to the compose branch, or compose without that change.",
298
+ { commit: s.commit, subject: s.subject, files },
299
+ );
300
+ }
301
+ git(dir, ["commit", "--quiet", "--amend", "--no-edit", "--allow-empty", "--no-verify", "--trailer", `${COMPOSED_FROM_TRAILER}: ${s.commit}`]);
302
+ }
303
+ return git(dir, ["rev-parse", "HEAD"]);
304
+ } finally {
305
+ try {
306
+ git(realTop, ["worktree", "remove", "--force", dir]);
307
+ } catch {
308
+ rmSync(dir, { recursive: true, force: true });
309
+ tryGit(realTop, ["worktree", "prune"]);
310
+ }
311
+ }
312
+ }
@@ -102,6 +102,10 @@ export interface CutRequest {
102
102
  * must be the principal the signers file lists the key for.
103
103
  */
104
104
  sign?: string | true;
105
+ /** The composition of a composed commit (#4168 4.3), recorded on the cut. */
106
+ composed?: { branch: string; base: string; sources: string[] };
107
+ /** The publish channel recorded on the cut (#4168 4.3), in place of `latest`. */
108
+ channel?: string;
105
109
  }
106
110
 
107
111
  export interface CutResult {
@@ -501,6 +505,8 @@ export async function cutRelease(req: CutRequest): Promise<CutResult> {
501
505
  runId: ci?.runId ?? `local-${Date.parse(timestamp)}`,
502
506
  runOrigin: ci?.runOrigin ?? { forge: "local" },
503
507
  signature,
508
+ ...(req.composed ? { composed: req.composed } : {}),
509
+ ...(req.channel ? { channel: req.channel } : {}),
504
510
  },
505
511
  { cwd },
506
512
  );
@@ -129,6 +129,10 @@ export interface PublishDeps {
129
129
  fetch?: PublishFetch;
130
130
  /** The npm executable. Default: `npm` on `PATH`. */
131
131
  npm?: string;
132
+ /** How a target waits between polls of a registry (#4244). Default: a timer. */
133
+ sleep?: (ms: number) => Promise<void>;
134
+ /** The clock a target's wait reads (#4244). Default: `Date.now`. */
135
+ now?: () => number;
132
136
  }
133
137
 
134
138
  /** The subset of the WHATWG `fetch` the targets use. */
@@ -278,6 +282,8 @@ export interface PublishPlan {
278
282
  redactor: Redactor;
279
283
  /** Whether a target with no SBOM source is refused (`publish-sbom-missing`): the workspace opts into `supply-chain` (#4168). Otherwise it publishes without one. */
280
284
  requireSbom: boolean;
285
+ /** The channel a cut of the release names (#4168 4.3), the tag moved in place of `latest`, or null. */
286
+ channel: string | null;
281
287
  }
282
288
 
283
289
  /**
@@ -399,7 +405,8 @@ export async function preparePublish(req: {
399
405
  const member = ledger.members.length > 0 ? ledger.members.join("/") : null;
400
406
  const releaseTag = label !== null ? releaseTagName(member, label) : null;
401
407
  const redactor = createRedactor(env, targets.flatMap((t) => t.credentialEnv));
402
- return { release, label, signature, provenance, warning, signatureWarning, cut, releaseTag, targets, redactor, requireSbom: groups.has("supply-chain") };
408
+ const channel = [...cuts].reverse().find((c) => c.channel)?.channel ?? null;
409
+ return { release, label, signature, provenance, warning, signatureWarning, cut, releaseTag, targets, redactor, requireSbom: groups.has("supply-chain"), channel };
403
410
  }
404
411
 
405
412
  /**
@@ -149,6 +149,17 @@ export interface ReleaseCut {
149
149
  * unsigned cut, and on every cut written before 0.124.28.
150
150
  */
151
151
  signature: string | null;
152
+ /**
153
+ * A cut of a composed commit (#4168 4.3): its compose branch, the base it
154
+ * was composed on, and the trunk commits picked onto the base, oldest
155
+ * first. Absent on a cut of a trunk commit.
156
+ */
157
+ composed?: { branch: string; base: string; sources: string[] };
158
+ /**
159
+ * The channel the release publishes to (#4168 4.3): the npm dist-tag or
160
+ * image tag a publish moves in place of `latest`. Absent for `latest`.
161
+ */
162
+ channel?: string;
152
163
  }
153
164
 
154
165
  /** The parts of a release a digest is computed from. */
@@ -364,7 +375,9 @@ export function parseCuts(text: string | null): { cuts: ReleaseCut[]; malformed:
364
375
  typeof c.timestamp === "string" &&
365
376
  (c.runOrigin === null || (typeof c.runOrigin === "object" && typeof c.runOrigin?.forge === "string")) &&
366
377
  (c.signature === null || c.signature === undefined || typeof c.signature === "string") &&
367
- (c.runId === null || c.runId === undefined || typeof c.runId === "string");
378
+ (c.runId === null || c.runId === undefined || typeof c.runId === "string") &&
379
+ (c.channel === undefined || typeof c.channel === "string") &&
380
+ (c.composed === undefined || (typeof c.composed === "object" && c.composed !== null && typeof c.composed.base === "string" && Array.isArray(c.composed.sources)));
368
381
  if (!ok) {
369
382
  malformed++;
370
383
  continue;
@@ -444,6 +457,8 @@ export async function appendCut(
444
457
  runId: input.runId ?? null,
445
458
  runOrigin: input.runOrigin,
446
459
  signature: input.signature ?? null,
460
+ ...(input.composed ? { composed: input.composed } : {}),
461
+ ...(input.channel ? { channel: input.channel } : {}),
447
462
  };
448
463
  const content = `${existing ? `${existing.replace(/\n$/, "")}\n` : ""}${JSON.stringify(cut, sortedJsonReplacer)}\n`;
449
464
  try {
@@ -47,6 +47,8 @@ const PIPELINE = [
47
47
  " - run: chant ci run record --in-place --env prod --gate pr-apply --forge github",
48
48
  "",
49
49
  ].join("\n");
50
+ /** #4168 step 9: under apply on merge a merge is recorded, and the apply applies the newest green commit, with no push base. */
51
+ const APPLY_PIPELINE = `${PIPELINE} apply:\n steps:\n - run: chant ci run apply --in-place --env prod --gate pr-apply --forge github\n`;
50
52
  const TICK = "jobs:\n tick:\n steps:\n - run: npx --yes @intentius/chant@0.124.33 ci tick\n";
51
53
  const PUBLISH = "jobs:\n publish:\n steps:\n - run: npx chant release publish \"$LABEL\"\n";
52
54
 
@@ -100,14 +102,22 @@ describe("ci.workflow (WSP151)", () => {
100
102
  });
101
103
 
102
104
  test("trunk-curated with an apply job on merge differs", async () => {
103
- const root = repo(complete({ ".github/workflows/chant-pr.yml": PIPELINE.replace("ci run record", "ci run apply") }));
105
+ const root = repo(complete({ ".github/workflows/chant-pr.yml": APPLY_PIPELINE }));
104
106
  expect(await found(root)).toEqual([["ci-workflow-pipeline-differs", null]]);
105
107
  });
106
108
 
107
109
  test("trunk-curated+apply-on-merge with only a record job differs, and with an apply job passes", async () => {
108
110
  const decl = declaration({ ci: { workflow: "trunk-curated+apply-on-merge", green: green() } });
109
111
  expect(await found(repo(complete({ "chant.workspace.json": decl })))).toEqual([["ci-workflow-pipeline-differs", null]]);
110
- expect(await found(repo(complete({ "chant.workspace.json": decl, ".github/workflows/chant-pr.yml": PIPELINE.replace("ci run record", "ci run apply") })))).toEqual([]);
112
+ expect(await found(repo(complete({ "chant.workspace.json": decl, ".github/workflows/chant-pr.yml": APPLY_PIPELINE })))).toEqual([]);
113
+ });
114
+
115
+ test("trunk-curated+apply-on-merge: an apply of each merge differs, and a pipeline with no record job is missing it (#4168 step 9)", async () => {
116
+ const decl = declaration({ ci: { workflow: "trunk-curated+apply-on-merge", green: green() } });
117
+ const perMerge = APPLY_PIPELINE.replace("ci run apply --in-place", 'ci run apply --in-place --base "$BASE_SHA"');
118
+ expect(await found(repo(complete({ "chant.workspace.json": decl, ".github/workflows/chant-pr.yml": perMerge })))).toEqual([["ci-workflow-pipeline-differs", null]]);
119
+ const noRecord = PIPELINE.replace("ci run record", "ci run apply");
120
+ expect(await found(repo(complete({ "chant.workspace.json": decl, ".github/workflows/chant-pr.yml": noRecord })))).toEqual([["ci-workflow-pipeline-missing", null]]);
111
121
  });
112
122
 
113
123
  test("a member's own workflow wins, and a top-level workflow skips members that don't build with chant", async () => {
@@ -106,7 +106,8 @@ export const CI_WORKFLOW_CHECKS: readonly WorkspaceCheck[] = [
106
106
  const applyOnMerge = under.some((u) => appliesOnMerge(u.workflow)) || (d.ci?.workflow ? appliesOnMerge(d.ci.workflow) : false);
107
107
  const missing = [
108
108
  ...(has(PLAN) ? [] : ["a plan job (ci run plan)"]),
109
- ...(has(RECORD) || has(APPLY) ? [] : [applyOnMerge ? "an apply job (ci run apply)" : "a record job (ci run record)"]),
109
+ // #4168 step 9: every merge is recorded, apply on merge or not, since the apply reads the merge records.
110
+ ...(has(RECORD) ? [] : ["a record job (ci run record)"]),
110
111
  ...(has(TICK) ? [] : ["chant ci tick"]),
111
112
  ];
112
113
  if (missing.length > 0) {
@@ -119,6 +120,11 @@ export const CI_WORKFLOW_CHECKS: readonly WorkspaceCheck[] = [
119
120
  } else if (!has(APPLY) && has(RECORD)) {
120
121
  const l = commandLines(ctx, RECORD)[0]!;
121
122
  finding("ci-workflow-pipeline-differs", `${l.path} records on merge, and the workflow is trunk-curated+apply-on-merge; regenerate with --apply-on-merge`, { treeFile: l.path, line: l.line });
123
+ } else {
124
+ // #4168 step 9: an apply measured from a push's base applies each merge; the workflow applies green commits.
125
+ for (const l of commandLines(ctx, APPLY).filter((c) => /--base\b/.test(c.text) || (/\bcomponents\s+pr-apply\b/.test(c.text) && !/--green\b/.test(c.text)))) {
126
+ finding("ci-workflow-pipeline-differs", `${l.path} applies each merge, and under trunk-curated+apply-on-merge the apply applies the newest green commit; regenerate with --apply-on-merge`, { treeFile: l.path, line: l.line });
127
+ }
122
128
  }
123
129
  if (!has(PUBLISH)) {
124
130
  finding("ci-workflow-publish-missing", "no pipeline file runs chant release publish, so a cut release is never published; generate one with chant release workflow");
@@ -154,7 +154,9 @@ export async function runCiWorkflow(ctx: CommandContext): Promise<number> {
154
154
  const sources = readWorkflowSources(repoRoot, fromRepo);
155
155
  const scan = workflowsHolding(green, sources);
156
156
  // #4169: the triggers and concurrency of the workflows holding a phase that sets build. Refused before anything is written.
157
- const edits = buildTriggerEdits(green, sources);
157
+ // #4220: under a ci.workflow, composed releases' branches run the phases and tick too.
158
+ const compose = declaration.ci?.workflow !== undefined || declaration.members.some((m) => m.ciWorkflow !== null);
159
+ const edits = buildTriggerEdits(green, sources, { compose });
158
160
  const extra = ctx.args.ciWorkflows ?? [];
159
161
  const workflows = [...new Set([...scan.workflows, ...extra])].sort();
160
162
  for (const f of scan.unreadable) console.error(`warning: ${f} is not valid YAML, so its jobs were not read`);
@@ -180,10 +182,10 @@ export async function runCiWorkflow(ctx: CommandContext): Promise<number> {
180
182
  const workspaceRoot = relative(repoRoot, root).split(sep).join("/") || ".";
181
183
  // The install runs in the workspace root, so its lockfile there keys setup-node's npm cache.
182
184
  const npmLock = ctx.args.ciInstall && existsSync(join(root, "package-lock.json")) ? (workspaceRoot === "." ? "package-lock.json" : `${workspaceRoot}/package-lock.json`) : undefined;
183
- const yaml = renderCiGreenWorkflow({ green, workflows, chantVersion: readerVersion(), workspaceRoot, chant: ctx.args.ciChant, install: ctx.args.ciInstall, npmLock, push, ...(runnerKeySecret ? { runnerKeySecret } : {}) });
185
+ const yaml = renderCiGreenWorkflow({ green, workflows, chantVersion: readerVersion(), workspaceRoot, chant: ctx.args.ciChant, install: ctx.args.ciInstall, npmLock, push, ...(runnerKeySecret ? { runnerKeySecret } : {}), ...(compose ? { compose } : {}) });
184
186
  const written = writeWorkflowFile(target, ciGreenHeader(command, member ? "member" : "root") + yaml);
185
187
  if (member) recordGeneratedFiles(member.memberRoot, [{ path: fromRepo, command }]);
186
- console.log(`Wrote ${written}: ticks when ${workflows.join(", ")} complete${workflows.length === 1 ? "s" : ""} on ${green.branch}, and every 15 minutes.`);
188
+ console.log(`Wrote ${written}: ticks when ${workflows.join(", ")} complete${workflows.length === 1 ? "s" : ""} on ${green.branch}${compose ? " or a chant/compose/ branch" : ""}, and every 15 minutes.`);
187
189
  for (const e of edits) {
188
190
  if (!e.changed) continue;
189
191
  const path = writeWorkflowFile(join(repoRoot, e.path), e.text);