@intentius/chant 0.124.40 → 0.124.41
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.
- package/dist/__fixtures__/oidc-emulator.d.ts +49 -0
- package/dist/__fixtures__/oidc-emulator.d.ts.map +1 -0
- package/dist/__fixtures__/oidc-emulator.js +89 -0
- package/dist/ci-handover.d.ts +117 -0
- package/dist/ci-handover.d.ts.map +1 -0
- package/dist/ci-handover.js +217 -0
- package/dist/ci-identity.d.ts +190 -0
- package/dist/ci-identity.d.ts.map +1 -0
- package/dist/ci-identity.js +219 -0
- package/dist/cli/handlers/approve.d.ts +2 -1
- package/dist/cli/handlers/approve.d.ts.map +1 -1
- package/dist/cli/handlers/approve.js +20 -6
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/build.js +15 -1
- package/dist/cli/handlers/fan-out.js +16 -3
- package/dist/cli/handlers/gate-resume.d.ts +3 -0
- package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
- package/dist/cli/handlers/gate-resume.js +15 -2
- package/dist/cli/handlers/lock.d.ts +8 -0
- package/dist/cli/handlers/lock.d.ts.map +1 -0
- package/dist/cli/handlers/lock.js +197 -0
- package/dist/cli/handlers/pr-env.d.ts +34 -0
- package/dist/cli/handlers/pr-env.d.ts.map +1 -0
- package/dist/cli/handlers/pr-env.js +436 -0
- package/dist/cli/handlers/pr.d.ts +74 -0
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/pr.js +503 -53
- package/dist/cli/handlers/promote.d.ts.map +1 -1
- package/dist/cli/handlers/promote.js +9 -0
- package/dist/cli/handlers/query.d.ts +6 -0
- package/dist/cli/handlers/query.d.ts.map +1 -0
- package/dist/cli/handlers/query.js +80 -0
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +41 -2
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +31 -2
- package/dist/cli/registry.d.ts +12 -2
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/fan-out-run.d.ts +28 -0
- package/dist/components/fan-out-run.d.ts.map +1 -1
- package/dist/components/fan-out-run.js +27 -3
- package/dist/components/pr-environment.d.ts +298 -0
- package/dist/components/pr-environment.d.ts.map +1 -0
- package/dist/components/pr-environment.js +406 -0
- package/dist/components/pr-hooks.d.ts +66 -0
- package/dist/components/pr-hooks.d.ts.map +1 -0
- package/dist/components/pr-hooks.js +119 -0
- package/dist/components/pr-pipeline.d.ts +72 -2
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/components/pr-pipeline.js +96 -5
- package/dist/components/pr-policy.d.ts +109 -0
- package/dist/components/pr-policy.d.ts.map +1 -0
- package/dist/components/pr-policy.js +150 -0
- package/dist/components/pr-run.d.ts.map +1 -1
- package/dist/components/pr-run.js +5 -3
- package/dist/gate-decision.d.ts +15 -2
- package/dist/gate-decision.d.ts.map +1 -1
- package/dist/gate-decision.js +17 -2
- package/dist/gate-policy.d.ts +140 -0
- package/dist/gate-policy.d.ts.map +1 -0
- package/dist/gate-policy.js +242 -0
- package/dist/lifecycle/gate-checkpoint.d.ts +1 -1
- package/dist/lifecycle/gate-checkpoint.js +1 -1
- package/dist/{workspace/trust → lifecycle}/gate-seal.d.ts +1 -1
- package/dist/lifecycle/gate-seal.d.ts.map +1 -0
- package/dist/lifecycle/query.d.ts +68 -0
- package/dist/lifecycle/query.d.ts.map +1 -0
- package/dist/lifecycle/query.js +246 -0
- package/dist/lifecycle/run-ledger.d.ts +13 -0
- package/dist/lifecycle/run-ledger.d.ts.map +1 -1
- package/dist/lifecycle/run-ledger.js +16 -0
- package/dist/lifecycle/target-lock.d.ts +97 -0
- package/dist/lifecycle/target-lock.d.ts.map +1 -0
- package/dist/lifecycle/target-lock.js +286 -0
- package/dist/op/activities/activity-contracts.d.ts +30 -0
- package/dist/op/activities/activity-contracts.d.ts.map +1 -1
- package/dist/op/activities/activity-contracts.js +26 -0
- package/dist/op/activities/index.d.ts +2 -0
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/index.js +4 -0
- package/dist/op/activities/review-agent.d.ts +137 -0
- package/dist/op/activities/review-agent.d.ts.map +1 -0
- package/dist/op/activities/review-agent.js +266 -0
- package/dist/op/builders.d.ts +9 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/builders.js +11 -0
- package/dist/op/cron.d.ts +8 -0
- package/dist/op/cron.d.ts.map +1 -1
- package/dist/op/cron.js +51 -0
- package/dist/op/gate-approval.d.ts +20 -13
- package/dist/op/gate-approval.d.ts.map +1 -1
- package/dist/op/gate-approval.js +13 -18
- package/dist/op/gate-quorum.d.ts +131 -0
- package/dist/op/gate-quorum.d.ts.map +1 -0
- package/dist/op/gate-quorum.js +183 -0
- package/dist/op/gate-resume.d.ts +4 -2
- package/dist/op/gate-resume.d.ts.map +1 -1
- package/dist/op/gate-resume.js +5 -4
- package/dist/op/gate-review.js +1 -1
- package/dist/op/gate.d.ts +32 -4
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/gate.js +40 -24
- package/dist/op/index.d.ts +6 -4
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/index.js +4 -3
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-executor.js +9 -3
- package/dist/op/op-waves-run.d.ts +62 -1
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves-run.js +190 -11
- package/dist/op/op-waves.d.ts +55 -3
- package/dist/op/op-waves.d.ts.map +1 -1
- package/dist/op/op-waves.js +63 -8
- package/dist/op/schedule-overdue.d.ts +57 -0
- package/dist/op/schedule-overdue.d.ts.map +1 -0
- package/dist/op/schedule-overdue.js +138 -0
- package/dist/override-gate.d.ts +2 -2
- package/dist/override-gate.d.ts.map +1 -1
- package/dist/override-gate.js +1 -1
- package/dist/pr-forge.d.ts +36 -0
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/pr-forge.js +82 -0
- package/dist/pr-loop.d.ts +90 -1
- package/dist/pr-loop.d.ts.map +1 -1
- package/dist/pr-loop.js +77 -3
- package/dist/pr-post.d.ts +72 -0
- package/dist/pr-post.d.ts.map +1 -0
- package/dist/pr-post.js +134 -0
- package/dist/wave-blocked.d.ts +79 -0
- package/dist/wave-blocked.d.ts.map +1 -0
- package/dist/wave-blocked.js +245 -0
- package/dist/wave-engine.d.ts +6 -0
- package/dist/wave-engine.d.ts.map +1 -1
- package/dist/wave-engine.js +2 -0
- package/dist/wave-runtime.mjs +242 -242
- package/dist/workspace/__fixtures__/two-member-workspace.d.ts +1 -0
- package/dist/workspace/__fixtures__/two-member-workspace.d.ts.map +1 -1
- package/dist/workspace/__fixtures__/two-member-workspace.js +1 -1
- package/dist/workspace/ci-branches.d.ts +51 -0
- package/dist/workspace/ci-branches.d.ts.map +1 -0
- package/dist/workspace/ci-branches.js +69 -0
- package/dist/workspace/ci-cloud-identity.d.ts +11 -0
- package/dist/workspace/ci-cloud-identity.d.ts.map +1 -0
- package/dist/workspace/ci-cloud-identity.js +24 -0
- package/dist/workspace/ci-locks.d.ts +12 -0
- package/dist/workspace/ci-locks.d.ts.map +1 -0
- package/dist/workspace/ci-locks.js +6 -0
- package/dist/workspace/ci-run.d.ts +17 -1
- package/dist/workspace/ci-run.d.ts.map +1 -1
- package/dist/workspace/ci-run.js +64 -3
- package/dist/workspace/declaration.d.ts +63 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.js +72 -1
- package/dist/workspace/declaration.schema.json +147 -0
- package/dist/workspace/identity-gate-rule.d.ts +1 -1
- package/dist/workspace/identity-gate-rule.d.ts.map +1 -1
- package/dist/workspace/identity-gate-rule.js +1 -1
- package/dist/workspace/identity.d.ts +1 -1
- package/dist/workspace/identity.d.ts.map +1 -1
- package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
- package/dist/workspace/lineage-upgrade.js +8 -5
- package/dist/workspace/member-pipeline.d.ts +6 -0
- package/dist/workspace/member-pipeline.d.ts.map +1 -1
- package/dist/workspace/member-pipeline.js +23 -0
- package/dist/workspace/pr-report.schema.json +73 -2
- package/dist/workspace/security.d.ts.map +1 -1
- package/dist/workspace/security.js +4 -0
- package/dist/workspace/status-gates.d.ts +3 -2
- package/dist/workspace/status-gates.d.ts.map +1 -1
- package/dist/workspace/status-gates.js +8 -7
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/status.js +4 -1
- package/dist/workspace/trust/seal.d.ts +2 -2
- package/dist/workspace/trust/seal.d.ts.map +1 -1
- package/dist/workspace/trust/seal.js +2 -2
- package/package.json +1 -1
- package/src/__fixtures__/oidc-emulator.ts +118 -0
- package/src/ci-handover.test.ts +209 -0
- package/src/ci-handover.ts +237 -0
- package/src/ci-identity.test.ts +201 -0
- package/src/ci-identity.ts +310 -0
- package/src/cli/handlers/approve.ts +18 -5
- package/src/cli/handlers/build.ts +15 -1
- package/src/cli/handlers/fan-out.ts +17 -4
- package/src/cli/handlers/gate-resume.ts +16 -2
- package/src/cli/handlers/lock.test.ts +98 -0
- package/src/cli/handlers/lock.ts +202 -0
- package/src/cli/handlers/operator.test.ts +3 -1
- package/src/cli/handlers/pr-env.ts +462 -0
- package/src/cli/handlers/pr-waves-blocked.test.ts +247 -0
- package/src/cli/handlers/pr.test.ts +184 -0
- package/src/cli/handlers/pr.ts +496 -59
- package/src/cli/handlers/promote.ts +9 -0
- package/src/cli/handlers/query.ts +78 -0
- package/src/cli/main.ts +41 -2
- package/src/cli/parse-args.ts +23 -2
- package/src/cli/registry.ts +12 -2
- package/src/components/fan-out-run.ts +39 -2
- package/src/components/fan-out-waves.test.ts +45 -1
- package/src/components/pr-environment.test.ts +249 -0
- package/src/components/pr-environment.ts +538 -0
- package/src/components/pr-hooks.test.ts +118 -0
- package/src/components/pr-hooks.ts +157 -0
- package/src/components/pr-pipeline.ts +130 -5
- package/src/components/pr-policy.test.ts +145 -0
- package/src/components/pr-policy.ts +211 -0
- package/src/components/pr-run.ts +5 -2
- package/src/gate-decision-bundle.test.ts +4 -3
- package/src/gate-decision.ts +39 -2
- package/src/gate-policy-bundle.test.ts +28 -0
- package/src/gate-policy.test.ts +162 -0
- package/src/gate-policy.ts +298 -0
- package/src/lifecycle/gate-checkpoint.ts +1 -1
- package/src/{workspace/trust → lifecycle}/gate-seal.ts +1 -1
- package/src/lifecycle/query.test.ts +91 -0
- package/src/lifecycle/query.ts +315 -0
- package/src/lifecycle/run-ledger.ts +18 -0
- package/src/lifecycle/target-lock.test.ts +195 -0
- package/src/lifecycle/target-lock.ts +347 -0
- package/src/op/activities/activity-contracts.ts +31 -0
- package/src/op/activities/index.ts +6 -0
- package/src/op/activities/review-agent.test.ts +240 -0
- package/src/op/activities/review-agent.ts +355 -0
- package/src/op/builders.ts +13 -0
- package/src/op/cron.ts +44 -0
- package/src/op/gate-alias.test.ts +2 -2
- package/src/op/gate-approval.test.ts +30 -9
- package/src/op/gate-approval.ts +23 -26
- package/src/op/gate-quorum.test.ts +139 -0
- package/src/op/gate-quorum.ts +248 -0
- package/src/op/gate-resume.ts +6 -3
- package/src/op/gate-review.ts +1 -1
- package/src/op/gate.ts +64 -23
- package/src/op/index.ts +13 -1
- package/src/op/local-executor.ts +9 -2
- package/src/op/op-waves-hook-targets.test.ts +126 -0
- package/src/op/op-waves-policy-hold.test.ts +66 -0
- package/src/op/op-waves-quorum.test.ts +101 -0
- package/src/op/op-waves-run.ts +233 -11
- package/src/op/op-waves.ts +83 -10
- package/src/op/schedule-overdue.test.ts +220 -0
- package/src/op/schedule-overdue.ts +161 -0
- package/src/override-gate-bundle.test.ts +1 -1
- package/src/override-gate.ts +2 -2
- package/src/pr-forge-bundle.test.ts +2 -2
- package/src/pr-forge.test.ts +33 -0
- package/src/pr-forge.ts +114 -0
- package/src/pr-loop-bundle.test.ts +2 -2
- package/src/pr-loop.test.ts +58 -0
- package/src/pr-loop.ts +146 -4
- package/src/pr-post.test.ts +141 -0
- package/src/pr-post.ts +159 -0
- package/src/wave-blocked-bundle.test.ts +25 -0
- package/src/wave-blocked.test.ts +146 -0
- package/src/wave-blocked.ts +271 -0
- package/src/wave-engine.ts +8 -0
- package/src/workspace/__fixtures__/two-member-workspace.ts +2 -2
- package/src/workspace/ci-branches.test.ts +62 -0
- package/src/workspace/ci-branches.ts +79 -0
- package/src/workspace/ci-cloud-identity.test.ts +53 -0
- package/src/workspace/ci-cloud-identity.ts +23 -0
- package/src/workspace/ci-locks.ts +16 -0
- package/src/workspace/ci-run-index.test.ts +2 -1
- package/src/workspace/ci-run.test.ts +107 -8
- package/src/workspace/ci-run.ts +72 -4
- package/src/workspace/declaration.schema.json +147 -0
- package/src/workspace/declaration.test.ts +76 -0
- package/src/workspace/declaration.ts +134 -2
- package/src/workspace/identity-gate-rule.ts +1 -1
- package/src/workspace/identity.ts +1 -1
- package/src/workspace/lineage-upgrade.ts +11 -8
- package/src/workspace/member-pipeline.test.ts +29 -0
- package/src/workspace/member-pipeline.ts +30 -0
- package/src/workspace/pr-report.schema.json +73 -2
- package/src/workspace/security.ts +4 -0
- package/src/workspace/status-gates.ts +9 -6
- package/src/workspace/status.ts +4 -1
- package/src/workspace/trust/gate-seal.test.ts +1 -1
- package/src/workspace/trust/seal.ts +2 -2
- package/dist/workspace/trust/gate-seal.d.ts.map +0 -1
- /package/dist/{workspace/trust → lifecycle}/gate-seal.js +0 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stage hooks on the pull-request path (#4288): `ci.hooks` in the workspace
|
|
3
|
+
* declaration, run by `chant components pr-plan`, `pr-record` and
|
|
4
|
+
* `pr-apply` with the runner an Op waves job uses (`runStageHooks`).
|
|
5
|
+
*
|
|
6
|
+
* The hooks are read from the declaration at the base the job measures the
|
|
7
|
+
* change from, never from the change itself, so a pull request cannot add,
|
|
8
|
+
* edit or remove a hook its own jobs run. The top level's hooks run first,
|
|
9
|
+
* then those of the member the job runs in. When the base has no workspace
|
|
10
|
+
* declaration no hook runs. When the declaration at base cannot be read and
|
|
11
|
+
* the working tree's names hooks, the job refuses to run rather than guess.
|
|
12
|
+
*
|
|
13
|
+
* The runs a hook sees are the components the change selects, in apply
|
|
14
|
+
* order: `{target}` and `CHANT_TARGET` name one, and `targets` match their
|
|
15
|
+
* names. After the plan, a `perRun` hook's `{plan}` is a JSON file with that
|
|
16
|
+
* component's members and the plans its steps hand the apply.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { join, relative, resolve } from "node:path";
|
|
21
|
+
import type { OpWaveHook, OpWaveHookMoment, OpWaveHooks } from "../op/op-waves";
|
|
22
|
+
import { PR_LOOP_HOOK_MOMENTS } from "../op/op-waves";
|
|
23
|
+
import { runStageHooks, type OpWaveExec, type OpWaveHookRun, type StageHooksRun } from "../op/op-waves-run";
|
|
24
|
+
import { readDeclaration, WorkspaceReadError, type Declaration } from "../workspace/declaration";
|
|
25
|
+
import { memberAtCwd } from "../workspace/security";
|
|
26
|
+
import { locateWorkspace } from "../workspace/which-chant";
|
|
27
|
+
import type { PrHookOutcome, PrMember } from "../pr-loop";
|
|
28
|
+
|
|
29
|
+
/** The hooks a pull-request job runs, by moment, and where they were read. */
|
|
30
|
+
export interface PrLoopHooks {
|
|
31
|
+
hooks: Partial<Record<OpWaveHookMoment, OpWaveHook[]>>;
|
|
32
|
+
/** The commit they were read at. */
|
|
33
|
+
base: string;
|
|
34
|
+
/** A line for the log when none run because the base has no declaration, or could not be read. */
|
|
35
|
+
note?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The hooks of one `ci.hooks` block at `moment`. */
|
|
39
|
+
function at(hooks: OpWaveHooks | null | undefined, moment: OpWaveHookMoment): OpWaveHook[] {
|
|
40
|
+
const [side, stage] = moment.split("-") as ["before" | "after", string];
|
|
41
|
+
return ((hooks?.[side] as Record<string, OpWaveHook[] | undefined> | undefined)?.[stage]) ?? [];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The hooks `declaration` gives a job run in `cwd`: the top level's, then the member's. */
|
|
45
|
+
export function prLoopHooksOf(declaration: Pick<Declaration, "ci" | "members">, member: string | null): Partial<Record<OpWaveHookMoment, OpWaveHook[]>> {
|
|
46
|
+
const own = member === null ? null : (declaration.members.find((m) => m.name === member)?.ciHooks ?? null);
|
|
47
|
+
const out: Partial<Record<OpWaveHookMoment, OpWaveHook[]>> = {};
|
|
48
|
+
for (const moment of PR_LOOP_HOOK_MOMENTS) {
|
|
49
|
+
const list = [...at(declaration.ci?.hooks, moment), ...at(own, moment)];
|
|
50
|
+
if (list.length > 0) out[moment] = list;
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Whether the working tree's declaration names any `ci.hooks`. */
|
|
56
|
+
function checkoutNamesHooks(cwd: string): boolean {
|
|
57
|
+
try {
|
|
58
|
+
const located = locateWorkspace(cwd);
|
|
59
|
+
const d = readDeclaration(located.tree);
|
|
60
|
+
return Object.keys(prLoopHooksOf(d, memberAtCwd({ declaration: d, rootOnDisk: located.rootOnDisk }, cwd))).length > 0;
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The hooks a pull-request job in `cwd` runs, read from the workspace
|
|
68
|
+
* declaration at `base`. Throws when the declaration at base cannot be read
|
|
69
|
+
* and the working tree's names hooks.
|
|
70
|
+
*/
|
|
71
|
+
export function prLoopHooksAt(cwd: string, base: string): PrLoopHooks {
|
|
72
|
+
let declaration: Declaration;
|
|
73
|
+
let rootOnDisk: string;
|
|
74
|
+
try {
|
|
75
|
+
const located = locateWorkspace(cwd, base);
|
|
76
|
+
declaration = readDeclaration(located.tree);
|
|
77
|
+
rootOnDisk = located.rootOnDisk;
|
|
78
|
+
} catch (err) {
|
|
79
|
+
const missing = err instanceof WorkspaceReadError && (err.code === "declaration-missing" || err.code === "not-a-git-repository");
|
|
80
|
+
const why = err instanceof Error ? err.message : String(err);
|
|
81
|
+
if (checkoutNamesHooks(cwd)) {
|
|
82
|
+
if (missing) return { hooks: {}, base, note: `ci.hooks: the base ${base.slice(0, 12)} has no workspace declaration, so the hooks this change declares do not run until it merges` };
|
|
83
|
+
throw new Error(`ci.hooks are read from the workspace declaration at ${base.slice(0, 12)}, and it could not be read (${why}), so no hook can be trusted to run`);
|
|
84
|
+
}
|
|
85
|
+
return missing ? { hooks: {}, base } : { hooks: {}, base, note: `ci.hooks: the declaration at ${base.slice(0, 12)} could not be read (${why}); this checkout names no hooks, so none run` };
|
|
86
|
+
}
|
|
87
|
+
return { hooks: prLoopHooksOf(declaration, memberAtCwd({ declaration, rootOnDisk }, cwd)), base };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Where a pull-request job's hooks run, and what they are told. */
|
|
91
|
+
export interface PrHookPlace {
|
|
92
|
+
/** The gate's op, `pr-<n>`: `{op}` and `CHANT_OP_WAVES`. */
|
|
93
|
+
op: string;
|
|
94
|
+
/** The environment: `{wave}`, `CHANT_WAVE` and `CHANT_ENV`. */
|
|
95
|
+
env: string;
|
|
96
|
+
stage: "plan" | "apply" | "record";
|
|
97
|
+
/** The report directory, relative to `cwd`; the runs and plan files go under `<dir>/hooks`. */
|
|
98
|
+
dir: string;
|
|
99
|
+
cwd: string;
|
|
100
|
+
exec?: OpWaveExec;
|
|
101
|
+
log?: (line: string) => void;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** What one moment's hooks came to on the pull-request path. */
|
|
105
|
+
export interface PrHooksRun {
|
|
106
|
+
outcomes: PrHookOutcome[];
|
|
107
|
+
failed?: string;
|
|
108
|
+
holds: string[];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Run the hooks of `moment` for `components` (#4288). `plans`, after the
|
|
113
|
+
* plan, gives each component's members and step plans, written where a
|
|
114
|
+
* `perRun` hook's `{plan}` points.
|
|
115
|
+
*/
|
|
116
|
+
export function runPrHooks(
|
|
117
|
+
read: PrLoopHooks,
|
|
118
|
+
moment: OpWaveHookMoment,
|
|
119
|
+
place: PrHookPlace,
|
|
120
|
+
components: readonly string[],
|
|
121
|
+
planned?: { members: readonly PrMember[]; plans: Record<string, Record<string, unknown>> },
|
|
122
|
+
): PrHooksRun {
|
|
123
|
+
const hooks = read.hooks[moment];
|
|
124
|
+
if (!hooks || hooks.length === 0) return { outcomes: [], holds: [] };
|
|
125
|
+
// Relative to cwd, as an Op waves job's paths are, whether --output was given relative or absolute.
|
|
126
|
+
const dir = relative(place.cwd, resolve(place.cwd, place.dir, "hooks")) || ".";
|
|
127
|
+
mkdirSync(join(place.cwd, dir, "plans"), { recursive: true });
|
|
128
|
+
const runs: OpWaveHookRun[] = components.map((component) => {
|
|
129
|
+
if (!planned) return { target: component, op: place.op };
|
|
130
|
+
const plan = join(dir, "plans", `${component.replace(/[^A-Za-z0-9._-]/g, "_")}.json`);
|
|
131
|
+
const body = { component, members: planned.members.filter((m) => m.component === component), plans: planned.plans[component] ?? {} };
|
|
132
|
+
writeFileSync(join(place.cwd, plan), JSON.stringify(body, null, 2) + "\n");
|
|
133
|
+
return { target: component, op: place.op, plan };
|
|
134
|
+
});
|
|
135
|
+
const runsFile = join(dir, "runs.json");
|
|
136
|
+
writeFileSync(join(place.cwd, runsFile), JSON.stringify({ version: 1, op: place.op, env: place.env, stage: place.stage, base: read.base, hook: moment, runs }, null, 2) + "\n");
|
|
137
|
+
const ran: StageHooksRun = runStageHooks(moment, hooks, {
|
|
138
|
+
name: place.op,
|
|
139
|
+
op: place.op,
|
|
140
|
+
wave: place.env,
|
|
141
|
+
runs,
|
|
142
|
+
runsFile,
|
|
143
|
+
holdFile: join(dir, "hold.json"),
|
|
144
|
+
env: { CHANT_ENV: place.env, CHANT_PR_STAGE: place.stage, CHANT_HOOKS_BASE: read.base },
|
|
145
|
+
cwd: place.cwd,
|
|
146
|
+
...(place.exec ? { exec: place.exec } : {}),
|
|
147
|
+
...(place.log ? { log: place.log } : {}),
|
|
148
|
+
});
|
|
149
|
+
const outcomes = ran.outcomes.map((o): PrHookOutcome => ({
|
|
150
|
+
moment: o.moment,
|
|
151
|
+
name: o.name,
|
|
152
|
+
...(o.target ? { target: o.target } : {}),
|
|
153
|
+
exit: o.exit,
|
|
154
|
+
outcome: o.outcome === "failed" ? "failed" : o.outcome === "held" ? "held" : "passed",
|
|
155
|
+
}));
|
|
156
|
+
return { outcomes, ...(ran.failed ? { failed: ran.failed } : {}), holds: ran.holds };
|
|
157
|
+
}
|
|
@@ -8,8 +8,11 @@
|
|
|
8
8
|
* values they read from the forge live here so the three cannot disagree.
|
|
9
9
|
*
|
|
10
10
|
* - The plan job runs `chant components pr-plan` against the pull request's
|
|
11
|
-
* target commit. It
|
|
12
|
-
*
|
|
11
|
+
* target commit. It writes nothing to `chant/lifecycle` and holds no forge
|
|
12
|
+
* token, since it runs the pull request's code (#4295): it writes the note
|
|
13
|
+
* and the status into its report with `--no-status`, and the `plan-note`
|
|
14
|
+
* job after it posts them with `chant components pr-post`
|
|
15
|
+
* ({@link prLoopPostJob}, ../ci-handover.ts).
|
|
13
16
|
* - The record job (#4168 step 1, the default) runs `chant components
|
|
14
17
|
* pr-record` against the commit the push replaced. It plans the same
|
|
15
18
|
* members on the merged commit, stores the change set and writes the merge
|
|
@@ -81,10 +84,12 @@
|
|
|
81
84
|
import type { ForgeKind } from "../pr-forge";
|
|
82
85
|
import { PR_APPLY_GATE } from "../pr-loop";
|
|
83
86
|
import { CI_IMAGE, GITHUB_JOB_TOKEN } from "../ci-jobs";
|
|
87
|
+
import { withoutForgeTokens } from "../ci-handover";
|
|
84
88
|
import { EMPTY_TREE_SHA } from "./changed-files";
|
|
85
89
|
import { layerWaves } from "../gated-waves";
|
|
86
90
|
import type { DriverComponent } from "./driver";
|
|
87
91
|
import type { GatedWavePipelineOptions } from "./gated-wave-pipeline";
|
|
92
|
+
import type { PipelineCloudIdentity } from "../ci-identity";
|
|
88
93
|
|
|
89
94
|
/** The variable `chant ci run` reads its runner key from (`RUNNER_KEY_ENV` in ../workspace/ci-green.ts). */
|
|
90
95
|
const RUNNER_KEY_ENV = "CHANT_RUNNER_KEY";
|
|
@@ -175,11 +180,27 @@ export interface PrLoopPipelineOptions {
|
|
|
175
180
|
* wave. Set from `--wave-gate` beside `--pr-loop` ({@link prLoopWithWaves}).
|
|
176
181
|
*/
|
|
177
182
|
waveGate?: string;
|
|
183
|
+
/**
|
|
184
|
+
* The plan and apply cloud identities (#4296), from `ci.identity.cloud`
|
|
185
|
+
* resolved for the pipeline's environment and member
|
|
186
|
+
* (`resolveCloudIdentity` in ../ci-identity.ts). The plan job trades its
|
|
187
|
+
* OIDC token for the plan identity before the checkout, the record job
|
|
188
|
+
* for the plan identity, and the apply jobs for the apply identity.
|
|
189
|
+
* Absent: no job requests an OIDC token.
|
|
190
|
+
*/
|
|
191
|
+
identity?: PipelineCloudIdentity;
|
|
178
192
|
/**
|
|
179
193
|
* Which GitHub-shaped forge the workflow is for. Set by the forgejo
|
|
180
194
|
* generator, which reuses the github one; a caller never needs to.
|
|
181
195
|
*/
|
|
182
196
|
forge?: "github" | "forgejo";
|
|
197
|
+
/**
|
|
198
|
+
* An environment per open pull request (#4298, `ci.prEnvironment`): an
|
|
199
|
+
* `env-up` job on each opened, updated or reopened pull request, an
|
|
200
|
+
* `env-down` job when it closes, and an `env-sweep` job on a schedule
|
|
201
|
+
* ({@link prEnvJobs}). Set from `--pr-env` beside `--pr-loop`. Default false.
|
|
202
|
+
*/
|
|
203
|
+
prEnvironment?: boolean;
|
|
183
204
|
}
|
|
184
205
|
|
|
185
206
|
/** One job's command and the forge values it reads, as environment variables for its step. */
|
|
@@ -264,16 +285,18 @@ export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelin
|
|
|
264
285
|
...(options.reportUrl ? ["--report-url", JSON.stringify(options.reportUrl)] : []),
|
|
265
286
|
];
|
|
266
287
|
const runEnv = (env: Record<string, string>): Record<string, string> => (github ? { ...env, [RUNNER_KEY_ENV]: `\${{ secrets.${RUNNER_KEY_ENV} }}` } : env);
|
|
288
|
+
// #4295: the plan runs the pull request's code, so it gets no forge token. It writes the note and the
|
|
289
|
+
// status into its report (--no-status), and the plan-note job posts them (prLoopPostJob).
|
|
267
290
|
const planEnv = github
|
|
268
|
-
? { BASE_SHA: "${{ github.event.pull_request.base.sha }}", PR_NUMBER: "${{ github.event.pull_request.number }}", ...jobEnv }
|
|
269
|
-
: { BASE_SHA: "$CI_MERGE_REQUEST_DIFF_BASE_SHA", PR_NUMBER: "$CI_MERGE_REQUEST_IID", ...jobEnv };
|
|
291
|
+
? { BASE_SHA: "${{ github.event.pull_request.base.sha }}", PR_NUMBER: "${{ github.event.pull_request.number }}", ...withoutForgeTokens(jobEnv) }
|
|
292
|
+
: { BASE_SHA: "$CI_MERGE_REQUEST_DIFF_BASE_SHA", PR_NUMBER: "$CI_MERGE_REQUEST_IID", ...withoutForgeTokens(jobEnv) };
|
|
270
293
|
const greenBranch = github ? JSON.stringify(options.branch ?? "main") : options.branch ? JSON.stringify(options.branch) : "$CI_DEFAULT_BRANCH";
|
|
271
294
|
const applyEnv = github ? { BASE_SHA: "${{ github.event.before }}", ...jobEnv } : { BASE_SHA: "$CI_COMMIT_BEFORE_SHA", ...jobEnv };
|
|
272
295
|
return [
|
|
273
296
|
{
|
|
274
297
|
jobName: "plan",
|
|
275
298
|
// With `waveGate` the plan's note says the later waves may stop at their own gates (#4003).
|
|
276
|
-
command: ["chant", "components", "pr-plan", "--base", '"$BASE_SHA"', "--pr", '"$PR_NUMBER"', ...common, ...(options.waveGate ? ["--wave-gate", options.waveGate] : [])].join(" "),
|
|
299
|
+
command: ["chant", "components", "pr-plan", "--base", '"$BASE_SHA"', "--pr", '"$PR_NUMBER"', ...common, ...(options.waveGate ? ["--wave-gate", options.waveGate] : []), "--no-status"].join(" "),
|
|
277
300
|
env: planEnv,
|
|
278
301
|
...build,
|
|
279
302
|
run: { command: ciRun("plan").join(" "), env: runEnv(planEnv) },
|
|
@@ -304,6 +327,35 @@ export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelin
|
|
|
304
327
|
];
|
|
305
328
|
}
|
|
306
329
|
|
|
330
|
+
/** The name of the job that posts the plan's note and status (#4295). */
|
|
331
|
+
export const PR_PLAN_NOTE_JOB = "plan-note";
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* The plan-note job's command and the values it reads (#4295). It runs in a
|
|
335
|
+
* fresh job after the plan, at the target branch's commit, and posts the
|
|
336
|
+
* note and the `chant/plan` status from the report the plan job handed on,
|
|
337
|
+
* with the forge token the plan job never held. `HEAD_SHA` is the commit
|
|
338
|
+
* the plan job checked out, `PR_NUMBER` the pull request, and on GitHub and
|
|
339
|
+
* Forgejo `PLAN_RESULT` the plan job's result, which fails the status when
|
|
340
|
+
* the plan job failed. `BASE_SHA` is the commit the note job checks out on
|
|
341
|
+
* GitLab, whose runner checks out the pipeline's own commit.
|
|
342
|
+
*/
|
|
343
|
+
export function prLoopPostJob(forge: ForgeKind, env: string, member?: string): { command: string; env: Record<string, string> } {
|
|
344
|
+
const github = forge !== "gitlab";
|
|
345
|
+
const command = [
|
|
346
|
+
"chant", "components", "pr-post", "--pr", '"$PR_NUMBER"', "--head", '"$HEAD_SHA"',
|
|
347
|
+
"--env", env, "--output", PR_LOOP_REPORT_DIR, "--forge", forge,
|
|
348
|
+
...(member ? ["--member", member] : []),
|
|
349
|
+
...(github ? ["--result", '"$PLAN_RESULT"'] : []),
|
|
350
|
+
].join(" ");
|
|
351
|
+
return {
|
|
352
|
+
command,
|
|
353
|
+
env: github
|
|
354
|
+
? { PR_NUMBER: "${{ github.event.pull_request.number }}", HEAD_SHA: "${{ github.sha }}", PLAN_RESULT: "${{ needs.plan.result }}", GITHUB_TOKEN: GITHUB_JOB_TOKEN }
|
|
355
|
+
: { PR_NUMBER: "$CI_MERGE_REQUEST_IID", HEAD_SHA: "$CI_COMMIT_SHA", BASE_SHA: "$CI_MERGE_REQUEST_DIFF_BASE_SHA" },
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
307
359
|
/**
|
|
308
360
|
* What starts the apply under apply on merge (#4168 step 9): on GitHub the
|
|
309
361
|
* tick's workflow finishing (`workflow_run`, since tags its token pushes start
|
|
@@ -450,3 +502,76 @@ export function gitlabBaseFallback(branch?: string): string {
|
|
|
450
502
|
export function githubBaseFallback(branch?: string): string {
|
|
451
503
|
return pushBaseFallback(JSON.stringify(branch ?? "main"), "$GITHUB_SHA");
|
|
452
504
|
}
|
|
505
|
+
|
|
506
|
+
/** Where the pull request environment jobs (#4298) write their reports, kept as a CI artifact. */
|
|
507
|
+
export const PR_ENV_REPORT_DIR = ".chant/pr-env";
|
|
508
|
+
|
|
509
|
+
/** The cron the generated `env-sweep` job runs on (#4298): hourly, at a minute off the hour. */
|
|
510
|
+
export const PR_ENV_SWEEP_CRON = "17 * * * *";
|
|
511
|
+
|
|
512
|
+
/** One job of a pull request's environment (#4298): its command and the forge values it reads. */
|
|
513
|
+
export interface PrEnvJob {
|
|
514
|
+
jobName: "env-up" | "env-post" | "env-down" | "env-sweep";
|
|
515
|
+
/** The command line. `env-up` reads `$BASE_SHA` and `$PR_NUMBER`, `env-down` `$PR_NUMBER`. */
|
|
516
|
+
command: string;
|
|
517
|
+
/** Values the command reads, as the forge spells them. `CHANT_ENV` is the pull request's environment. */
|
|
518
|
+
env: Record<string, string>;
|
|
519
|
+
/** With `build`: the build and lint lines, run before the command. */
|
|
520
|
+
build?: string[];
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** The name of the job that keeps an environment's record and posts its note (#4298, #4295). */
|
|
524
|
+
export const PR_ENV_POST_JOB = "env-post";
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* The jobs of a pull request's environment (#4298), when `prEnvironment` is
|
|
528
|
+
* on, in this order:
|
|
529
|
+
*
|
|
530
|
+
* - `env-up` runs `chant components pr-env up --no-push`. It runs the
|
|
531
|
+
* change's code, so it gets no forge token (#4295): it passes no
|
|
532
|
+
* `--forge`, and it hands its report and its record on.
|
|
533
|
+
* - `env-post` runs `pr-env post` in a fresh job at the target branch's
|
|
534
|
+
* commit, with the token: it keeps the record on chant/lifecycle and posts
|
|
535
|
+
* the note and the `chant/pr-env` status. On GitHub and Forgejo it reads
|
|
536
|
+
* the up job's result as `UP_RESULT`.
|
|
537
|
+
* - `env-down` runs `pr-env down --reason closed`, and `env-sweep` runs
|
|
538
|
+
* `pr-env sweep`. Neither builds the change's code: a teardown reads the
|
|
539
|
+
* markers off the live resources.
|
|
540
|
+
*
|
|
541
|
+
* Each writes its report under {@link PR_ENV_REPORT_DIR}.
|
|
542
|
+
*/
|
|
543
|
+
export function prEnvJobs(forge: ForgeKind, options: PrLoopPipelineOptions = {}, member?: string): PrEnvJob[] {
|
|
544
|
+
const github = forge !== "gitlab";
|
|
545
|
+
const out = ["--output", PR_ENV_REPORT_DIR, ...(member ? ["--member", member] : [])];
|
|
546
|
+
const common = ["--forge", forge, ...out];
|
|
547
|
+
const token: Record<string, string> = github ? { GITHUB_TOKEN: GITHUB_JOB_TOKEN } : {};
|
|
548
|
+
const prNumber = github ? "${{ github.event.pull_request.number }}" : "$CI_MERGE_REQUEST_IID";
|
|
549
|
+
const prEnv = { CHANT_ENV: `pr-${prNumber}` };
|
|
550
|
+
const buildLines = prLoopBuildLines('"$CHANT_ENV"', options);
|
|
551
|
+
return [
|
|
552
|
+
{
|
|
553
|
+
jobName: "env-up",
|
|
554
|
+
// #4295: no --forge and no token; the record stays in the report directory for env-post.
|
|
555
|
+
command: ["chant", "components", "pr-env", "up", "--base", '"$BASE_SHA"', "--pr", '"$PR_NUMBER"', ...out, "--no-push"].join(" "),
|
|
556
|
+
env: { BASE_SHA: github ? "${{ github.event.pull_request.base.sha }}" : "$CI_MERGE_REQUEST_DIFF_BASE_SHA", PR_NUMBER: prNumber, ...prEnv },
|
|
557
|
+
...(buildLines.length > 0 ? { build: buildLines } : {}),
|
|
558
|
+
},
|
|
559
|
+
{
|
|
560
|
+
jobName: PR_ENV_POST_JOB,
|
|
561
|
+
command: ["chant", "components", "pr-env", "post", "--pr", '"$PR_NUMBER"', "--head", '"$HEAD_SHA"', ...common, ...(github ? ["--result", '"$UP_RESULT"'] : [])].join(" "),
|
|
562
|
+
env: github
|
|
563
|
+
? { PR_NUMBER: prNumber, HEAD_SHA: "${{ github.sha }}", UP_RESULT: "${{ needs.env-up.result }}", ...token }
|
|
564
|
+
: { PR_NUMBER: prNumber, HEAD_SHA: "$CI_COMMIT_SHA", BASE_SHA: "$CI_MERGE_REQUEST_DIFF_BASE_SHA" },
|
|
565
|
+
},
|
|
566
|
+
{
|
|
567
|
+
jobName: "env-down",
|
|
568
|
+
command: ["chant", "components", "pr-env", "down", "--pr", '"$PR_NUMBER"', "--reason", "closed", ...common].join(" "),
|
|
569
|
+
env: { PR_NUMBER: prNumber, ...prEnv, ...token },
|
|
570
|
+
},
|
|
571
|
+
{
|
|
572
|
+
jobName: "env-sweep",
|
|
573
|
+
command: ["chant", "components", "pr-env", "sweep", ...common].join(" "),
|
|
574
|
+
env: { ...token },
|
|
575
|
+
},
|
|
576
|
+
];
|
|
577
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ci.policy` on the pull-request path (#4294): the settings and the policy
|
|
3
|
+
* read at base, the engine run over the change set, and a denial decided
|
|
4
|
+
* against the override ledger. The engine is a fake that prints opa's
|
|
5
|
+
* result shape; nothing reaches a network.
|
|
6
|
+
*/
|
|
7
|
+
import { afterAll, describe, expect, test } from "vitest";
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
9
|
+
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { parseDeclaration, WorkspaceReadError } from "../workspace/declaration";
|
|
13
|
+
import { composeChangeSet } from "../change-set";
|
|
14
|
+
import { memoryGateLedgerPort } from "../op/gate";
|
|
15
|
+
import { overrideDigest } from "../override-gate";
|
|
16
|
+
import type { GatePolicyExec } from "../gate-policy";
|
|
17
|
+
import { decidePrPolicyOverride, prLoopPolicyAt, prPolicyReport, runPrPolicy, spendPrPolicyOverride, type PrPolicyInput } from "./pr-policy";
|
|
18
|
+
|
|
19
|
+
const scratch: string[] = [];
|
|
20
|
+
afterAll(() => {
|
|
21
|
+
for (const d of scratch) rmSync(d, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
const tmp = (): string => {
|
|
24
|
+
const d = realpathSync(mkdtempSync(join(tmpdir(), "pr-policy-")));
|
|
25
|
+
scratch.push(d);
|
|
26
|
+
return d;
|
|
27
|
+
};
|
|
28
|
+
const git = (cwd: string, ...args: string[]): string => execFileSync("git", args, { cwd, encoding: "utf-8" }).trim();
|
|
29
|
+
|
|
30
|
+
const declaration = (policy: unknown) => ({
|
|
31
|
+
name: "acme",
|
|
32
|
+
schema: 1,
|
|
33
|
+
members: [{ name: "infra", dir: "infra", kind: "other", because: "test" }],
|
|
34
|
+
...(policy ? { ci: { policy } } : {}),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/** A repo whose base declares `ci.policy` with an opa policy and one overrider. */
|
|
38
|
+
function repoWithPolicy(): { repo: string; base: string } {
|
|
39
|
+
const repo = tmp();
|
|
40
|
+
git(repo, "init", "-q", "-b", "main");
|
|
41
|
+
git(repo, "config", "user.email", "t@example.com");
|
|
42
|
+
git(repo, "config", "user.name", "t");
|
|
43
|
+
mkdirSync(join(repo, "infra"));
|
|
44
|
+
mkdirSync(join(repo, "policy"));
|
|
45
|
+
mkdirSync(join(repo, ".chant"));
|
|
46
|
+
writeFileSync(join(repo, "chant.workspace.json"), JSON.stringify(declaration({ engine: "opa", path: "policy" })));
|
|
47
|
+
writeFileSync(join(repo, "policy", "main.rego"), "package main\n# base\n");
|
|
48
|
+
writeFileSync(join(repo, ".chant", "overriders"), "github:carol\n");
|
|
49
|
+
git(repo, "add", ".");
|
|
50
|
+
git(repo, "commit", "-q", "-m", "base");
|
|
51
|
+
return { repo, base: git(repo, "rev-parse", "HEAD") };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const doc = composeChangeSet([]);
|
|
55
|
+
const input = (base: string): PrPolicyInput => ({ version: 1, stage: "apply", op: "pr-7", gate: "pr-apply", env: "prod", pr: 7, base, head: "h".repeat(40), changeSet: doc });
|
|
56
|
+
const opaOut = (value: unknown): string => JSON.stringify({ result: [{ expressions: [{ value }] }] });
|
|
57
|
+
|
|
58
|
+
describe("ci.policy in the declaration (#4294)", () => {
|
|
59
|
+
test("parses at the top level, and refuses an unattended outcome", () => {
|
|
60
|
+
const read = (policy: unknown) => parseDeclaration(JSON.stringify(declaration(policy)), "chant.workspace.json", "0.124.41");
|
|
61
|
+
expect(read({ engine: "opa", path: "rules" }).ci?.policy).toEqual({ engine: "opa", path: "rules" });
|
|
62
|
+
expect(() => read({ outcomes: ["allow_unattended"] })).toThrow(WorkspaceReadError);
|
|
63
|
+
expect(() => read({ engine: "rego" })).toThrow(/conftest or opa/);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("prLoopPolicyAt and runPrPolicy (#4294)", () => {
|
|
68
|
+
test("the settings and the policy come from the base: the change's edits to either do not run", () => {
|
|
69
|
+
const { repo, base } = repoWithPolicy();
|
|
70
|
+
writeFileSync(join(repo, "chant.workspace.json"), JSON.stringify(declaration({ engine: "opa", path: "policy", command: ["true", "{input}"] })));
|
|
71
|
+
writeFileSync(join(repo, "policy", "main.rego"), "package main\n# the change loosens it\n");
|
|
72
|
+
const read = prLoopPolicyAt(join(repo, "infra"), base);
|
|
73
|
+
expect(read.settings).toEqual({ engine: "opa", path: "policy" });
|
|
74
|
+
let seen = "";
|
|
75
|
+
let fed: unknown;
|
|
76
|
+
const exec: GatePolicyExec = (argv) => {
|
|
77
|
+
seen = readFileSync(join(argv[argv.indexOf("--data") + 1]!, "main.rego"), "utf-8");
|
|
78
|
+
fed = JSON.parse(readFileSync(argv[argv.indexOf("--input") + 1]!, "utf-8"));
|
|
79
|
+
return { status: 0, stdout: opaOut({ deny: ["public bucket"] }), stderr: "" };
|
|
80
|
+
};
|
|
81
|
+
const verdict = runPrPolicy(read, input(base), exec)!;
|
|
82
|
+
expect(seen).toBe("package main\n# base\n");
|
|
83
|
+
expect(fed).toMatchObject({ op: "pr-7", stage: "apply", changeSet: { digest: doc.digest } });
|
|
84
|
+
expect(prPolicyReport(verdict, base)).toEqual({ result: "denied", engine: "opa", base, denials: [{ rule: "main.deny", message: "public bucket" }], rules: ["main.deny"], holds: [], warnings: [] });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("no policy at base runs none, and says so when the change declares one", () => {
|
|
88
|
+
const repo = tmp();
|
|
89
|
+
git(repo, "init", "-q", "-b", "main");
|
|
90
|
+
git(repo, "config", "user.email", "t@example.com");
|
|
91
|
+
git(repo, "config", "user.name", "t");
|
|
92
|
+
writeFileSync(join(repo, "README"), "x");
|
|
93
|
+
git(repo, "add", ".");
|
|
94
|
+
git(repo, "commit", "-q", "-m", "base");
|
|
95
|
+
const base = git(repo, "rev-parse", "HEAD");
|
|
96
|
+
expect(prLoopPolicyAt(repo, base)).toEqual({ base });
|
|
97
|
+
writeFileSync(join(repo, "chant.workspace.json"), JSON.stringify(declaration({ engine: "opa" })));
|
|
98
|
+
const read = prLoopPolicyAt(repo, base);
|
|
99
|
+
expect(read.settings).toBeUndefined();
|
|
100
|
+
expect(read.note).toMatch(/runs once it merges/);
|
|
101
|
+
expect(runPrPolicy(read, input(base))).toBeUndefined();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("a policy directory the base lacks is an error, which stops the apply", () => {
|
|
105
|
+
const { repo, base } = repoWithPolicy();
|
|
106
|
+
const read = { ...prLoopPolicyAt(repo, base), settings: { engine: "opa" as const, path: "missing" } };
|
|
107
|
+
expect(runPrPolicy(read, input(base), () => ({ status: 0, stdout: opaOut({}), stderr: "" }))).toMatchObject({ result: "error", error: "the policy directory missing does not exist at base" });
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("a denial and its override (#4294)", () => {
|
|
112
|
+
test("no override records the denial once; a listed overrider's override counts; spent, it counts no more", async () => {
|
|
113
|
+
const { repo, base } = repoWithPolicy();
|
|
114
|
+
const port = memoryGateLedgerPort();
|
|
115
|
+
const ask = (record: boolean) =>
|
|
116
|
+
decidePrPolicyOverride({ port, subject: "pr-7", digest: doc.digest, rules: ["main.deny"], base, cwd: repo, sealed: false, record, now: "2026-10-22T10:00:00Z" });
|
|
117
|
+
|
|
118
|
+
const plan = await ask(false);
|
|
119
|
+
expect(plan).toMatchObject({ status: "none", recorded: false, overriders: ["github:carol"], command: 'chant approve policy-override pr-7 --rule main.deny --reason "<why>"' });
|
|
120
|
+
expect(port.appended).toHaveLength(0);
|
|
121
|
+
|
|
122
|
+
expect(await ask(true)).toMatchObject({ status: "none", recorded: true });
|
|
123
|
+
expect(port.appended[0]).toMatchObject({ op: "policy-override", gate: "pr-7", plan: doc.digest, rules: ["main.deny"] });
|
|
124
|
+
// The denial stands, so a second refusal records no other.
|
|
125
|
+
expect(await ask(true)).toMatchObject({ status: "none", recorded: false });
|
|
126
|
+
|
|
127
|
+
const od = overrideDigest("pr-7", doc.digest, ["main.deny"]);
|
|
128
|
+
await port.appendResolution!({ op: "policy-override", gate: "pr-7", resolvedBy: "github:dave", timestamp: "2026-10-22T11:00:00Z", planDigest: od, note: "not listed" });
|
|
129
|
+
expect(await ask(true)).toMatchObject({ status: "none", refusals: [expect.stringContaining("github:dave is not listed")] });
|
|
130
|
+
|
|
131
|
+
await port.appendResolution!({ op: "policy-override", gate: "pr-7", resolvedBy: "github:carol", timestamp: "2026-10-22T12:00:00Z", planDigest: od, note: "the bucket is the public docs site" });
|
|
132
|
+
const counted = await ask(true);
|
|
133
|
+
expect(counted).toEqual({ status: "overridden", override: { by: "github:carol", at: "2026-10-22T12:00:00Z", reason: "the bucket is the public docs site", digest: od } });
|
|
134
|
+
|
|
135
|
+
if (counted.status !== "overridden") throw new Error("expected an override");
|
|
136
|
+
await spendPrPolicyOverride(port, "pr-7", counted.override, { now: "2026-10-22T12:30:00Z", commit: "c".repeat(40) });
|
|
137
|
+
expect(port.applied).toEqual([expect.objectContaining({ op: "policy-override", gate: "pr-7", planDigest: od, approvedBy: "github:carol", commit: "c".repeat(40) })]);
|
|
138
|
+
expect(await ask(true)).toMatchObject({ status: "none", refusals: expect.arrayContaining([expect.stringContaining("is spent")]) });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("an applied record that does not reach the remote throws, so nothing applies", async () => {
|
|
142
|
+
const port = { ...memoryGateLedgerPort(), appendApplied: async () => ({ pushed: false, pushWarning: 'the push of chant/lifecycle to "origin" failed' }) };
|
|
143
|
+
await expect(spendPrPolicyOverride(port, "pr-7", { by: "github:carol", at: "2026-10-22T12:00:00Z", reason: "ok", digest: "sha256:1" })).rejects.toThrow(/did not reach the remote/);
|
|
144
|
+
});
|
|
145
|
+
});
|