@intentius/chant 0.124.3 → 0.124.4

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.
@@ -759,3 +759,136 @@ describe("a product's wave gate moved onto the waves (#3821)", () => {
759
759
  h.done();
760
760
  });
761
761
  });
762
+
763
+ describe("gates and holds a product's wave needs (#3821)", () => {
764
+ const digest = (n: string) => `sha256:${n.repeat(64)}`;
765
+ const tg: OpWavesSpec = {
766
+ name: "tf-apply",
767
+ op: "tf-apply",
768
+ gateName: "wave-{k}",
769
+ planWave: ["tg", "plan", "{wave}", "{runs}", "{plans}"],
770
+ applyWave: ["tg", "apply", "{wave}", "{runs}", "{plans}"],
771
+ hooks: { after: { gate: [{ name: "report", command: ["tg", "report", "{runs}"] }] } },
772
+ waves: [{ name: "net", runs: [{ target: "vpc" }, { target: "dns" }], gate: "always" }],
773
+ };
774
+ type Plan = { planDigest: string; empty?: boolean; denied?: { rules: string[] } };
775
+ function harness(plans: Record<string, Plan>, opts: { extra?: Record<string, unknown>; atBase?: OpWavesSpec; hook?: (argv: string[], env: Record<string, string>, dir: string) => number } = {}) {
776
+ const cwd = mkdtempSync(join(tmpdir(), "op-waves-3821b-"));
777
+ const calls: string[] = [];
778
+ const envs: Array<Record<string, string>> = [];
779
+ const exec: OpWaveExec = (argv, dir, env = {}) => {
780
+ calls.push(argv.join(" "));
781
+ envs.push(env);
782
+ if (argv[0] !== "tg") return opts.hook?.(argv, env, dir) ?? 0;
783
+ if (argv[1] === "report") return opts.hook?.(argv, env, dir) ?? 0;
784
+ const runs = JSON.parse(readFileSync(join(dir, argv[3]!), "utf-8")) as OpWaveRunsFile;
785
+ if (argv[1] === "plan") writeFileSync(join(dir, argv[4]!), JSON.stringify({ runs: runs.runs.map((r) => ({ target: r.target, ...plans[r.target] })), ...(opts.extra ?? {}) }));
786
+ return 0;
787
+ };
788
+ const show: OpWaveShowAtBase = () => ({ sha: "b".repeat(40), text: JSON.stringify(opts.atBase ?? tg) });
789
+ return { cwd, calls, envs, exec, show, done: () => rmSync(cwd, { recursive: true, force: true }) };
790
+ }
791
+ const run = (h: ReturnType<typeof harness>, gates = memoryGateLedgerPort(), spec: OpWavesSpec = tg) =>
792
+ runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates, now: "2026-10-11T00:00:00.000Z", log: () => {} });
793
+
794
+ test("the spec checks after.gate hooks and override", () => {
795
+ expect(() => assertOpWavesSpec(tg)).not.toThrow();
796
+ expect(() => assertOpWavesSpec({ ...tg, hooks: { before: { gate: [{ command: ["x"] }] } } as OpWavesSpec["hooks"] })).toThrow(/runs after the gate/);
797
+ expect(() => assertOpWavesSpec({ ...tg, hooks: { after: { gate: [{ command: ["x"], onFailure: "approve" }] } } })).toThrow(/already decided/);
798
+ expect(() => assertOpWavesSpec({ ...tg, override: { overriders: "" } })).toThrow(/override.overriders/);
799
+ expect(() => assertOpWavesSpec({ ...tg, override: { who: "x" } as OpWavesSpec["override"] })).toThrow(/override.who/);
800
+ });
801
+
802
+ test("the after-gate hook runs when the wave waits, with the decision in its environment", async () => {
803
+ const h = harness({ vpc: { planDigest: digest("1") }, dns: { planDigest: digest("2") } });
804
+ const result = await run(h);
805
+ expect(result.exitCode).toBe(3);
806
+ const at = h.calls.indexOf(`tg report .chant/op-waves/tf-apply/wave-1/runs.json`);
807
+ expect(at).toBeGreaterThan(0);
808
+ const env = h.envs[at]!;
809
+ expect(env).toMatchObject({ CHANT_HOOK: "after-gate", CHANT_GATE_STATUS: "waiting", CHANT_GATE_OP: "tf-apply", CHANT_GATE: "wave-1", CHANT_GATE_DIGEST: result.decision!.digest, CHANT_GATE_RECORDED: "true", CHANT_GATE_WAITING_SINCE: "2026-10-11T00:00:00.000Z" });
810
+ expect(env.CHANT_GATE_APPROVE).toBe(`chant approve tf-apply wave-1 --plan ${result.decision!.digest}`);
811
+ const record = JSON.parse(readFileSync(join(h.cwd, env.CHANT_DECISION!), "utf-8")) as OpWaveDecision;
812
+ expect(record).toMatchObject({ status: "waiting", recorded: true, pendingFact: { gate: "wave-1", planDigest: result.decision!.digest } });
813
+ expect(h.calls.some((c) => c.startsWith("tg apply"))).toBe(false);
814
+ h.done();
815
+ });
816
+
817
+ test("the after-gate hook names the approval of another digest; a failing one stops a wave that would apply, and not a waiting one", async () => {
818
+ const h = harness({ vpc: { planDigest: digest("1") }, dns: { planDigest: digest("2") } }, { hook: () => 1 });
819
+ const gates = memoryGateLedgerPort({ resolutions: [{ version: 1, op: "tf-apply", gate: "wave-1", resolvedBy: "alice", timestamp: "2026-10-10T00:00:00Z", planDigest: digest("9") }] });
820
+ const waiting = await run(h, gates);
821
+ expect(waiting.exitCode).toBe(3);
822
+ expect(waiting.decision).toMatchObject({ mismatch: { approved: digest("9"), resolvedBy: "alice" } });
823
+ h.done();
824
+
825
+ const open = harness({ vpc: { planDigest: digest("1"), empty: true }, dns: { planDigest: digest("2"), empty: true } }, { hook: () => 1 });
826
+ const stopped = await run(open);
827
+ expect(stopped).toMatchObject({ exitCode: 1, decision: { status: "not-required" } });
828
+ expect(open.calls.some((c) => c.startsWith("tg apply"))).toBe(false);
829
+ open.done();
830
+ });
831
+
832
+ test("a run the policy denied refuses the wave (exit 7) and records a denial an override answers", async () => {
833
+ const h = harness({ vpc: { planDigest: digest("1"), denied: { rules: ["no-public-s3"] } }, dns: { planDigest: digest("2") } });
834
+ const gates = memoryGateLedgerPort();
835
+ const result = await run(h, gates);
836
+ expect(result).toMatchObject({ exitCode: 7, decision: { status: "refused", refused: { reason: "policy", runs: ["vpc"] } } });
837
+ expect(gates.appended).toHaveLength(1);
838
+ expect(gates.appended[0]).toMatchObject({ op: "policy-override", gate: "vpc", plan: digest("1"), rules: ["no-public-s3"], members: [{ member: "vpc", planDigest: digest("1") }] });
839
+ expect(h.envs.find((e) => e.CHANT_HOOK === "after-gate")).toMatchObject({ CHANT_GATE_STATUS: "refused" });
840
+ expect(h.calls.some((c) => c.startsWith("tg apply"))).toBe(false);
841
+ const { opWaveNotifyFields } = await import("./op-waves-notify");
842
+ expect(opWaveNotifyFields(tg, 1, { result })).toMatchObject({ event: "refused", status: "denied", targets: ["vpc"] });
843
+ h.done();
844
+ });
845
+
846
+ test("an override by a listed overrider lets the denied run through, and is spent before the apply", async () => {
847
+ const { overrideDigest } = await import("../override-gate");
848
+ const od = overrideDigest("vpc", digest("1"), ["no-public-s3"]);
849
+ const h = harness({ vpc: { planDigest: digest("1"), denied: { rules: ["no-public-s3"] } }, dns: { planDigest: digest("2"), empty: true } }, { extra: { overriders: ["github:carol"] }, atBase: { ...tg, waves: [{ ...tg.waves[0]!, gate: "never" }] } });
850
+ const gates = memoryGateLedgerPort({
851
+ resolutions: [{ version: 1, op: "policy-override", gate: "vpc", resolvedBy: "github:carol", timestamp: "2026-10-10T00:00:00Z", planDigest: od, note: "the bucket is the public docs site" }],
852
+ });
853
+ const result = await run(h, gates);
854
+ expect(result).toMatchObject({ exitCode: 0, applied: ["vpc", "dns"] });
855
+ expect(result.decision!.members.find((m) => m.member === "vpc")).toMatchObject({ override: { by: "github:carol", digest: od } });
856
+ expect(gates.applied).toEqual([expect.objectContaining({ op: "policy-override", gate: "vpc", planDigest: od, approvedBy: "github:carol" })]);
857
+ h.done();
858
+
859
+ // Somebody the list does not name does not count.
860
+ const other = harness({ vpc: { planDigest: digest("1"), denied: { rules: ["no-public-s3"] } }, dns: { planDigest: digest("2") } }, { extra: { overriders: ["github:dave"] } });
861
+ expect((await run(other, memoryGateLedgerPort({ resolutions: [{ version: 1, op: "policy-override", gate: "vpc", resolvedBy: "github:carol", timestamp: "2026-10-10T00:00:00Z", planDigest: od, note: "ok" }] }))).exitCode).toBe(7);
862
+ other.done();
863
+ });
864
+
865
+ test("a hook holds the wave on another gate: it waits on that gate's digest, then runs again once approved", async () => {
866
+ const migrate = { name: "migrate", command: ["migrate", "{runs}"], onFailure: "approve" as const };
867
+ const spec: OpWavesSpec = { ...tg, hooks: { before: { plan: [migrate] }, after: tg.hooks!.after } };
868
+ const hook = (argv: string[], env: Record<string, string>, dir: string) => {
869
+ if (argv[0] !== "migrate") return 0;
870
+ if (env.CHANT_HOLD_APPROVED) return 0;
871
+ writeFileSync(join(dir, env.CHANT_HOLD!), JSON.stringify({ op: "tf-migrate", gate: "split-queues", digest: digest("m"), pending: { members: [] } }));
872
+ return 3;
873
+ };
874
+ const h = harness({ vpc: { planDigest: digest("1") }, dns: { planDigest: digest("2") } }, { hook, atBase: spec });
875
+ const gates = memoryGateLedgerPort();
876
+ const waiting = await run(h, gates, spec);
877
+ expect(waiting).toMatchObject({ exitCode: 3, decision: { status: "waiting", heldOn: { op: "tf-migrate", gate: "split-queues", digest: digest("m"), status: "waiting" } } });
878
+ expect(waiting.decision!.approve).toBe(`chant approve tf-migrate split-queues --plan ${digest("m")}`);
879
+ expect(gates.appended[0]).toMatchObject({ op: "tf-migrate", gate: "split-queues", planDigest: digest("m"), members: [] });
880
+ expect(h.calls.some((c) => c.startsWith("tg plan"))).toBe(false);
881
+ expect(h.envs.find((e) => e.CHANT_HOOK === "after-gate")).toMatchObject({ CHANT_GATE_STATUS: "waiting", CHANT_GATE_OP: "tf-migrate", CHANT_GATE: "split-queues" });
882
+ h.done();
883
+
884
+ const again = harness({ vpc: { planDigest: digest("1"), empty: true }, dns: { planDigest: digest("2"), empty: true } }, { hook, atBase: spec });
885
+ const approved = memoryGateLedgerPort({ resolutions: [{ version: 1, op: "tf-migrate", gate: "split-queues", resolvedBy: "alice", timestamp: "2026-10-10T00:00:00Z", planDigest: digest("m") }] });
886
+ const result = await run(again, approved, spec);
887
+ expect(result.exitCode).toBe(0);
888
+ const reruns = again.envs.filter((e, i) => again.calls[i]!.startsWith("migrate"));
889
+ expect(reruns).toHaveLength(2);
890
+ expect(JSON.parse(reruns[1]!.CHANT_HOLD_APPROVED!)).toMatchObject({ op: "tf-migrate", gate: "split-queues", approvedBy: "alice" });
891
+ expect(result.hooks.filter((o) => o.name === "migrate").map((o) => o.outcome)).toEqual(["held", "passed"]);
892
+ again.done();
893
+ });
894
+ });
@@ -58,6 +58,15 @@
58
58
  import type { OpEnvironment, OpSetupStep } from "../lexicon";
