@intentius/chant 0.105.1 → 0.106.3

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 (111) hide show
  1. package/dist/change-set.d.ts +30 -8
  2. package/dist/change-set.d.ts.map +1 -1
  3. package/dist/cli/commands/build.d.ts +11 -0
  4. package/dist/cli/commands/build.d.ts.map +1 -1
  5. package/dist/cli/commands/lint.d.ts.map +1 -1
  6. package/dist/cli/handlers/build.d.ts.map +1 -1
  7. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  8. package/dist/cli/handlers/operator.d.ts.map +1 -1
  9. package/dist/cli/handlers/pr.d.ts +16 -2
  10. package/dist/cli/handlers/pr.d.ts.map +1 -1
  11. package/dist/cli/handlers/promote.d.ts.map +1 -1
  12. package/dist/cli/handlers/run.d.ts.map +1 -1
  13. package/dist/cli/main.d.ts.map +1 -1
  14. package/dist/cli/mcp/op-tools.d.ts.map +1 -1
  15. package/dist/cli/registry.d.ts +7 -0
  16. package/dist/cli/registry.d.ts.map +1 -1
  17. package/dist/components/changed-files.d.ts +8 -2
  18. package/dist/components/changed-files.d.ts.map +1 -1
  19. package/dist/components/fan-out-record.d.ts +24 -0
  20. package/dist/components/fan-out-record.d.ts.map +1 -1
  21. package/dist/components/pr-pipeline.d.ts +67 -4
  22. package/dist/components/pr-pipeline.d.ts.map +1 -1
  23. package/dist/components/pr-run.d.ts +94 -3
  24. package/dist/components/pr-run.d.ts.map +1 -1
  25. package/dist/gated-waves.d.ts +4 -1
  26. package/dist/gated-waves.d.ts.map +1 -1
  27. package/dist/lexicon.d.ts +13 -2
  28. package/dist/lexicon.d.ts.map +1 -1
  29. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  30. package/dist/op/gate-name.d.ts +7 -0
  31. package/dist/op/gate-name.d.ts.map +1 -1
  32. package/dist/op/gate-summary.d.ts +2 -0
  33. package/dist/op/gate-summary.d.ts.map +1 -1
  34. package/dist/op/gate.d.ts +10 -2
  35. package/dist/op/gate.d.ts.map +1 -1
  36. package/dist/op/index.d.ts +2 -2
  37. package/dist/op/index.d.ts.map +1 -1
  38. package/dist/op/local-executor.d.ts.map +1 -1
  39. package/dist/op/local-output.d.ts +2 -2
  40. package/dist/op/local-output.d.ts.map +1 -1
  41. package/dist/op/runtime.d.ts +29 -4
  42. package/dist/op/runtime.d.ts.map +1 -1
  43. package/dist/op/runtimes/local.d.ts.map +1 -1
  44. package/dist/pr-loop.d.ts +32 -6
  45. package/dist/pr-loop.d.ts.map +1 -1
  46. package/dist/workspace/lineage-upgrade-cli.d.ts.map +1 -1
  47. package/dist/workspace/member-pipeline.d.ts +15 -1
  48. package/dist/workspace/member-pipeline.d.ts.map +1 -1
  49. package/package.json +1 -1
  50. package/src/__fixtures__/change-set/lifecycle.change-set.golden.json +1 -1
  51. package/src/__fixtures__/change-set/warden.change-set.golden.json +2 -2
  52. package/src/__fixtures__/change-set/warden.plan.json +47 -40
  53. package/src/__fixtures__/plan-summary/normalization-vectors.json +275 -2
  54. package/src/__fixtures__/pr-loop/pr-apply-applied.note.golden.md +1 -1
  55. package/src/__fixtures__/pr-loop/pr-apply-refused.note.golden.md +2 -2
  56. package/src/__fixtures__/pr-loop/pr-plan.golden.json +2 -2
  57. package/src/__fixtures__/pr-loop/pr-plan.note.golden.md +2 -2
  58. package/src/build.test.ts +1 -1
  59. package/src/build.ts +1 -1
  60. package/src/change-set.test.ts +42 -12
  61. package/src/change-set.ts +50 -11
  62. package/src/cli/commands/build.ts +33 -14
  63. package/src/cli/commands/lint.ts +34 -1
  64. package/src/cli/handlers/build-lexicon-output.test.ts +102 -0
  65. package/src/cli/handlers/build.ts +68 -0
  66. package/src/cli/handlers/fan-out.test.ts +12 -1
  67. package/src/cli/handlers/fan-out.ts +7 -3
  68. package/src/cli/handlers/operator.ts +1 -2
  69. package/src/cli/handlers/pr.ts +223 -35
  70. package/src/cli/handlers/promote.test.ts +15 -1
  71. package/src/cli/handlers/promote.ts +5 -3
  72. package/src/cli/handlers/run.test.ts +215 -1
  73. package/src/cli/handlers/run.ts +36 -15
  74. package/src/cli/main.ts +13 -1
  75. package/src/cli/mcp/never-over-mcp.test.ts +120 -0
  76. package/src/cli/mcp/op-approve-origin.test.ts +1 -1
  77. package/src/cli/mcp/op-tools.ts +14 -2
  78. package/src/cli/mcp/server.test.ts +2 -1
  79. package/src/cli/registry.ts +7 -0
  80. package/src/components/changed-files.test.ts +58 -0
  81. package/src/components/changed-files.ts +19 -4
  82. package/src/components/fan-out-record.test.ts +47 -2
  83. package/src/components/fan-out-record.ts +53 -0
  84. package/src/components/pr-pipeline.ts +96 -6
  85. package/src/components/pr-run.test.ts +311 -4
  86. package/src/components/pr-run.ts +198 -5
  87. package/src/gated-waves.test.ts +11 -0
  88. package/src/gated-waves.ts +7 -2
  89. package/src/lexicon.ts +13 -1
  90. package/src/lifecycle/gate-ledger.ts +4 -3
  91. package/src/op/gate-name.ts +17 -0
  92. package/src/op/gate-summary.test.ts +12 -0
  93. package/src/op/gate-summary.ts +3 -1
  94. package/src/op/gate.test.ts +15 -1
  95. package/src/op/gate.ts +21 -4
  96. package/src/op/index.ts +2 -2
  97. package/src/op/local-executor.test.ts +35 -0
  98. package/src/op/local-executor.ts +2 -2
  99. package/src/op/local-output.test.ts +20 -0
  100. package/src/op/local-output.ts +4 -4
  101. package/src/op/runtime.ts +31 -4
  102. package/src/op/runtimes/local.test.ts +15 -0
  103. package/src/op/runtimes/local.ts +3 -1
  104. package/src/pr-loop.test.ts +41 -1
  105. package/src/pr-loop.ts +41 -10
  106. package/src/workspace/change-set.schema.json +1 -1
  107. package/src/workspace/lineage-upgrade-cli.ts +4 -3
  108. package/src/workspace/lineage-upgrade.test.ts +40 -0
  109. package/src/workspace/member-pipeline.test.ts +28 -0
  110. package/src/workspace/member-pipeline.ts +21 -3
  111. package/src/workspace/pr-report.schema.json +14 -1
