@intentius/chant 0.124.3 → 0.124.5

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,271 @@ 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
+ });
895
+
896
+ describe("discovered waves, a cost member and applies that apply nothing (#3821)", () => {
897
+ const digest = (n: string) => `sha256:${n.repeat(64)}`;
898
+ const tg: OpWavesSpec = {
899
+ name: "tf-apply",
900
+ op: "tf-apply",
901
+ gateName: "wave-{k}",
902
+ digest: { over: "changing" },
903
+ planWave: ["tg", "plan", "{wave}", "{runs}", "{plans}"],
904
+ applyWave: ["tg", "apply", "{wave}", "{runs}", "{plans}"],
905
+ waves: [
906
+ { name: "layer", runs: [], discover: true, gate: "never" },
907
+ { name: "rest", runs: [], discover: true, rest: true, gate: "never" },
908
+ ],
909
+ };
910
+ /** `layers[k-1]` is what wave k's plan finds; `apply` says what each apply call writes to CHANT_APPLY_OUTCOME. */
911
+ function harness(layers: Array<Record<string, { planDigest: string; empty?: boolean }>>, opts: { extra?: (k: number) => Record<string, unknown>; apply?: (k: number, n: number) => unknown; atBase?: OpWavesSpec } = {}) {
912
+ const cwd = mkdtempSync(join(tmpdir(), "op-waves-3821c-"));
913
+ const calls: string[] = [];
914
+ const runsFiles: OpWaveRunsFile[] = [];
915
+ const applies = new Map<number, number>();
916
+ const exec: OpWaveExec = (argv, dir, env = {}) => {
917
+ calls.push(argv.join(" "));
918
+ const runs = JSON.parse(readFileSync(join(dir, argv[3]!), "utf-8")) as OpWaveRunsFile;
919
+ runsFiles.push(runs);
920
+ if (argv[1] === "plan") {
921
+ const found = layers[runs.number - 1] ?? {};
922
+ const entries = runs.discover ? Object.entries(found).map(([target, p]) => ({ target, ...p })) : runs.runs.map((r) => ({ target: r.target, ...found[r.target] }));
923
+ writeFileSync(join(dir, argv[4]!), JSON.stringify({ runs: entries, waves: layers.length, ...(opts.extra?.(runs.number) ?? {}) }));
924
+ }
925
+ if (argv[1] === "apply") {
926
+ const n = (applies.get(runs.number) ?? 0) + 1;
927
+ applies.set(runs.number, n);
928
+ const said = opts.apply?.(runs.number, n);
929
+ if (said) writeFileSync(join(dir, env.CHANT_APPLY_OUTCOME!), JSON.stringify(said));
930
+ }
931
+ return 0;
932
+ };
933
+ const show: OpWaveShowAtBase = () => ({ sha: "b".repeat(40), text: JSON.stringify(opts.atBase ?? tg) });
934
+ return { cwd, calls, runsFiles, exec, show, done: () => rmSync(cwd, { recursive: true, force: true }) };
935
+ }
936
+ const run = (h: ReturnType<typeof harness>, wave: number, gates = memoryGateLedgerPort(), spec: OpWavesSpec = tg) =>
937
+ runOpWave({ spec, specFile: "waves.json", wave, cwd: h.cwd, exec: h.exec, show: h.show, gates, now: "2026-10-11T00:00:00.000Z", log: () => {} });
938
+
939
+ test("the spec checks discover and rest", () => {
940
+ expect(() => assertOpWavesSpec(tg)).not.toThrow();
941
+ expect(() => assertOpWavesSpec({ ...tg, planWave: undefined, plan: ["p", "{plan}"] })).toThrow(/only `planWave` can report/);
942
+ expect(() => assertOpWavesSpec({ ...tg, waves: [{ name: "a", runs: [{ target: "x" }], discover: true }] })).toThrow(/lists none/);
943
+ expect(() => assertOpWavesSpec({ ...tg, waves: [{ name: "a", runs: [], rest: true, discover: true }, { name: "b", runs: [], discover: true }] })).toThrow(/only the last wave/);
944
+ expect(() => assertOpWavesSpec({ ...tg, waves: [{ name: "a", runs: [], rest: true }] })).toThrow(/set discover too/);
945
+ expect(() => assertOpWavesSpec({ ...tg, waves: [{ name: "a", runs: [], discover: true, rest: true, shares: 2 }] })).toThrow(/cannot be split/);
946
+ });
947
+
948
+ test("a wave plans the runs its plan command finds, and the rest wave walks on to the last wave it found", async () => {
949
+ const layers: Array<Record<string, { planDigest: string }>> = [{ "a/vpc": { planDigest: digest("1") } }, { "b/db": { planDigest: digest("2") } }, { "c/app": { planDigest: digest("3") } }, { "d/dns": { planDigest: digest("4") } }];
950
+ const h = harness(layers);
951
+ const first = await run(h, 1);
952
+ expect(first).toMatchObject({ exitCode: 0, applied: ["a/vpc"], decision: { gate: "wave-1", discovered: { waves: 4 } } });
953
+ expect(h.runsFiles[0]).toMatchObject({ discover: true, runs: [] });
954
+ const rest = await run(h, 2);
955
+ expect(rest).toMatchObject({ exitCode: 0, applied: ["b/db", "c/app", "d/dns"], decision: { wave: 4, gate: "wave-4", name: "rest" } });
956
+ expect(rest.rest!.map((r) => [r.wave, r.exitCode])).toEqual([[2, 0], [3, 0], [4, 0]]);
957
+ h.done();
958
+
959
+ // A repo with fewer waves than jobs: the later job finds nothing to apply.
960
+ const short = harness([{ "a/vpc": { planDigest: digest("1") } }]);
961
+ expect(await run(short, 2)).toMatchObject({ exitCode: 0, applied: [] });
962
+ short.done();
963
+ });
964
+
965
+ test("a wide discovering wave hands its found runs to its shares", async () => {
966
+ const spec: OpWavesSpec = { ...tg, waves: [{ name: "layer", runs: [], discover: true, gate: "never", shares: 2 }] };
967
+ const layers = [{ "a/1": { planDigest: digest("1") }, "a/2": { planDigest: digest("2") }, "a/3": { planDigest: digest("3") } }];
968
+ const h = harness(layers, { atBase: spec });
969
+ expect(await runOpWave({ spec, specFile: "waves.json", wave: 1, decide: true, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort(), log: () => {} })).toMatchObject({ exitCode: 0, applied: [] });
970
+ const share = await runOpWave({ spec, specFile: "waves.json", wave: 1, share: 2, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort(), log: () => {} });
971
+ expect(share).toMatchObject({ exitCode: 0, applied: ["a/3"] });
972
+ h.done();
973
+ });
974
+
975
+ test("a cost member joins the digest, so an approval at one cost does not apply at another", async () => {
976
+ const spec: OpWavesSpec = { ...tg, waves: [{ name: "layer", runs: [], discover: true, gate: "always" }] };
977
+ const layers = [{ "a/vpc": { planDigest: digest("1") }, "a/idle": { planDigest: digest("2"), empty: true } }];
978
+ const cost = (usd: string) => ({ members: [{ member: "cost", planDigest: `usd:${usd}` }] });
979
+ const h = harness(layers, { extra: () => cost("120.00"), atBase: spec });
980
+ const gates = memoryGateLedgerPort();
981
+ const result = await run(h, 1, gates, spec);
982
+ const priced = waveSetDigest([{ member: "a/vpc", planDigest: digest("1") }, { member: "cost", planDigest: "usd:120.00" }]);
983
+ expect(result).toMatchObject({ exitCode: 3, decision: { digest: priced, extras: [{ member: "cost", planDigest: "usd:120.00" }] } });
984
+ h.done();
985
+
986
+ const dearer = harness(layers, { extra: () => cost("900.00"), atBase: spec });
987
+ const approved = memoryGateLedgerPort({ resolutions: [{ version: 1, op: "tf-apply", gate: "wave-1", resolvedBy: "alice", timestamp: "2026-10-10T00:00:00Z", planDigest: priced }] });
988
+ expect(await run(dearer, 1, approved, spec)).toMatchObject({ exitCode: 3, decision: { mismatch: { approved: priced } } });
989
+ dearer.done();
990
+
991
+ const clash = harness([{ cost: { planDigest: digest("1") } }], { extra: () => cost("1"), atBase: spec });
992
+ await expect(run(clash, 1, memoryGateLedgerPort(), spec)).rejects.toThrow(/also a run's target/);
993
+ clash.done();
994
+ });
995
+
996
+ test("an apply that stood down or was held applies nothing and fails nothing; one that asks plans again", async () => {
997
+ const layers = [{ "a/vpc": { planDigest: digest("1") } }];
998
+ const down = harness(layers, { apply: () => ({ outcome: "stood-down", reason: "a newer push is on main" }) });
999
+ const stood = await run(down, 1);
1000
+ expect(stood).toMatchObject({ exitCode: 6, applied: [], failed: [], outcome: { outcome: "stood-down", reason: "a newer push is on main" } });
1001
+ const { opWaveNotifyFields } = await import("./op-waves-notify");
1002
+ expect(opWaveNotifyFields(tg, 1, { result: stood })).toBeUndefined();
1003
+ down.done();
1004
+
1005
+ const busy = harness(layers, { apply: () => ({ outcome: "held", holder: { run: "github:123", url: "https://example.test/run/123" } }) });
1006
+ const held = await run(busy, 1);
1007
+ expect(held).toMatchObject({ exitCode: 5, applied: [], failed: [], outcome: { outcome: "held", holder: { run: "github:123" } } });
1008
+ expect(opWaveNotifyFields(tg, 1, { result: held })).toMatchObject({ event: "refused", status: "held", targets: ["a/vpc"] });
1009
+ busy.done();
1010
+
1011
+ const again = harness(layers, { apply: (_k, n) => (n === 1 ? { outcome: "replan", reason: "run 123 let go" } : undefined) });
1012
+ const replanned = await run(again, 1);
1013
+ expect(replanned).toMatchObject({ exitCode: 0, applied: ["a/vpc"] });
1014
+ expect(again.calls.filter((c) => c.startsWith("tg plan"))).toHaveLength(2);
1015
+ again.done();
1016
+ });
1017
+
1018
+ test("planned again to the same digest, the approval the job found still holds and is not spent twice", async () => {
1019
+ const spec: OpWavesSpec = { ...tg, approvals: { spend: true }, waves: [{ name: "layer", runs: [], discover: true, gate: "always" }] };
1020
+ const layers = [{ "a/vpc": { planDigest: digest("1") } }];
1021
+ const set = waveSetDigest([{ member: "a/vpc", planDigest: digest("1") }]);
1022
+ const h = harness(layers, { apply: (_k, n) => (n === 1 ? { outcome: "replan" } : undefined), atBase: spec });
1023
+ const gates = memoryGateLedgerPort({ resolutions: [{ version: 1, op: "tf-apply", gate: "wave-1", resolvedBy: "alice", timestamp: "2026-10-10T00:00:00Z", planDigest: set }] });
1024
+ const result = await run(h, 1, gates, spec);
1025
+ expect(result).toMatchObject({ exitCode: 0, applied: ["a/vpc"], decision: { status: "approved", approvedBy: "alice" } });
1026
+ expect(gates.applied).toHaveLength(1);
1027
+ h.done();
1028
+ });
1029
+ });
@@ -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
  }
