@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
@@ -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
  );
@@ -278,6 +278,8 @@ export interface PublishPlan {
278
278
  redactor: Redactor;
279
279
  /** 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
280
  requireSbom: boolean;
281
+ /** The channel a cut of the release names (#4168 4.3), the tag moved in place of `latest`, or null. */
282
+ channel: string | null;
281
283
  }
282
284
 
283
285
  /**
@@ -399,7 +401,8 @@ export async function preparePublish(req: {
399
401
  const member = ledger.members.length > 0 ? ledger.members.join("/") : null;
400
402
  const releaseTag = label !== null ? releaseTagName(member, label) : null;
401
403
  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") };
404
+ const channel = [...cuts].reverse().find((c) => c.channel)?.channel ?? null;
405
+ return { release, label, signature, provenance, warning, signatureWarning, cut, releaseTag, targets, redactor, requireSbom: groups.has("supply-chain"), channel };
403
406
  }
404
407
 
405
408
  /**
@@ -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);
@@ -381,6 +381,58 @@ describe("the triggers of a phase that sets build (#4169)", () => {
381
381
  expect(before.on.workflow_run.branches).toEqual(["main"]);
382
382
  });
383
383
 
384
+ test("under a ci.workflow, both builds run on chant/compose/** pushes, a compose branch's every runs are keyed by commit, and the tick ticks on them (#4220)", () => {
385
+ const sources = [
386
+ { path: ".github/workflows/ci.yml", text: LINT_WF },
387
+ { path: ".github/workflows/suites.yml", text: SUITES_WF },
388
+ ];
389
+ const edits = buildTriggerEdits(green(), sources, { compose: true });
390
+ const ci = parseYAMLDocument(edits[0].text) as Yaml;
391
+ expect(ci.on).toEqual({ push: { branches: ["main", "chant/compose/**"] }, pull_request: null });
392
+ expect(ci.concurrency).toEqual({
393
+ group: "chant-build-lint-${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/chant/compose/')) && github.sha || github.ref }}",
394
+ "cancel-in-progress": "${{ !(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/chant/compose/')) }}",
395
+ });
396
+ const suites = parseYAMLDocument(edits[1].text) as Yaml;
397
+ expect(suites.on).toEqual({ push: { branches: ["main", "chant/compose/**"] }, workflow_dispatch: {} });
398
+ // Writing again changes nothing.
399
+ expect(buildTriggerEdits(green(), edits.map((e) => ({ path: e.path, text: e.text })), { compose: true }).map((e) => e.changed)).toEqual([false, false]);
400
+ // A push with no branch list already runs on every branch, so its triggers stay.
401
+ const anyBranch = LINT_WF.replace(" push:\n branches: [main]\n", " push:\n");
402
+ expect((parseYAMLDocument(buildTriggerEdits(green(), [{ path: ".github/workflows/ci.yml", text: anyBranch }], { compose: true })[0].text) as Yaml).on).toEqual({ push: null, pull_request: null });
403
+ // Without compose the bytes are what #4169 wrote.
404
+ expect(buildTriggerEdits(green(), sources).map((e) => e.text)).toEqual(buildTriggerEdits(green(), sources, { compose: false }).map((e) => e.text));
405
+ const tick = parseYAMLDocument(renderCiGreenWorkflow({ green: green(), workflows: ["ci"], chantVersion: "0.124.33", workspaceRoot: ".", compose: true })) as Yaml;
406
+ expect(tick.on.workflow_run.branches).toEqual(["main", "ci/build/**", "chant/compose/**"]);
407
+ });
408
+
409
+ test("chant ci workflow writes the compose triggers only when the declaration names a ci.workflow (#4220)", async () => {
410
+ const run = async (ci: Record<string, unknown>) => {
411
+ const root = scratchDir();
412
+ git(root, "init", "-q");
413
+ writeFiles(root, {
414
+ "chant.workspace.json": declaration([{ name: "root", dir: ".", kind: "other", because: "x" }], { ci }),
415
+ ".github/workflows/ci.yml": LINT_WF,
416
+ });
417
+ const cwd = vi.spyOn(process, "cwd").mockReturnValue(root);
418
+ const log = vi.spyOn(console, "log").mockImplementation(() => {});
419
+ try {
420
+ expect(await runCiWorkflow({ args: {} } as unknown as CommandContext)).toBe(0);
421
+ return { tick: readFileSync(join(root, CI_GREEN_WORKFLOW_FILE), "utf-8"), ci: readFileSync(join(root, ".github/workflows/ci.yml"), "utf-8") };
422
+ } finally {
423
+ cwd.mockRestore();
424
+ log.mockRestore();
425
+ }
426
+ };
427
+ const lint = { branch: "main", phases: { lint: { runs: ["lint"], build: "every" } }, require: ["lint"] };
428
+ const without = await run({ green: lint });
429
+ expect(without.tick).not.toContain("chant/compose");
430
+ expect(without.ci).not.toContain("chant/compose");
431
+ const curated = await run({ green: lint, workflow: "trunk-curated" });
432
+ expect(curated.tick).toContain("chant/compose/**");
433
+ expect(curated.ci).toContain("chant/compose/**");
434
+ });
435
+
384
436
  test("the workflows a build request can start are the ones holding its phases with workflow_dispatch", () => {
385
437
  expect(dispatchableWorkflows(green(), ["lint", "suites"], [{ path: ".github/workflows/ci.yml", text: LINT_WF }, { path: ".github/workflows/suites.yml", text: SUITES_WF }])).toEqual({ dispatchable: ["suites.yml"], without: ["ci.yml"] });
386
438
  });
@@ -52,6 +52,7 @@ import { emitYAMLEntry, parseYAMLDocument } from "../yaml";
52
52
  import type { CiBuild, CiGreen } from "./declaration";
53
53
  import { matchesCheckRun } from "./ci-green";
54
54
  import { githubSecretOrJobToken } from "../ci-jobs";
55
+ import { COMPOSE_BRANCH_PREFIX } from "../lifecycle/compose-refs";
55
56
 
56
57
  /** Where the workflow goes, relative to the repository root. */