@@ -5,7 +5,7 @@
5
5
  * ```
6
6
  * chant components pr-plan --base <ref> --pr <n> --env <env> [--forge github|gitlab|forgejo]
7
7
  * chant approve pr-<n> pr-apply --plan <digest> --approver github:<login> --sign
8
- * chant components pr-apply --base <ref> --env <env> [--pr <n>] [--forge <kind>] [--require-review]
8
+ * chant components pr-apply --base <ref> --env <env> [--pr <n>] [--forge <kind>] [--require-review] [--resume <file>]
9
9
  * ```
10
10
  *
11
11
  * Both measure the change from the merge base of `--base` and `HEAD`, select
@@ -18,8 +18,22 @@
18
18
  * applies only when an approval stands for that digest. A moved plan stops
19
19
  * it with both digests named and nothing applied (exit 3).
20
20
  *
21
+ * `--resume <file>` (#3464) finishes an apply that failed partway, the way
22
+ * `components fan-out --resume` does (#2417, #3049). The apply writes the
23
+ * attempt record as each component settles: the change set the gate
24
+ * approved and the components that applied. Repeated on the same commit
25
+ * with the same record, it plans only what is left, checks each member
26
+ * against the approved set (`resumePrSet`) and, when all are covered and the
27
+ * approval still stands, applies the rest without a fresh approval. When
28
+ * they are not covered it says why and runs as it would without a record.
29
+ *
21
30
  * Every run writes `pr-plan.json` or `pr-apply.json` (`pr-report.schema.json`),
22
- * `change-set.json` and `pr-note.md` under `--output` (default `.chant/pr`).
31
+ * `change-set.json` and `pr-note.md` under `--output` (default `.chant/pr`). *
32
+ * `--member <name>` (#3465) runs a workspace member's loop: the member's
33
+ * generated pipeline passes it from the member's directory. The gate is
34
+ * recorded under `pr-<n>-<member>`, and the note and the statuses carry the
35
+ * member's name, so each member's loop on one pull request stands apart.
36
+ * The name must be the member that owns the current directory.
23
37
  */
24
38
 
25
39
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -35,17 +49,20 @@ import {
35
49
  planPrSet,
36
50
  prReport,
37
51
  readOnlyLedger,
52
+ resumePrSet,
38
53
  type PrPlanSet,
39
54
  } from "../../components/pr-run";
55
+ import { readFanOutAttempt, writeFanOutAttempt, type FanOutAttempt, type PrApplyRecord } from "../../components/fan-out-record";
56
+ import { samePlanDigest } from "../../lifecycle/plan-digest";
40
57
  import { gitGateLedgerPort } from "../../op/gate";
41
58
  import {
42
59
  describePlanChanged,
43
60
  PR_APPLY_GATE,
44
- PR_STATUS_CONTEXTS,
45
61
  prApproveCommand,
46
62
  prNoteMarker,
47
63
  prOp,
48
64
  prStatusDescription,
65
+ prStatusContext,
49
66
  prStatusState,
50
67
  renderPrNote,
51
68
  type PrApproval,
@@ -55,6 +72,7 @@ import { FORGE_KINDS, forgeFromEnv, forgePrincipalOf, type ForgeKind, type PrFor
55
72
  import { GITHUB_COMMENT_LIMIT, GITLAB_NOTE_LIMIT } from "../../plan-summary";
56
73
  import { resolveCliBuildParams, parseParamFlags } from "../build-params-cli";
57
74
  import { formatError, formatInfo, formatSuccess, formatWarning } from "../format";
75
+ import { findWorkspaceRoot } from "../../project-root";
58
76
  import { resolveChangedUnits } from "./fan-out";
59
77
  import { GATED_EXIT_CODE, recordAutoReleasesForRun } from "./run";
60
78
  import type { CommandContext } from "../registry";
@@ -69,6 +87,8 @@ export const PR_REPORT_DIR = ".chant/pr";
69
87
  interface Prepared {
70
88
  config: ChantConfig;
71
89
  env: string;
90
+ /** The workspace member, with `--member` (#3465). */
91
+ member?: string;
72
92
  base: string;
73
93
  head: string;
74
94
  gate: string;
@@ -77,7 +97,7 @@ interface Prepared {
77
97
  plan: FanOutPlan;
78
98
  signal: ComponentChangeSignal;
79
99
  registry: CapabilityRegistry;
80
- set: PrPlanSet;
100
+ seededOutputs: Record<string, Record<string, unknown>>;
81
101
  }
82
102
 
83
103
  const fail = (message: string, hint?: string): number => {
@@ -85,7 +105,7 @@ const fail = (message: string, hint?: string): number => {
85
105
  return 1;
86
106
  };
87
107
 
88
- /** Everything both stages share: the change, the derivation and the plan of every member. */
108
+ /** Everything both stages share: the change and the derivation. Each stage plans the members itself. */
89
109
  async function prepare(ctx: CommandContext, stage: "plan" | "apply"): Promise<Prepared | number> {
90
110
  const { args } = ctx;
91
111
  const projectPath = resolve(".");
@@ -106,6 +126,12 @@ async function prepare(ctx: CommandContext, stage: "plan" | "apply"): Promise<Pr
106
126
  );
107
127
  }
108
128
  if (args.fromAffected) return fail(`pr-${stage} derives the change itself; drop --from-affected`);
129
+ let member: string | undefined;
130
+ if (args.members?.length) {
131
+ const checked = await checkMember(projectPath, args.members);
132
+ if (typeof checked !== "string") return fail(checked.error, checked.hint);
133
+ member = checked;
134
+ }
109
135
  if (args.forge !== undefined && !(FORGE_KINDS as readonly string[]).includes(args.forge)) {
110
136
  return fail(`--forge ${args.forge} is not one of ${FORGE_KINDS.join(", ")}`);
111
137
  }
@@ -156,10 +182,10 @@ async function prepare(ctx: CommandContext, stage: "plan" | "apply"): Promise<Pr
156
182
 
157
183
  const env = args.env ?? "local";
158
184
  const registry = await fanOutRegistry(projectPath, config);
159
- const set = await planPrSet({ components: derived.components, plan: derived.plan, registry, env, seededOutputs });
160
185
  return {
161
186
  config,
162
187
  env,
188
+ ...(member ? { member } : {}),
163
189
  base,
164
190
  head,
165
191
  gate: args.gate ?? PR_APPLY_GATE,
@@ -168,10 +194,41 @@ async function prepare(ctx: CommandContext, stage: "plan" | "apply"): Promise<Pr
168
194
  plan: derived.plan,
169
195
  signal: derived.signal,
170
196
  registry,
171
- set,
197
+ seededOutputs,
172
198
  };
173
199
  }
174
200
 
201
+ /**
202
+ * The one `--member` name, checked against the workspace member that owns
203
+ * `projectPath`: a pipeline copied into another member would otherwise
204
+ * record its gate under the wrong name.
205
+ */
206
+ async function checkMember(projectPath: string, names: string[]): Promise<string | { error: string; hint?: string }> {
207
+ if (names.length !== 1) return { error: `pr-plan and pr-apply take one --member, not ${names.join(", ")}` };
208
+ const name = names[0];
209
+ const found = findWorkspaceRoot(projectPath);
210
+ if (!found) return { error: `--member ${name}: no chant.workspace.json above ${projectPath}`, hint: "Drop --member outside a workspace." };
211
+ const { resolveMemberContext } = await import("../../workspace/member-pipeline");
212
+ let owner: string | undefined;
213
+ try {
214
+ owner = resolveMemberContext(projectPath, found)?.member.name;
215
+ } catch (err) {
216
+ return { error: err instanceof Error ? err.message : String(err) };
217
+ }
218
+ if (owner !== name) {
219
+ return {
220
+ error: `--member ${name}: ${owner ? `this directory belongs to member ${owner}` : "no workspace member owns this directory"}`,
221
+ hint: "Run the command in the member's directory, the way its generated pipeline does.",
222
+ };
223
+ }
224
+ return name;
225
+ }
226
+
227
+ /** Plan every member the derivation selects. */
228
+ function planAll(p: Prepared): Promise<PrPlanSet> {
229
+ return planPrSet({ components: p.components, plan: p.plan, registry: p.registry, env: p.env, seededOutputs: p.seededOutputs });
230
+ }
231
+
175
232
  /** Write the report, the change set and the note; print the note's path. */
176
233
  function writeOutputs(ctx: CommandContext, report: PrReport, note: string): void {
177
234
  const dir = resolve(ctx.args.output ?? PR_REPORT_DIR);
@@ -185,7 +242,7 @@ function writeOutputs(ctx: CommandContext, report: PrReport, note: string): void
185
242
  async function publish(forge: PrForge | undefined, report: PrReport, note: string): Promise<void> {
186
243
  if (!forge) return;
187
244
  const status = {
188
- context: PR_STATUS_CONTEXTS[report.stage],
245
+ context: prStatusContext(report.stage, report.member),
189
246
  state: prStatusState(report),
190
247
  description: prStatusDescription(report),
191
248
  ...(forge.runUrl ? { url: forge.runUrl } : {}),
@@ -196,8 +253,11 @@ async function publish(forge: PrForge | undefined, report: PrReport, note: strin
196
253
  console.error(formatWarning({ message: `could not set the ${status.context} status: ${err instanceof Error ? err.message : String(err)}` }));
197
254
  }
198
255
  if (report.pr === null) return;
256
+ // A member the change does not reach has nothing to say: one note per
257
+ // member would put a line on every pull request for each member it skips.
258
+ if (report.member && report.status === "nothing") return;
199
259
  try {
200
- await forge.upsertNote(report.pr, prNoteMarker(report.env), note);
260
+ await forge.upsertNote(report.pr, prNoteMarker(report.env, report.member), note);
201
261
  } catch (err) {
202
262
  console.error(formatWarning({ message: `could not post the note on #${report.pr}: ${err instanceof Error ? err.message : String(err)}` }));
203
263
  }
@@ -231,28 +291,30 @@ export async function runComponentsPrPlan(ctx: CommandContext): Promise<number>
231
291
  }
232
292
  const p = await prepare(ctx, "plan");
233
293
  if (typeof p === "number") return p;
234
- const op = prOp(ctx.args.pr);
294
+ const set = await planAll(p);
295
+ const op = prOp(ctx.args.pr, p.member);
235
296
 
236
297
  let approval: PrApproval = { status: "pending" };
237
298
  if (p.plan.order.length > 0) {
238
299
  try {
239
- approval = approvalOf(await decidePrGate(readOnlyLedger(gitGateLedgerPort()), { op, gate: p.gate, digest: p.set.doc.digest }));
300
+ approval = approvalOf(await decidePrGate(readOnlyLedger(gitGateLedgerPort()), { op, gate: p.gate, digest: set.doc.digest }));
240
301
  } catch (err) {
241
302
  console.error(formatWarning({ message: `could not read the gate ledger, so the approval reads as pending: ${err instanceof Error ? err.message : String(err)}` }));
242
303
  }
243
304
  }
244
- const status = p.plan.order.length === 0 ? "nothing" : p.set.failed ? "plan-failed" : "planned";
305
+ const status = p.plan.order.length === 0 ? "nothing" : set.failed ? "plan-failed" : "planned";
245
306
  const report = prReport({
246
307
  stage: "plan",
247
308
  pr: ctx.args.pr,
248
309
  env: p.env,
310
+ ...(p.member ? { member: p.member } : {}),
249
311
  base: p.base,
250
312
  head: p.head,
251
313
  op,
252
314
  gate: p.gate,
253
315
  plan: p.plan,
254
316
  signal: p.signal,
255
- set: p.set,
317
+ set,
256
318
  approval,
257
319
  status,
258
320
  });
@@ -282,8 +344,24 @@ export async function runComponentsPrApply(ctx: CommandContext): Promise<number>
282
344
  return fail(`could not ask ${p.forge.kind} which pull request merged ${p.head}: ${err instanceof Error ? err.message : String(err)}`);
283
345
  }
284
346
  }
285
- const op = pr === null ? "pr-unknown" : prOp(pr);
286
- const base = { stage: "apply" as const, pr, env: p.env, base: p.base, head: p.head, op, gate: p.gate, plan: p.plan, signal: p.signal, set: p.set };
347
+ const op = pr === null ? "pr-unknown" : prOp(pr, p.member);
348
+
349
+ // `--resume` (#3464): an earlier attempt at this change on this commit
350
+ // applied part of an approved set. When the members left are covered by
351
+ // it and its approval still stands, finish it under that approval.
352
+ const resumePath = args.resume ? resolve(args.resume) : undefined;
353
+ let attempt: FanOutAttempt | undefined;
354
+ if (resumePath) {
355
+ try {
356
+ attempt = readFanOutAttempt(resumePath);
357
+ } catch (err) {
358
+ return fail(`--resume: could not read "${args.resume}": ${err instanceof Error ? err.message : String(err)}`);
359
+ }
360
+ }
361
+ const resumed = pr !== null && p.plan.order.length > 0 && attempt ? await tryResume(p, attempt, args.resume!, op) : undefined;
362
+
363
+ const set = resumed?.set ?? (await planAll(p));
364
+ const base = { stage: "apply" as const, pr, env: p.env, ...(p.member ? { member: p.member } : {}), base: p.base, head: p.head, op, gate: p.gate, plan: p.plan, signal: p.signal, set };
287
365
 
288
366
  const stop = async (report: PrReport, code: number): Promise<number> => {
289
367
  await finish(ctx, p, report);
@@ -308,7 +386,7 @@ export async function runComponentsPrApply(ctx: CommandContext): Promise<number>
308
386
  GATED_EXIT_CODE,
309
387
  );
310
388
  }
311
- if (p.set.failed) {
389
+ if (set.failed) {
312
390
  return stop(
313
391
  prReport({
314
392
  ...base,
@@ -320,23 +398,28 @@ export async function runComponentsPrApply(ctx: CommandContext): Promise<number>
320
398
  );
321
399
  }
322
400
 
323
- const check = await decidePrGate(gitGateLedgerPort(), {
324
- op,
325
- gate: p.gate,
326
- digest: p.set.doc.digest,
327
- description: `pull request #${pr}: ${p.plan.order.join(", ")}`,
328
- });
329
- const approval = approvalOf(check);
330
- if (!check.satisfied) {
331
- const partial = { ...base, approval, status: "refused" as const };
332
- const report = approval.status === "changed"
333
- ? prReport({ ...partial, refusal: "plan-changed", message: describePlanChanged({ op, gate: p.gate, digest: p.set.doc.digest, approval }) })
334
- : prReport({
335
- ...partial,
336
- refusal: "not-approved",
337
- message: `No approval stands for this plan, so nothing was applied. Approve it: ${prApproveCommand({ op, gate: p.gate, digest: p.set.doc.digest })}; then run the apply again.`,
338
- });
339
- return stop(report, GATED_EXIT_CODE);
401
+ let approval: PrApproval;
402
+ if (resumed) {
403
+ approval = resumed.approval;
404
+ } else {
405
+ const check = await decidePrGate(gitGateLedgerPort(), {
406
+ op,
407
+ gate: p.gate,
408
+ digest: set.doc.digest,
409
+ description: `pull request #${pr}: ${p.plan.order.join(", ")}`,
410
+ });
411
+ approval = approvalOf(check);
412
+ if (!check.satisfied) {
413
+ const partial = { ...base, approval, status: "refused" as const };
414
+ const report = approval.status === "changed"
415
+ ? prReport({ ...partial, refusal: "plan-changed", message: describePlanChanged({ op, gate: p.gate, digest: set.doc.digest, approval }) })
416
+ : prReport({
417
+ ...partial,
418
+ refusal: "not-approved",
419
+ message: `No approval stands for this plan, so nothing was applied. Approve it: ${prApproveCommand({ op, gate: p.gate, digest: set.doc.digest })}; then run the apply again.`,
420
+ });
421
+ return stop(report, GATED_EXIT_CODE);
422
+ }
340
423
  }
341
424
 
342
425
  if (args.requireReview && p.forge) {
@@ -364,10 +447,115 @@ export async function runComponentsPrApply(ctx: CommandContext): Promise<number>
364
447
  }
365
448
  }
366
449
 
367
- const applied = await applyPrSet({ components: p.components, plan: p.plan, registry: p.registry, env: p.env, set: p.set });
450
+ // The record is written before the first component runs and again as each
451
+ // one settles, so a run killed partway still knows what applied.
452
+ const prior = resumed ? { completed: resumed.completed, outputs: resumed.outputs } : { completed: [], outputs: {} };
453
+ const completedSoFar = new Set<string>(prior.completed);
454
+ const failedSoFar: string[] = [];
455
+ const outputsSoFar: Record<string, Record<string, unknown>> = { ...prior.outputs };
456
+ const record: PrApplyRecord = {
457
+ op,
458
+ gate: p.gate,
459
+ head: p.head,
460
+ digest: set.doc.digest,
461
+ approvedBy: approval.approvedBy ?? [],
462
+ members: set.members,
463
+ changeSet: set.doc,
464
+ planOutputs: set.outputs,
465
+ };
466
+ const recordAttempt = (): void => {
467
+ if (!resumePath) return;
468
+ writeFanOutAttempt(resumePath, {
469
+ digest: p.plan.digest,
470
+ completed: [...completedSoFar].sort(),
471
+ failed: [...failedSoFar].sort(),
472
+ outputs: outputsSoFar,
473
+ prApply: record,
474
+ });
475
+ };
476
+ recordAttempt();
477
+
478
+ const applied = await applyPrSet({
479
+ components: p.components,
480
+ plan: resumed?.plan ?? p.plan,
481
+ registry: p.registry,
482
+ env: p.env,
483
+ set,
484
+ ...(resumed ? { resumed: prior } : {}),
485
+ ...(resumePath
486
+ ? {
487
+ onComponentSettled: (settled, outputs) => {
488
+ if (settled.status === "ok") {
489
+ completedSoFar.add(settled.component);
490
+ if (outputs) outputsSoFar[settled.component] = outputs;
491
+ } else if (settled.status === "fail") {
492
+ failedSoFar.push(settled.component);
493
+ }
494
+ recordAttempt();
495
+ },
496
+ }
497
+ : {}),
498
+ });
499
+ recordAttempt();
368
500
  await recordAutoReleasesForRun(applied.run.results, p.env, `${op}-${Date.now()}`, resolveAutoReleaseDisabled(p.config, args.noReleaseRecord));
369
- const report = prReport({ ...base, approval, status: applied.status, members: applied.members });
501
+ const report = prReport({
502
+ ...base,
503
+ approval,
504
+ status: applied.status,
505
+ members: applied.members,
506
+ ...(resumed ? { resumed: resumed.completed } : {}),
507
+ });
370
508
  const code = await stop(report, applied.status === "applied" ? 0 : 1);
371
509
  if (applied.status === "applied") console.error(formatSuccess(`applied the plan ${report.digest} approved by ${(approval.approvedBy ?? []).join(", ")}`));
372
510
  return code;
373
511
  }
512
+
513
+ interface Resumed {
514
+ plan: FanOutPlan;
515
+ set: PrPlanSet;
516
+ approval: PrApproval;
517
+ completed: string[];
518
+ outputs: Record<string, Record<string, unknown>>;
519
+ }
520
+
521
+ /**
522
+ * Whether `attempt` finishes here (#3464). It carries only when it was made
523
+ * by an apply of this pull request's gate, on this commit, for this
524
+ * derivation; when its approval still stands; and when every member left is
525
+ * covered by the approved set. Otherwise it says why on stderr and the apply
526
+ * runs as it would with no record.
527
+ */
528
+ async function tryResume(p: Prepared, attempt: FanOutAttempt, file: string, op: string): Promise<Resumed | undefined> {
529
+ const skip = (why: string): undefined => {
530
+ console.error(formatWarning({ message: `--resume: "${file}" does not carry: ${why}`, hint: "Every member is planned again, and the plan needs an approval of its own." }));
531
+ return undefined;
532
+ };
533
+ const record = attempt.prApply;
534
+ if (!record) return skip("it was not written by pr-apply");
535
+ if (record.op !== op || record.gate !== p.gate) return skip(`it records ${record.op} ${record.gate}, not ${op} ${p.gate}`);
536
+ if (record.head !== p.head) return skip(`it records an apply of ${record.head.slice(0, 12)}, not ${p.head.slice(0, 12)}`);
537
+ if (!samePlanDigest(attempt.digest, p.plan.digest)) return skip("the change selects other components than it did");
538
+ if (attempt.completed.length === 0) return undefined;
539
+
540
+ let check;
541
+ try {
542
+ check = await decidePrGate(readOnlyLedger(gitGateLedgerPort()), { op, gate: p.gate, digest: record.digest });
543
+ } catch (err) {
544
+ return skip(`could not read the gate ledger: ${err instanceof Error ? err.message : String(err)}`);
545
+ }
546
+ if (!check.satisfied) return skip(`no approval stands for the plan it applied (${record.digest}) any more`);
547
+
548
+ const outputs = Object.fromEntries(Object.entries(attempt.outputs).filter(([name]) => attempt.completed.includes(name)));
549
+ const resumed = await resumePrSet({
550
+ components: p.components,
551
+ plan: p.plan,
552
+ registry: p.registry,
553
+ env: p.env,
554
+ seededOutputs: p.seededOutputs,
555
+ record,
556
+ completed: attempt.completed,
557
+ });
558
+ if (!resumed.ok) return skip(`what is left is not what was approved. ${resumed.reasons.join("; ")}`);
559
+ console.error(formatInfo(`resume : ${attempt.completed.join(", ")} applied in an earlier attempt; applying ${resumed.plan.order.join(", ") || "nothing more"} under the approval of ${record.digest}`));
560
+ return { plan: resumed.plan, set: resumed.set, approval: approvalOf(check), completed: attempt.completed, outputs };
561
+ }
@@ -61,8 +61,9 @@ vi.mock("../../components/fan-out-support", () => ({
61
61
  vi.mock("../../op/gate", async () => {
62
62
  const actual = await vi.importActual<typeof import("../../op/gate")>("../../op/gate");
63
63
  const port = actual.memoryGateLedgerPort();
64
- return { ...actual, gitGateLedgerPort: () => port };
64
+ return { ...actual, gitGateLedgerPort: () => port, gateIsSealed: async (gate: string) => sealedGates.has(gate) };
65
65
  });
66
+ const sealedGates = new Set<string>();
66
67
 
67
68
  // The run handler pulls in every runtime; the promote needs only its exit code.
68
69
  vi.mock("./run", () => ({ GATED_EXIT_CODE: 3 }));
@@ -113,6 +114,7 @@ let errors: string[];
113
114
  let logs: string[];
114
115
 
115
116
  beforeEach(() => {
117
+ sealedGates.clear();
116
118
  ran.length = 0;
117
119
  publishedDigest = "sha256:api";
118
120
  failApplyFor = undefined;
@@ -186,6 +188,18 @@ describe("chant components promote", () => {
186
188
  expect(ran).not.toContain("prod:apply");
187
189
  expect(appendReleaseRecordMock).not.toHaveBeenCalled();
188
190
  expect(errors.join("\n")).toMatch(/chant approve api prod-release/);
191
+ expect(errors.join("\n")).not.toContain("--sign");
192
+ });
193
+
194
+ test("a sealed gate's approve line carries --sign (#3521)", async () => {
195
+ sealedGates.add("prod-release");
196
+ const gated: DriverComponent = {
197
+ ...api,
198
+ deploy: [...api.deploy.slice(0, 2), { phase: "Apply", steps: [{ kind: "gate", gate: "prod-release" }, { kind: "apply" }] }],
199
+ };
200
+ resolveTargetsMock.mockResolvedValue({ success: true, targets: [gated] });
201
+ expect(await runComponentsPromote(ctx({}))).toBe(3);
202
+ expect(errors.join("\n")).toMatch(/approve : chant approve api prod-release.* --sign/);
189
203
  });
190
204
 
191
205
  test("a component that deployed is recorded even when another one failed", async () => {
@@ -47,7 +47,7 @@ import {
47
47
  } from "../../components/promote";
48
48
  import type { DriverComponent } from "../../components/driver";
49
49
  import { summaryLedgerPrefix, writeGatedRunSummary } from "../../op/gate-summary";
50
- import { approveCommand, describeGateMismatch } from "../../op/gate";
50
+ import { approveCommand, describeGateMismatch, gateIsSealed } from "../../op/gate";
51
51
  import { resolveCliBuildParams, parseParamFlags } from "../build-params-cli";
52
52
  import { formatError, formatWarning, formatSuccess, formatBold, formatInfo } from "../format";
53
53
  import { GATED_EXIT_CODE } from "./run";
@@ -248,12 +248,14 @@ async function deployPlan(
248
248
  const pending = run.gate;
249
249
  console.error(formatWarning({ message: `component "${run.gatedComponent}" is gated on "${pending.gate}" — pending approval` }));
250
250
  // #2574: an approval stands, but for another environment, plan or none.
251
+ const sealed = await gateIsSealed(pending.gate);
251
252
  if (run.gateMismatch) {
252
- console.error(formatWarning({ message: describeGateMismatch(pending.op, pending.gate, run.gateMismatch) }));
253
+ console.error(formatWarning({ message: describeGateMismatch(pending.op, pending.gate, run.gateMismatch, sealed) }));
253
254
  }
254
- console.error(formatInfo(`approve : ${approveCommand(pending.op, pending.gate, pending.environment)}`));
255
+ console.error(formatInfo(`approve : ${approveCommand(pending.op, pending.gate, pending.environment, undefined, sealed)}`));
255
256
  console.error(formatInfo(`then run the same ${verb} again`));
256
257
  writeGatedRunSummary({
258
+ sealed,
257
259
  op: pending.op,
258
260
  gate: pending.gate,
259
261
  ...(pending.description ? { description: pending.description } : {}),