@intentius/chant 0.124.33 → 0.124.34

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 (75) hide show
  1. package/dist/cli/handlers/promote.d.ts.map +1 -1
  2. package/dist/cli/handlers/promote.js +261 -73
  3. package/dist/cli/main.d.ts.map +1 -1
  4. package/dist/cli/main.js +18 -1
  5. package/dist/cli/parse-args.d.ts.map +1 -1
  6. package/dist/cli/parse-args.js +7 -0
  7. package/dist/cli/registry.d.ts +3 -1
  8. package/dist/cli/registry.d.ts.map +1 -1
  9. package/dist/components/promote-rounds.d.ts +102 -0
  10. package/dist/components/promote-rounds.d.ts.map +1 -0
  11. package/dist/components/promote-rounds.js +174 -0
  12. package/dist/components/release-gate.d.ts +30 -0
  13. package/dist/components/release-gate.d.ts.map +1 -1
  14. package/dist/components/release-gate.js +92 -42
  15. package/dist/graph-declared.d.ts +4 -0
  16. package/dist/graph-declared.d.ts.map +1 -1
  17. package/dist/graph-declared.js +11 -1
  18. package/dist/wave-runtime.mjs +39 -39
  19. package/dist/workspace/__fixtures__/contract-repo.d.ts.map +1 -1
  20. package/dist/workspace/__fixtures__/contract-repo.js +13 -2
  21. package/dist/workspace/check.schema.json +20 -2
  22. package/dist/workspace/checks/ci-workflow.d.ts +17 -0
  23. package/dist/workspace/checks/ci-workflow.d.ts.map +1 -0
  24. package/dist/workspace/checks/ci-workflow.js +123 -0
  25. package/dist/workspace/checks/security.d.ts +16 -1
  26. package/dist/workspace/checks/security.d.ts.map +1 -1
  27. package/dist/workspace/checks/security.js +4 -4
  28. package/dist/workspace/checks.d.ts +1 -1
  29. package/dist/workspace/checks.d.ts.map +1 -1
  30. package/dist/workspace/checks.js +4 -1
  31. package/dist/workspace/ci-init.d.ts +100 -0
  32. package/dist/workspace/ci-init.d.ts.map +1 -0
  33. package/dist/workspace/ci-init.js +434 -0
  34. package/dist/workspace/ci-workflow.d.ts +91 -0
  35. package/dist/workspace/ci-workflow.d.ts.map +1 -0
  36. package/dist/workspace/ci-workflow.js +33 -0
  37. package/dist/workspace/declaration.d.ts +18 -0
  38. package/dist/workspace/declaration.d.ts.map +1 -1
  39. package/dist/workspace/declaration.js +9 -2
  40. package/dist/workspace/declaration.schema.json +28 -0
  41. package/dist/workspace/init.js +2 -2
  42. package/dist/workspace/reason-codes.d.ts +9 -0
  43. package/dist/workspace/reason-codes.d.ts.map +1 -1
  44. package/dist/workspace/reason-codes.js +10 -0
  45. package/dist/workspace/release-workflow.d.ts +31 -0
  46. package/dist/workspace/release-workflow.d.ts.map +1 -0
  47. package/dist/workspace/release-workflow.js +150 -0
  48. package/package.json +1 -1
  49. package/src/cli/handlers/promote.ts +280 -83
  50. package/src/cli/main.ts +18 -1
  51. package/src/cli/parse-args.ts +5 -0
  52. package/src/cli/registry.ts +3 -1
  53. package/src/components/promote-rounds.test.ts +220 -0
  54. package/src/components/promote-rounds.ts +232 -0
  55. package/src/components/release-gate.test.ts +47 -0
  56. package/src/components/release-gate.ts +111 -46
  57. package/src/graph-declared-exports.test.ts +40 -0
  58. package/src/graph-declared.ts +9 -1
  59. package/src/workspace/__fixtures__/contract-repo.ts +11 -2
  60. package/src/workspace/check-contract.test.ts +3 -2
  61. package/src/workspace/check.schema.json +20 -2
  62. package/src/workspace/checks/ci-workflow.test.ts +150 -0
  63. package/src/workspace/checks/ci-workflow.ts +129 -0
  64. package/src/workspace/checks/security.ts +4 -4
  65. package/src/workspace/checks.ts +4 -1
  66. package/src/workspace/ci-init.test.ts +159 -0
  67. package/src/workspace/ci-init.ts +445 -0
  68. package/src/workspace/ci-report-url.test.ts +6 -1
  69. package/src/workspace/ci-workflow.ts +71 -0
  70. package/src/workspace/declaration.schema.json +28 -0
  71. package/src/workspace/declaration.ts +32 -3
  72. package/src/workspace/init.ts +2 -2
  73. package/src/workspace/reason-codes.test.ts +2 -0
  74. package/src/workspace/reason-codes.ts +10 -0
  75. package/src/workspace/release-workflow.ts +167 -0