59
59
  import { notifyConfigProblem, type NotifyConfig } from "../notify";
60
60
 
61
+ /**
62
+ * The variables a wave job reads its notify secrets from, for the lexicons
63
+ * that generate wave pipelines. Re-exported here because a lexicon's source
64
+ * in this repository cannot import `@intentius/chant/notify`: under any
65
+ * loader that entry resolves its `import` condition to the compiled
66
+ * `dist/notify.js` (#3803), which only a packed release carries.
67
+ */
68
+ export { notifyVariables } from "../notify";
69
+
61
70
  /** When a wave waits for an approval. */
62
71
  export type OpWaveGatePolicy =
63
72
  /** Whenever the wave changes something. */
@@ -102,12 +111,17 @@ export interface OpWaveHook {
102
111
  }
103
112
 
104
113
  /** The stages a hook runs around. */
105
- export type OpWaveHookStage = "plan" | "apply";
114
+ export type OpWaveHookStage = "plan" | "apply" | "gate";
106
115
 
107
116
  /** When a hook runs. */
108
- export type OpWaveHookMoment = "before-plan" | "after-plan" | "before-apply" | "after-apply";
117
+ export type OpWaveHookMoment = "before-plan" | "after-plan" | "after-gate" | "before-apply" | "after-apply";
109
118
 
