@intentius/chant 0.124.33 → 0.124.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (218) hide show
  1. package/dist/cli/handlers/graph.js +2 -2
  2. package/dist/cli/handlers/pr.d.ts.map +1 -1
  3. package/dist/cli/handlers/pr.js +147 -51
  4. package/dist/cli/handlers/promote.d.ts.map +1 -1
  5. package/dist/cli/handlers/promote.js +284 -81
  6. package/dist/cli/handlers/release.d.ts +12 -0
  7. package/dist/cli/handlers/release.d.ts.map +1 -1
  8. package/dist/cli/handlers/release.js +125 -10
  9. package/dist/cli/handlers/run.d.ts +3 -1
  10. package/dist/cli/handlers/run.d.ts.map +1 -1
  11. package/dist/cli/handlers/run.js +12 -2
  12. package/dist/cli/main.d.ts.map +1 -1
  13. package/dist/cli/main.js +37 -2
  14. package/dist/cli/parse-args.d.ts.map +1 -1
  15. package/dist/cli/parse-args.js +24 -0
  16. package/dist/cli/registry.d.ts +17 -1
  17. package/dist/cli/registry.d.ts.map +1 -1
  18. package/dist/components/auto-release.d.ts +8 -1
  19. package/dist/components/auto-release.d.ts.map +1 -1
  20. package/dist/components/auto-release.js +3 -2
  21. package/dist/components/green-cover.d.ts +77 -0
  22. package/dist/components/green-cover.d.ts.map +1 -0
  23. package/dist/components/green-cover.js +153 -0
  24. package/dist/components/pr-pipeline.d.ts +34 -0
  25. package/dist/components/pr-pipeline.d.ts.map +1 -1
  26. package/dist/components/pr-pipeline.js +39 -5
  27. package/dist/components/promote-rounds.d.ts +102 -0
  28. package/dist/components/promote-rounds.d.ts.map +1 -0
  29. package/dist/components/promote-rounds.js +174 -0
  30. package/dist/components/promote.d.ts +4 -1
  31. package/dist/components/promote.d.ts.map +1 -1
  32. package/dist/components/promote.js +6 -3
  33. package/dist/components/release-gate.d.ts +30 -0
  34. package/dist/components/release-gate.d.ts.map +1 -1
  35. package/dist/components/release-gate.js +92 -42
  36. package/dist/components/release-promote.d.ts +9 -0
  37. package/dist/components/release-promote.d.ts.map +1 -1
  38. package/dist/components/release-promote.js +15 -0
  39. package/dist/config.d.ts +2 -0
  40. package/dist/config.d.ts.map +1 -1
  41. package/dist/deep-observation.d.ts +11 -0
  42. package/dist/deep-observation.d.ts.map +1 -1
  43. package/dist/graph-declared.d.ts +9 -1
  44. package/dist/graph-declared.d.ts.map +1 -1
  45. package/dist/graph-declared.js +18 -3
  46. package/dist/graph-detail.d.ts.map +1 -1
  47. package/dist/graph-detail.js +30 -1
  48. package/dist/graph-ir.d.ts.map +1 -1
  49. package/dist/graph-ir.js +13 -2
  50. package/dist/lifecycle/__fixtures__/publish-fakes.d.ts.map +1 -1
  51. package/dist/lifecycle/__fixtures__/publish-fakes.js +5 -1
  52. package/dist/lifecycle/compose-refs.d.ts +52 -0
  53. package/dist/lifecycle/compose-refs.d.ts.map +1 -0
  54. package/dist/lifecycle/compose-refs.js +115 -0
  55. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  56. package/dist/lifecycle/deep-observe.js +3 -2
  57. package/dist/lifecycle/green-apply.d.ts +39 -0
  58. package/dist/lifecycle/green-apply.d.ts.map +1 -0
  59. package/dist/lifecycle/green-apply.js +51 -0
  60. package/dist/lifecycle/publish-declaration.d.ts +4 -0
  61. package/dist/lifecycle/publish-declaration.d.ts.map +1 -1
  62. package/dist/lifecycle/publish-declaration.js +15 -0
  63. package/dist/lifecycle/publish-npm.d.ts +7 -4
  64. package/dist/lifecycle/publish-npm.d.ts.map +1 -1
  65. package/dist/lifecycle/publish-npm.js +60 -13
  66. package/dist/lifecycle/release-compose.d.ts +91 -0
  67. package/dist/lifecycle/release-compose.d.ts.map +1 -0
  68. package/dist/lifecycle/release-compose.js +235 -0
  69. package/dist/lifecycle/release-cut.d.ts +8 -0
  70. package/dist/lifecycle/release-cut.d.ts.map +1 -1
  71. package/dist/lifecycle/release-cut.js +2 -0
  72. package/dist/lifecycle/release-publish.d.ts +2 -0
  73. package/dist/lifecycle/release-publish.d.ts.map +1 -1
  74. package/dist/lifecycle/release-publish.js +2 -1
  75. package/dist/lifecycle/release-store.d.ts +15 -0
  76. package/dist/lifecycle/release-store.d.ts.map +1 -1
  77. package/dist/lifecycle/release-store.js +5 -1
  78. package/dist/wave-runtime.mjs +226 -224
  79. package/dist/workspace/__fixtures__/contract-repo.d.ts.map +1 -1
  80. package/dist/workspace/__fixtures__/contract-repo.js +13 -2
  81. package/dist/workspace/check.schema.json +20 -2
  82. package/dist/workspace/checks/ci-workflow.d.ts +17 -0
  83. package/dist/workspace/checks/ci-workflow.d.ts.map +1 -0
  84. package/dist/workspace/checks/ci-workflow.js +130 -0
  85. package/dist/workspace/checks/security.d.ts +16 -1
  86. package/dist/workspace/checks/security.d.ts.map +1 -1
  87. package/dist/workspace/checks/security.js +4 -4
  88. package/dist/workspace/checks.d.ts +1 -1
  89. package/dist/workspace/checks.d.ts.map +1 -1
  90. package/dist/workspace/checks.js +4 -1
  91. package/dist/workspace/ci-green-cli.d.ts.map +1 -1
  92. package/dist/workspace/ci-green-cli.js +5 -3
  93. package/dist/workspace/ci-green-workflow.d.ts +18 -2
  94. package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
  95. package/dist/workspace/ci-green-workflow.js +27 -5
  96. package/dist/workspace/ci-green.d.ts +31 -0
  97. package/dist/workspace/ci-green.d.ts.map +1 -1
  98. package/dist/workspace/ci-green.js +78 -4
  99. package/dist/workspace/ci-init.d.ts +100 -0
  100. package/dist/workspace/ci-init.d.ts.map +1 -0
  101. package/dist/workspace/ci-init.js +434 -0
  102. package/dist/workspace/ci-run.d.ts +6 -0
  103. package/dist/workspace/ci-run.d.ts.map +1 -1
  104. package/dist/workspace/ci-run.js +2 -1
  105. package/dist/workspace/ci-serve.d.ts +94 -13
  106. package/dist/workspace/ci-serve.d.ts.map +1 -1
  107. package/dist/workspace/ci-serve.js +248 -30
  108. package/dist/workspace/ci-workflow.d.ts +91 -0
  109. package/dist/workspace/ci-workflow.d.ts.map +1 -0
  110. package/dist/workspace/ci-workflow.js +33 -0
  111. package/dist/workspace/declaration.d.ts +18 -0
  112. package/dist/workspace/declaration.d.ts.map +1 -1
  113. package/dist/workspace/declaration.js +9 -2
  114. package/dist/workspace/declaration.schema.json +28 -0
  115. package/dist/workspace/delivery-metrics.d.ts +3 -1
  116. package/dist/workspace/delivery-metrics.d.ts.map +1 -1
  117. package/dist/workspace/delivery-metrics.js +12 -3
  118. package/dist/workspace/init.js +2 -2
  119. package/dist/workspace/member-commands.d.ts.map +1 -1
  120. package/dist/workspace/member-commands.js +4 -0
  121. package/dist/workspace/nested-graph.d.ts.map +1 -1
  122. package/dist/workspace/nested-graph.js +1 -0
  123. package/dist/workspace/reason-codes.d.ts +9 -0
  124. package/dist/workspace/reason-codes.d.ts.map +1 -1
  125. package/dist/workspace/reason-codes.js +10 -0
  126. package/dist/workspace/release-candidates.d.ts +28 -0
  127. package/dist/workspace/release-candidates.d.ts.map +1 -1
  128. package/dist/workspace/release-candidates.js +38 -2
  129. package/dist/workspace/release-candidates.schema.json +51 -0
  130. package/dist/workspace/release-workflow.d.ts +31 -0
  131. package/dist/workspace/release-workflow.d.ts.map +1 -0
  132. package/dist/workspace/release-workflow.js +150 -0
  133. package/dist/workspace/releases.schema.json +30 -0
  134. package/package.json +1 -1
  135. package/src/cli/handlers/graph.ts +2 -2
  136. package/src/cli/handlers/pr-green.test.ts +230 -0
  137. package/src/cli/handlers/pr.ts +148 -66
  138. package/src/cli/handlers/promote.test.ts +34 -0
  139. package/src/cli/handlers/promote.ts +302 -91
  140. package/src/cli/handlers/release-compose.e2e.test.ts +133 -0
  141. package/src/cli/handlers/release.ts +112 -10
  142. package/src/cli/handlers/run.ts +12 -1
  143. package/src/cli/main.ts +37 -2
  144. package/src/cli/parse-args.ts +18 -0
  145. package/src/cli/registry.ts +17 -1
  146. package/src/codegen/release-wiring.test.ts +56 -9
  147. package/src/components/auto-release.test.ts +22 -0
  148. package/src/components/auto-release.ts +9 -2
  149. package/src/components/green-cover.test.ts +170 -0
  150. package/src/components/green-cover.ts +186 -0
  151. package/src/components/pr-pipeline.test.ts +14 -4
  152. package/src/components/pr-pipeline.ts +56 -5
  153. package/src/components/promote-rounds.test.ts +220 -0
  154. package/src/components/promote-rounds.ts +232 -0
  155. package/src/components/promote.test.ts +7 -0
  156. package/src/components/promote.ts +9 -4
  157. package/src/components/release-gate.test.ts +47 -0
  158. package/src/components/release-gate.ts +111 -46
  159. package/src/components/release-promote.test.ts +24 -1
  160. package/src/components/release-promote.ts +15 -0
  161. package/src/deep-observation.ts +8 -0
  162. package/src/graph-declared-exports.test.ts +40 -0
  163. package/src/graph-declared.test.ts +18 -0
  164. package/src/graph-declared.ts +17 -2
  165. package/src/graph-detail.test.ts +68 -1
  166. package/src/graph-detail.ts +29 -1
  167. package/src/graph-ir.ts +12 -4
  168. package/src/lifecycle/__fixtures__/publish-fakes.ts +5 -1
  169. package/src/lifecycle/compose-refs.ts +125 -0
  170. package/src/lifecycle/deep-observe.test.ts +20 -0
  171. package/src/lifecycle/deep-observe.ts +4 -2
  172. package/src/lifecycle/green-apply.ts +71 -0
  173. package/src/lifecycle/publish-declaration.ts +15 -0
  174. package/src/lifecycle/publish-npm.ts +55 -13
  175. package/src/lifecycle/publish-targets.test.ts +45 -0
  176. package/src/lifecycle/release-compose.test.ts +125 -0
  177. package/src/lifecycle/release-compose.ts +312 -0
  178. package/src/lifecycle/release-cut.ts +6 -0
  179. package/src/lifecycle/release-publish.ts +4 -1
  180. package/src/lifecycle/release-store.ts +16 -1
  181. package/src/workspace/__fixtures__/contract-repo.ts +11 -2
  182. package/src/workspace/check-contract.test.ts +3 -2
  183. package/src/workspace/check.schema.json +20 -2
  184. package/src/workspace/checks/ci-workflow.test.ts +160 -0
  185. package/src/workspace/checks/ci-workflow.ts +135 -0
  186. package/src/workspace/checks/security.ts +4 -4
  187. package/src/workspace/checks.ts +4 -1
  188. package/src/workspace/ci-green-cli.ts +5 -3
  189. package/src/workspace/ci-green-workflow.test.ts +52 -0
  190. package/src/workspace/ci-green-workflow.ts +34 -5
  191. package/src/workspace/ci-green.test.ts +33 -0
  192. package/src/workspace/ci-green.ts +85 -4
  193. package/src/workspace/ci-init.test.ts +159 -0
  194. package/src/workspace/ci-init.ts +445 -0
  195. package/src/workspace/ci-report-url.test.ts +6 -1
  196. package/src/workspace/ci-run.test.ts +18 -13
  197. package/src/workspace/ci-run.ts +8 -1
  198. package/src/workspace/ci-serve-land.e2e.test.ts +138 -0
  199. package/src/workspace/ci-serve-land.test.ts +176 -0
  200. package/src/workspace/ci-serve.test.ts +77 -43
  201. package/src/workspace/ci-serve.ts +268 -34
  202. package/src/workspace/ci-workflow.ts +71 -0
  203. package/src/workspace/declaration.schema.json +28 -0
  204. package/src/workspace/declaration.ts +32 -3
  205. package/src/workspace/delivery-metrics.ts +12 -3
  206. package/src/workspace/graph-contract.test.ts +3 -2
  207. package/src/workspace/init.ts +2 -2
  208. package/src/workspace/member-commands.test.ts +11 -4
  209. package/src/workspace/member-commands.ts +3 -0
  210. package/src/workspace/nested-graph.test.ts +4 -0
  211. package/src/workspace/nested-graph.ts +1 -0
  212. package/src/workspace/reason-codes.test.ts +2 -0
  213. package/src/workspace/reason-codes.ts +10 -0
  214. package/src/workspace/release-candidates.schema.json +51 -0
  215. package/src/workspace/release-candidates.test.ts +30 -3
  216. package/src/workspace/release-candidates.ts +54 -2
  217. package/src/workspace/release-workflow.ts +167 -0
  218. package/src/workspace/releases.schema.json +30 -0