@@ -21,8 +21,8 @@
21
21
  * ran from it, through the release gate `promote --release` binds (#4045).
22
22
  */
23
23
 
24
- import { resolve } from "node:path";
25
- import { readFileSync } from "node:fs";
24
+ import { dirname, join, resolve } from "node:path";
25
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
26
26
  import { loadChantConfig, type ChantConfig } from "../../config";
27
27
  import { promotionOrder } from "../../workspace/promotion-order";
28
28
  import { resolvePromotionSource } from "../../components/promote-order";
@@ -70,7 +70,7 @@ import type { CommandContext } from "../registry";
70
70
  import { redeployLine, releaseRollbackLine, rollbackTarget, unsettledComponents } from "../../lifecycle/mixed-environment";
71
71
 
72
72
  const PROMOTE_USAGE =
73
- "chant components promote --to <env> [--from <env>] [--allow-skip] [--release <label|digest>] [--component <name> [--digest <sha256:...>] | --digest <component>=<sha256:...> ...] [--dry-run] [--json]";
73
+ "chant components promote --to <env> [--from <env>] [--allow-skip] [--release <label|digest>] [--component <name> [--digest <sha256:...>] | --digest <component>=<sha256:...> ...] [--wave-gate <gate>] [--resume <file>] [--dry-run] [--json]";
74
74
  const ROLLBACK_USAGE =
75
75
  "chant components rollback <env> (--component <name> [--digest <sha256:...>] | --release <label|digest>) [--dry-run] [--json]";
76
76
  const REDEPLOY_USAGE =
@@ -152,6 +152,20 @@ async function readLedger(env: string): Promise<ReleaseRecord[]> {
152
152
  return ledger.records;
153
153
  }
154
154
 
155
+ /** What {@link bindReleaseGate} hands the run: the round-one binding, and how to plan and bind a later round or wave (#4168 step 4). */
156
+ interface BoundReleaseGate {
157
+ binding: ReleaseGateBinding;
158
+ doc: ChangeSetDocument;
159
+ empty: boolean;
160
+ reads: import("../../components/release-gate").ReleasePlan["reads"];
161
+ plannedOutputs?: Record<string, string[]>;
162
+ unknownInputs?: Record<string, string[]>;
163
+ /** The approval a resume record carried on, when it still covers what is left. */
164
+ resumedFrom?: string;
165
+ replan: (only: ReadonlySet<string>, outputs: Record<string, Record<string, unknown>>) => Promise<ChangeSetDocument>;
166
+ bind: (doc: ChangeSetDocument, gates: string[]) => Promise<ReleaseGateBinding>;
167
+ }
168
+
155
169
  /**
156
170
  * Plan, store and bind the release gate for a promote under a release
157
171
  * (#4045, ws-104 questions 2 and 6). Undefined when no promoted composition
@@ -159,6 +173,11 @@ async function readLedger(env: string): Promise<ReleaseRecord[]> {
159
173
  * a refusal already printed: a provisional member, or a member that failed
160
174
  * to plan. On an empty change set, each gate gets an `empty: true`
161
175
  * resolution the run then counts once it reads the stored document back.
176
+ *
177
+ * A dependent is planned against what a promoted upstream's plan says it will
178
+ * expose (#4168 step 4). With a resume record, only what is left is planned,
179
+ * against the outputs the record kept, and the approval the record names
180
+ * carries on when it covers that plan.
162
181
  */
163
182
  async function bindReleaseGate(input: {
164
183
  plan: PromotionPlan;
@@ -170,7 +189,8 @@ async function bindReleaseGate(input: {
170
189
  actor: string;
171
190
  projectPath: string;
172
191
  head: string | undefined;
173
- }): Promise<{ binding: ReleaseGateBinding; doc: ChangeSetDocument; empty: boolean } | number | undefined> {
192
+ resume?: import("../../components/promote-rounds").PromoteResume;
193
+ }): Promise<BoundReleaseGate | number | undefined> {
174
194
  const { plan, under, port } = input;
175
195
  const gate = await import("../../components/release-gate");
176
196
  const gates = gate.gateNamesOf(input.prepared);
@@ -199,75 +219,162 @@ async function bindReleaseGate(input: {
199
219
  unsettled.set(component, `the ${u.outcome.verb} run ${u.outcome.runId} left ${component} ${u.state} in "${env}" and nothing has been recorded for it since, so what it runs is not known`);
200
220
  }
201
221
 
202
- const doc = await gate.planReleaseChangeSet({
203
- release: under.release,
204
- env,
205
- components: input.prepared,
206
- digests: new Map(plan.items.map((i) => [i.component, i.digest])),
207
- registry: input.registry,
208
- current,
209
- unsettled,
210
- seededOutputs: input.seededOutputs,
211
- });
212
- const provisional = gate.provisionalMembers(doc);
213
- if (provisional.length > 0) {
214
- console.error(formatError({
215
- message: `the change set of release ${under.label ?? releaseDigest} for "${env}" has provisional members (${provisional.join(", ")}), which no approval covers; nothing was deployed`,
216
- hint: "Deploy what they read first, so they plan against real values.",
217
- }));
218
- return 1;
219
- }
220
- const failed = doc.members.filter((m) => m.status === "failed");
221
- if (failed.length > 0) {
222
- for (const m of failed) console.error(formatError({ message: `${m.member} failed to plan for "${env}"${m.error ? `: ${m.error}` : ""}` }));
223
- console.error(formatError({ message: "nothing was deployed" }));
224
- return 1;
225
- }
222
+ const digests = new Map(plan.items.map((i) => [i.component, i.digest]));
223
+ const planSet = (only: ReadonlySet<string> | undefined, outputs: Record<string, Record<string, unknown>>) =>
224
+ gate.planReleaseSet({
225
+ release: under.release,
226
+ env,
227
+ components: input.prepared,
228
+ digests,
229
+ registry: input.registry,
230
+ current,
231
+ unsettled,
232
+ seededOutputs: outputs,
233
+ ...(only ? { only } : {}),
234
+ });
226
235
 
227
- // The document is stored before the gate is decided, so the plan a pending
228
- // gate waits on can be read, and an empty pass can be checked.
229
236
  const { storeGateChangeSet } = await import("../../lifecycle/gate-change-set");
230
- try {
231
- const stored = await storeGateChangeSet({ doc, cwd: input.projectPath, at: input.head ?? null });
232
- if (stored.warning) console.error(formatWarning({ message: stored.warning }));
233
- } catch (err) {
234
- console.error(formatWarning({ message: `could not store the change set ${doc.digest}: ${err instanceof Error ? err.message : String(err)}` }));
235
- }
237
+ const { readStoredChangeSet } = await import("../../lifecycle/change-set-store");
236
238
  const { isEmptyChangeSet } = await import("../../change-set");
237
- const empty = isEmptyChangeSet(doc);
238
- console.error(formatInfo(
239
- `release gate: change set ${doc.digest} for "${env}"` +
240
- (empty ? ", empty: its gates pass without an approval" : `, ${doc.summary.entries} entr${doc.summary.entries === 1 ? "y" : "ies"}, ${doc.summary.holes} hole(s)`) +
241
- ` (chant change-set show ${doc.digest})`,
242
- ));
243
-
244
- if (empty) {
245
- if (!port.appendResolution) throw new Error("the gate ledger can't record a resolution, so an empty change set can't pass its gates");
246
- const { resolutions } = await port.read(gate.RELEASE_GATE_OP);
247
- const timestamp = new Date().toISOString();
248
- for (const name of gates) {
249
- const standing = resolutions.some(
250
- (r) => r.empty === true && r.gate === name && r.environment === env && r.releaseDigest === releaseDigest && r.planDigest === doc.digest,
251
- );
252
- if (standing) continue;
253
- // Pushed with the resolution; the deploy records the promote writes push the same branch.
254
- await port.appendResolution(
255
- gate.emptyResolution({ gate: name, doc, actor: input.actor, timestamp, ...(input.head ? { gitSha: input.head } : {}) }),
256
- );
239
+ const verifyEmpty = gate.emptyVerifier((digest) => readStoredChangeSet(digest, { cwd: input.projectPath }), { releaseDigest, environment: env });
240
+
241
+ /** Refuse, store, and pass the gates of an empty plan; the binding, or the exit code of a refusal already printed. */
242
+ const bindDoc = async (doc: ChangeSetDocument, names: readonly string[], what: string): Promise<{ binding: ReleaseGateBinding; empty: boolean } | number> => {
243
+ const provisional = gate.provisionalMembers(doc);
244
+ if (provisional.length > 0) {
245
+ console.error(formatError({
246
+ message: `${what} of release ${under.label ?? releaseDigest} for "${env}" has provisional members (${provisional.join(", ")}), which no approval covers; nothing more was deployed`,
247
+ hint: "Deploy what they read first, so they plan against real values.",
248
+ }));
249
+ return 1;
257
250
  }
258
- }
251
+ const failed = doc.members.filter((m) => m.status === "failed");
252
+ if (failed.length > 0) {
253
+ for (const m of failed) console.error(formatError({ message: `${m.member} failed to plan for "${env}"${m.error ? `: ${m.error}` : ""}` }));
254
+ console.error(formatError({ message: "nothing more was deployed" }));
255
+ return 1;
256
+ }
257
+ // The document is stored before the gate is decided, so the plan a pending
258
+ // gate waits on can be read, and an empty pass can be checked.
259
+ try {
260
+ const stored = await storeGateChangeSet({ doc, cwd: input.projectPath, at: input.head ?? null });
261
+ if (stored.warning) console.error(formatWarning({ message: stored.warning }));
262
+ } catch (err) {
263
+ console.error(formatWarning({ message: `could not store the change set ${doc.digest}: ${err instanceof Error ? err.message : String(err)}` }));
264
+ }
265
+ const empty = isEmptyChangeSet(doc);
266
+ console.error(formatInfo(
267
+ `release gate: ${what} ${doc.digest} for "${env}"` +
268
+ (empty ? ", empty: its gates pass without an approval" : `, ${doc.summary.entries} entr${doc.summary.entries === 1 ? "y" : "ies"}, ${doc.summary.holes} hole(s)`) +
269
+ ` (chant change-set show ${doc.digest})`,
270
+ ));
271
+ if (empty) {
272
+ if (!port.appendResolution) throw new Error("the gate ledger can't record a resolution, so an empty change set can't pass its gates");
273
+ const { resolutions } = await port.read(gate.RELEASE_GATE_OP);
274
+ const timestamp = new Date().toISOString();
275
+ for (const name of names) {
276
+ const standing = resolutions.some(
277
+ (r) => r.empty === true && r.gate === name && r.environment === env && r.releaseDigest === releaseDigest && r.planDigest === doc.digest,
278
+ );
279
+ if (standing) continue;
280
+ // Pushed with the resolution; the deploy records the promote writes push the same branch.
281
+ await port.appendResolution(
282
+ gate.emptyResolution({ gate: name, doc, actor: input.actor, timestamp, ...(input.head ? { gitSha: input.head } : {}) }),
283
+ );
284
+ }
285
+ }
286
+ return { binding: { op: gate.RELEASE_GATE_OP, planDigest: doc.digest, releaseDigest, verifyEmpty }, empty };
287
+ };
259
288
 
260
- const { readStoredChangeSet } = await import("../../lifecycle/change-set-store");
261
- return {
262
- doc,
263
- empty,
264
- binding: {
265
- op: gate.RELEASE_GATE_OP,
266
- planDigest: doc.digest,
267
- releaseDigest,
268
- verifyEmpty: gate.emptyVerifier((digest) => readStoredChangeSet(digest, { cwd: input.projectPath }), { releaseDigest, environment: env }),
289
+ const later = {
290
+ replan: async (only: ReadonlySet<string>, outputs: Record<string, Record<string, unknown>>) => (await planSet(only, outputs)).doc,
291
+ bind: async (doc: ChangeSetDocument, names: string[]) => {
292
+ const bound = await bindDoc(doc, names, "a later plan");
293
+ if (typeof bound === "number") throw new PromoteStageRefused();
294
+ return bound.binding;
269
295
  },
270
296
  };
297
+
298
+ // #4168 step 4: a resume plans what is left against the outputs it kept.
299
+ if (input.resume) {
300
+ const done = new Set(input.resume.completed);
301
+ const left = new Set(plan.items.map((i) => i.component).filter((c) => !done.has(c)));
302
+ const fresh = await planSet(left, { ...input.seededOutputs, ...input.resume.outputs });
303
+ const stored = await readStoredChangeSet(input.resume.approvedDigest, { cwd: input.projectPath });
304
+ if (stored.status === "found") {
305
+ const { waveNotCovered } = await import("../../components/pr-run");
306
+ const notCovered = waveNotCovered(stored.document, fresh.doc);
307
+ if (notCovered.length === 0) {
308
+ console.error(formatInfo(`resuming: ${done.size} component(s) applied earlier; what is left is covered by change set ${stored.document.digest}`));
309
+ return {
310
+ doc: stored.document,
311
+ empty: isEmptyChangeSet(stored.document),
312
+ binding: { op: gate.RELEASE_GATE_OP, planDigest: stored.document.digest, releaseDigest, verifyEmpty },
313
+ reads: fresh.reads,
314
+ resumedFrom: stored.document.digest,
315
+ ...(fresh.plannedOutputs ? { plannedOutputs: fresh.plannedOutputs } : {}),
316
+ ...(fresh.unknownInputs ? { unknownInputs: fresh.unknownInputs } : {}),
317
+ ...later,
318
+ };
319
+ }
320
+ for (const line of notCovered) console.error(formatWarning({ message: `resume: ${line}` }));
321
+ }
322
+ const bound = await bindDoc(fresh.doc, gates, "change set");
323
+ if (typeof bound === "number") return bound;
324
+ return { doc: fresh.doc, ...bound, reads: fresh.reads, ...(fresh.plannedOutputs ? { plannedOutputs: fresh.plannedOutputs } : {}), ...(fresh.unknownInputs ? { unknownInputs: fresh.unknownInputs } : {}), ...later };
325
+ }
326
+
327
+ const first = await planSet(undefined, input.seededOutputs);
328
+ const bound = await bindDoc(first.doc, gates, "change set");
329
+ if (typeof bound === "number") return bound;
330
+ for (const [component, refs] of Object.entries(first.unknownInputs ?? {})) {
331
+ console.error(formatInfo(`${component} reads ${refs.join(", ")}, known only once its upstream applies; it is planned again then, and needs its own approval if its plan moves`));
332
+ }
333
+ return { doc: first.doc, ...bound, reads: first.reads, ...(first.plannedOutputs ? { plannedOutputs: first.plannedOutputs } : {}), ...(first.unknownInputs ? { unknownInputs: first.unknownInputs } : {}), ...later };
334
+ }
335
+
336
+ /** A later round's or wave's plan was refused (provisional or failed members); the refusal is already printed. */
337
+ class PromoteStageRefused extends Error {
338
+ constructor() {
339
+ super("a later round's plan was refused");
340
+ this.name = "PromoteStageRefused";
341
+ }
342
+ }
343
+
344
+ /**
345
+ * The pull requests a release carries into `env` and how each was approved
346
+ * on merge (#4168 decision 2): shown beside the release gate, never counted
347
+ * by it. The commits are the release commit's first-parent history since
348
+ * the commits `env` runs, at most 200, read from the merge records.
349
+ */
350
+ async function pullRequestApprovals(release: { commit: string }, since: readonly string[], cwd: string): Promise<Array<import("../../lifecycle/merge-record").MergeRecord>> {
351
+ const { execFileSync } = await import("node:child_process");
352
+ const { readMergeRecord } = await import("../../lifecycle/merge-record");
353
+ let commits: string[] = [];
354
+ try {
355
+ const out = execFileSync("git", ["rev-list", "--first-parent", "--max-count=200", release.commit, ...since.map((c) => `^${c}`)], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
356
+ commits = out.split("\n").filter(Boolean);
357
+ } catch {
358
+ return [];
359
+ }
360
+ const records: Array<import("../../lifecycle/merge-record").MergeRecord> = [];
361
+ for (const commit of commits) {
362
+ const record = await readMergeRecord(commit, { cwd }).catch(() => null);
363
+ if (record) records.push(record);
364
+ }
365
+ return records;
366
+ }
367
+
368
+ /** One line per merge record: where it came from and how its plan stood against its approval. */
369
+ function approvalLine(r: import("../../lifecycle/merge-record").MergeRecord): string {
370
+ const from = r.source.kind === "pull-request" ? `#${r.source.pr}` : r.source.kind === "work-item" ? r.source.records.map((x) => `${x.kind}:${x.id}`).join(", ") : "direct push";
371
+ const how =
372
+ r.plan === "approved"
373
+ ? `approved by ${r.approvedBy.join(", ") || "nobody named"}${r.authorApproved?.length ? ` (by its author: ${r.authorApproved.join(", ")})` : ""}`
374
+ : r.plan === "changed"
375
+ ? `its plan moved after the approval of ${r.approvedDigest ?? "another digest"}`
376
+ : r.plan;
377
+ return `${r.commit.slice(0, 12)} ${from}: ${how}`;
271
378
  }
272
379
 
273
380
  /**
@@ -387,22 +494,50 @@ async function deployPlan(
387
494
  // #4045: under a release, every gate binds the release's change set for this environment.
388
495
  let releaseGate: ReleaseGateBinding | undefined;
389
496
  let changeSetJson: { changeSet: string; empty: boolean } | undefined;
497
+ let bound: BoundReleaseGate | undefined;
498
+ // #4168 step 4: a promote that stopped at a round's or a wave's gate left a resume record.
499
+ const rounds = await import("../../components/promote-rounds");
500
+ const resumePath = resolve(project.projectPath, args.resume ?? join(".chant", "promote", `${plan.to}.resume.json`));
501
+ let resume: import("../../components/promote-rounds").PromoteResume | undefined;
502
+ if (under && existsSync(resumePath)) {
503
+ try {
504
+ const record = JSON.parse(readFileSync(resumePath, "utf8")) as import("../../components/promote-rounds").PromoteResume;
505
+ if (record.schema === rounds.PROMOTE_RESUME_SCHEMA && record.env === plan.to && record.releaseDigest === under.release.releaseDigest) resume = record;
506
+ else if (args.resume) console.error(formatWarning({ message: `--resume ${args.resume} is for another release or environment; planning from the start` }));
507
+ } catch (err) {
508
+ console.error(formatWarning({ message: `could not read the resume record ${resumePath}: ${err instanceof Error ? err.message : String(err)}` }));
509
+ }
510
+ }
511
+ let pullRequests: Array<import("../../lifecycle/merge-record").MergeRecord> = [];
390
512
  if (under) {
391
513
  try {
392
- const bound = await bindReleaseGate({ plan, prepared, under, registry, port, seededOutputs, actor: actor!, projectPath: project.projectPath, head });
393
- if (typeof bound === "number") {
514
+ const result = await bindReleaseGate({ plan, prepared, under, registry, port, seededOutputs, actor: actor!, projectPath: project.projectPath, head, ...(resume ? { resume } : {}) });
515
+ if (typeof result === "number") {
394
516
  tidy();
395
- return bound;
517
+ return result;
396
518
  }
397
- if (bound) {
398
- releaseGate = bound.binding;
399
- changeSetJson = { changeSet: bound.doc.digest, empty: bound.empty };
519
+ if (result) {
520
+ bound = result;
521
+ releaseGate = result.binding;
522
+ changeSetJson = { changeSet: result.doc.digest, empty: result.empty };
523
+ // #4168 decision 2: the pull requests' own approvals, shown beside the gate.
524
+ const since = [...new Set((await readLedger(plan.to)).filter((r) => r.env === plan.to).map((r) => r.gitSha))];
525
+ pullRequests = await pullRequestApprovals(under.release, since, project.projectPath);
526
+ if (pullRequests.length > 0 && !args.json) {
527
+ console.error(formatInfo(`pull requests this release carries into "${plan.to}" (shown, not counted by the release gate):`));
528
+ for (const r of pullRequests) console.error(` ${approvalLine(r)}`);
529
+ }
400
530
  }
401
531
  } catch (err) {
402
532
  tidy();
403
533
  throw err;
404
534
  }
405
535
  }
536
+ if (args.waveGate && !bound) {
537
+ console.error(formatError({ message: "--wave-gate needs a promote under --release whose compositions declare a gate", hint: PROMOTE_USAGE }));
538
+ tidy();
539
+ return 1;
540
+ }
406
541
  // The run id the release records carry, chosen before the run so that
407
542
  // `--events-json` (#4041) names the run by it too.
408
543
  const { runId, runOrigin } = resolveRunId(args.runId);
@@ -413,23 +548,83 @@ async function deployPlan(
413
548
  cwd: project.projectPath,
414
549
  });
415
550
  let run: Awaited<ReturnType<typeof runPromotion>>;
551
+ let staged: import("../../components/promote-rounds").PromoteRoundsResult | undefined;
552
+ // #4168 step 4: a wave at a time when a dependent read an upstream's plan,
553
+ // under --wave-gate, or on a resume; one run otherwise.
554
+ const inPlan = new Set(plan.items.map((i) => i.component));
555
+ const readsPlanned = bound?.plannedOutputs !== undefined && Object.keys(bound.plannedOutputs).some((d) => inPlan.has(d));
556
+ const inRounds = bound !== undefined && (readsPlanned || args.waveGate !== undefined || resume !== undefined);
416
557
  try {
417
- run = await runPromotion({
418
- plan,
419
- components: prepared,
420
- registry,
421
- componentOutputs: seededOutputs,
422
- gates: port,
423
- ...(releaseGate ? { releaseGate } : {}),
424
- ...(args.progressJson ? { onProgress: ndjsonProgressSink() } : {}),
425
- ...(events ? { onRunEvent: events.emit } : {}),
426
- });
558
+ if (inRounds && bound) {
559
+ staged = await rounds.runPromotionInRounds({
560
+ components: prepared,
561
+ outputs: { ...seededOutputs, ...(resume?.outputs ?? {}) },
562
+ binding: bound.binding,
563
+ approved: bound.doc,
564
+ reads: bound.reads,
565
+ ...(resume ? { completed: resume.completed } : {}),
566
+ ...(args.waveGate ? { waveGate: args.waveGate } : {}),
567
+ replan: bound.replan,
568
+ bind: bound.bind,
569
+ run: (components, outputs, binding) =>
570
+ runPromotion({
571
+ plan: { ...plan, items: plan.items.filter((i) => components.some((c) => c.name === i.component)) },
572
+ components,
573
+ registry,
574
+ componentOutputs: outputs,
575
+ gates: port,
576
+ releaseGate: binding,
577
+ ...(args.progressJson ? { onProgress: ndjsonProgressSink() } : {}),
578
+ ...(events ? { onRunEvent: events.emit } : {}),
579
+ }),
580
+ });
581
+ run = staged.run;
582
+ } else {
583
+ run = await runPromotion({
584
+ plan,
585
+ components: prepared,
586
+ registry,
587
+ componentOutputs: seededOutputs,
588
+ gates: port,
589
+ ...(releaseGate ? { releaseGate } : {}),
590
+ ...(args.progressJson ? { onProgress: ndjsonProgressSink() } : {}),
591
+ ...(events ? { onRunEvent: events.emit } : {}),
592
+ });
593
+ }
427
594
  } catch (err) {
595
+ if (err instanceof PromoteStageRefused) {
596
+ events?.settle("failed", err.message);
597
+ return 1;
598
+ }
428
599
  events?.settle("failed", err instanceof Error ? err.message : String(err));
429
600
  throw err;
430
601
  } finally {
431
602
  tidy();
432
603
  }
604
+ if (staged && bound) {
605
+ for (const stage of staged.stages) {
606
+ const label = stage.kind === "round" ? `round ${stage.number}` : `wave ${stage.number}`;
607
+ console.error(formatInfo(`${label} (${stage.components.join(", ")}): planned again as ${stage.digest}, ${stage.status}`));
608
+ for (const line of stage.notCovered) console.error(` not covered by ${bound.doc.digest}: ${line}`);
609
+ if (stage.message) console.error(formatError({ message: stage.message }));
610
+ }
611
+ if (run.status === "ok") {
612
+ rmSync(resumePath, { force: true });
613
+ } else if (staged.completed.length > 0) {
614
+ const record: import("../../components/promote-rounds").PromoteResume = {
615
+ schema: rounds.PROMOTE_RESUME_SCHEMA,
616
+ env: plan.to,
617
+ releaseDigest: under!.release.releaseDigest,
618
+ approvedDigest: resume?.approvedDigest ?? bound.resumedFrom ?? bound.doc.digest,
619
+ completed: staged.completed,
620
+ outputs: staged.outputs,
621
+ at: new Date().toISOString(),
622
+ };
623
+ mkdirSync(dirname(resumePath), { recursive: true });
624
+ writeFileSync(resumePath, `${JSON.stringify(record, null, 2)}\n`);
625
+ console.error(formatInfo(`resume record: ${resumePath}; the same promote carries on from here`));
626
+ }
627
+ }
433
628
  // The driver settles the stream; this only ends one it left open.
434
629
  events?.settle(run.status === "fail" ? "failed" : run.status);
435
630
  if (!args.json) renderDriverHuman(run);
@@ -464,7 +659,9 @@ async function deployPlan(
464
659
 
465
660
  if (args.json) {
466
661
  const withChangeSet = under && changeSetJson ? { release: { ...releaseJson.release!, ...changeSetJson } } : releaseJson;
467
- console.log(JSON.stringify({ ...planJson(plan, removed), ...withChangeSet, run, recorded, ...(outcome ? { outcome } : {}) }, null, 2));
662
+ const stagedJson = staged ? { stages: staged.stages, ...(bound?.plannedOutputs ? { plannedOutputs: bound.plannedOutputs } : {}), ...(bound?.unknownInputs ? { unknownInputs: bound.unknownInputs } : {}) } : {};
663
+ const prJson = pullRequests.length > 0 ? { pullRequests: pullRequests.map((r) => ({ commit: r.commit, source: r.source, plan: r.plan, digest: r.digest, approvedBy: r.approvedBy, ...(r.authorApproved ? { authorApproved: r.authorApproved } : {}) })) } : {};
664
+ console.log(JSON.stringify({ ...planJson(plan, removed), ...withChangeSet, ...stagedJson, ...prJson, run, recorded, ...(outcome ? { outcome } : {}) }, null, 2));
468
665
  } else {
469
666
  for (const r of recorded) {
470
667
  console.error(formatSuccess(
package/src/cli/main.ts CHANGED
@@ -342,6 +342,12 @@ CI:
342
342
  key. --trust commits its runner entry on a branch of its
343
343
  own and pushes it for review; the target branch is never
344
344
  written
345
+ ci init --workflow trunk-curated|trunk-curated+apply-on-merge
346
+ [--forge github|gitlab|forgejo] [--security <group>]... [--yes]
347
+ Set the workspace up for a prescribed delivery workflow:
348
+ print what it would write as a diff, then write it on
349
+ --yes or a yes at the prompt and run the generators. A
350
+ rerun proposes only what workspace check still reports
345
351
  ci workflow [--workflow <name>]... [--output <file>] Write
346
352
  [--chant <command>] [--install <command>]
347
353
  [--token-secret <NAME> | --app-id-var <VAR> --app-key-secret <NAME>]
@@ -405,6 +411,11 @@ Releases:
405
411
  release verify <label|digest> [--json]
406
412
  Check, offline, that the release was signed by a key
407
413
  .chant/allowed_signers lists at the release's commit.
414
+ release workflow [--forge github|gitlab|forgejo] [--install <command>]
415
+ [--build <command>] [--chant <command>] [--output <file>]
416
+ Write the publish workflow, run on a release/** tag.
417
+ Under supply-chain it passes --require-provenance, and
418
+ under verified-releases it runs release verify first
408
419
  release list [--json] Every release of this member with its targets, its
409
420
  cuts and the environments that run it.
410
421
  release export <label|digest> --env <env> [-o <dir>] [--json]
@@ -1022,7 +1033,11 @@ Component release ledger + status:
1022
1033
  [--component <name> [--digest <sha256:...>]];
1023
1034
  --from defaults to the environment before --to
1024
1035
  in the config's environments; --dry-run prints
1025
- the plan; the target's gates still apply)
1036
+ the plan; the target's gates still apply. Under
1037
+ --release a dependent plans against its
1038
+ upstream's planned outputs and plans again once
1039
+ it applies; --wave-gate <gate> checks each wave
1040
+ at <gate>-wave-<k>; a stop writes --resume <file>)
1026
1041
  components rollback <env> Redeploy an earlier release of an environment
1027
1042
  from its recorded digest (--component <name>
1028
1043
  [--digest <sha256:...>]; defaults to the release
@@ -1437,6 +1452,7 @@ export const commandRegistry: CommandDef[] = [
1437
1452
  { name: "ci list", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/ci-green-cli")).runCiList(ctx) },
1438
1453
  { name: "ci tick", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/ci-green-cli")).runCiTick(ctx) },
1439
1454
  { name: "ci workflow", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/ci-green-cli")).runCiWorkflow(ctx) },
1455
+ { name: "ci init", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/ci-init")).runCiInit(ctx) },
1440
1456
  // #4094: one pull-request job as a pipeline Op in a fresh checkout, the chant runner's first slice.
1441
1457
  { name: "ci run", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/ci-run")).runCiRun(ctx) },
1442
1458
  // #4098: the run index at the report URL.
@@ -1450,6 +1466,7 @@ export const commandRegistry: CommandDef[] = [
1450
1466
  { name: "release publish", runsNoConfig: true, handler: async (ctx) => (await import("./handlers/release")).runReleasePublish(ctx) },
1451
1467
  // #4049: check a release's signature offline.
1452
1468
  { name: "release verify", runsNoConfig: true, handler: async (ctx) => (await import("./handlers/release")).runReleaseVerify(ctx) },
1469
+ { name: "release workflow", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/release-workflow")).runReleaseWorkflow(ctx) },
1453
1470
  { name: "release candidates", runsNoConfig: true, handler: async (ctx) => (await import("./handlers/release")).runReleaseCandidates(ctx) },
1454
1471
  { name: "release list", runsNoConfig: true, handler: async (ctx) => (await import("./handlers/release")).runReleaseList(ctx) },
1455
1472
  { name: "release export", runsNoConfig: true, handler: async (ctx) => (await import("./handlers/release")).runReleaseExport(ctx) },
@@ -540,6 +540,11 @@ export function parseArgs(args: string[]): ParsedArgs {
540
540
  // `chant workspace wip --branch <branch>` (#3172)
541
541
  result.branch = args[++i];
542
542
  if (!result.branch || result.branch.startsWith("-")) throw new Error("--branch needs a branch name: --branch <branch>");
543
+ } else if (arg === "--security") {
544
+ // `chant ci init --security <group>`, repeatable (#4168 step 6): a security group to turn on.
545
+ const group = args[++i];
546
+ if (!group || group.startsWith("-")) throw new Error("--security needs a group: --security verified-releases|supply-chain|who-ran-it|review-rules|isolated-runners");
547
+ result.securityGroups = [...(result.securityGroups ?? []), group];
543
548
  } else if (arg === "--workflow") {
544
549
  // `chant ci workflow --workflow <name>`, repeatable (#3573)
545
550
  const name = args[++i];
@@ -467,8 +467,10 @@ export interface ParsedArgs {
467
467
  label?: string;
468
468
  /** `chant workspace wip --branch <branch>` (#3172): list one branch's snapshots. */
469
469
  branch?: string;
470
- /** `chant ci workflow --workflow <name>`, repeatable (#3573): a workflow whose completion triggers a tick, beside those the scan finds. */
470
+ /** `chant ci workflow --workflow <name>`, repeatable (#3573): a workflow whose completion triggers a tick, beside those the scan finds. Also `chant ci init --workflow <name>` (#4168 step 6), the prescribed workflow to set up. */
471
471
  ciWorkflows?: string[];
472
+ /** `chant ci init --security <group>`, repeatable (#4168 step 6): a security group to turn on. */
473
+ securityGroups?: string[];
472
474
  /** `chant ci run plan|apply --commit <sha>` (#4094): the commit the job runs at. Default `HEAD`. */
473
475
  commit?: string;
474
476
  /** `chant ci run --pass-env <NAME>`, repeatable (#4094): a variable of the runner's environment the job gets, beside the few it always gets. */