110
- export const OP_WAVE_HOOK_MOMENTS: readonly OpWaveHookMoment[] = ["before-plan", "after-plan", "before-apply", "after-apply"];
119
+ /**
120
+ * Every moment, in the order a wave reaches them. `after-gate` (#3821) runs
121
+ * once the gate decided, whatever it decided: approved, let through,
122
+ * waiting, or refused by a policy denial.
123
+ */
124
+ export const OP_WAVE_HOOK_MOMENTS: readonly OpWaveHookMoment[] = ["before-plan", "after-plan", "after-gate", "before-apply", "after-apply"];
111
125
 
112
126
  /**
113
127
  * Who runs the `before.apply` and `after.apply` hooks (#3804). `chant`: the
@@ -122,7 +136,8 @@ export const OP_WAVE_APPLY_HOOKS_RUNNERS: readonly OpWaveApplyHooksRunner[] = ["
122
136
  /** Stage hooks (#3762), read from the spec at the base commit like the gate policy. */
123
137
  export interface OpWaveHooks {
124
138
  before?: { plan?: OpWaveHook[]; apply?: OpWaveHook[] };
125
- after?: { plan?: OpWaveHook[]; apply?: OpWaveHook[] };
139
+ /** `after.gate` (#3821) runs once the gate decided, whatever it decided, with the decision in its environment. */
140
+ after?: { plan?: OpWaveHook[]; gate?: OpWaveHook[]; apply?: OpWaveHook[] };
126
141
  /** Who runs the apply-stage hooks (#3804). Default `{ runBy: "chant" }`. A wave's own setting wins over the spec's. */
127
142
  apply?: { runBy?: OpWaveApplyHooksRunner };
128
143
  }