@@ -24,6 +24,11 @@
24
24
  * flags whose record files changed in its range, from the declared flag kinds
25
25
  * (#4024).
26
26
  *
27
+ * Each candidate also groups its changes (#4168 4.4) by work item, then by
28
+ * pull request, then by commit: a change lands under every work item its
29
+ * trailers name, or under no work item, and within that under its pull
30
+ * request, or under none for a commit that landed without one.
31
+ *
27
32
  * It reads local refs and tags only and never fetches. Git is read through a
28
33
  * local `git` subprocess.
29
34
  */
@@ -66,6 +71,34 @@ export function prFromSubject(subject) {
66
71
  const m = PR_IN_SUBJECT.exec(subject);
67
72
  return m ? Number(m[1]) : null;
68
73
  }
74
+ /** The pull request a merge record names in its `source`, or null. */
75
+ export function mergeRecordPr(merge) {
76
+ const source = merge?.source;
77
+ return source?.kind === "pull-request" && typeof source.pr === "number" ? source.pr : null;
78
+ }
79
+ /**
80
+ * Group changes, given newest first, by work item, then pull request, then
81
+ * commit. A change with work items lands in each one's group; the rest land
82
+ * in the `work: null` group, last. Within a group, pull requests come in the
83
+ * order their oldest commit landed, and commits without one last.
84
+ */
85
+ export function groupChanges(changes) {
86
+ const oldestFirst = [...changes].reverse();
87
+ const groups = new Map();
88
+ const add = (work, pr, commit) => {
89
+ const byPr = groups.get(work) ?? new Map();
90
+ groups.set(work, byPr);
91
+ byPr.set(pr, [commit, ...(byPr.get(pr) ?? [])]);
92
+ };
93
+ for (const c of oldestFirst)
94
+ for (const work of c.work.length > 0 ? c.work : [null])
95
+ add(work, c.pr, c.commit);
96
+ const nullLast = (keys) => [...keys.filter((k) => k !== null), ...keys.filter((k) => k === null)];
97
+ return nullLast([...groups.keys()]).map((work) => {
98
+ const byPr = groups.get(work);
99
+ return { work, pullRequests: nullLast([...byPr.keys()]).map((pr) => ({ pr, commits: byPr.get(pr) })) };
100
+ });
101
+ }
69
102
  /** Read the release candidates of the member at `query.cwd`. */
70
103
  export async function releaseCandidatesDocument(query) {
71
104
  const head = { $schema: RELEASE_CANDIDATES_SCHEMA_ID, contract: RELEASE_CANDIDATES_CONTRACT_VERSION, chant: readerVersion() };
@@ -153,7 +186,8 @@ export async function releaseCandidatesDocument(query) {
153
186
  const d = details.get(sha);
154
187
  const { work, records } = recordTrailers(d?.trailers ?? {});
155
188
  const merge = merges.get(sha) ?? null;
156
- const mergePr = merge && typeof merge.pr === "number" ? merge.pr : null;
189
+ // The merge record names its pull request in `source` (../lifecycle/merge-record.ts).
190
+ const mergePr = mergeRecordPr(merge);
157
191
  const subject = d?.subject ?? "";
158
192
  return { commit: sha, subject, date: d?.date ?? "", work, records, pr: mergePr ?? prFromSubject(subject), merge };
159
193
  };
@@ -178,13 +212,15 @@ export async function releaseCandidatesDocument(query) {
178
212
  if (!tags.green.has(sha))
179
213
  continue;
180
214
  const own = change(sha);
215
+ const changes = open.map(change).reverse();
181
216
  candidates.push({
182
217
  commit: sha,
183
218
  subject: own.subject,
184
219
  date: own.date,
185
220
  newFor: environments.filter((e) => i < newestRun.get(e.env)).map((e) => e.env),
186
221
  cut: cutLabels.get(sha) ?? [],
187
- changes: open.map(change).reverse(),
222
+ changes,
223
+ groups: groupChanges(changes),
188
224
  flags: flagReader(open),
189
225
  });
190
226
  open = [];
@@ -212,6 +212,7 @@
212
212
  "newFor",
213
213
  "cut",
214
214
  "changes",
215
+ "groups",
215
216
  "flags"
216
217
  ],
217
218
  "properties": {
@@ -245,6 +246,13 @@
245
246
  },
246
247
  "description": "The first-parent commits after the previous candidate, up to and including this one, newest first."
247
248
  },
249
+ "groups": {
250
+ "type": "array",
251
+ "items": {
252
+ "$ref": "#/$defs/group"
253
+ },
254
+ "description": "The same changes by work item, then pull request, then commit (#4168 4.4). Work items come first, in the order the oldest change names them, then `work: null` for changes that name none. A change with two work items is under both."
255
+ },
248
256
  "flags": {
249
257
  "type": "array",
250
258
  "items": {
@@ -254,6 +262,49 @@
254
262
  }
255
263
  }
256
264
  },
265
+ "group": {
266
+ "type": "object",
267
+ "required": [
268
+ "work",
269
+ "pullRequests"
270
+ ],
271
+ "properties": {
272
+ "work": {
273
+ "type": [
274
+ "string",
275
+ "null"
276
+ ],
277
+ "description": "The work item id a `Chant-Record: work:<id>` trailer names, or null."
278
+ },
279
+ "pullRequests": {
280
+ "type": "array",
281
+ "items": {
282
+ "type": "object",
283
+ "required": [
284
+ "pr",
285
+ "commits"
286
+ ],
287
+ "properties": {
288
+ "pr": {
289
+ "type": [
290
+ "integer",
291
+ "null"
292
+ ],
293
+ "description": "The pull request, or null for commits that landed without one."
294
+ },
295
+ "commits": {
296
+ "type": "array",
297
+ "items": {
298
+ "type": "string"
299
+ },
300
+ "description": "Newest first."
301
+ }
302
+ }
303
+ },
304
+ "description": "Pull requests in the order their oldest commit landed, then `pr: null`."
305
+ }
306
+ }
307
+ },
257
308
  "change": {
258
309
  "type": "object",
259
310
  "required": [
@@ -0,0 +1,31 @@
1
+ import type { SecurityGroup } from "./security.js";
2
+ export declare const RELEASE_WORKFLOW_FORGES: readonly ["github", "forgejo", "gitlab"];
3
+ export type ReleaseWorkflowForge = (typeof RELEASE_WORKFLOW_FORGES)[number];
4
+ /** Where each forge's publish workflow is written, relative to the repository root. */
5
+ export declare const RELEASE_WORKFLOW_FILES: Record<ReleaseWorkflowForge, string>;
6
+ export interface ReleaseWorkflowInput {
7
+ forge: ReleaseWorkflowForge;
8
+ /** The security groups the workspace opts into at its root. */
9
+ groups: ReadonlySet<SecurityGroup>;
10
+ /** The command that installs what the build needs. Default `npm ci`. */
11
+ install?: string;
12
+ /** The command that builds what the targets upload, or none. Default `npm run build`. */
13
+ build?: string | null;
14
+ /** What runs chant. Default `npx chant`. */
15
+ chant?: string;
16
+ /** The workspace root relative to the repository root, `.` at the top. */
17
+ workspaceRoot?: string;
18
+ }
19
+ /** The flags `chant release publish` gets under the groups. */
20
+ export declare function publishFlags(groups: ReadonlySet<SecurityGroup>): string[];
21
+ /** The workflow file's text, without the generated-by header. */
22
+ export declare function renderReleaseWorkflow(input: ReleaseWorkflowInput): string;
23
+ /** The header that marks the file generated, naming the command that regenerates it. */
24
+ export declare function releaseWorkflowHeader(command: string): string;
25
+ /**
26
+ * `chant release workflow [--forge github|gitlab|forgejo] [--install <command>]
27
+ * [--build <command>|--no-build] [--chant <command>] [--output <file>]`: write
28
+ * the publish workflow for the workspace's forge, under its security groups.
29
+ */
30
+ export declare function runReleaseWorkflow(ctx: import("../cli/registry.js").CommandContext): Promise<number>;
31
+ //# sourceMappingURL=release-workflow.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"release-workflow.d.ts","sourceRoot":"","sources":["../../src/workspace/release-workflow.ts"],"names":[],"mappings":"AAmBA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhD,eAAO,MAAM,uBAAuB,YAAI,QAAQ,EAAE,SAAS,EAAE,QAAQ,CAAU,CAAC;AAChF,MAAM,MAAM,oBAAoB,GAAG,CAAC,OAAO,uBAAuB,CAAC,CAAC,MAAM,CAAC,CAAC;AAE5E,uFAAuF;AACvF,eAAO,MAAM,sBAAsB,EAAE,MAAM,CAAC,oBAAoB,EAAE,MAAM,CAKvE,CAAC;AAEF,MAAM,WAAW,oBAAoB;IACnC,KAAK,EAAE,oBAAoB,CAAC;IAC5B,+DAA+D;IAC/D,MAAM,EAAE,WAAW,CAAC,aAAa,CAAC,CAAC;IACnC,wEAAwE;IACxE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,yFAAyF;IACzF,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,4CAA4C;IAC5C,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,0EAA0E;IAC1E,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,+DAA+D;AAC/D,wBAAgB,YAAY,CAAC,MAAM,EAAE,WAAW,CAAC,aAAa,CAAC,GAAG,MAAM,EAAE,CAEzE;AAED,iEAAiE;AACjE,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,oBAAoB,GAAG,MAAM,CAwDzE;AAED,wFAAwF;AACxF,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAE7D;AAED;;;;GAIG;AACH,wBAAsB,kBAAkB,CAAC,GAAG,EAAE,OAAO,iBAAiB,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CA8CvG"}
@@ -0,0 +1,150 @@
1
+ /**
2
+ * `chant release workflow` (#4168 step 6): the publish workflow, generated
3
+ * the way `chant ci workflow` writes the tick, where the guides used to have
4
+ * one pasted (guide/release-from-workstation.mdx).
5
+ *
6
+ * The job runs on a `release/**` tag, checks the tag out, fetches the ci tags
7
+ * and `chant/lifecycle`, builds what the targets upload, and runs `chant
8
+ * release publish` for the label the tag names. The security groups decide
9
+ * the rest, and nothing is on without one (#4168 section 8):
10
+ *
11
+ * - `supply-chain` adds `--require-provenance`, so a run with no OIDC token
12
+ * fails rather than publishing without provenance.
13
+ * - `verified-releases` adds a `chant release verify` step before the publish.
14
+ *
15
+ * The OIDC permission (`id-token: write`, or GitLab's `id_tokens`) is always
16
+ * there: it costs nothing, and with it what the job uploads carries
17
+ * provenance whether or not the group requires it.
18
+ */
19
+ import { GENERATED_MARKER } from "../discovery/files.js";
20
+ export const RELEASE_WORKFLOW_FORGES = ["github", "forgejo", "gitlab"];
21
+ /** Where each forge's publish workflow is written, relative to the repository root. */
22
+ export const RELEASE_WORKFLOW_FILES = {
23
+ github: ".github/workflows/chant-release.yml",
24
+ forgejo: ".forgejo/workflows/chant-release.yml",
25
+ // GitLab reads one .gitlab-ci.yml, which includes this file: `include: { local: .gitlab/chant-release.yml }`.
26
+ gitlab: ".gitlab/chant-release.yml",
27
+ };
28
+ /** The flags `chant release publish` gets under the groups. */
29
+ export function publishFlags(groups) {
30
+ return [...(groups.has("supply-chain") ? ["--require-provenance"] : []), "--yes"];
31
+ }
32
+ /** The workflow file's text, without the generated-by header. */
33
+ export function renderReleaseWorkflow(input) {
34
+ const chant = input.chant ?? "npx chant";
35
+ const install = input.install ?? "npm ci";
36
+ const build = input.build === undefined ? "npm run build" : input.build;
37
+ const root = input.workspaceRoot && input.workspaceRoot !== "." ? input.workspaceRoot : null;
38
+ const verify = input.groups.has("verified-releases");
39
+ const flags = publishFlags(input.groups).join(" ");
40
+ if (input.forge === "gitlab") {
41
+ const label = '"${CI_COMMIT_TAG#release/}"';
42
+ const lines = [
43
+ "chant-release-publish:",
44
+ " rules:",
45
+ " - if: $CI_COMMIT_TAG =~ /^release\\//",
46
+ " id_tokens:",
47
+ " SIGSTORE_ID_TOKEN:",
48
+ " aud: sigstore",
49
+ " script:",
50
+ ...(root ? [` - cd ${root}`] : []),
51
+ ` - ${install}`,
52
+ ...(build ? [` - ${build}`] : []),
53
+ " - git fetch origin 'refs/tags/ci/*:refs/tags/ci/*'",
54
+ " - git fetch origin chant/lifecycle:chant/lifecycle",
55
+ ...(verify ? [` - ${chant} release verify ${label}`] : []),
56
+ ` - ${chant} release publish ${label} ${flags}`,
57
+ ];
58
+ return `${lines.join("\n")}\n`;
59
+ }
60
+ const label = '"${GITHUB_REF_NAME#release/}"';
61
+ const lines = [
62
+ "name: chant release publish",
63
+ "on:",
64
+ " push:",
65
+ ' tags: ["release/**"]',
66
+ "permissions:",
67
+ " contents: write",
68
+ " id-token: write",
69
+ "jobs:",
70
+ " publish:",
71
+ " runs-on: ubuntu-latest",
72
+ ...(root ? [" defaults:", " run:", ` working-directory: ${root}`] : []),
73
+ " steps:",
74
+ " - uses: actions/checkout@v4",
75
+ " with:",
76
+ " fetch-depth: 0",
77
+ " - uses: actions/setup-node@v4",
78
+ " with:",
79
+ " node-version: 24",
80
+ " registry-url: https://registry.npmjs.org",
81
+ ` - run: ${install}`,
82
+ ...(build ? [` - run: ${build}`] : []),
83
+ " - run: git fetch origin 'refs/tags/ci/*:refs/tags/ci/*'",
84
+ " - run: git fetch origin chant/lifecycle:chant/lifecycle",
85
+ ...(verify ? [` - run: ${chant} release verify ${label}`] : []),
86
+ ` - run: ${chant} release publish ${label} ${flags}`,
87
+ ];
88
+ return `${lines.join("\n")}\n`;
89
+ }
90
+ /** The header that marks the file generated, naming the command that regenerates it. */
91
+ export function releaseWorkflowHeader(command) {
92
+ return `# ${GENERATED_MARKER}. Regenerate with: ${command} (in the workspace root)\n`;
93
+ }
94
+ /**
95
+ * `chant release workflow [--forge github|gitlab|forgejo] [--install <command>]
96
+ * [--build <command>|--no-build] [--chant <command>] [--output <file>]`: write
97
+ * the publish workflow for the workspace's forge, under its security groups.
98
+ */
99
+ export async function runReleaseWorkflow(ctx) {
100
+ const { existsSync, mkdirSync, realpathSync, writeFileSync } = await import("node:fs");
101
+ const { dirname, join, relative, resolve, sep } = await import("node:path");
102
+ const { formatError, formatSuccess } = await import("../cli/format.js");
103
+ const { findWorkspaceRoot } = await import("../project-root.js");
104
+ const { gitTop } = await import("./tree.js");
105
+ const { securityGroupsAt } = await import("./security.js");
106
+ const { shellArg } = await import("./member-pipeline.js");
107
+ const usage = "chant release workflow [--forge github|gitlab|forgejo] [--install <command>] [--build <command>] [--chant <command>] [--output <file>]";
108
+ const { args } = ctx;
109
+ const found = findWorkspaceRoot(process.cwd());
110
+ if (!found) {
111
+ console.error(formatError({ message: "no chant.workspace.json between this directory and the git root", hint: usage }));
112
+ return 1;
113
+ }
114
+ const root = realpathSync(found.dir);
115
+ const repoRoot = gitTop(root) ?? root;
116
+ const forge = (args.forge ??
117
+ (existsSync(join(repoRoot, ".github", "workflows")) ? "github" : existsSync(join(repoRoot, ".forgejo", "workflows")) ? "forgejo" : existsSync(join(repoRoot, ".gitlab-ci.yml")) ? "gitlab" : "github"));
118
+ if (!RELEASE_WORKFLOW_FORGES.includes(forge)) {
119
+ console.error(formatError({ message: `--forge ${JSON.stringify(forge)} is not one of ${RELEASE_WORKFLOW_FORGES.join(", ")}`, hint: usage }));
120
+ return 1;
121
+ }
122
+ const groups = securityGroupsAt(root);
123
+ const workspaceRoot = relative(repoRoot, root).split(sep).join("/") || ".";
124
+ const text = renderReleaseWorkflow({
125
+ forge,
126
+ groups,
127
+ workspaceRoot,
128
+ ...(args.ciInstall ? { install: args.ciInstall } : {}),
129
+ ...(args.buildCommand ? { build: args.buildCommand } : {}),
130
+ ...(args.ciChant ? { chant: args.ciChant } : {}),
131
+ });
132
+ const target = args.output ? resolve(process.cwd(), args.output) : join(repoRoot, RELEASE_WORKFLOW_FILES[forge]);
133
+ const parts = ["chant", "release", "workflow", "--forge", forge];
134
+ if (args.ciInstall)
135
+ parts.push("--install", args.ciInstall);
136
+ if (args.buildCommand)
137
+ parts.push("--build", args.buildCommand);
138
+ if (args.ciChant)
139
+ parts.push("--chant", args.ciChant);
140
+ if (args.output)
141
+ parts.push("--output", relative(root, target).split(sep).join("/"));
142
+ mkdirSync(dirname(target), { recursive: true });
143
+ writeFileSync(target, releaseWorkflowHeader(parts.map(shellArg).join(" ")) + text);
144
+ const shown = relative(process.cwd(), target).split(sep).join("/") || target;
145
+ const extras = [groups.has("supply-chain") ? "requires OIDC provenance (supply-chain)" : null, groups.has("verified-releases") ? "verifies the release first (verified-releases)" : null].filter(Boolean);
146
+ console.log(formatSuccess(`Wrote ${shown}: publishes the release a release/** tag names${extras.length > 0 ? `, and ${extras.join(" and ")}` : ""}.`));
147
+ if (forge === "gitlab")
148
+ console.log(`Include it from .gitlab-ci.yml: include: [{ local: ${RELEASE_WORKFLOW_FILES.gitlab} }]`);
149
+ return 0;
150
+ }
@@ -363,6 +363,36 @@
363
363
  "type": "string"
364
364
  }
365
365
  }
366
+ },
367
+ "composed": {
368
+ "type": "object",
369
+ "required": [
370
+ "branch",
371
+ "base",
372
+ "sources"
373
+ ],
374
+ "properties": {
375
+ "branch": {
376
+ "type": [
377
+ "string",
378
+ "null"
379
+ ]
380
+ },
381
+ "base": {
382
+ "type": "string"
383
+ },
384
+ "sources": {
385
+ "type": "array",
386
+ "items": {
387
+ "type": "string"
388
+ }
389
+ }
390
+ },
391
+ "description": "Added by #4168 4.3, written by chant 0.124.35 and newer. On a cut of a composed commit only: its chant/compose/<name> branch, the trunk commit it was composed on, and the trunk commits picked onto that base, oldest first."
392
+ },
393
+ "channel": {
394
+ "type": "string",
395
+ "description": "Added by #4168 4.3, written by chant 0.124.35 and newer. The tag release publish moves in place of latest (an npm dist-tag, an image tag), from release cut --channel. Absent for latest."
366
396
  }
