@intentius/chant 0.117.0 → 0.118.1

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 (37) hide show
  1. package/dist/build.d.ts +1 -1
  2. package/dist/build.d.ts.map +1 -1
  3. package/dist/cli/commands/build.d.ts.map +1 -1
  4. package/dist/cli/handlers/run-wave.d.ts.map +1 -1
  5. package/dist/cli/main.d.ts.map +1 -1
  6. package/dist/cli/registry.d.ts +2 -0
  7. package/dist/cli/registry.d.ts.map +1 -1
  8. package/dist/discovery/files.d.ts +8 -0
  9. package/dist/discovery/files.d.ts.map +1 -1
  10. package/dist/lexicon.d.ts +10 -1
  11. package/dist/lexicon.d.ts.map +1 -1
  12. package/dist/lifecycle/gate-ledger.d.ts +11 -0
  13. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  14. package/dist/op/activities/lifecycle.d.ts +6 -1
  15. package/dist/op/activities/lifecycle.d.ts.map +1 -1
  16. package/dist/op/gate-review.d.ts +113 -0
  17. package/dist/op/gate-review.d.ts.map +1 -0
  18. package/dist/op/op-waves-run.d.ts +47 -1
  19. package/dist/op/op-waves-run.d.ts.map +1 -1
  20. package/dist/op/op-waves.d.ts +20 -0
  21. package/dist/op/op-waves.d.ts.map +1 -1
  22. package/package.json +1 -1
  23. package/src/build.ts +13 -2
  24. package/src/cli/commands/build.ts +2 -1
  25. package/src/cli/handlers/run-wave.ts +13 -1
  26. package/src/cli/main.ts +7 -1
  27. package/src/cli/plugins.ts +1 -1
  28. package/src/cli/registry.ts +2 -0
  29. package/src/discovery/files.ts +18 -0
  30. package/src/lexicon.ts +10 -1
  31. package/src/lifecycle/gate-ledger.ts +7 -0
  32. package/src/op/activities/lifecycle-gate.test.ts +147 -0
  33. package/src/op/activities/lifecycle.ts +15 -6
  34. package/src/op/gate-review.test.ts +225 -0
  35. package/src/op/gate-review.ts +339 -0
  36. package/src/op/op-waves-run.ts +190 -12
  37. package/src/op/op-waves.ts +25 -0
@@ -11,6 +11,11 @@
11
11
  * needs an approval waits on `<gate>-wave-<k>` bound to
12
12
  * {@link waveSetDigest} over its members, and exits 3 when nobody approved
13
13
  * that digest.