@@ -228,6 +243,13 @@ export interface OpWavesSpec {
228
243
  hooks?: OpWaveHooks;
229
244
  /** What happens to an approval once a wave applies under it (#3784). Read at the base commit. */
230
245
  approvals?: OpWavesApprovals;
246
+ /**
247
+ * Who may override a run the product's policy denied at plan time (#3821).
248
+ * A plan entry with `denied` refuses the wave unless an override on
249
+ * `_gates/policy-override.jsonl` counts for that run, its plan digest and
250
+ * the rules (`@intentius/chant/override-gate`).
251
+ */
252
+ override?: OpWavesOverride;
231
253
  waves: OpWave[];
232
254
  /**
233
255
  * A scheduled job that starts again a wave job whose approval has arrived
@@ -270,6 +292,16 @@ export function opWaveGateName(spec: Pick<OpWavesSpec, "name" | "gate" | "gateNa
270
292
  return (spec.gateName ?? OP_WAVE_DEFAULT_GATE_NAME).replaceAll("{gate}", spec.gate ?? spec.name).replaceAll("{k}", String(k));
271
293
  }
272
294
 
295
+ /** Where a wave reads who may override a policy denial (#3821). */
296
+ export interface OpWavesOverride {
297
+ /**
298
+ * The overriders file, read at the base commit. Default
299
+ * `.chant/overriders`. A plans file that names `overriders` (a product
300
+ * that keeps its list in its own config at base) wins over it.
301
+ */
302
+ overriders?: string;
303
+ }
304
+
273
305
  /** What happens to an approval once a wave applies under it (#3784). */
274
306
  export interface OpWavesApprovals {
275
307
  /**
@@ -386,6 +418,14 @@ export function assertOpWavesSpec(spec: OpWavesSpec): void {
386
418
  if (!OP_WAVES_DIGEST_OVER.includes(value as "all")) throw new Error(`${where}: digest.over is ${JSON.stringify(value)}; use "all" or "changing".`);
387
419
  }
388
420
  }
421
+ if (spec.override !== undefined) {
422
+ const o = spec.override as unknown;
423
+ if (o === null || typeof o !== "object" || Array.isArray(o)) throw new Error(`${where}: \`override\` must be { "overriders"?: "<file at base>" }.`);
424
+ for (const [key, value] of Object.entries(o as Record<string, unknown>)) {
425
+ if (key !== "overriders") throw new Error(`${where}: override.${key} is not a setting; the one there is override.overriders.`);
426
+ if (typeof value !== "string" || value.trim() === "") throw new Error(`${where}: override.overriders must be a path, such as ".chant/overriders".`);
427
+ }
428
+ }
389
429
  if (spec.neverOverMcp !== undefined && typeof spec.neverOverMcp !== "boolean") throw new Error(`${where}: neverOverMcp must be true or false.`);
390
430
  if (spec.notify !== undefined) {
391
431
  const problem = notifyConfigProblem(spec.notify, "notify");
@@ -449,7 +489,8 @@ export function assertOpWaveHooks(where: string, hooks: OpWaveHooks | undefined)
449
489
  if (stages === null || typeof stages !== "object" || Array.isArray(stages)) throw new Error(`${where}: hooks.${side} must be { "plan"?: [...], "apply"?: [...] }.`);
450
490
  for (const [stage, list] of Object.entries(stages as Record<string, unknown>)) {
451
491
  const at = `hooks.${side}.${stage}`;
452
- if (stage !== "plan" && stage !== "apply") throw new Error(`${where}: ${at} is not a stage; a hook runs before or after plan or apply.`);
492
+ if (stage === "gate" && side === "before") throw new Error(`${where}: ${at} is not a moment; a hook runs after the gate (hooks.after.gate), or before the plan or the apply.`);
493
+ if (stage !== "plan" && stage !== "apply" && stage !== "gate") throw new Error(`${where}: ${at} is not a stage; a hook runs before or after plan or apply, or after the gate.`);
453
494
  if (!Array.isArray(list)) throw new Error(`${where}: ${at} must be a list of hooks.`);
454
495
  list.forEach((hook: OpWaveHook, i: number) => {
455
496
  const which = `${at}[${i}]${hook && typeof hook === "object" && typeof hook.name === "string" ? ` (${hook.name})` : ""}`;
@@ -461,6 +502,9 @@ export function assertOpWaveHooks(where: string, hooks: OpWaveHooks | undefined)
461
502
  if (hook.onFailure === "approve" && side === "after" && stage === "apply") {
462
503
  throw new Error(`${where}: ${which} has onFailure "approve", but after the apply there is nothing left to hold. Use "fail".`);
463
504
  }
505
+ if (hook.onFailure === "approve" && stage === "gate") {
506
+ throw new Error(`${where}: ${which} has onFailure "approve", but the gate has already decided when it runs. Use "fail", or hold the wave from an after.plan hook.`);
507
+ }
464
508
  if (hook.perRun !== undefined && typeof hook.perRun !== "boolean") throw new Error(`${where}: ${which}.perRun must be true or false.`);
465
509
  if (!hook.perRun) {
466
510
  const named = hook.command.find((part) => part.includes("{target}") || part.includes("{plan}"));
@@ -478,7 +522,7 @@ export function opWaveHooksAt(spec: Pick<OpWavesSpec, "hooks" | "waves">, name:
478
522
  const wave = spec.waves.find((w) => w.name === name);
479
523
  const at = (hooks: OpWaveHooks | undefined, moment: OpWaveHookMoment): OpWaveHook[] => {
480
524
  const [side, stage] = moment.split("-") as ["before" | "after", OpWaveHookStage];
481
- return hooks?.[side]?.[stage] ?? [];
525
+ return (hooks?.[side] as Partial<Record<OpWaveHookStage, OpWaveHook[]>> | undefined)?.[stage] ?? [];
482
526
  };
483
527
  return Object.fromEntries(OP_WAVE_HOOK_MOMENTS.map((m) => [m, [...at(spec.hooks, m), ...at(wave?.hooks, m)]])) as Record<OpWaveHookMoment, OpWaveHook[]>;
484
528
  }