57
58
  export const CI_GREEN_WORKFLOW_FILE = ".github/workflows/chant-ci-green.yml";
@@ -194,8 +195,17 @@ export interface WorkflowInput {
194
195
  * The key's principal needs the `runner` role in `.chant/trust.json`.
195
196
  */
196
197
  runnerKeySecret?: string;
198
+ /**
199
+ * The workspace declares a `ci.workflow` (#4220), so composed releases
200
+ * live on `chant/compose/<name>` branches (#4168 4.3): the tick also runs
201
+ * when a phase workflow completes on one of them.
202
+ */
203
+ compose?: boolean;
197
204
  }
198
205
 
206
+ /** The branch filter for composed releases' branches (#4220). */
207
+ export const COMPOSE_BRANCHES = `${COMPOSE_BRANCH_PREFIX}**`;
208
+
199
209
  /**
200
210
  * A token the checkout persists for the tick's `git push`: a repository
201
211
  * secret, or a GitHub App's installation token minted from its id (an
@@ -240,6 +250,8 @@ export function renderCiGreenWorkflow(input: WorkflowInput): string {
240
250
  ` - ${q(green.branch)}`,
241
251
  // #4169: a build the tick asked for runs at a ci/build/<sha> tag, and its completion ticks too.
242
252
  ...(newestPhases(green) ? [` - ${q("ci/build/**")}`] : []),
253
+ // #4220: a composed commit's phases run on its chant/compose/<name> branch, and their completion ticks too.
254
+ ...(input.compose ? [` - ${q(COMPOSE_BRANCHES)}`] : []),
243
255
  " schedule:",
244
256
  " - cron: \"*/15 * * * *\"",
245
257
  " workflow_dispatch: {}",