367
397
  }
368
398
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.124.33",
3
+ "version": "0.124.35",
4
4
  "description": "Declarative infrastructure-as-code toolkit — TypeScript on Node.js",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -432,7 +432,7 @@ async function runGraphLive(
432
432
  // no stack carries a `src`.
433
433
  const scopedStacks = stacks.filter((s) => s.src);
434
434
  if (scopedStacks.length > 0) {
435
- const declaredIr = await buildDeclaredPerStack(scopedStacks, projectPath);
435
+ const declaredIr = await buildDeclaredPerStack(scopedStacks, projectPath, (g, entities) => withLexiconEdges(g, plugins, entities));
436
436
  ir =
437
437
  args.overlayAnchor === "live"
438
438
  ? overlayGraphs(ir, declaredIr, overlayOpts)
@@ -455,7 +455,7 @@ async function runGraphLive(
455
455
  console.error(formatWarning({ message: "overlay: a build root failed to render — showing the provisioned graph without the declared overlay" }));
456
456
  } else {
457
457
  await mergeGraphOps(declared.entities, declared.sourceFiles, projectPath);
458
- const declaredIr = buildGraphIr(declared.entities, projectPath);
458
+ const declaredIr = withLexiconEdges(buildGraphIr(declared.entities, projectPath), plugins, declared.entities);
459
459
  ir =
460
460
  args.overlayAnchor === "live"
461
461
  ? overlayGraphs(ir, declaredIr, overlayOpts)
@@ -0,0 +1,230 @@
1
+ /**
2
+ * `chant components pr-apply --green` (#4168 step 9): apply on merge applies
3
+ * a green commit, under the approved plans of the merges since the last
4
+ * applied one when they cover it, and otherwise at a gate of its own on the
5
+ * combined digest. Git, the green target, the derivation and the gate ledger
6
+ * are fakes; the planning, the coverage and the apply are the real ones.
7
+ */
8
+
9
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
13
+ import type { DriverComponent } from "../../components/driver";
14
+ import type { DeployContext } from "../../components/capability";
15
+ import type { ChangeSetDocument } from "../../change-set";
16
+ import type { GreenApplyRecord } from "../../lifecycle/green-apply";
17
+ import type { GreenTargetResult } from "../../components/green-cover";
18
+ import type { ParsedArgs } from "../registry";
19
+
20
+ const HEAD = "a".repeat(40);
21
+ const BASE = "b".repeat(40);
22
+
23
+ vi.mock("../../workspace/security", async (original) => ({
24
+ ...(await original<typeof import("../../workspace/security")>()),
25
+ securityGroupsAt: () => new Set<string>(),
26
+ }));
27
+ vi.mock("../../components/changed-files", () => ({
28
+ resolveCommit: async () => HEAD,
29
+ resolveMergeBase: async () => BASE,
30
+ }));
31
+ vi.mock("../../config", async () => {
32
+ const actual = await vi.importActual<typeof import("../../config")>("../../config");
33
+ return { ...actual, loadChantConfig: async () => ({ config: {} }) };
34
+ });
35
+ vi.mock("./fan-out", () => ({
36
+ resolveChangedUnits: async () => ({ units: { changed: ["net"], indeterminate: [] } }),
37
+ }));
38
+ // Nothing here may write or push the real repository's chant/lifecycle.
39
+ vi.mock("../../lifecycle/gate-change-set", () => ({
40
+ storeGateChangeSet: async () => ({ written: false }),
41
+ readGraphIds: async () => ({ ids: [] }),
42
+ setGraphIdReader: () => {},
43
+ gateGraphReader: () => async () => ({ ids: [] }),
44
+ }));
45
+ vi.mock("../../lifecycle/git", async () => {
46
+ const actual = await vi.importActual<typeof import("../../lifecycle/git")>("../../lifecycle/git");
47
+ return { ...actual, pushLifecycleReport: async () => ({}) };
48
+ });
49
+ const applied: GreenApplyRecord[] = [];
50
+ vi.mock("../../lifecycle/green-apply", async () => {
51
+ const actual = await vi.importActual<typeof import("../../lifecycle/green-apply")>("../../lifecycle/green-apply");
52
+ return { ...actual, writeGreenApply: async (r: GreenApplyRecord) => (applied.push(r), "ledger-commit") };
53
+ });
54
+ vi.mock("./run", () => ({
55
+ GATED_EXIT_CODE: 3,
56
+ autoReleaseRun: (id: string) => ({ runId: id, runOrigin: { forge: "local" } }),
57
+ recordAutoReleasesForRun: async () => [],
58
+ }));
59
+
60
+ let gateLedger: ReturnType<typeof import("../../op/gate").memoryGateLedgerPort>;
61
+ vi.mock("../../op/gate", async () => {
62
+ const actual = await vi.importActual<typeof import("../../op/gate")>("../../op/gate");
63
+ return { ...actual, gitGateLedgerPort: () => gateLedger, gateIsSealed: async () => false };
64
+ });
65
+
66
+ let target: GreenTargetResult;
67
+ let asked: Array<{ base?: string }> = [];
68
+ vi.mock("../../components/green-cover", async () => {
69
+ const actual = await vi.importActual<typeof import("../../components/green-cover")>("../../components/green-cover");
70
+ return { ...actual, resolveGreenTarget: async (req: { base?: string }) => (asked.push(req), target) };
71
+ });
72
+
73
+ /** What each root's plan writes, so a test can move it. */
74
+ const values = new Map<string, string>();
75
+ const ran: string[] = [];
76
+ vi.mock("../../components/fan-out-support", async () => {
77
+ const { CapabilityRegistry } = await import("../../components/capability");
78
+ const { planFanOut } = await import("../../components/fan-out");
79
+ const { computePlanDigest } = await import("../../lifecycle/plan-digest");
80
+ const registry = new CapabilityRegistry();
81
+ registry.register({
82
+ kind: "fake-root",
83
+ async plan(_ctx: DeployContext, input: { root: string }) {
84
+ const value = values.get(input.root) ?? "1";
85
+ const planDigest = computePlanDigest("fake-root", { root: input.root, value });
86
+ return {
87
+ member: input.root,
88
+ planDigest,
89
+ artifact: { planDigest },
90
+ changeSet: {
91
+ member: { member: input.root, lexicon: "terraform", planner: "tofu", status: "planned", planDigest, holes: [] },
92
+ entries: [{ member: input.root, lexicon: "terraform", planner: "tofu", address: `${input.root}.main`, type: "fake", action: "update", attributes: [{ path: "value", after: value }] }],
93
+ },
94
+ };
95
+ },
96
+ async outputs() {
97
+ return {};
98
+ },
99
+ async run(_ctx: DeployContext, input: { root: string }) {
100
+ ran.push(input.root);
101
+ return { outputs: {} };
102
+ },
103
+ } as never);
104
+ return {
105
+ fanOutRegistry: async () => registry,
106
+ deriveFanOut: async () => ({
107
+ success: true,
108
+ components: ESTATE,
109
+ plan: planFanOut({ components: ESTATE, changed: ["net"] }),
110
+ signal: { changed: ["net"], indeterminate: [], unclaimed: [] },
111
+ }),
112
+ };
113
+ });
114
+
115
+ const root = (name: string, dependsOn: string[] = []): DriverComponent => ({
116
+ name,
117
+ dependsOn,
118
+ deploy: [{ phase: "Apply", steps: [{ kind: "fake-root", root: name }] }],
119
+ });
120
+ const ESTATE: DriverComponent[] = [root("net"), root("app", ["net"])];
121
+
122
+ const { memoryGateLedgerPort } = await import("../../op/gate");
123
+ const { runComponentsPrApply, runComponentsPrPlan } = await import("./pr");
124
+ const { composeChangeSet } = await import("../../change-set");
125
+
126
+ let dir: string;
127
+ let errors: string[];
128
+
129
+ const ctx = (a: Partial<ParsedArgs>) =>
130
+ ({ args: { command: "components", path: "pr-apply", format: "", fix: false, watch: false, verbose: false, help: false, live: false, base: "main", env: "prod", output: dir, ...a } as ParsedArgs, plugins: [] }) as never;
131
+
132
+ beforeEach(async () => {
133
+ dir = await mkdtemp(join(tmpdir(), "pr-green-"));
134
+ errors = [];
135
+ applied.length = 0;
136
+ ran.length = 0;
137
+ values.clear();
138
+ asked = [];
139
+ gateLedger = memoryGateLedgerPort();
140
+ vi.spyOn(process.stdout, "write").mockImplementation(() => true);
141
+ vi.spyOn(console, "error").mockImplementation((...m: unknown[]) => { errors.push(m.map(String).join(" ")); });
142
+ vi.spyOn(console, "log").mockImplementation(() => {});
143
+ });
144
+
145
+ afterEach(async () => {
146
+ vi.restoreAllMocks();
147
+ await rm(dir, { recursive: true, force: true });
148
+ });
149
+
150
+ /** The change set a pull request's plan made with the values set now. */
151
+ async function planned(): Promise<ChangeSetDocument> {
152
+ await runComponentsPrPlan(ctx({ pr: 12, path: "pr-plan" }));
153
+ return JSON.parse(await readFile(join(dir, "change-set.json"), "utf8")) as ChangeSetDocument;
154
+ }
155
+
156
+ const greenTarget = (approved: Array<{ doc: ChangeSetDocument; pr: number; approvedBy: string[] }>): GreenTargetResult => ({
157
+ status: "target",
158
+ target: {
159
+ commit: HEAD,
160
+ base: BASE,
161
+ baseFrom: "applied",
162
+ merges: [BASE.replace(/b/g, "c"), HEAD],
163
+ prs: approved.map((m) => m.pr),
164
+ approved: approved.map((m, i) => ({ commit: i === approved.length - 1 ? HEAD : BASE.replace(/b/g, "c"), ...m })),
165
+ },
166
+ });
167
+ const OP = `green-${HEAD.slice(0, 12)}`;
168
+
169
+ describe("pr-apply --green (#4168 step 9)", () => {
170
+ test("the merges' approved plans cover the plan at the green commit: it applies under them, with no gate, and records the commit applied", async () => {
171
+ const doc = await planned();
172
+ target = greenTarget([{ doc, pr: 12, approvedBy: ["github:alice"] }]);
173
+ expect(await runComponentsPrApply(ctx({ green: true }))).toBe(0);
174
+ expect(ran).toEqual(["net", "app"]);
175
+ expect(gateLedger.appended).toEqual([]);
176
+ expect(applied).toEqual([expect.objectContaining({ env: "prod", commit: HEAD, base: BASE, approval: "merges", approvedBy: ["github:alice"], digest: doc.digest })]);
177
+ const report = JSON.parse(await readFile(join(dir, "pr-apply.json"), "utf8"));
178
+ expect(report).toMatchObject({ op: OP, pr: null, status: "applied", approval: { status: "approved", approvedBy: ["github:alice"] } });
179
+ });
180
+
181
+ test("two merges, each approving one member, cover the plan together (the case one pull request's apply stopped at plan-changed)", async () => {
182
+ values.set("app", "1");
183
+ const first = await planned();
184
+ values.set("app", "2");
185
+ const both = await planned();
186
+ // #13 touched app alone, so its plan holds app alone.
187
+ const second = composeChangeSet([{ member: both.members.find((m) => m.member === "app")!, entries: both.entries.filter((e) => e.member === "app") }]);
188
+ // #12 approved net and app at 1; #13 approved app at 2; the green commit plans net at 1 and app at 2.
189
+ target = greenTarget([
190
+ { doc: first, pr: 12, approvedBy: ["github:alice"] },
191
+ { doc: second, pr: 13, approvedBy: ["github:bob"] },
192
+ ]);
193
+ expect(await runComponentsPrApply(ctx({ green: true }))).toBe(0);
194
+ expect(ran).toEqual(["net", "app"]);
195
+ expect(applied[0]).toMatchObject({ approval: "merges", approvedBy: ["github:alice", "github:bob"] });
196
+ });
197
+
198
+ test("a plan the merges did not approve waits at the green commit's own gate on the combined digest, then applies once that is approved", async () => {
199
+ const doc = await planned();
200
+ values.set("net", "9");
201
+ target = greenTarget([{ doc, pr: 12, approvedBy: ["github:alice"] }]);
202
+ expect(await runComponentsPrApply(ctx({ green: true }))).toBe(3);
203
+ expect(ran).toEqual([]);
204
+ expect(applied).toEqual([]);
205
+ const refused = JSON.parse(await readFile(join(dir, "pr-apply.json"), "utf8"));
206
+ expect(refused).toMatchObject({ op: OP, status: "refused", refusal: "not-approved" });
207
+ expect(refused.message).toContain("do not cover it: net now writes net.main.value to a different value than the approved plan (checked against #12)");
208
+ expect(refused.message).toContain(`chant approve ${OP} pr-apply --plan ${refused.digest}`);
209
+
210
+ gateLedger = memoryGateLedgerPort({
211
+ resolutions: [{ version: 1, kind: "resolution", op: OP, gate: "pr-apply", resolvedBy: "github:carol", timestamp: new Date().toISOString(), planDigest: refused.digest }],
212
+ });
213
+ expect(await runComponentsPrApply(ctx({ green: true }))).toBe(0);
214
+ expect(ran).toEqual(["net", "app"]);
215
+ expect(applied[0]).toMatchObject({ approval: "gate", approvedBy: ["github:carol"], digest: refused.digest });
216
+ });
217
+
218
+ test("the green commit last applied is nothing to do, and --green takes no --pr", async () => {
219
+ target = { status: "applied", commit: HEAD };
220
+ expect(await runComponentsPrApply(ctx({ green: true }))).toBe(0);
221
+ expect(ran).toEqual([]);
222
+ expect(errors.join("\n")).toContain("is the green commit last applied to prod");
223
+ expect(await runComponentsPrApply(ctx({ green: true, pr: 12 }))).toBe(1);
224
+ target = { status: "error", message: "abc is not a green commit", hint: "check it out" };
225
+ expect(await runComponentsPrApply(ctx({ green: true }))).toBe(1);
226
+ expect(errors.join("\n")).toContain("abc is not a green commit");
227
+ // --base reaches the target as the first apply's fallback.
228
+ expect(asked.at(-1)).toMatchObject({ base: "main" });
229
+ });
230
+ });