@intentius/chant 0.124.40 → 0.124.42
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 +18 -1
- package/dist/cli/handlers/fan-out.d.ts.map +1 -1
- package/dist/cli/handlers/fan-out.js +42 -5
- 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 +76 -0
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/pr.js +615 -55
- 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/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/run.js +11 -0
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +49 -4
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +46 -2
- package/dist/cli/registry.d.ts +31 -2
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/capability.d.ts +47 -0
- package/dist/components/capability.d.ts.map +1 -1
- package/dist/components/capability.js +44 -0
- package/dist/components/cli-support.d.ts +6 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/cli-support.js +8 -1
- package/dist/components/driver.d.ts +10 -0
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/driver.js +52 -2
- package/dist/components/fan-out-run.d.ts +37 -0
- package/dist/components/fan-out-run.d.ts.map +1 -1
- package/dist/components/fan-out-run.js +44 -4
- package/dist/components/gate-plan.d.ts +2 -0
- package/dist/components/gate-plan.d.ts.map +1 -1
- package/dist/components/gate-plan.js +1 -0
- 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 +92 -2
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/components/pr-pipeline.js +150 -21
- 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 +6 -0
- package/dist/components/pr-run.d.ts.map +1 -1
- package/dist/components/pr-run.js +8 -3
- package/dist/components/wave-plan.d.ts +14 -1
- package/dist/components/wave-plan.d.ts.map +1 -1
- package/dist/components/wave-plan.js +34 -2
- 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/pr-refs.d.ts +10 -0
- package/dist/op/activities/pr-refs.d.ts.map +1 -0
- package/dist/op/activities/pr-refs.js +26 -0
- package/dist/op/activities/predict-behaviour.d.ts +2 -4
- package/dist/op/activities/predict-behaviour.d.ts.map +1 -1
- package/dist/op/activities/predict-behaviour.js +2 -21
- 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 +28 -43
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves-run.js +170 -120
- 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/op/stage-hooks.d.ts +132 -0
- package/dist/op/stage-hooks.d.ts.map +1 -0
- package/dist/op/stage-hooks.js +142 -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 +64 -0
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/pr-forge.js +331 -44
- package/dist/pr-loop.d.ts +107 -1
- package/dist/pr-loop.d.ts.map +1 -1
- package/dist/pr-loop.js +84 -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/pr-requires.d.ts +38 -0
- package/dist/pr-requires.d.ts.map +1 -0
- package/dist/pr-requires.js +48 -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 +243 -243
- 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/checks/ci-workflow.d.ts.map +1 -1
- package/dist/workspace/checks/ci-workflow.js +19 -4
- 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-init.d.ts.map +1 -1
- package/dist/workspace/ci-init.js +5 -4
- 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 +66 -4
- package/dist/workspace/ci-workflow-at.d.ts +14 -0
- package/dist/workspace/ci-workflow-at.d.ts.map +1 -0
- package/dist/workspace/ci-workflow-at.js +17 -0
- package/dist/workspace/ci-workflow.d.ts +11 -3
- package/dist/workspace/ci-workflow.d.ts.map +1 -1
- package/dist/workspace/ci-workflow.js +10 -2
- package/dist/workspace/declaration.d.ts +69 -3
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.js +72 -1
- package/dist/workspace/declaration.schema.json +149 -2
- 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 +94 -3
- package/dist/workspace/reason-codes.d.ts +1 -1
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/reason-codes.js +1 -1
- 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 +18 -1
- package/src/cli/handlers/fan-out.ts +43 -6
- 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-before-merge.test.ts +276 -0
- 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 +605 -61
- package/src/cli/handlers/promote.ts +9 -0
- package/src/cli/handlers/query.ts +78 -0
- package/src/cli/handlers/run.ts +10 -0
- package/src/cli/main.ts +49 -4
- package/src/cli/parse-args.ts +34 -2
- package/src/cli/registry.ts +31 -2
- package/src/components/capability-targets.test.ts +162 -0
- package/src/components/capability.ts +71 -0
- package/src/components/cli-support.ts +14 -1
- package/src/components/driver.ts +54 -3
- package/src/components/fan-out-run.ts +58 -3
- package/src/components/fan-out-waves.test.ts +45 -1
- package/src/components/gate-plan.ts +3 -0
- 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 +194 -21
- package/src/components/pr-policy.test.ts +145 -0
- package/src/components/pr-policy.ts +211 -0
- package/src/components/pr-run.ts +14 -2
- package/src/components/wave-plan.ts +38 -3
- 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/pr-refs.ts +28 -0
- package/src/op/activities/predict-behaviour.ts +3 -23
- 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 +197 -183
- 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/op/stage-hooks.ts +251 -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 +347 -35
- package/src/pr-loop-bundle.test.ts +2 -2
- package/src/pr-loop.test.ts +58 -0
- package/src/pr-loop.ts +170 -4
- package/src/pr-post.test.ts +141 -0
- package/src/pr-post.ts +159 -0
- package/src/pr-requires.test.ts +184 -0
- package/src/pr-requires.ts +79 -0
- package/src/review-agent-bundle.test.ts +35 -0
- package/src/stage-hooks-bundle.test.ts +38 -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/checks/ci-workflow.test.ts +9 -0
- package/src/workspace/checks/ci-workflow.ts +18 -4
- 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-init.ts +5 -4
- 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 +74 -5
- package/src/workspace/ci-workflow-at.ts +23 -0
- package/src/workspace/ci-workflow.ts +13 -2
- package/src/workspace/declaration.schema.json +149 -2
- package/src/workspace/declaration.test.ts +76 -0
- package/src/workspace/declaration.ts +140 -5
- 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 +94 -3
- package/src/workspace/reason-codes.ts +1 -1
- 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";
|
|
@@ -143,6 +148,16 @@ export interface PrLoopPipelineOptions {
|
|
|
143
148
|
* false, so a merge deploys nothing. `waveGate` needs it.
|
|
144
149
|
*/
|
|
145
150
|
applyOnMerge?: boolean;
|
|
151
|
+
/**
|
|
152
|
+
* Apply an open pull request before it merges (#4301, ws-106): the apply
|
|
153
|
+
* job runs `pr-apply --before-merge` at the pull request's head, started
|
|
154
|
+
* by a `/chant apply` comment on GitHub and Forgejo, or on GitLab by a
|
|
155
|
+
* pipeline of the target branch with `CHANT_APPLY_PR` set. A merge still
|
|
156
|
+
* records. `pr-apply` refuses unless the declaration at base sets
|
|
157
|
+
* `ci.workflow: "apply-before-merge"`. It takes neither `applyOnMerge`,
|
|
158
|
+
* `waveGate` nor `build`.
|
|
159
|
+
*/
|
|
160
|
+
applyBeforeMerge?: boolean;
|
|
146
161
|
/**
|
|
147
162
|
* Stream each job's run events (#4007): `--events-json` on both commands,
|
|
148
163
|
* and `CHANT_RUN_EVENTS_DIR` set to {@link PR_LOOP_EVENTS_DIR}. Default false.
|
|
@@ -175,11 +190,27 @@ export interface PrLoopPipelineOptions {
|
|
|
175
190
|
* wave. Set from `--wave-gate` beside `--pr-loop` ({@link prLoopWithWaves}).
|
|
176
191
|
*/
|
|
177
192
|
waveGate?: string;
|
|
193
|
+
/**
|
|
194
|
+
* The plan and apply cloud identities (#4296), from `ci.identity.cloud`
|
|
195
|
+
* resolved for the pipeline's environment and member
|
|
196
|
+
* (`resolveCloudIdentity` in ../ci-identity.ts). The plan job trades its
|
|
197
|
+
* OIDC token for the plan identity before the checkout, the record job
|
|
198
|
+
* for the plan identity, and the apply jobs for the apply identity.
|
|
199
|
+
* Absent: no job requests an OIDC token.
|
|
200
|
+
*/
|
|
201
|
+
identity?: PipelineCloudIdentity;
|
|
178
202
|
/**
|
|
179
203
|
* Which GitHub-shaped forge the workflow is for. Set by the forgejo
|
|
180
204
|
* generator, which reuses the github one; a caller never needs to.
|
|
181
205
|
*/
|
|
182
206
|
forge?: "github" | "forgejo";
|
|
207
|
+
/**
|
|
208
|
+
* An environment per open pull request (#4298, `ci.prEnvironment`): an
|
|
209
|
+
* `env-up` job on each opened, updated or reopened pull request, an
|
|
210
|
+
* `env-down` job when it closes, and an `env-sweep` job on a schedule
|
|
211
|
+
* ({@link prEnvJobs}). Set from `--pr-env` beside `--pr-loop`. Default false.
|
|
212
|
+
*/
|
|
213
|
+
prEnvironment?: boolean;
|
|
183
214
|
}
|
|
184
215
|
|
|
185
216
|
/** One job's command and the forge values it reads, as environment variables for its step. */
|
|
@@ -238,6 +269,10 @@ export function prLoopBuildLines(env: string, options: PrLoopPipelineOptions = {
|
|
|
238
269
|
*/
|
|
239
270
|
export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelineOptions = {}, member?: string): PrLoopJob[] {
|
|
240
271
|
const gate = options.gate ?? PR_APPLY_GATE;
|
|
272
|
+
const early = options.applyBeforeMerge === true;
|
|
273
|
+
if (early && options.applyOnMerge) throw new Error("--apply-before-merge applies an open pull request and a merge records, so it takes no --apply-on-merge; pass one of them");
|
|
274
|
+
if (early && options.waveGate) throw new Error("--apply-before-merge applies the pull request's plan in one job, so it takes no --wave-gate");
|
|
275
|
+
if (early && (options.build || options.buildCommand)) throw new Error("--apply-before-merge runs no build before its requires checks, so it takes no --pr-build or --pr-build-command yet");
|
|
241
276
|
const common = [
|
|
242
277
|
"--env", env, "--gate", gate, "--output", PR_LOOP_REPORT_DIR, "--forge", forge,
|
|
243
278
|
...(member ? ["--member", member] : []),
|
|
@@ -254,7 +289,8 @@ export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelin
|
|
|
254
289
|
// #4156: the flags `chant ci run` rebuilds `command` from. It finds the member from the directory it runs in.
|
|
255
290
|
const ciRun = (job: PrLoopJob["jobName"]): string[] => [
|
|
256
291
|
"chant", "ci", "run", job, "--in-place", ...(job === "plan" ? ["--no-push"] : []),
|
|
257
|
-
...(job === "apply" ? [] : ["--base", '"$BASE_SHA"']), ...(job === "plan" ? ["--pr", '"$PR_NUMBER"'] : []),
|
|
292
|
+
...(job === "apply" && !early ? [] : ["--base", '"$BASE_SHA"']), ...(job === "plan" || (job === "apply" && early) ? ["--pr", '"$PR_NUMBER"'] : []),
|
|
293
|
+
...(job === "apply" && early ? ["--apply-before-merge"] : []),
|
|
258
294
|
"--env", env, "--gate", gate, "--forge", forge,
|
|
259
295
|
...(job !== "record" && options.waveGate ? ["--wave-gate", options.waveGate] : []),
|
|
260
296
|
...(job !== "plan" && options.branch ? ["--branch", JSON.stringify(options.branch)] : []),
|
|
@@ -264,35 +300,54 @@ export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelin
|
|
|
264
300
|
...(options.reportUrl ? ["--report-url", JSON.stringify(options.reportUrl)] : []),
|
|
265
301
|
];
|
|
266
302
|
const runEnv = (env: Record<string, string>): Record<string, string> => (github ? { ...env, [RUNNER_KEY_ENV]: `\${{ secrets.${RUNNER_KEY_ENV} }}` } : env);
|
|
303
|
+
// #4295: the plan runs the pull request's code, so it gets no forge token. It writes the note and the
|
|
304
|
+
// status into its report (--no-status), and the plan-note job posts them (prLoopPostJob).
|
|
267
305
|
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 };
|
|
306
|
+
? { BASE_SHA: "${{ github.event.pull_request.base.sha }}", PR_NUMBER: "${{ github.event.pull_request.number }}", ...withoutForgeTokens(jobEnv) }
|
|
307
|
+
: { BASE_SHA: "$CI_MERGE_REQUEST_DIFF_BASE_SHA", PR_NUMBER: "$CI_MERGE_REQUEST_IID", ...withoutForgeTokens(jobEnv) };
|
|
270
308
|
const greenBranch = github ? JSON.stringify(options.branch ?? "main") : options.branch ? JSON.stringify(options.branch) : "$CI_DEFAULT_BRANCH";
|
|
271
309
|
const applyEnv = github ? { BASE_SHA: "${{ github.event.before }}", ...jobEnv } : { BASE_SHA: "$CI_COMMIT_BEFORE_SHA", ...jobEnv };
|
|
310
|
+
// #4301: the apply before merge runs in a job of the target branch, whose commit is the base, at the pull request's head.
|
|
311
|
+
const earlyEnv = github
|
|
312
|
+
? { BASE_SHA: "${{ github.sha }}", PR_NUMBER: "${{ github.event.issue.number }}", ...jobEnv }
|
|
313
|
+
: { BASE_SHA: "$CI_COMMIT_SHA", PR_NUMBER: `$${GITLAB_APPLY_PR_VAR}`, ...jobEnv };
|
|
314
|
+
const greenApply: PrLoopJob = {
|
|
315
|
+
// #4168 step 9: the apply applies the newest green commit, measured from the one applied before it.
|
|
316
|
+
jobName: "apply",
|
|
317
|
+
command: [
|
|
318
|
+
"chant", "components", "pr-apply", "--green", ...common,
|
|
319
|
+
...(options.requireReview === true ? ["--require-review"] : []),
|
|
320
|
+
...(options.waveGate ? ["--wave-gate", options.waveGate] : []),
|
|
321
|
+
"--resume", PR_APPLY_RECORD,
|
|
322
|
+
].join(" "),
|
|
323
|
+
env: jobEnv,
|
|
324
|
+
checkout: [greenCheckout(greenBranch)],
|
|
325
|
+
greenBranch,
|
|
326
|
+
...build,
|
|
327
|
+
run: { command: ciRun("apply").join(" "), env: runEnv(jobEnv) },
|
|
328
|
+
};
|
|
329
|
+
const earlyApply: PrLoopJob = {
|
|
330
|
+
// #4301: the requires checks run inside pr-apply, before any of the head's code loads.
|
|
331
|
+
jobName: "apply",
|
|
332
|
+
command: [
|
|
333
|
+
"chant", "components", "pr-apply", "--before-merge", "--base", '"$BASE_SHA"', "--pr", '"$PR_NUMBER"', ...common,
|
|
334
|
+
...(options.requireReview === true ? ["--require-review"] : []),
|
|
335
|
+
"--resume", PR_APPLY_RECORD,
|
|
336
|
+
].join(" "),
|
|
337
|
+
env: earlyEnv,
|
|
338
|
+
checkout: [pullRequestCheckout(forge)],
|
|
339
|
+
run: { command: ciRun("apply").join(" "), env: runEnv(earlyEnv) },
|
|
340
|
+
};
|
|
272
341
|
return [
|
|
273
342
|
{
|
|
274
343
|
jobName: "plan",
|
|
275
344
|
// 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(" "),
|
|
345
|
+
command: ["chant", "components", "pr-plan", "--base", '"$BASE_SHA"', "--pr", '"$PR_NUMBER"', ...common, ...(options.waveGate ? ["--wave-gate", options.waveGate] : []), "--no-status"].join(" "),
|
|
277
346
|
env: planEnv,
|
|
278
347
|
...build,
|
|
279
348
|
run: { command: ciRun("plan").join(" "), env: runEnv(planEnv) },
|
|
280
349
|
},
|
|
281
|
-
|
|
282
|
-
// #4168 step 9: the apply applies the newest green commit, measured from the one applied before it.
|
|
283
|
-
jobName: "apply",
|
|
284
|
-
command: [
|
|
285
|
-
"chant", "components", "pr-apply", "--green", ...common,
|
|
286
|
-
...(options.requireReview === true ? ["--require-review"] : []),
|
|
287
|
-
...(options.waveGate ? ["--wave-gate", options.waveGate] : []),
|
|
288
|
-
"--resume", PR_APPLY_RECORD,
|
|
289
|
-
].join(" "),
|
|
290
|
-
env: jobEnv,
|
|
291
|
-
checkout: [greenCheckout(greenBranch)],
|
|
292
|
-
greenBranch,
|
|
293
|
-
...build,
|
|
294
|
-
run: { command: ciRun("apply").join(" "), env: runEnv(jobEnv) },
|
|
295
|
-
},
|
|
350
|
+
early ? earlyApply : greenApply,
|
|
296
351
|
{
|
|
297
352
|
jobName: "record",
|
|
298
353
|
command: ["chant", "components", "pr-record", "--base", '"$BASE_SHA"', ...common].join(" "),
|
|
@@ -304,6 +359,35 @@ export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelin
|
|
|
304
359
|
];
|
|
305
360
|
}
|
|
306
361
|
|
|
362
|
+
/** The name of the job that posts the plan's note and status (#4295). */
|
|
363
|
+
export const PR_PLAN_NOTE_JOB = "plan-note";
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* The plan-note job's command and the values it reads (#4295). It runs in a
|
|
367
|
+
* fresh job after the plan, at the target branch's commit, and posts the
|
|
368
|
+
* note and the `chant/plan` status from the report the plan job handed on,
|
|
369
|
+
* with the forge token the plan job never held. `HEAD_SHA` is the commit
|
|
370
|
+
* the plan job checked out, `PR_NUMBER` the pull request, and on GitHub and
|
|
371
|
+
* Forgejo `PLAN_RESULT` the plan job's result, which fails the status when
|
|
372
|
+
* the plan job failed. `BASE_SHA` is the commit the note job checks out on
|
|
373
|
+
* GitLab, whose runner checks out the pipeline's own commit.
|
|
374
|
+
*/
|
|
375
|
+
export function prLoopPostJob(forge: ForgeKind, env: string, member?: string): { command: string; env: Record<string, string> } {
|
|
376
|
+
const github = forge !== "gitlab";
|
|
377
|
+
const command = [
|
|
378
|
+
"chant", "components", "pr-post", "--pr", '"$PR_NUMBER"', "--head", '"$HEAD_SHA"',
|
|
379
|
+
"--env", env, "--output", PR_LOOP_REPORT_DIR, "--forge", forge,
|
|
380
|
+
...(member ? ["--member", member] : []),
|
|
381
|
+
...(github ? ["--result", '"$PLAN_RESULT"'] : []),
|
|
382
|
+
].join(" ");
|
|
383
|
+
return {
|
|
384
|
+
command,
|
|
385
|
+
env: github
|
|
386
|
+
? { PR_NUMBER: "${{ github.event.pull_request.number }}", HEAD_SHA: "${{ github.sha }}", PLAN_RESULT: "${{ needs.plan.result }}", GITHUB_TOKEN: GITHUB_JOB_TOKEN }
|
|
387
|
+
: { PR_NUMBER: "$CI_MERGE_REQUEST_IID", HEAD_SHA: "$CI_COMMIT_SHA", BASE_SHA: "$CI_MERGE_REQUEST_DIFF_BASE_SHA" },
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
307
391
|
/**
|
|
308
392
|
* What starts the apply under apply on merge (#4168 step 9): on GitHub the
|
|
309
393
|
* tick's workflow finishing (`workflow_run`, since tags its token pushes start
|
|
@@ -408,6 +492,22 @@ function pushBaseFallback(target: string, commit: string, diffBase?: string): st
|
|
|
408
492
|
);
|
|
409
493
|
}
|
|
410
494
|
|
|
495
|
+
/** The GitLab variable a person sets on a pipeline of the target branch to apply a merge request before it merges (#4301). */
|
|
496
|
+
export const GITLAB_APPLY_PR_VAR = "CHANT_APPLY_PR";
|
|
497
|
+
|
|
498
|
+
/** The comment that starts the apply before merge on GitHub and Forgejo (#4301). */
|
|
499
|
+
export const APPLY_COMMENT = "/chant apply";
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* The line that checks out pull request `$PR_NUMBER`'s head (#4301) in a job
|
|
503
|
+
* of the target branch: the forge's `refs/pull/<n>/head`, or GitLab's
|
|
504
|
+
* `refs/merge-requests/<n>/head`. A number that is not one fails the job.
|
|
505
|
+
*/
|
|
506
|
+
export function pullRequestCheckout(forge: ForgeKind): string {
|
|
507
|
+
const ref = forge === "gitlab" ? 'refs/merge-requests/$PR_NUMBER/head' : 'refs/pull/$PR_NUMBER/head';
|
|
508
|
+
return `echo "$PR_NUMBER" | grep -Eq '^[0-9]+$' && git fetch --quiet origin "+${ref}:refs/remotes/origin/chant-apply/$PR_NUMBER" && git checkout --quiet --detach "refs/remotes/origin/chant-apply/$PR_NUMBER"`;
|
|
509
|
+
}
|
|
510
|
+
|
|
411
511
|
/**
|
|
412
512
|
* The line that checks out the newest green commit of `branch` (#4168 step
|
|
413
513
|
* 9), after fetching the branch and its tags, for the apply job. `chant ci
|
|
@@ -450,3 +550,76 @@ export function gitlabBaseFallback(branch?: string): string {
|
|
|
450
550
|
export function githubBaseFallback(branch?: string): string {
|
|
451
551
|
return pushBaseFallback(JSON.stringify(branch ?? "main"), "$GITHUB_SHA");
|
|
452
552
|
}
|
|
553
|
+
|
|
554
|
+
/** Where the pull request environment jobs (#4298) write their reports, kept as a CI artifact. */
|
|
555
|
+
export const PR_ENV_REPORT_DIR = ".chant/pr-env";
|
|
556
|
+
|
|
557
|
+
/** The cron the generated `env-sweep` job runs on (#4298): hourly, at a minute off the hour. */
|
|
558
|
+
export const PR_ENV_SWEEP_CRON = "17 * * * *";
|
|
559
|
+
|
|
560
|
+
/** One job of a pull request's environment (#4298): its command and the forge values it reads. */
|
|
561
|
+
export interface PrEnvJob {
|
|
562
|
+
jobName: "env-up" | "env-post" | "env-down" | "env-sweep";
|
|
563
|
+
/** The command line. `env-up` reads `$BASE_SHA` and `$PR_NUMBER`, `env-down` `$PR_NUMBER`. */
|
|
564
|
+
command: string;
|
|
565
|
+
/** Values the command reads, as the forge spells them. `CHANT_ENV` is the pull request's environment. */
|
|
566
|
+
env: Record<string, string>;
|
|
567
|
+
/** With `build`: the build and lint lines, run before the command. */
|
|
568
|
+
build?: string[];
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** The name of the job that keeps an environment's record and posts its note (#4298, #4295). */
|
|
572
|
+
export const PR_ENV_POST_JOB = "env-post";
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* The jobs of a pull request's environment (#4298), when `prEnvironment` is
|
|
576
|
+
* on, in this order:
|
|
577
|
+
*
|
|
578
|
+
* - `env-up` runs `chant components pr-env up --no-push`. It runs the
|
|
579
|
+
* change's code, so it gets no forge token (#4295): it passes no
|
|
580
|
+
* `--forge`, and it hands its report and its record on.
|
|
581
|
+
* - `env-post` runs `pr-env post` in a fresh job at the target branch's
|
|
582
|
+
* commit, with the token: it keeps the record on chant/lifecycle and posts
|
|
583
|
+
* the note and the `chant/pr-env` status. On GitHub and Forgejo it reads
|
|
584
|
+
* the up job's result as `UP_RESULT`.
|
|
585
|
+
* - `env-down` runs `pr-env down --reason closed`, and `env-sweep` runs
|
|
586
|
+
* `pr-env sweep`. Neither builds the change's code: a teardown reads the
|
|
587
|
+
* markers off the live resources.
|
|
588
|
+
*
|
|
589
|
+
* Each writes its report under {@link PR_ENV_REPORT_DIR}.
|
|
590
|
+
*/
|
|
591
|
+
export function prEnvJobs(forge: ForgeKind, options: PrLoopPipelineOptions = {}, member?: string): PrEnvJob[] {
|
|
592
|
+
const github = forge !== "gitlab";
|
|
593
|
+
const out = ["--output", PR_ENV_REPORT_DIR, ...(member ? ["--member", member] : [])];
|
|
594
|
+
const common = ["--forge", forge, ...out];
|
|
595
|
+
const token: Record<string, string> = github ? { GITHUB_TOKEN: GITHUB_JOB_TOKEN } : {};
|
|
596
|
+
const prNumber = github ? "${{ github.event.pull_request.number }}" : "$CI_MERGE_REQUEST_IID";
|
|
597
|
+
const prEnv = { CHANT_ENV: `pr-${prNumber}` };
|
|
598
|
+
const buildLines = prLoopBuildLines('"$CHANT_ENV"', options);
|
|
599
|
+
return [
|
|
600
|
+
{
|
|
601
|
+
jobName: "env-up",
|
|
602
|
+
// #4295: no --forge and no token; the record stays in the report directory for env-post.
|
|
603
|
+
command: ["chant", "components", "pr-env", "up", "--base", '"$BASE_SHA"', "--pr", '"$PR_NUMBER"', ...out, "--no-push"].join(" "),
|
|
604
|
+
env: { BASE_SHA: github ? "${{ github.event.pull_request.base.sha }}" : "$CI_MERGE_REQUEST_DIFF_BASE_SHA", PR_NUMBER: prNumber, ...prEnv },
|
|
605
|
+
...(buildLines.length > 0 ? { build: buildLines } : {}),
|
|
606
|
+
},
|
|
607
|
+
{
|
|
608
|
+
jobName: PR_ENV_POST_JOB,
|
|
609
|
+
command: ["chant", "components", "pr-env", "post", "--pr", '"$PR_NUMBER"', "--head", '"$HEAD_SHA"', ...common, ...(github ? ["--result", '"$UP_RESULT"'] : [])].join(" "),
|
|
610
|
+
env: github
|
|
611
|
+
? { PR_NUMBER: prNumber, HEAD_SHA: "${{ github.sha }}", UP_RESULT: "${{ needs.env-up.result }}", ...token }
|
|
612
|
+
: { PR_NUMBER: prNumber, HEAD_SHA: "$CI_COMMIT_SHA", BASE_SHA: "$CI_MERGE_REQUEST_DIFF_BASE_SHA" },
|
|
613
|
+
},
|
|
614
|
+
{
|
|
615
|
+
jobName: "env-down",
|
|
616
|
+
command: ["chant", "components", "pr-env", "down", "--pr", '"$PR_NUMBER"', "--reason", "closed", ...common].join(" "),
|
|
617
|
+
env: { PR_NUMBER: prNumber, ...prEnv, ...token },
|
|
618
|
+
},
|
|
619
|
+
{
|
|
620
|
+
jobName: "env-sweep",
|
|
621
|
+
command: ["chant", "components", "pr-env", "sweep", ...common].join(" "),
|
|
622
|
+
env: { ...token },
|
|
623
|
+
},
|
|
624
|
+
];
|
|
625
|
+
}
|
|
@@ -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
|
+
});
|