@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
package/src/op/op-waves-run.ts
CHANGED
|
@@ -68,11 +68,16 @@ import type { GateAnswerRef, PendingGateRecord } from "../lifecycle/gate-ledger"
|
|
|
68
68
|
import type { GatePointAsker } from "../point-decision";
|
|
69
69
|
import type { WaitingPoint } from "./steward-points";
|
|
70
70
|
import { isApprovalSpent, type AppliedGateRecord } from "../lifecycle/gate-applied";
|
|
71
|
+
import { samePlanDigest } from "../lifecycle/plan-digest";
|
|
71
72
|
import { isLifecycleLocalNote } from "../lifecycle/local-note";
|
|
72
73
|
import type { HeadPlans, MergedReview, ReviewSource } from "./gate-review";
|
|
73
74
|
import type { TrustPolicy } from "../workspace/trust/policy";
|
|
74
|
-
import type { GateSigners } from "../
|
|
75
|
+
import type { GateSigners } from "../lifecycle/gate-seal";
|
|
75
76
|
import { ciRunIdFromEnv } from "./gate-run-locator";
|
|
77
|
+
import {
|
|
78
|
+
quorumProblems, quorumRuleLine, quorumRulesFor, quorumWaiting, ruleNeedsProof, samePrincipal, trustQuorumJudge,
|
|
79
|
+
type GateQuorumRule, type GateQuorumRuleProgress,
|
|
80
|
+
} from "./gate-quorum";
|
|
76
81
|
import {
|
|
77
82
|
OP_WAVE_DEFAULT_APPLY,
|
|
78
83
|
OP_WAVE_GATE_POLICIES,
|
|
@@ -83,6 +88,7 @@ import {
|
|
|
83
88
|
opWaveApplyHooksRunBy,
|
|
84
89
|
opWaveGateName,
|
|
85
90
|
opWaveHooksAt,
|
|
91
|
+
opWaveHookRuns,
|
|
86
92
|
opWavePointAt,
|
|
87
93
|
opWavePointProblems,
|
|
88
94
|
opWaveRecordPath,
|
|
@@ -113,6 +119,13 @@ export interface OpWavePlanFile {
|
|
|
113
119
|
* plan digest and these rules counts (`@intentius/chant/override-gate`).
|
|
114
120
|
*/
|
|
115
121
|
denied?: { rules: string[]; reason?: string };
|
|
122
|
+
/**
|
|
123
|
+
* The product's policy holds this plan for a person (#4294): the ids of
|
|
124
|
+
* the hold rules that matched. The wave waits for an approval of its set
|
|
125
|
+
* digest whatever its gate policy says, as a hook with `onFailure:
|
|
126
|
+
* approve` holds it. `@intentius/chant/gate-policy` gives the rules.
|
|
127
|
+
*/
|
|
128
|
+
held?: { rules: string[]; reason?: string };
|
|
116
129
|
}
|
|
117
130
|
|
|
118
131
|
/** What a wave-level plan command writes to `{plans}` (#3762): one plan per run, by target. The array alone is read too. */
|
|
@@ -170,6 +183,8 @@ export interface OpWaveMember extends WaveMember {
|
|
|
170
183
|
denied?: { rules: string[]; reason?: string };
|
|
171
184
|
/** The override that lets the denied plan through (#3821). */
|
|
172
185
|
override?: { by: string; at: string; digest: string; reason: string };
|
|
186
|
+
/** The product's policy holds the plan for a person (#4294). */
|
|
187
|
+
held?: { rules: string[]; reason?: string };
|
|
173
188
|
}
|
|
174
189
|
|
|
175
190
|
/** Where a wave's policy came from. */
|
|
@@ -207,6 +222,10 @@ export interface OpWaveDecision {
|
|
|
207
222
|
point?: OpWavePoint & { question?: WaitingPoint; answer?: GateAnswerRef };
|
|
208
223
|
/** The review that approved the wave, or why the review did not, under `pr-review`. */
|
|
209
224
|
review?: { pr?: number; head?: string; approvers?: string[]; url?: string; reason?: string };
|
|
225
|
+
/** The approval rules at base the wave's targets take (#4290). Absent when it takes none. */
|
|
226
|
+
quorum?: GateQuorumRule[];
|
|
227
|
+
/** Where each of those rules stands (#4290), while the wave waits. */
|
|
228
|
+
quorumProgress?: GateQuorumRuleProgress[];
|
|
210
229
|
/** The command that approves this digest, when waiting. */
|
|
211
230
|
approve?: string;
|
|
212
231
|
/** The hooks read at base (#3762), which a wide wave's share jobs run too. Absent when there are none. */
|
|
@@ -489,6 +508,10 @@ export interface OpWaveBaseRules {
|
|
|
489
508
|
point?: OpWavePoint;
|
|
490
509
|
/** Set when the point at base cannot be asked: the wave fails rather than fall back to the ledger (#3836). */
|
|
491
510
|
pointRefused?: string;
|
|
511
|
+
/** The approval rules at base (#4290), `approvals.quorum`. */
|
|
512
|
+
quorum?: GateQuorumRule[];
|
|
513
|
+
/** Set when the rules at base cannot be read: the wave fails rather than wait on fewer approvers than they ask. */
|
|
514
|
+
quorumRefused?: string;
|
|
492
515
|
}
|
|
493
516
|
|
|
494
517
|
const noHooks = (): Record<OpWaveHookMoment, OpWaveHook[]> =>
|
|
@@ -521,7 +544,7 @@ export function readOpWavePolicy(
|
|
|
521
544
|
const spend = (spec as { approvals?: { spend?: unknown } }).approvals?.spend === true;
|
|
522
545
|
const neverOverMcp = (spec as { neverOverMcp?: unknown }).neverOverMcp === true;
|
|
523
546
|
// #3836: the gate's decision point, the spec's for a wave the base does not have yet.
|
|
524
|
-
const asks = pointAtBase(spec, name, `${specFile} at ${sha.slice(0, 12)}`);
|
|
547
|
+
const asks = { ...pointAtBase(spec, name, `${specFile} at ${sha.slice(0, 12)}`), ...quorumAtBase(spec, `${specFile} at ${sha.slice(0, 12)}`) };
|
|
525
548
|
const wave = spec.waves.find((w) => w.name === name);
|
|
526
549
|
if (!wave) return { ...absent, spend, neverOverMcp, ...asks };
|
|
527
550
|
const policy = wave.gate ?? "always";
|
|
@@ -540,6 +563,15 @@ export function readOpWavePolicy(
|
|
|
540
563
|
return rules;
|
|
541
564
|
}
|
|
542
565
|
|
|
566
|
+
/** The approval rules at base (#4290), or why they cannot be read. A spec at base with none has none. */
|
|
567
|
+
function quorumAtBase(spec: OpWavesSpec, where: string): Pick<OpWaveBaseRules, "quorum" | "quorumRefused"> {
|
|
568
|
+
const raw = (spec as { approvals?: { quorum?: unknown } }).approvals?.quorum;
|
|
569
|
+
if (raw === undefined) return {};
|
|
570
|
+
const problems = Array.isArray(raw) ? quorumProblems(raw, "approvals.quorum", { notAuthor: true }) : ["`approvals.quorum` must be a list of rules"];
|
|
571
|
+
if (problems.length > 0) return { quorumRefused: `the approval rules in ${where} cannot be read (${problems.join("; ")})` };
|
|
572
|
+
return (raw as GateQuorumRule[]).length > 0 ? { quorum: raw as GateQuorumRule[] } : {};
|
|
573
|
+
}
|
|
574
|
+
|
|
543
575
|
/** The point wave `name` asks at base (#3836), or why it cannot be asked. */
|
|
544
576
|
function pointAtBase(spec: OpWavesSpec, name: string, where: string): Pick<OpWaveBaseRules, "point" | "pointRefused"> {
|
|
545
577
|
const raw = spec.waves.find((w) => w.name === name)?.point ?? spec.point;
|
|
@@ -674,11 +706,12 @@ function planRun(opts: { spec: OpWavesSpec; wave: number; cwd: string; exec: OpW
|
|
|
674
706
|
}
|
|
675
707
|
|
|
676
708
|
function member(target: string, op: string, file: OpWavePlanFile): OpWaveMember {
|
|
677
|
-
const
|
|
678
|
-
|
|
679
|
-
|
|
709
|
+
for (const key of ["denied", "held"] as const) {
|
|
710
|
+
const value = file[key] as unknown;
|
|
711
|
+
if (value === undefined) continue;
|
|
712
|
+
const d = value as { rules?: unknown; reason?: unknown } | null;
|
|
680
713
|
if (!d || typeof d !== "object" || !Array.isArray(d.rules) || d.rules.length === 0 || !d.rules.every((r) => typeof r === "string" && r.trim() !== "") || (d.reason !== undefined && typeof d.reason !== "string")) {
|
|
681
|
-
throw new Error(`the plan of ${target} has
|
|
714
|
+
throw new Error(`the plan of ${target} has ${key} ${JSON.stringify(value)}; ${key === "denied" ? "a denial" : "a hold"} is { "rules": ["<rule id>", ...], "reason"?: "<text>" }`);
|
|
682
715
|
}
|
|
683
716
|
}
|
|
684
717
|
return {
|
|
@@ -688,6 +721,7 @@ function member(target: string, op: string, file: OpWavePlanFile): OpWaveMember
|
|
|
688
721
|
...(file.destructive === true ? { destructive: true } : {}),
|
|
689
722
|
...(file.empty === true ? { empty: true } : {}),
|
|
690
723
|
...(file.denied ? { denied: { rules: file.denied.rules, ...(file.denied.reason ? { reason: file.denied.reason } : {}) } } : {}),
|
|
724
|
+
...(file.held ? { held: { rules: [...new Set(file.held.rules)].sort(), ...(file.held.reason ? { reason: file.held.reason } : {}) } } : {}),
|
|
691
725
|
};
|
|
692
726
|
}
|
|
693
727
|
|
|
@@ -768,7 +802,7 @@ function planRuns(at: { spec: OpWavesSpec; k: number; wave: OpWave; cwd: string;
|
|
|
768
802
|
const members = runs.map((run) => {
|
|
769
803
|
const m = member(run.target, run.op ?? spec.op, byTarget.get(run.target)!);
|
|
770
804
|
const path = join(cwd, opWavePlanPath(spec, k, run.target));
|
|
771
|
-
writeFileSync(path, JSON.stringify({ planDigest: m.planDigest, ...(m.destructive ? { destructive: true } : {}), ...(m.empty ? { empty: true } : {}), ...(m.denied ? { denied: m.denied } : {}) }) + "\n");
|
|
805
|
+
writeFileSync(path, JSON.stringify({ planDigest: m.planDigest, ...(m.destructive ? { destructive: true } : {}), ...(m.empty ? { empty: true } : {}), ...(m.denied ? { denied: m.denied } : {}), ...(m.held ? { held: m.held } : {}) }) + "\n");
|
|
772
806
|
return m;
|
|
773
807
|
});
|
|
774
808
|
const overriders = Array.isArray(parsed) ? undefined : (parsed as { overriders?: unknown }).overriders;
|
|
@@ -884,6 +918,8 @@ interface HookPlace {
|
|
|
884
918
|
cwd: string;
|
|
885
919
|
exec: OpWaveExec;
|
|
886
920
|
log: (line: string) => void;
|
|
921
|
+
/** Where a hook writes a hold on another gate (`CHANT_HOLD`). Default: under the wave's directory. */
|
|
922
|
+
holdPath?: string;
|
|
887
923
|
}
|
|
888
924
|
|
|
889
925
|
/**
|
|
@@ -903,12 +939,18 @@ function runHookList(
|
|
|
903
939
|
if (!hooks || hooks.length === 0) return out;
|
|
904
940
|
const { name: specName, op, waveName, k, cwd, exec, log } = at;
|
|
905
941
|
// #3821: a hook that holds the wave on another gate names it in this file.
|
|
906
|
-
const holdPath = `.chant/op-waves/${specName}/wave-${k}/hold.json`;
|
|
942
|
+
const holdPath = at.holdPath ?? `.chant/op-waves/${specName}/wave-${k}/hold.json`;
|
|
907
943
|
const env = { CHANT_OP_WAVES: specName, CHANT_WAVE: waveName, CHANT_WAVE_NUMBER: String(k), CHANT_HOOK: moment, CHANT_RUNS: runsPath, CHANT_HOLD: holdPath, ...extraEnv };
|
|
908
944
|
for (const hook of hooks) {
|
|
909
945
|
const name = hook.name ?? (hook.command.join(" ").length > 60 ? `${hook.command.join(" ").slice(0, 57)}...` : hook.command.join(" "));
|
|
946
|
+
// #4288: a hook with `targets` is for the runs they match. Once for the wave, it runs when one matches or no run is known yet.
|
|
947
|
+
const mine = opWaveHookRuns(hook, runs);
|
|
948
|
+
if (hook.targets && (hook.perRun ? mine.length === 0 : mine.length === 0 && runs.length > 0)) {
|
|
949
|
+
log(`${moment} hook ${name}: no run matches its targets (${hook.targets.join(", ")}), so it did not run`);
|
|
950
|
+
continue;
|
|
951
|
+
}
|
|
910
952
|
const calls = hook.perRun
|
|
911
|
-
?
|
|
953
|
+
? mine.map((run) => ({
|
|
912
954
|
target: run.target as string | undefined,
|
|
913
955
|
argv: opWaveArgv(hook.command, { op: run.op, wave: waveName, target: run.target, runs: runsPath, ...(run.plan ? { plan: run.plan } : {}) }),
|
|
914
956
|
env: { ...env, CHANT_TARGET: run.target, ...(run.plan ? { CHANT_PLAN: run.plan } : {}) } as Record<string, string>,
|
|
@@ -1034,6 +1076,64 @@ async function runHooks(
|
|
|
1034
1076
|
return back;
|
|
1035
1077
|
}
|
|
1036
1078
|
|
|
1079
|
+
/** The options of {@link runStageHooks} (#4288). */
|
|
1080
|
+
export interface RunStageHooksOptions {
|
|
1081
|
+
/** What the hooks belong to: `CHANT_OP_WAVES`, and the `{op}` of a hook that runs once. */
|
|
1082
|
+
name: string;
|
|
1083
|
+
op: string;
|
|
1084
|
+
/** `{wave}` and `CHANT_WAVE`. */
|
|
1085
|
+
wave: string;
|
|
1086
|
+
/** `CHANT_WAVE_NUMBER`. Default 0. */
|
|
1087
|
+
k?: number;
|
|
1088
|
+
/** The runs a `perRun` hook runs for and `targets` match against. */
|
|
1089
|
+
runs: readonly OpWaveHookRun[];
|
|
1090
|
+
/** `{runs}` and `CHANT_RUNS`, relative to `cwd`; the caller writes it. */
|
|
1091
|
+
runsFile: string;
|
|
1092
|
+
/** `CHANT_HOLD`, relative to `cwd`. */
|
|
1093
|
+
holdFile: string;
|
|
1094
|
+
/** Added to every hook's environment. */
|
|
1095
|
+
env?: Record<string, string>;
|
|
1096
|
+
cwd?: string;
|
|
1097
|
+
exec?: OpWaveExec;
|
|
1098
|
+
log?: (line: string) => void;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/** What {@link runStageHooks} gave. */
|
|
1102
|
+
export interface StageHooksRun {
|
|
1103
|
+
outcomes: OpWaveHookOutcome[];
|
|
1104
|
+
/** Set when a hook with `onFailure: fail` failed; the hooks after it did not run. */
|
|
1105
|
+
failed?: string;
|
|
1106
|
+
/** The `onFailure: approve` hooks that failed. A hold on another gate counts as one here. */
|
|
1107
|
+
holds: string[];
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* Run one moment's hooks outside an Op waves job (#4288): the pull-request
|
|
1112
|
+
* jobs, or a product's own stage, with the same `targets`, `perRun`,
|
|
1113
|
+
* `onFailure` and argv filling a wave job uses. A hook that holds on another
|
|
1114
|
+
* gate (`CHANT_HOLD`) is counted as a plain hold; settling that gate is the
|
|
1115
|
+
* caller's.
|
|
1116
|
+
*/
|
|
1117
|
+
export function runStageHooks(moment: OpWaveHookMoment, hooks: readonly OpWaveHook[] | undefined, options: RunStageHooksOptions): StageHooksRun {
|
|
1118
|
+
const cwd = options.cwd ?? process.cwd();
|
|
1119
|
+
const log = options.log ?? ((line: string) => console.error(line));
|
|
1120
|
+
const place: HookPlace = { name: options.name, op: options.op, waveName: options.wave, k: options.k ?? 0, cwd, exec: options.exec ?? spawnExec, log, holdPath: options.holdFile };
|
|
1121
|
+
const out = runHookList(place, moment, hooks, options.runs, options.runsFile, options.env);
|
|
1122
|
+
return { outcomes: out.outcomes, ...(out.failed ? { failed: out.failed } : {}), holds: [...out.holds, ...out.otherGates.map((g) => `${g.label} (on ${g.request.op ?? options.name} / ${g.request.gate})`)] };
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
/**
|
|
1126
|
+
* The hooks that run before a job plans (#4288): `before-observe`, then
|
|
1127
|
+
* `before-plan`. A failure or a hold on another gate in the first ends it
|
|
1128
|
+
* there; the holds of both add up.
|
|
1129
|
+
*/
|
|
1130
|
+
async function runBeforePlanHooks(ctx: WaveContext, hooks: Partial<Record<OpWaveHookMoment, OpWaveHook[]>> | undefined, runs: readonly OpWaveRun[]): Promise<HooksOutcome> {
|
|
1131
|
+
const observe = await runHooks(ctx, "before-observe", hooks?.["before-observe"], runs);
|
|
1132
|
+
if (observe.failed || observe.waitingOn) return observe;
|
|
1133
|
+
const plan = await runHooks(ctx, "before-plan", hooks?.["before-plan"], runs);
|
|
1134
|
+
return { ...plan, holds: [...observe.holds, ...plan.holds] };
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1037
1137
|
/** How many gates one hook call holds a wave on in turn (#3847): the first hold and the holds its approved re-runs ask for. */
|
|
1038
1138
|
export const OP_WAVE_MAX_CHAINED_HOLDS = 10;
|
|
1039
1139
|
|
|
@@ -1396,7 +1496,7 @@ async function planOpWave(options: RunOpWaveOptions, { k, pass }: WaveAttempt<Ru
|
|
|
1396
1496
|
log(`${part} of wave ${k} (${wave.name}) holds none of the wave's runs; nothing to apply`);
|
|
1397
1497
|
return { end: result };
|
|
1398
1498
|
}
|
|
1399
|
-
const before = await
|
|
1499
|
+
const before = await runBeforePlanHooks(ctx, decision.hooks, slice);
|
|
1400
1500
|
if (before.failed) return { end: failed(ctx, `a hook before the plan failed, so nothing in ${part} of wave ${k} was planned or applied`) };
|
|
1401
1501
|
if (before.waitingOn) return { end: await waitOnOther(ctx, decision, before.waitingOn) };
|
|
1402
1502
|
const { members } = planRuns(ctx, slice);
|
|
@@ -1415,6 +1515,12 @@ async function planOpWave(options: RunOpWaveOptions, { k, pass }: WaveAttempt<Ru
|
|
|
1415
1515
|
noteHooksAtBase(ctx, rules);
|
|
1416
1516
|
// #3836: a point at base that cannot be asked fails the wave; the ledger never stands in for it.
|
|
1417
1517
|
if (rules.pointRefused) return { end: failed(ctx, `wave ${k} (${wave.name}): ${rules.pointRefused}, so nothing in it was planned or applied`) };
|
|
1518
|
+
// #4290: rules at base that cannot be read fail the wave; fewer approvers never stand in for them.
|
|
1519
|
+
if (rules.quorumRefused) return { end: failed(ctx, `wave ${k} (${wave.name}): ${rules.quorumRefused}, so nothing in it was planned or applied`) };
|
|
1520
|
+
if (rules.quorum && rules.point) return { end: failed(ctx, `wave ${k} (${wave.name}): approvals.quorum and a decision point are both set at ${base.slice(0, 12)}; a gate that asks a point takes its deciders from the point, so nothing in it was planned or applied`) };
|
|
1521
|
+
if (JSON.stringify(spec.approvals?.quorum ?? null) !== JSON.stringify(rules.quorum ?? null)) {
|
|
1522
|
+
log(`wave ${k} (${wave.name}): its approval rules are read at ${base.slice(0, 12)}, and this change's differ; the ones at base hold`);
|
|
1523
|
+
}
|
|
1418
1524
|
const own = opWavePointAt(spec, wave.name);
|
|
1419
1525
|
if (JSON.stringify(own) !== JSON.stringify(rules.point)) {
|
|
1420
1526
|
log(`wave ${k} (${wave.name}): its gate's decision point is read at ${base.slice(0, 12)} (${rules.point?.name ?? "none"}), and this change's differs (${own?.name ?? "none"}); the one at base holds`);
|
|
@@ -1445,7 +1551,7 @@ async function planOpWave(options: RunOpWaveOptions, { k, pass }: WaveAttempt<Ru
|
|
|
1445
1551
|
...(neverOverMcp ? { neverOverMcp: true as const } : {}),
|
|
1446
1552
|
...(rules.point ? { point: { ...rules.point } } : {}),
|
|
1447
1553
|
};
|
|
1448
|
-
const before = await
|
|
1554
|
+
const before = await runBeforePlanHooks(ctx, rules.hooks, wave.runs);
|
|
1449
1555
|
if (before.failed) return { end: failed(ctx, `a hook before the plan failed, so nothing in wave ${k} was planned or applied`) };
|
|
1450
1556
|
if (before.waitingOn) return { end: await decided(ctx, decision, recordPath, before.waitingOn) };
|
|
1451
1557
|
const { members, pending, overriders, overridersSource, extras, waves: found } = planRuns(ctx, wave.runs, wave.discover === true);
|
|
@@ -1519,11 +1625,16 @@ async function decideOpWave(
|
|
|
1519
1625
|
const { policy, source, base } = planned.rules;
|
|
1520
1626
|
decision.digest = digest;
|
|
1521
1627
|
decision.members = members;
|
|
1628
|
+
// #4290: the rules at base whose targets glob matches one of the wave's runs.
|
|
1629
|
+
const takes = planned.rules.quorum ? quorumRulesFor(planned.rules.quorum, members.map((m) => m.member)) : [];
|
|
1630
|
+
if (takes.length > 0) decision.quorum = takes;
|
|
1522
1631
|
if (extras) decision.extras = extras;
|
|
1523
1632
|
if (pending) decision.pending = pending;
|
|
1524
1633
|
const after = await runHooks(ctx, "after-plan", planned.rules.hooks["after-plan"], wave.runs, planned.planned, digest);
|
|
1525
1634
|
if (after.failed) return { end: failed(ctx, `a hook after the plan failed, so nothing in wave ${k} was applied`) };
|
|
1526
1635
|
holds.push(...planned.before, ...after.holds);
|
|
1636
|
+
// #4294: a run the product's policy holds makes the wave wait for a person, as a held hook does.
|
|
1637
|
+
for (const m of members) if (m.held) holds.push(`policy on ${m.member} (${m.held.rules.join(", ")})${m.held.reason ? `: ${m.held.reason}` : ""}`);
|
|
1527
1638
|
if (holds.length > 0) decision.heldBy = holds;
|
|
1528
1639
|
if (after.waitingOn) return { end: await decided(ctx, decision, recordPath, after.waitingOn) };
|
|
1529
1640
|
// #3821: a run the product's policy denied applies only under an override; with none the wave is refused, and no approval is waited for.
|
|
@@ -1779,6 +1890,7 @@ function failed(ctx: WaveContext, line: string): RunOpWaveResult {
|
|
|
1779
1890
|
*/
|
|
1780
1891
|
async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision: OpWaveDecision): Promise<void> {
|
|
1781
1892
|
if (decision.point) return settlePointGate(options, ctx, decision, decision.point);
|
|
1893
|
+
if (decision.quorum?.length) return settleQuorumGate(options, ctx, decision, decision.quorum);
|
|
1782
1894
|
const { spec, k, wave, cwd } = ctx;
|
|
1783
1895
|
const { gate, digest, approval, base, policy, members } = decision;
|
|
1784
1896
|
const { ledger, port } = await gatePort(options, cwd, gate, approval, base);
|
|
@@ -1833,6 +1945,116 @@ async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision:
|
|
|
1833
1945
|
decision.approve = approveCommand(spec.name, gate, undefined, digest, approval === "sealed");
|
|
1834
1946
|
}
|
|
1835
1947
|
|
|
1948
|
+
/**
|
|
1949
|
+
* Decide a gate under approval rules (#4290): the rules at base the wave's
|
|
1950
|
+
* targets take, each met by distinct human approvers of the digest. Roles
|
|
1951
|
+
* come from `.chant/trust.json` at base, for an approver a seal proves, or
|
|
1952
|
+
* under `pr-review` the merged pull request's review read from the forge in
|
|
1953
|
+
* this run. That review is written to the ledger once per approving
|
|
1954
|
+
* reviewer, so each counts on their own. `notAuthor` reads the author from
|
|
1955
|
+
* the same pull request; with none, no approver can show they are someone
|
|
1956
|
+
* else and the rule waits.
|
|
1957
|
+
*/
|
|
1958
|
+
async function settleQuorumGate(options: RunOpWaveOptions, ctx: WaveContext, decision: OpWaveDecision, rules: GateQuorumRule[]): Promise<void> {
|
|
1959
|
+
const { spec, k, wave, cwd, log } = ctx;
|
|
1960
|
+
const { gate, digest, approval, base, policy, members } = decision;
|
|
1961
|
+
const { ledger, port } = await gatePort(options, cwd, gate, approval, base);
|
|
1962
|
+
const now = options.now ?? new Date().toISOString();
|
|
1963
|
+
const spent: AppliedGateRecord[] | undefined = decision.spend === "base" ? ((await ledger.readApplied?.(spec.name)) ?? []).filter((a) => a.gate === gate) : undefined;
|
|
1964
|
+
const review = await import("./gate-review");
|
|
1965
|
+
const trust = (options.trust ?? ((b) => review.trustPolicyAt(cwd, b)))(base);
|
|
1966
|
+
const label = `wave ${k} (${wave.name})`;
|
|
1967
|
+
|
|
1968
|
+
// The merged pull request: its author for notAuthor, and its review under pr-review.
|
|
1969
|
+
let merged: MergedReview | null = null;
|
|
1970
|
+
let unknown: string | undefined;
|
|
1971
|
+
const asks = approval === "pr-review" || rules.some((r) => r.notAuthor);
|
|
1972
|
+
const head = asks ? (options.head ?? execFileSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf-8" }).trim()) : "";
|
|
1973
|
+
if (asks) {
|
|
1974
|
+
try {
|
|
1975
|
+
merged = await (options.reviews ?? review.reviewSourceFromEnv()).mergedReview(head);
|
|
1976
|
+
if (!merged) unknown = `no merged pull request produced ${head.slice(0, 12)}`;
|
|
1977
|
+
} catch (err) {
|
|
1978
|
+
unknown = `the forge's review could not be read: ${err instanceof Error ? err.message : String(err)}`;
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
if (unknown && rules.some((r) => r.notAuthor)) log(`${label}: ${unknown}, so the change's author is not known`);
|
|
1982
|
+
if (approval === "pr-review" && unknown) decision.review = { reason: unknown };
|
|
1983
|
+
|
|
1984
|
+
let reviewed: { head: string; approvers: string[] } | undefined;
|
|
1985
|
+
if (approval === "pr-review" && merged) {
|
|
1986
|
+
const plans = merged.approvers.length === 0 ? null : await (options.headPlans ?? ((name, h) => readHeadPlans(name, h, cwd)))(spec.name, merged.head);
|
|
1987
|
+
const { reviewApproval } = await import("../gate-decision");
|
|
1988
|
+
const outcome = reviewApproval(merged, plans?.waves.find((w) => w.wave === k)?.digest, digest);
|
|
1989
|
+
decision.review = outcome.review;
|
|
1990
|
+
const reviewSpent = spent !== undefined && isApprovalSpent(spent, { gate, planDigest: digest, review: { head: merged.head } });
|
|
1991
|
+
if (reviewSpent) decision.review = { ...outcome.review, reason: `a wave already applied ${digest} under the review of #${merged.pr} at ${merged.head.slice(0, 12)}, so the review is spent` };
|
|
1992
|
+
if (outcome.approved && !reviewSpent) {
|
|
1993
|
+
reviewed = { head: merged.head, approvers: merged.approvers };
|
|
1994
|
+
// Each approving reviewer once, so the rules count them one by one.
|
|
1995
|
+
const had = (await ledger.read(spec.name)).resolutions.filter((r) => r.gate === gate && r.review?.head === merged!.head && samePlanDigest(r.planDigest, digest));
|
|
1996
|
+
const note = `approved by the review of #${merged.pr} at ${merged.head.slice(0, 12)}: ${merged.approvers.join(", ")}`;
|
|
1997
|
+
for (const who of merged.approvers) {
|
|
1998
|
+
if (had.some((r) => samePrincipal(r.resolvedBy, who))) continue;
|
|
1999
|
+
await ledger.appendResolution?.({
|
|
2000
|
+
op: spec.name,
|
|
2001
|
+
gate,
|
|
2002
|
+
resolvedBy: who,
|
|
2003
|
+
timestamp: now,
|
|
2004
|
+
planDigest: digest,
|
|
2005
|
+
approver: { kind: "human" },
|
|
2006
|
+
gitSha: head,
|
|
2007
|
+
...(ciRunIdFromEnv() ? { runId: ciRunIdFromEnv()! } : {}),
|
|
2008
|
+
note,
|
|
2009
|
+
...(merged.url ? { url: merged.url } : {}),
|
|
2010
|
+
review: { pr: merged.pr, head: merged.head, approvers: merged.approvers },
|
|
2011
|
+
});
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
const judge = trustQuorumJudge({
|
|
2017
|
+
signers: trust,
|
|
2018
|
+
roles: trust.roles,
|
|
2019
|
+
...(merged ? { authors: [merged.author] } : {}),
|
|
2020
|
+
...(reviewed ? { review: reviewed } : {}),
|
|
2021
|
+
});
|
|
2022
|
+
const held = decision.heldBy?.length ? `, held by ${decision.heldBy.join(", ")}` : "";
|
|
2023
|
+
const check = await decideWaveGate(port, {
|
|
2024
|
+
op: spec.name,
|
|
2025
|
+
gate,
|
|
2026
|
+
digest,
|
|
2027
|
+
description: (typeof decision.pending?.description === "string" ? decision.pending.description : undefined) ?? `wave ${k} (${wave.name}): ${members.length} run${members.length === 1 ? "" : "s"}, gate ${policy}, approval ${approval}${held}`,
|
|
2028
|
+
now,
|
|
2029
|
+
...(spent ? { spent } : {}),
|
|
2030
|
+
fields: { ...decision.pending, approvalMode: approval },
|
|
2031
|
+
...(decision.neverOverMcp ? { neverOverMcp: true } : {}),
|
|
2032
|
+
approval: { quorum: rules, mode: "log-only" },
|
|
2033
|
+
judge,
|
|
2034
|
+
});
|
|
2035
|
+
if (check.satisfied) {
|
|
2036
|
+
const by = (check.approvals ?? [check.resolution]).map((r) => r.resolvedBy);
|
|
2037
|
+
decision.status = "approved";
|
|
2038
|
+
decision.via = reviewed && check.resolution.review?.head === reviewed.head ? "pr-review" : "ledger";
|
|
2039
|
+
decision.approvedBy = by.join(", ");
|
|
2040
|
+
decision.approvedAt = check.resolution.timestamp;
|
|
2041
|
+
log(`${label}: the approval rules at ${base.slice(0, 12)} are met by ${by.join(", ")}`);
|
|
2042
|
+
return;
|
|
2043
|
+
}
|
|
2044
|
+
logRefused(log, label, check);
|
|
2045
|
+
decision.pendingFact = check.pending;
|
|
2046
|
+
decision.recorded = check.recorded;
|
|
2047
|
+
decision.waitingSince = check.pending.timestamp;
|
|
2048
|
+
if (check.mismatch) decision.mismatch = check.mismatch;
|
|
2049
|
+
if (check.quorum?.rules) {
|
|
2050
|
+
decision.quorumProgress = check.quorum.rules;
|
|
2051
|
+
for (const p of check.quorum.rules) log(`${label}: approval rule ${quorumRuleLine(p)}`);
|
|
2052
|
+
log(`${label}: the approval rules at ${base.slice(0, 12)} are not met (${quorumWaiting(check.quorum.rules)}), so it waits for more approvals of ${digest}`);
|
|
2053
|
+
}
|
|
2054
|
+
decision.status = "waiting";
|
|
2055
|
+
decision.approve = approveCommand(spec.name, gate, undefined, digest, approval === "sealed" || rules.some(ruleNeedsProof));
|
|
2056
|
+
}
|
|
2057
|
+
|
|
1836
2058
|
/**
|
|
1837
2059
|
* Decide a gate that asks a decision point (#3836), as an Op gate with a
|
|
1838
2060
|
* `point` is decided (#3170): ask it with `gate.planDigest` set to the set
|
package/src/op/op-waves.ts
CHANGED
|
@@ -43,6 +43,14 @@
|
|
|
43
43
|
* `chant/lifecycle`, as terragucci's wave gate does, and that approval is
|
|
44
44
|
* spent: the same digest needs a new approval to apply again.
|
|
45
45
|
*
|
|
46
|
+
* ## Approval rules (#4290)
|
|
47
|
+
*
|
|
48
|
+
* With `approvals: { quorum: [...] }` (read at base), a wave takes each rule
|
|
49
|
+
* whose `targets` glob matches one of its runs' targets and waits until each
|
|
50
|
+
* is met: `count` distinct people, from `roles` when it names them (granted
|
|
51
|
+
* in `.chant/trust.json` at base), one of them not the author with
|
|
52
|
+
* `notAuthor`. See `./gate-quorum.ts`.
|
|
53
|
+
*
|
|
46
54
|
* ## Wave-level commands and hooks (#3762)
|
|
47
55
|
*
|
|
48
56
|
* A product whose tool plans a whole wave in one call (Terragrunt's `run
|
|
@@ -69,6 +77,7 @@
|
|
|
69
77
|
import type { OpEnvironment, OpSetupStep } from "../lexicon";
|
|
70
78
|
import type { CiRunner } from "../ci-jobs";
|
|
71
79
|
import { gatePointProblems, type GatePoint } from "./gate-point";
|
|
80
|
+
import { quorumProblems, type GateQuorumRule } from "./gate-quorum";
|
|
72
81
|
import { notifyConfigProblem, type NotifyConfig } from "../notify";
|
|
73
82
|
import { WAVE_GATE_NAME_PATTERN, waveGateNameFrom, waveShare } from "../wave-layout";
|
|
74
83
|
|
|
@@ -156,20 +165,30 @@ export interface OpWaveHook {
|
|
|
156
165
|
onFailure?: OpWaveHookFailure;
|
|
157
166
|
/** Run once per run of the wave, rather than once for the wave. */
|
|
158
167
|
perRun?: boolean;
|
|
168
|
+
/**
|
|
169
|
+
* The runs the hook is for (#4288): targets, or patterns where `*` matches
|
|
170
|
+
* within one `/` segment and `**` across segments, as a job group's `runs`.
|
|
171
|
+
* A `perRun` hook runs for the matching runs only. A hook that runs once
|
|
172
|
+
* for the wave runs when any run matches, or when no run is known yet (a
|
|
173
|
+
* `discover` wave before its plan). Absent: every run.
|
|
174
|
+
*/
|
|
175
|
+
targets?: string[];
|
|
159
176
|
}
|
|
160
177
|
|
|
161
178
|
/** The stages a hook runs around. */
|
|
162
|
-
export type OpWaveHookStage = "plan" | "apply" | "gate";
|
|
179
|
+
export type OpWaveHookStage = "observe" | "plan" | "apply" | "gate";
|
|
163
180
|
|
|
164
181
|
/** When a hook runs. */
|
|
165
|
-
export type OpWaveHookMoment = "before-plan" | "after-plan" | "after-gate" | "before-apply" | "after-apply";
|
|
182
|
+
export type OpWaveHookMoment = "before-observe" | "before-plan" | "after-plan" | "after-gate" | "before-apply" | "after-apply";
|
|
166
183
|
|
|
167
184
|
/**
|
|
168
|
-
* Every moment, in the order a wave reaches them. `
|
|
185
|
+
* Every moment, in the order a wave reaches them. `before-observe` (#4288)
|
|
186
|
+
* runs before anything reads live state: ahead of `before-plan` in every job
|
|
187
|
+
* that plans, the deciding job's and each share's. `after-gate` (#3821) runs
|
|
169
188
|
* once the gate decided, whatever it decided: approved, let through,
|
|
170
189
|
* waiting, or refused by a policy denial.
|
|
171
190
|
*/
|
|
172
|
-
export const OP_WAVE_HOOK_MOMENTS: readonly OpWaveHookMoment[] = ["before-plan", "after-plan", "after-gate", "before-apply", "after-apply"];
|
|
191
|
+
export const OP_WAVE_HOOK_MOMENTS: readonly OpWaveHookMoment[] = ["before-observe", "before-plan", "after-plan", "after-gate", "before-apply", "after-apply"];
|
|
173
192
|
|
|
174
193
|
/**
|
|
175
194
|
* Who runs the `before.apply` and `after.apply` hooks (#3804). `chant`: the
|
|
@@ -183,7 +202,8 @@ export const OP_WAVE_APPLY_HOOKS_RUNNERS: readonly OpWaveApplyHooksRunner[] = ["
|
|
|
183
202
|
|
|
184
203
|
/** Stage hooks (#3762), read from the spec at the base commit like the gate policy. */
|
|
185
204
|
export interface OpWaveHooks {
|
|
186
|
-
before
|
|
205
|
+
/** `before.observe` (#4288) runs before anything reads live state, ahead of `before.plan`. */
|
|
206
|
+
before?: { observe?: OpWaveHook[]; plan?: OpWaveHook[]; apply?: OpWaveHook[] };
|
|
187
207
|
/** `after.gate` (#3821) runs once the gate decided, whatever it decided, with the decision in its environment. */
|
|
188
208
|
after?: { plan?: OpWaveHook[]; gate?: OpWaveHook[]; apply?: OpWaveHook[] };
|
|
189
209
|
/** Who runs the apply-stage hooks (#3804). Default `{ runBy: "chant" }`. A wave's own setting wins over the spec's. */
|
|
@@ -456,6 +476,18 @@ export interface OpWavesApprovals {
|
|
|
456
476
|
* approval of a digest passes that digest as often as it is planned.
|
|
457
477
|
*/
|
|
458
478
|
spend?: boolean;
|
|
479
|
+
/**
|
|
480
|
+
* Approval rules (#4290): a wave takes each rule whose `targets` glob
|
|
481
|
+
* matches one of its runs' targets (a rule with no `targets` covers every
|
|
482
|
+
* wave), and applies once each is met by distinct human approvers of its
|
|
483
|
+
* set digest. Read at the base commit, with the roles from
|
|
484
|
+
* `.chant/trust.json` there, so a change cannot loosen the rules its own
|
|
485
|
+
* wave is judged by. A role, or `notAuthor`, counts only a proven
|
|
486
|
+
* approver: a seal that verifies against the signers at base, or under
|
|
487
|
+
* `pr-review` the merged pull request's review. `notAuthor` reads the
|
|
488
|
+
* author from that pull request. See `./gate-quorum.ts`.
|
|
489
|
+
*/
|
|
490
|
+
quorum?: GateQuorumRule[];
|
|
459
491
|
}
|
|
460
492
|
|
|
461
493
|
/** The scheduled resume job of an Op waves pipeline (#3683). */
|
|
@@ -543,11 +575,21 @@ export function assertOpWavesSpec(spec: OpWavesSpec): void {
|
|
|
543
575
|
}
|
|
544
576
|
if (spec.approvals !== undefined) {
|
|
545
577
|
const a = spec.approvals as unknown;
|
|
546
|
-
if (a === null || typeof a !== "object" || Array.isArray(a)) throw new Error(`${where}: \`approvals\` must be { "spend"?: true | false }.`);
|
|
578
|
+
if (a === null || typeof a !== "object" || Array.isArray(a)) throw new Error(`${where}: \`approvals\` must be { "spend"?: true | false, "quorum"?: [...] }.`);
|
|
547
579
|
for (const [key, value] of Object.entries(a as Record<string, unknown>)) {
|
|
548
|
-
if (key
|
|
580
|
+
if (key === "quorum") {
|
|
581
|
+
// #4290: approval rules, read at base like the policy.
|
|
582
|
+
if (!Array.isArray(value)) throw new Error(`${where}: approvals.quorum must be a list of rules, each { "targets"?, "count", "roles"?, "notAuthor"? }.`);
|
|
583
|
+
const problems = quorumProblems(value, "approvals.quorum", { notAuthor: true });
|
|
584
|
+
if (problems.length > 0) throw new Error(`${where}: ${problems.join("; ")}.`);
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
if (key !== "spend") throw new Error(`${where}: approvals.${key} is not a setting; the ones there are approvals.spend and approvals.quorum.`);
|
|
549
588
|
if (typeof value !== "boolean") throw new Error(`${where}: approvals.spend must be true or false.`);
|
|
550
589
|
}
|
|
590
|
+
if ((a as OpWavesApprovals).quorum?.length && (spec.point !== undefined || spec.waves.some((w) => w.point !== undefined))) {
|
|
591
|
+
throw new Error(`${where}: approvals.quorum and a decision point are both set; a gate that asks a point takes its deciders and quorum from the point, so drop one of them.`);
|
|
592
|
+
}
|
|
551
593
|
}
|
|
552
594
|
if (spec.gateName !== undefined) {
|
|
553
595
|
if (typeof spec.gateName !== "string" || !spec.gateName.includes("{k}") || /\s/.test(spec.gateName)) {
|
|
@@ -720,6 +762,15 @@ export function opWaveRunMatches(pattern: string, target: string): boolean {
|
|
|
720
762
|
return new RegExp(`^${re}$`).test(target);
|
|
721
763
|
}
|
|
722
764
|
|
|
765
|
+
/**
|
|
766
|
+
* The runs of `runs` hook `hook` is for (#4288): those its `targets` match,
|
|
767
|
+
* or every run when it names none.
|
|
768
|
+
*/
|
|
769
|
+
export function opWaveHookRuns<T extends { target: string }>(hook: Pick<OpWaveHook, "targets">, runs: readonly T[]): T[] {
|
|
770
|
+
if (!hook.targets) return [...runs];
|
|
771
|
+
return runs.filter((run) => hook.targets!.some((pattern) => opWaveRunMatches(pattern, run.target)));
|
|
772
|
+
}
|
|
773
|
+
|
|
723
774
|
/** The runs of `runs` that job group `group` holds (#3914). */
|
|
724
775
|
export function opWaveGroupRuns<T extends { target: string }>(group: Pick<OpWaveJobGroup, "runs">, runs: readonly T[]): T[] {
|
|
725
776
|
return runs.filter((run) => group.runs.some((pattern) => opWaveRunMatches(pattern, run.target)));
|
|
@@ -748,7 +799,7 @@ function assertArgv(where: string, field: string, argv: unknown): void {
|
|
|
748
799
|
export function assertOpWaveHooks(where: string, hooks: OpWaveHooks | undefined): void {
|
|
749
800
|
if (hooks === undefined) return;
|
|
750
801
|
if (hooks === null || typeof hooks !== "object" || Array.isArray(hooks)) {
|
|
751
|
-
throw new Error(`${where}: \`hooks\` must be { "before"?: { "plan"?, "apply"? }, "after"?: { "plan"?, "apply"? } }.`);
|
|
802
|
+
throw new Error(`${where}: \`hooks\` must be { "before"?: { "observe"?, "plan"?, "apply"? }, "after"?: { "plan"?, "gate"?, "apply"? } }.`);
|
|
752
803
|
}
|
|
753
804
|
for (const [side, stages] of Object.entries(hooks as Record<string, unknown>)) {
|
|
754
805
|
if (side === "apply") {
|
|
@@ -766,7 +817,8 @@ export function assertOpWaveHooks(where: string, hooks: OpWaveHooks | undefined)
|
|
|
766
817
|
for (const [stage, list] of Object.entries(stages as Record<string, unknown>)) {
|
|
767
818
|
const at = `hooks.${side}.${stage}`;
|
|
768
819
|
if (stage === "gate" && side === "before") throw new Error(`${where}: ${at} is not a moment; a hook runs after the gate (hooks.after.gate), or before the plan or the apply.`);
|
|
769
|
-
if (stage
|
|
820
|
+
if (stage === "observe" && side === "after") throw new Error(`${where}: ${at} is not a moment; a hook runs before the observe (hooks.before.observe), or after the plan, which reads what the observe saw.`);
|
|
821
|
+
if (stage !== "observe" && stage !== "plan" && stage !== "apply" && stage !== "gate") throw new Error(`${where}: ${at} is not a stage; a hook runs before the observe, before or after plan or apply, or after the gate.`);
|
|
770
822
|
if (!Array.isArray(list)) throw new Error(`${where}: ${at} must be a list of hooks.`);
|
|
771
823
|
list.forEach((hook: OpWaveHook, i: number) => {
|
|
772
824
|
const which = `${at}[${i}]${hook && typeof hook === "object" && typeof hook.name === "string" ? ` (${hook.name})` : ""}`;
|
|
@@ -782,10 +834,13 @@ export function assertOpWaveHooks(where: string, hooks: OpWaveHooks | undefined)
|
|
|
782
834
|
throw new Error(`${where}: ${which} has onFailure "approve", but the gate has already decided when it runs. Use "fail", or hold the wave from an after.plan hook.`);
|
|
783
835
|
}
|
|
784
836
|
if (hook.perRun !== undefined && typeof hook.perRun !== "boolean") throw new Error(`${where}: ${which}.perRun must be true or false.`);
|
|
837
|
+
if (hook.targets !== undefined && (!Array.isArray(hook.targets) || hook.targets.length === 0 || !hook.targets.every((t) => typeof t === "string" && t !== ""))) {
|
|
838
|
+
throw new Error(`${where}: ${which}.targets must be a non-empty list of targets or patterns, such as ["prod/*"].`);
|
|
839
|
+
}
|
|
785
840
|
if (!hook.perRun) {
|
|
786
841
|
const named = hook.command.find((part) => part.includes("{target}") || part.includes("{plan}"));
|
|
787
842
|
if (named) throw new Error(`${where}: ${which} runs once for the wave, so it has no {target} or {plan} ("${named}"). Set "perRun": true, or read the runs from {runs}.`);
|
|
788
|
-
} else if (side === "before" && stage === "plan" && hook.command.some((part) => part.includes("{plan}"))) {
|
|
843
|
+
} else if (side === "before" && (stage === "plan" || stage === "observe") && hook.command.some((part) => part.includes("{plan}"))) {
|
|
789
844
|
throw new Error(`${where}: ${which} runs before the plan, so it has no {plan} yet.`);
|
|
790
845
|
}
|
|
791
846
|
});
|
|
@@ -793,6 +848,24 @@ export function assertOpWaveHooks(where: string, hooks: OpWaveHooks | undefined)
|
|
|
793
848
|
}
|
|
794
849
|
}
|
|
795
850
|
|
|
851
|
+
/**
|
|
852
|
+
* The moments a pull-request job runs hooks at (#4288): `pr-plan` and
|
|
853
|
+
* `pr-record` run the observe and plan moments, and `pr-apply` the apply
|
|
854
|
+
* moments too. The pull request's gate has no `after-gate` moment.
|
|
855
|
+
*/
|
|
856
|
+
export const PR_LOOP_HOOK_MOMENTS: readonly OpWaveHookMoment[] = ["before-observe", "before-plan", "after-plan", "before-apply", "after-apply"];
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Refuse `ci.hooks` a pull-request job could not run (#4288): what
|
|
860
|
+
* {@link assertOpWaveHooks} refuses, plus `after.gate` and `apply.runBy`,
|
|
861
|
+
* since chant itself runs a pull request's apply.
|
|
862
|
+
*/
|
|
863
|
+
export function assertPrLoopHooks(where: string, hooks: OpWaveHooks | undefined): void {
|
|
864
|
+
assertOpWaveHooks(where, hooks);
|
|
865
|
+
if (hooks?.after?.gate !== undefined) throw new Error(`${where}: hooks.after.gate runs in an Op waves job only; a pull-request job runs hooks before the observe, before and after the plan, and before and after the apply.`);
|
|
866
|
+
if (hooks?.apply !== undefined) throw new Error(`${where}: hooks.apply.runBy is for an Op waves spec; chant runs a pull request's apply, so it runs the apply-stage hooks itself.`);
|
|
867
|
+
}
|
|
868
|
+
|
|
796
869
|
/** The hooks of each moment for wave `name`: the spec's, then the wave's own. */
|
|
797
870
|
export function opWaveHooksAt(spec: Pick<OpWavesSpec, "hooks" | "waves">, name: string): Record<OpWaveHookMoment, OpWaveHook[]> {
|
|
798
871
|
const wave = spec.waves.find((w) => w.name === name);
|