@@ -319,12 +331,19 @@ export interface BuildTriggerEdit {
319
331
  * branch, so no push cancels or replaces another's run; elsewhere, such as
320
332
  * a pull request, a new push cancels the run it supersedes.
321
333
  *
322
- * Only the top-level `on:` (for `newest`) and `concurrency:` blocks change,
334
+ * With `compose` (#4220, a workspace declaring `ci.workflow`), both builds
335
+ * also run on pushes to `chant/compose/**`: `newest` adds the filter to the
336
+ * branches it writes, `every` adds it to an existing `push.branches` list,
337
+ * and `every`'s group keys a compose branch's runs by commit, as the trunk
338
+ * branch's.
339
+ *
340
+ * Only the top-level `on:` (for `newest`, or `every` with `compose`) and `concurrency:` blocks change,
323
341
  * each under {@link BUILD_BLOCK_MARKER}; the rest of the file is kept as it
324
342
  * is. Throws when one workflow holds a phase of each build, which no one
325
343
  * concurrency group can serve.
326
344
  */
327
- export function buildTriggerEdits(green: CiGreen, sources: readonly WorkflowSource[]): BuildTriggerEdit[] {
345
+ export function buildTriggerEdits(green: CiGreen, sources: readonly WorkflowSource[], options: { compose?: boolean } = {}): BuildTriggerEdit[] {
346
+ const compose = options.compose === true;
328
347
  const out: BuildTriggerEdit[] = [];
329
348
  for (const s of sources) {
330
349
  let doc: unknown;
@@ -349,17 +368,27 @@ export function buildTriggerEdits(green: CiGreen, sources: readonly WorkflowSour
349
368
  delete on.schedule;
350
369
  const push = isObject(on.push) ? { ...on.push } : {};
351
370
  const branches = Array.isArray(push.branches) ? push.branches.filter((b): b is string => typeof b === "string") : [];
352
- push.branches = branches.includes(green.branch) ? branches : [...branches, green.branch];
371
+ const withBranch = branches.includes(green.branch) ? branches : [...branches, green.branch];
372
+ push.branches = compose && !withBranch.includes(COMPOSE_BRANCHES) ? [...withBranch, COMPOSE_BRANCHES] : withBranch;
353
373
  const { push: _push, workflow_dispatch: dispatch, ...rest } = on;
354
374
  text = writeTopLevel(text, "on", { push, workflow_dispatch: dispatch ?? {}, ...rest });
355
375
  text = writeTopLevel(text, "concurrency", { group: `chant-build-${tag}-\${{ github.ref }}`, "cancel-in-progress": false });
356
376
  } else {
357
377
  const main = `refs/heads/${green.branch}`;
378
+ // A push filter that lists branches gains the compose branches; one with no list already runs on every branch.
379
+ const on = isObject(doc) && isObject(doc.on) ? doc.on : undefined;
380
+ const pushOn = on && isObject(on.push) ? on.push : undefined;
381
+ if (compose && on && pushOn && Array.isArray(pushOn.branches) && !pushOn.branches.includes(COMPOSE_BRANCHES)) {
382
+ text = writeTopLevel(text, "on", { ...on, push: { ...pushOn, branches: [...pushOn.branches, COMPOSE_BRANCHES] } });
383
+ }
384
+ // #4220: a compose branch's commits each get a verdict, so its runs are keyed by commit, as the trunk branch's are.
385
+ const keyed = compose ? `(github.ref == '${main}' || startsWith(github.ref, 'refs/heads/${COMPOSE_BRANCH_PREFIX}'))` : `github.ref == '${main}'`;
386
+ const unkeyed = compose ? `!${keyed}` : `github.ref != '${main}'`;
358
387
  // Written as lines: the emitter would quote the expression, and GitHub reads it the same either way.
359
388
  text = writeTopLevel(text, "concurrency", [
360
389
  "concurrency:",
361
- ` group: chant-build-${tag}-\${{ github.ref == '${main}' && github.sha || github.ref }}`,
362
- ` cancel-in-progress: \${{ github.ref != '${main}' }}`,
390
+ ` group: chant-build-${tag}-\${{ ${keyed} && github.sha || github.ref }}`,
391
+ ` cancel-in-progress: \${{ ${unkeyed} }}`,
363
392
  ]);
364
393
  }
365
394
  out.push({ path: s.path, build, phases, text, changed: text !== s.text });