@@ -139,7 +154,23 @@ export interface OpWaveRun {
139
154
  export interface OpWave {
140
155
  /** The wave's name, usually its environment. Lowercase letters, digits, `-` and `_`. */
141
156
  name: string;
157
+ /** The wave's runs. Empty or absent on a `discover` wave. */
142
158
  runs: OpWaveRun[];
159
+ /**
160
+ * The runs come from the product at plan time (#3821): `planWave` gets a
161
+ * runs file with no runs and `discover: true`, and plans the runs it finds
162
+ * for wave k (a Terragrunt repo's dependency layer from `terragrunt find`).
163
+ * Needs `planWave`.
164
+ */
165
+ discover?: boolean;
166
+ /**
167
+ * The last wave only, with `discover` (#3821): once it applies, the job
168
+ * goes on to wave k+1, k+2, ... while `planWave` reports more waves
169
+ * (`waves` in its plans file), each behind its own gate and read at base
170
+ * by this wave's name. For a pipeline whose wave count is not known up
171
+ * front.
172
+ */
173
+ rest?: boolean;
143
174
  /**
144
175
  * When the wave waits. Default `"always"`. The runner reads this from the
145
176
  * spec file at the base commit, never from the change being applied.
@@ -228,6 +259,13 @@ export interface OpWavesSpec {
228
259
  hooks?: OpWaveHooks;
229
260
  /** What happens to an approval once a wave applies under it (#3784). Read at the base commit. */
230
261
  approvals?: OpWavesApprovals;
262
+ /**
263
+ * Who may override a run the product's policy denied at plan time (#3821).
264
+ * A plan entry with `denied` refuses the wave unless an override on
265
+ * `_gates/policy-override.jsonl` counts for that run, its plan digest and
266
+ * the rules (`@intentius/chant/override-gate`).
267
+ */
268
+ override?: OpWavesOverride;
231
269
  waves: OpWave[];
232
270
  /**
233
271
  * A scheduled job that starts again a wave job whose approval has arrived
@@ -270,6 +308,16 @@ export function opWaveGateName(spec: Pick<OpWavesSpec, "name" | "gate" | "gateNa
270
308
  return (spec.gateName ?? OP_WAVE_DEFAULT_GATE_NAME).replaceAll("{gate}", spec.gate ?? spec.name).replaceAll("{k}", String(k));
271
309
  }
272
310
 
311
+ /** Where a wave reads who may override a policy denial (#3821). */
312
+ export interface OpWavesOverride {
313
+ /**
314
+ * The overriders file, read at the base commit. Default
315
+ * `.chant/overriders`. A plans file that names `overriders` (a product
316
+ * that keeps its list in its own config at base) wins over it.
317
+ */
318
+ overriders?: string;
319
+ }
320
+
273
321
  /** What happens to an approval once a wave applies under it (#3784). */
274
322
  export interface OpWavesApprovals {
275
323
  /**
@@ -386,6 +434,14 @@ export function assertOpWavesSpec(spec: OpWavesSpec): void {
386
434
  if (!OP_WAVES_DIGEST_OVER.includes(value as "all")) throw new Error(`${where}: digest.over is ${JSON.stringify(value)}; use "all" or "changing".`);
387
435
  }
388
436
  }
437
+ if (spec.override !== undefined) {
438
+ const o = spec.override as unknown;
439
+ if (o === null || typeof o !== "object" || Array.isArray(o)) throw new Error(`${where}: \`override\` must be { "overriders"?: "<file at base>" }.`);
440
+ for (const [key, value] of Object.entries(o as Record<string, unknown>)) {
441
+ if (key !== "overriders") throw new Error(`${where}: override.${key} is not a setting; the one there is override.overriders.`);
442
+ if (typeof value !== "string" || value.trim() === "") throw new Error(`${where}: override.overriders must be a path, such as ".chant/overriders".`);
443
+ }
444
+ }
389
445
  if (spec.neverOverMcp !== undefined && typeof spec.neverOverMcp !== "boolean") throw new Error(`${where}: neverOverMcp must be true or false.`);
390
446
  if (spec.notify !== undefined) {
391
447
  const problem = notifyConfigProblem(spec.notify, "notify");
@@ -405,7 +461,23 @@ export function assertOpWavesSpec(spec: OpWavesSpec): void {
405
461
  throw new Error(`${where}: wave "${wave.name}" has approval "${wave.approval}"; use ${GATE_APPROVAL_SOURCES.join(", ")}.`);
406
462
  }
407
463
  assertOpWaveHooks(`${where}, wave "${wave.name}"`, wave.hooks);
408
- if (wave.runs.length === 0) throw new Error(`${where}: wave "${wave.name}" has no runs.`);
464
+ if (wave.discover !== undefined && typeof wave.discover !== "boolean") throw new Error(`${where}: wave "${wave.name}" has discover ${JSON.stringify(wave.discover)}; use true or false.`);
465
+ if (wave.rest !== undefined && typeof wave.rest !== "boolean") throw new Error(`${where}: wave "${wave.name}" has rest ${JSON.stringify(wave.rest)}; use true or false.`);
466
+ if (wave.discover) {
467
+ if (spec.planWave === undefined) throw new Error(`${where}: wave "${wave.name}" discovers its runs, which only \`planWave\` can report. Give planWave.`);
468
+ if ((wave.runs ?? []).length > 0) throw new Error(`${where}: wave "${wave.name}" discovers its runs, so it lists none.`);
469
+ }
470
+ if (wave.rest) {
471
+ if (!wave.discover) throw new Error(`${where}: wave "${wave.name}" has rest, which follows the waves planWave discovers; set discover too.`);
472
+ if (wave !== spec.waves[spec.waves.length - 1]) throw new Error(`${where}: wave "${wave.name}" has rest, which only the last wave can have.`);
473
+ if ((wave.shares ?? 1) > 1) throw new Error(`${where}: wave "${wave.name}" has rest and shares; the waves after it run in its one job, so it cannot be split.`);
474
+ }
475
+ if (wave.discover) {
476
+ const shares = wave.shares ?? 1;
477
+ if (!Number.isInteger(shares) || shares < 1) throw new Error(`${where}: wave "${wave.name}" has shares ${wave.shares}; use a whole number from 1.`);
478
+ continue;
479
+ }
480
+ if (!Array.isArray(wave.runs) || wave.runs.length === 0) throw new Error(`${where}: wave "${wave.name}" has no runs.`);
409
481
  const targets = new Set<string>();
410
482
  for (const run of wave.runs) {
411
483
  if (!run.target) throw new Error(`${where}: wave "${wave.name}" has a run with no target.`);
@@ -449,7 +521,8 @@ export function assertOpWaveHooks(where: string, hooks: OpWaveHooks | undefined)
449
521
  if (stages === null || typeof stages !== "object" || Array.isArray(stages)) throw new Error(`${where}: hooks.${side} must be { "plan"?: [...], "apply"?: [...] }.`);
450
522
  for (const [stage, list] of Object.entries(stages as Record<string, unknown>)) {
451
523
  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.`);
524
+ 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.`);
525
+ 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
526
  if (!Array.isArray(list)) throw new Error(`${where}: ${at} must be a list of hooks.`);
454
527
  list.forEach((hook: OpWaveHook, i: number) => {
455
528
  const which = `${at}[${i}]${hook && typeof hook === "object" && typeof hook.name === "string" ? ` (${hook.name})` : ""}`;
@@ -461,6 +534,9 @@ export function assertOpWaveHooks(where: string, hooks: OpWaveHooks | undefined)
461
534
  if (hook.onFailure === "approve" && side === "after" && stage === "apply") {
462
535
  throw new Error(`${where}: ${which} has onFailure "approve", but after the apply there is nothing left to hold. Use "fail".`);
463
536
  }
537
+ if (hook.onFailure === "approve" && stage === "gate") {
538
+ 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.`);
539
+ }
464
540
  if (hook.perRun !== undefined && typeof hook.perRun !== "boolean") throw new Error(`${where}: ${which}.perRun must be true or false.`);
465
541
  if (!hook.perRun) {
466
542
  const named = hook.command.find((part) => part.includes("{target}") || part.includes("{plan}"));
@@ -478,7 +554,7 @@ export function opWaveHooksAt(spec: Pick<OpWavesSpec, "hooks" | "waves">, name:
478
554
  const wave = spec.waves.find((w) => w.name === name);
479
555
  const at = (hooks: OpWaveHooks | undefined, moment: OpWaveHookMoment): OpWaveHook[] => {
480
556
  const [side, stage] = moment.split("-") as ["before" | "after", OpWaveHookStage];
481
- return hooks?.[side]?.[stage] ?? [];
557
+ return (hooks?.[side] as Partial<Record<OpWaveHookStage, OpWaveHook[]>> | undefined)?.[stage] ?? [];
482
558
  };
483
559
  return Object.fromEntries(OP_WAVE_HOOK_MOMENTS.map((m) => [m, [...at(spec.hooks, m), ...at(wave?.hooks, m)]])) as Record<OpWaveHookMoment, OpWaveHook[]>;
484
560
  }