14
+ * - Which approvals count is the wave's `approval`, read at base like its
15
+ * policy (#3684): `ledger`, `sealed` or `pr-review` (`./gate-review.ts`).
16
+ * Under `pr-review` the pull request's pipeline runs `--record-plans`
17
+ * ({@link recordOpWaveHeadPlans}), which plans every wave at the head and
18
+ * records the digests a review of that head approves.
14
19
  * - Apply: each run's `apply` command. A share job (`--share i`) re-plans its
15
20
  * slice first and refuses, without applying it, a run whose plan digest is
16
21
  * not the one the deciding job recorded (exit 4).
@@ -21,12 +26,16 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
21
26
  import { dirname, join, posix } from "node:path";
22
27
  import { waveGateName, waveSetDigest, type WaveMember } from "../gated-waves";
23
28
  import { approveCommand, evaluateGate, gitGateLedgerPort, type GateLedgerPort } from "./gate";
29
+ import type { HeadPlans, MergedReview, ReviewSource } from "./gate-review";
30
+ import type { TrustPolicy } from "../workspace/trust/policy";
24
31
  import {
25
32
  OP_WAVE_DEFAULT_APPLY,
26
33
  OP_WAVE_GATE_POLICIES,
34
+ GATE_APPROVAL_SOURCES,
27
35
  assertOpWavesSpec,
28
36
  opWaveRecordPath,
29
37
  opWaveShare,
38
+ type GateApprovalSource,
30
39
  type OpWaveGatePolicy,
31
40
  type OpWaveRun,
32
41
  type OpWavesSpec,
@@ -59,6 +68,8 @@ export interface OpWaveDecision {
59
68
  op: string;
60
69
  gate: string;
61
70
  policy: OpWaveGatePolicy;
71
+ /** Which approvals count, read at base with the policy (#3684). */
72
+ approval: GateApprovalSource;
62
73
  policySource: OpWavePolicySource;
63
74
  /** The base commit the policy was read at. */
64
75
  base: string;
@@ -67,6 +78,10 @@ export interface OpWaveDecision {
67
78
  /** `approved`: an approval of this digest stands. `not-required`: the policy lets it through. `waiting`: neither. */
68
79
  status: "approved" | "not-required" | "waiting";
69
80
  approvedBy?: string;
81
+ /** How the approval arrived: a resolution on the ledger, or the merged pull request's review (#3684). */
82
+ via?: "ledger" | "pr-review";
83
+ /** The review that approved the wave, or why the review did not, under `pr-review`. */
84
+ review?: { pr?: number; head?: string; approvers?: string[]; url?: string; reason?: string };
70
85
  /** The command that approves this digest, when waiting. */
71
86
  approve?: string;
72
87
  }
@@ -128,19 +143,22 @@ export function readOpWavePolicy(
128
143
  base: string,
129
144
  cwd: string,
130
145
  show: OpWaveShowAtBase = gitShowAtBase,
131
- ): { policy: OpWaveGatePolicy; source: OpWavePolicySource; base: string } {
146
+ ): { policy: OpWaveGatePolicy; approval: GateApprovalSource; source: OpWavePolicySource; base: string } {
132
147
  const { sha, text } = show(base, specFile.replace(/^\.\//, "").split("\\").join(posix.sep), cwd);
133
- if (text === null) return { policy: "always", source: "absent-at-base", base: sha };
148
+ const absent = { policy: "always" as const, approval: "ledger" as const, source: "absent-at-base" as const, base: sha };
149
+ if (text === null) return absent;
134
150
  let spec: OpWavesSpec;
135
151
  try {
136
152
  spec = parseOpWavesSpec(text, `${specFile} at ${base}`);
137
153
  } catch {
138
- return { policy: "always", source: "absent-at-base", base: sha };
154
+ return absent;
139
155
  }
140
156
  const wave = spec.waves.find((w) => w.name === name);
141
- if (!wave) return { policy: "always", source: "absent-at-base", base: sha };
157
+ if (!wave) return absent;
142
158
  const policy = wave.gate ?? "always";
143
- return { policy: OP_WAVE_GATE_POLICIES.includes(policy) ? policy : "always", source: "base", base: sha };
159
+ // An approval mode this chant does not know reads as sealed: the strictest, so an unknown rule never loosens a gate.
160
+ const approval = wave.approval === undefined ? "ledger" : GATE_APPROVAL_SOURCES.includes(wave.approval) ? wave.approval : "sealed";
161
+ return { policy: OP_WAVE_GATE_POLICIES.includes(policy) ? policy : "always", approval, source: "base", base: sha };
144
162
  }
145
163
 
146
164
  /** Fill one argv template for one run. */
@@ -172,6 +190,14 @@ export interface RunOpWaveOptions {
172
190
  show?: OpWaveShowAtBase;
173
191
  now?: string;
174
192
  log?: (line: string) => void;
193
+ /** The applied commit, which a `pr-review` gate finds the merged pull request by. Default `git rev-parse HEAD`. */
194
+ head?: string;
195
+ /** The forge's reviews, for a `pr-review` gate. Default: the CI job's forge (`reviewSourceFromEnv`). */
196
+ reviews?: ReviewSource;
197
+ /** The plans recorded for a pull request's head. Default: read from `chant/lifecycle`. */
198
+ headPlans?: (name: string, head: string) => Promise<HeadPlans | null>;
199
+ /** The signers policy at base, for a `sealed` gate. Default: read from the base commit. */
200
+ trust?: (base: string) => TrustPolicy;
175
201
  }
176
202
 
177
203
  export interface RunOpWaveResult {
@@ -268,23 +294,50 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
268
294
 
269
295
  const members = wave.runs.map((run) => planRun(ctx, wave.name, run));
270
296
  const digest = waveSetDigest(members);
271
- const { policy, source, base } = readOpWavePolicy(options.specFile, wave.name, options.base ?? spec.base ?? "HEAD^1", cwd, options.show);
297
+ const { policy, approval, source, base } = readOpWavePolicy(options.specFile, wave.name, options.base ?? spec.base ?? "HEAD^1", cwd, options.show);
272
298
  const gate = waveGateName(spec.gate ?? spec.name, k);
273
- const decision: OpWaveDecision = { wave: k, name: wave.name, op: spec.name, gate, policy, policySource: source, base, digest, members, status: "not-required" };
299
+ const decision: OpWaveDecision = { wave: k, name: wave.name, op: spec.name, gate, policy, approval, policySource: source, base, digest, members, status: "not-required" };
274
300
  if (opWaveNeedsApproval(policy, members)) {
275
- const check = await evaluateGate(options.gates ?? gitGateLedgerPort({ cwd }), {
301
+ const ledger = options.gates ?? gitGateLedgerPort({ cwd });
302
+ let port = ledger;
303
+ if (approval === "sealed") {
304
+ // #3684: only an approval sealed by a signer listed at base counts.
305
+ const review = await import("./gate-review");
306
+ const sealed = review.sealedApprovalRule(gate, (options.trust ?? ((b) => review.trustPolicyAt(cwd, b)))(base));
307
+ port = {
308
+ ...ledger,
309
+ async approvalRule(g) {
310
+ const own = (await ledger.approvalRule?.(g)) ?? null;
311
+ return g === gate ? review.bothRules(own, sealed) : own;
312
+ },
313
+ };
314
+ }
315
+ const now = options.now ?? new Date().toISOString();
316
+ const check = await evaluateGate(port, {
276
317
  op: spec.name,
277
318
  gate,
278
319
  planDigest: digest,
279
- description: `wave ${k} (${wave.name}): ${members.length} run${members.length === 1 ? "" : "s"}, gate ${policy}`,
280
- ...(options.now ? { now: options.now } : {}),
320
+ description: `wave ${k} (${wave.name}): ${members.length} run${members.length === 1 ? "" : "s"}, gate ${policy}, approval ${approval}`,
321
+ now,
281
322
  });
282
323
  if (check.satisfied) {
283
324
  decision.status = "approved";
325
+ decision.via = "ledger";
284
326
  decision.approvedBy = check.resolution.resolvedBy;
327
+ } else if (approval === "pr-review") {
328
+ const byReview = await approveByReview(options, { op: spec.name, gate, k, digest, cwd, now, port: ledger });
329
+ decision.review = byReview.review;
330
+ if (byReview.approved) {
331
+ decision.status = "approved";
332
+ decision.via = "pr-review";
333
+ decision.approvedBy = byReview.review.approvers!.join(", ");
334
+ } else {
335
+ decision.status = "waiting";
336
+ decision.approve = approveCommand(spec.name, gate, undefined, digest);
337
+ }
285
338
  } else {
286
339
  decision.status = "waiting";
287
- decision.approve = approveCommand(spec.name, gate, undefined, digest);
340
+ decision.approve = approveCommand(spec.name, gate, undefined, digest, approval === "sealed");
288
341
  }
289
342
  }
290
343
  mkdirSync(dirname(recordPath), { recursive: true });
@@ -292,8 +345,12 @@ export async function runOpWave(options: RunOpWaveOptions): Promise<RunOpWaveRes
292
345
  result.decision = decision;
293
346
  log(
294
347
  `wave ${k} (${wave.name}): gate ${policy} (read at ${base.slice(0, 12)}${source === "absent-at-base" ? ", where the wave is absent" : ""}), ` +
295
- `digest ${digest}: ${decision.status}${decision.approvedBy ? ` by ${decision.approvedBy}` : ""}`,
348
+ `digest ${digest}: ${decision.status}${decision.approvedBy ? ` by ${decision.approvedBy}` : ""}` +
349
+ (decision.via === "pr-review" ? ` (review of #${decision.review?.pr})` : ""),
296
350
  );
351
+ if (approval === "pr-review" && decision.status === "waiting" && decision.review?.reason) {
352
+ log(`The pull request's review does not approve wave ${k}: ${decision.review.reason}`);
353
+ }
297
354
  if (decision.status === "waiting") {
298
355
  log(`Nothing in wave ${k} or after it ran. Read the plans, then approve: ${decision.approve} (with --resume, the approval also starts this job again)`);
299
356
  return { ...result, exitCode: 3 };
@@ -323,3 +380,124 @@ function applyRun(
323
380
  result.failed.push(run.target);
324
381
  }
325
382
  }
383
+
384
+ /** What {@link approveByReview} found. */
385
+ interface ReviewOutcome {
386
+ approved: boolean;
387
+ review: NonNullable<OpWaveDecision["review"]>;
388
+ }
389
+
390
+ /**
391
+ * Under `pr-review` (#3684): the merged pull request's review approves the
392
+ * wave when a writer other than the author approved its head, and the head
393
+ * planned this wave's digest. The approval is then written to the ledger as a
394
+ * resolution that names the review, so the record says how the wave passed.
395
+ */
396
+ async function approveByReview(
397
+ options: RunOpWaveOptions,
398
+ at: { op: string; gate: string; k: number; digest: string; cwd: string; now: string; port: GateLedgerPort },
399
+ ): Promise<ReviewOutcome> {
400
+ const review = await import("./gate-review");
401
+ const head = options.head ?? execFileSync("git", ["rev-parse", "HEAD"], { cwd: at.cwd, encoding: "utf-8" }).trim();
402
+ let merged: MergedReview | null;
403
+ try {
404
+ merged = await (options.reviews ?? review.reviewSourceFromEnv()).mergedReview(head);
405
+ } catch (err) {
406
+ return { approved: false, review: { reason: `the forge's review could not be read: ${err instanceof Error ? err.message : String(err)}` } };
407
+ }
408
+ if (!merged) return { approved: false, review: { reason: `no merged pull request produced ${head.slice(0, 12)}` } };
409
+ const seen = { pr: merged.pr, head: merged.head, ...(merged.url ? { url: merged.url } : {}) };
410
+ if (merged.approvers.length === 0) {
411
+ const why = merged.refused.length > 0 ? merged.refused.join("; ") : "nobody approved it";
412
+ return { approved: false, review: { ...seen, reason: `no writer other than its author approved the head of #${merged.pr}: ${why}` } };
413
+ }
414
+ const plans = await (options.headPlans ?? ((name, h) => readHeadPlans(name, h, at.cwd)))(options.spec.name, merged.head);
415
+ const planned = plans?.waves.find((w) => w.wave === at.k);
416
+ if (!planned) {
417
+ return {
418
+ approved: false,
419
+ review: { ...seen, approvers: merged.approvers, reason: `no plans are recorded for the head ${merged.head.slice(0, 12)} of #${merged.pr} (its pipeline runs \`chant run wave --record-plans\`)` },
420
+ };
421
+ }
422
+ if (planned.digest !== at.digest) {
423
+ return {
424
+ approved: false,
425
+ review: { ...seen, approvers: merged.approvers, reason: `the plan moved after the review: the head planned ${planned.digest}, and the merge plans ${at.digest}` },
426
+ };
427
+ }
428
+ const note = `approved by the review of #${merged.pr} at ${merged.head.slice(0, 12)}: ${merged.approvers.join(", ")}`;
429
+ await at.port.appendResolution?.({
430
+ op: at.op,
431
+ gate: at.gate,
432
+ resolvedBy: merged.approvers[0]!,
433
+ timestamp: at.now,
434
+ planDigest: at.digest,
435
+ approver: { kind: "human" },
436
+ note,
437
+ ...(merged.url ? { url: merged.url } : {}),
438
+ review: { pr: merged.pr, head: merged.head, approvers: merged.approvers },
439
+ });
440
+ return { approved: true, review: { ...seen, approvers: merged.approvers } };
441
+ }
442
+
443
+ async function readHeadPlans(name: string, head: string, cwd: string): Promise<HeadPlans | null> {
444
+ const { readBlobFromPath, requireLifecycleLedger } = await import("../lifecycle/git");
445
+ const { headPlansPath } = await import("./gate-review");
446
+ await requireLifecycleLedger({ cwd });
447
+ const { dir, file } = headPlansPath(name, head);
448
+ const text = await readBlobFromPath(dir, file, { cwd });
449
+ if (!text) return null;
450
+ try {
451
+ return JSON.parse(text) as HeadPlans;
452
+ } catch {
453
+ return null;
454
+ }
455
+ }
456
+
457
+ export interface RecordHeadPlansOptions {
458
+ spec: OpWavesSpec;
459
+ cwd?: string;
460
+ exec?: OpWaveExec;
461
+ /** The commit the plans are for. Default: the pull request's head from the CI job's environment, else `git rev-parse HEAD`. */
462
+ head?: string;
463
+ now?: string;
464
+ /** Write the record. Default: append to `chant/lifecycle` and push. */
465
+ write?: (plans: HeadPlans) => Promise<void>;
466
+ }
467
+
468
+ /**
469
+ * `chant run wave --spec <file> --record-plans` (#3684): plan every wave at a
470
+ * pull request's head and record each wave's digest on `chant/lifecycle`,
471
+ * `_wave-plans/<name>/<head>.json`. A `pr-review` wave reads it after the
472
+ * merge: the review of that head approves the digests it planned, and only
473
+ * those.
474
+ */
475
+ export async function recordOpWaveHeadPlans(options: RecordHeadPlansOptions): Promise<HeadPlans> {
476
+ const { spec } = options;
477
+ assertOpWavesSpec(spec);
478
+ const cwd = options.cwd ?? process.cwd();
479
+ const exec = options.exec ?? spawnExec;
480
+ const review = await import("./gate-review");
481
+ const head = options.head ?? review.pullRequestHeadFromEnv() ?? execFileSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf-8" }).trim();
482
+ const plans: HeadPlans = {
483
+ version: 1,
484
+ name: spec.name,
485
+ head,
486
+ timestamp: options.now ?? new Date().toISOString(),
487
+ waves: spec.waves.map((wave, i) => {
488
+ const members = wave.runs.map((run) => planRun({ spec, wave: i + 1, cwd, exec }, wave.name, run));
489
+ return { wave: i + 1, name: wave.name, digest: waveSetDigest(members) };
490
+ }),
491
+ };
492
+ await (options.write ?? ((p) => writeHeadPlans(p, cwd)))(plans);
493
+ return plans;
494
+ }
495
+
496
+ async function writeHeadPlans(plans: HeadPlans, cwd: string): Promise<void> {
497
+ const { pushLifecycle, requireLifecycleLedger, writeBlobToPath } = await import("../lifecycle/git");
498
+ const { headPlansPath } = await import("./gate-review");
499
+ await requireLifecycleLedger({ cwd });
500
+ const { dir, file } = headPlansPath(plans.name, plans.head);
501
+ await writeBlobToPath(dir, file, JSON.stringify(plans, null, 2) + "\n", `Op waves plans of ${plans.name} at ${plans.head.slice(0, 12)}`, { cwd });
502
+ if (!(await pushLifecycle({ cwd }))) throw new Error("the plans were recorded locally only: chant/lifecycle has no remote to push to");
503
+ }
@@ -27,6 +27,12 @@
27
27
  * the same digest a component fan-out's wave binds). Whether it waits at all
28
28
  * is the wave's gate policy, read from the base commit's copy of the spec
29
29
  * file, so a change cannot loosen the rule that gates its own merge.
30
+ *
31
+ * So is which approvals count (#3684), the wave's `approval`: `ledger` (any
32
+ * `chant approve` of the digest), `sealed` (only one whose seal verifies
33
+ * against the signers file at base) or `pr-review` (also the merged pull
34
+ * request's review of its head, by a writer other than its author, when the
35
+ * head planned the same digest). See `./gate-review.ts`.
30
36
  */
31
37
 
32
38
  import type { OpEnvironment, OpSetupStep } from "../lexicon";
@@ -42,6 +48,17 @@ export type OpWaveGatePolicy =
42
48
 
43
49
  export const OP_WAVE_GATE_POLICIES: readonly OpWaveGatePolicy[] = ["always", "on-destructive", "never"];
44
50
 
51
+ /** Which approvals of a gate's digest count (#3684). See `./gate-review.ts`. */
52
+ export type GateApprovalSource =
53
+ /** Any resolution on `chant/lifecycle` that names the digest. */
54
+ | "ledger"
55
+ /** As `ledger`, and the merged pull request's review of its head, by a writer other than its author. */
56
+ | "pr-review"
57
+ /** Only a resolution sealed by a key the signers file at base lists for its approver. */
58
+ | "sealed";
59
+
60
+ export const GATE_APPROVAL_SOURCES: readonly GateApprovalSource[] = ["ledger", "pr-review", "sealed"];
61
+
45
62
  /** One Op run in a wave: an Op applied to one target (an environment, a database, a tenant). */
46
63
  export interface OpWaveRun {
47
64
  /** What the run applies to. Unique within its wave; it names the run in the set digest. */
@@ -60,6 +77,11 @@ export interface OpWave {
60
77
  * spec file at the base commit, never from the change being applied.
61
78
  */
62
79
  gate?: OpWaveGatePolicy;
80
+ /**
81
+ * Which approvals of the wave's digest count (#3684). Default `"ledger"`.
82
+ * Like `gate`, the runner reads it from the spec file at the base commit.
83
+ */
84
+ approval?: GateApprovalSource;
63
85
  /**
64
86
  * Split the wave into a deciding job and this many share jobs, each
65
87
  * applying a slice of the runs. Default 1: one job plans, decides and
@@ -182,6 +204,9 @@ export function assertOpWavesSpec(spec: OpWavesSpec): void {
182
204
  if (wave.gate !== undefined && !OP_WAVE_GATE_POLICIES.includes(wave.gate)) {
183
205
  throw new Error(`${where}: wave "${wave.name}" has gate "${wave.gate}"; use ${OP_WAVE_GATE_POLICIES.join(", ")}.`);
184
206
  }
207
+ if (wave.approval !== undefined && !GATE_APPROVAL_SOURCES.includes(wave.approval)) {
208
+ throw new Error(`${where}: wave "${wave.name}" has approval "${wave.approval}"; use ${GATE_APPROVAL_SOURCES.join(", ")}.`);
209
+ }
185
210
  if (wave.runs.length === 0) throw new Error(`${where}: wave "${wave.name}" has no runs.`);
186
211
  const targets = new Set<string>();
187
212
  for (const run of wave.runs) {