@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
package/src/op/gate.ts
CHANGED
|
@@ -51,6 +51,10 @@ import { isModelAuthored } from "../lifecycle/gate-origin";
|
|
|
51
51
|
import { appliedGatePath, isApprovalSpent, parseAppliedGateLines, type AppliedGateRecord } from "../lifecycle/gate-applied";
|
|
52
52
|
import { sortedJsonReplacer } from "../utils";
|
|
53
53
|
import { gateContextDigest, type GateApprover, type ResolvedGateApproval } from "./gate-approval";
|
|
54
|
+
import {
|
|
55
|
+
quorumNeed, quorumRuleProgress, quorumRules, ruleNeedsProof, samePrincipal, trustQuorumJudge, UNPROVEN_JUDGE,
|
|
56
|
+
type GateQuorumJudge, type GateQuorumRuleProgress,
|
|
57
|
+
} from "./gate-quorum";
|
|
54
58
|
import { pushLifecycleReport, readBlobBySha, readBlobFromPath, readPathSha, requireLifecycleLedger, writeBlobToPath, RefCASConflictError } from "../lifecycle/git";
|
|
55
59
|
import { parseDuration } from "./duration";
|
|
56
60
|
import { ciRunIdFromEnv, resolveGateRunLocator } from "./gate-run-locator";
|
|
@@ -99,6 +103,14 @@ export interface GateLedgerPort {
|
|
|
99
103
|
* Absent, every approval is judged as before.
|
|
100
104
|
*/
|
|
101
105
|
approvalRule?(gate: string, at?: { base?: string }): Promise<GateApprovalRule | null>;
|
|
106
|
+
/**
|
|
107
|
+
* The judge a gate's approval rules are met by (#4290): which approvals
|
|
108
|
+
* prove their approver (a seal verified against the signers at base) and
|
|
109
|
+
* the roles `.chant/trust.json` at base gives them. `at.base` as for
|
|
110
|
+
* {@link GateLedgerPort.approvalRule}. Absent, no approval proves anyone,
|
|
111
|
+
* so a rule naming roles is never met.
|
|
112
|
+
*/
|
|
113
|
+
quorumJudge?(gate: string, at?: { base?: string }): Promise<GateQuorumJudge>;
|
|
102
114
|
/** The approvals a wave applied under (#3784), from `_gates/<op>/applied.jsonl`. Absent on a port that never spends approvals. */
|
|
103
115
|
readApplied?(op: string): Promise<AppliedGateRecord[]>;
|
|
104
116
|
/** Record that a wave is about to apply under an approval (#3784), which spends it. */
|
|
@@ -152,6 +164,12 @@ export function gitGateLedgerPort(opts?: { cwd?: string }): GateLedgerPort {
|
|
|
152
164
|
const { gateAdmission } = await import("../workspace/identity");
|
|
153
165
|
return gateAdmission(opts?.cwd ?? process.cwd(), gate, at?.base);
|
|
154
166
|
},
|
|
167
|
+
async quorumJudge(_gate, at) {
|
|
168
|
+
// #4290: the signers and role grants at base, never the approver's claim.
|
|
169
|
+
const { scopeSource } = await import("../workspace/write-scope");
|
|
170
|
+
const { policy } = scopeSource(opts?.cwd ?? process.cwd(), at?.base);
|
|
171
|
+
return trustQuorumJudge({ signers: policy, roles: policy.roles });
|
|
172
|
+
},
|
|
155
173
|
async appendResolution(input) {
|
|
156
174
|
const { record } = await appendGateResolution(input, opts);
|
|
157
175
|
return { record, ...(await pushLifecycleReport(opts)) };
|
|
@@ -293,6 +311,13 @@ export interface GateCheckInput {
|
|
|
293
311
|
* it so `chant approve` can evaluate the policy.
|
|
294
312
|
*/
|
|
295
313
|
approval?: ResolvedGateApproval;
|
|
314
|
+
/**
|
|
315
|
+
* Judges approvals for `approval.quorum`'s rules (#4290). Absent, the
|
|
316
|
+
* port's {@link GateLedgerPort.quorumJudge} at its default base, else no
|
|
317
|
+
* approval proves anyone. A wave passes one that knows the change's author
|
|
318
|
+
* and the merged pull request's review.
|
|
319
|
+
*/
|
|
320
|
+
judge?: GateQuorumJudge;
|
|
296
321
|
/** ISO-8601 "now" — supplied by the caller, so the decision is deterministic under test. */
|
|
297
322
|
now?: string;
|
|
298
323
|
/**
|
|
@@ -399,6 +424,8 @@ export interface GateQuorumProgress {
|
|
|
399
424
|
/** Approvers whose approval counts toward the quorum, oldest first. */
|
|
400
425
|
approvers: string[];
|
|
401
426
|
need: number;
|
|
427
|
+
/** Where each approval rule the gate takes stands (#4290). Absent when it takes none. */
|
|
428
|
+
rules?: GateQuorumRuleProgress[];
|
|
402
429
|
}
|
|
403
430
|
|
|
404
431
|
/** Either the gate is answered, or it is a standing fact. */
|
|
@@ -464,8 +491,12 @@ export function approverOf(record: GateResolutionRecord): GateApprover {
|
|
|
464
491
|
export interface GateTally {
|
|
465
492
|
/** Distinct human approvers of this plan who count toward the quorum, one record each (their newest), oldest first. */
|
|
466
493
|
counted: GateResolutionRecord[];
|
|
467
|
-
/** The
|
|
494
|
+
/** The most approvers a rule needs, 1 when the gate declares none. */
|
|
468
495
|
need: number;
|
|
496
|
+
/** Every rule is met (#4290); with no rules, one human approved. */
|
|
497
|
+
met: boolean;
|
|
498
|
+
/** Where each rule stands (#4290). Absent when the gate takes no rule. */
|
|
499
|
+
rules?: GateQuorumRuleProgress[];
|
|
469
500
|
/** The newest approval whose recorded permit passes the gate on its own. Only in `enforce` mode, only for a permit recorded under `enforce`, and only under the gate's current policy version and resolved context. */
|
|
470
501
|
permit?: GateResolutionRecord;
|
|
471
502
|
/** The newest approval of this gate for a different plan, when the gate binds one. */
|
|
@@ -483,8 +514,12 @@ export interface GateTally {
|
|
|
483
514
|
* pending fact re-recorded for a changed mode or context keeps a partly
|
|
484
515
|
* filled quorum (#3206). It defaults to `sinceIso`.
|
|
485
516
|
*
|
|
486
|
-
* Of those, only a human's counts toward the quorum, once per `resolvedBy
|
|
487
|
-
*
|
|
517
|
+
* Of those, only a human's counts toward the quorum, once per `resolvedBy`.
|
|
518
|
+
* The quorum is a list of rules (#4290, `./gate-quorum.ts`), already the ones
|
|
519
|
+
* the gate takes: each is met by distinct humans, counting toward `roles`
|
|
520
|
+
* only an approver `judge` proves and gives one of them (roles from
|
|
521
|
+
* `.chant/trust.json` at base, never the approver's claim), and the gate
|
|
522
|
+
* passes when every rule is met. With no rule, one human passes it. An agent counts
|
|
488
523
|
* only through a recorded `allow` in `enforce` mode, evaluated under the
|
|
489
524
|
* policy version the gate declares now, recorded while the gate was in
|
|
490
525
|
* `enforce` (#2512), and evaluated under the context the gate resolves now
|
|
@@ -499,14 +534,16 @@ export function tallyGateApprovals(
|
|
|
499
534
|
planDigest: string | undefined,
|
|
500
535
|
approval: ResolvedGateApproval,
|
|
501
536
|
humansSinceIso: string = sinceIso,
|
|
537
|
+
judge: GateQuorumJudge = UNPROVEN_JUDGE,
|
|
502
538
|
): GateTally {
|
|
503
539
|
const since = new Date(sinceIso).getTime();
|
|
504
540
|
const humansSince = Math.min(since, new Date(humansSinceIso).getTime());
|
|
505
541
|
const at = (r: GateResolutionRecord) => new Date(r.timestamp).getTime();
|
|
506
|
-
const
|
|
542
|
+
const rules = quorumRules(approval.quorum);
|
|
507
543
|
const context = gateContextDigest(approval.context);
|
|
508
544
|
|
|
509
|
-
|
|
545
|
+
// Each human once: their newest approval, or their newest proven one when they have one.
|
|
546
|
+
const byActor = new Map<string, { record: GateResolutionRecord; proven: boolean }>();
|
|
510
547
|
let permit: GateResolutionRecord | undefined;
|
|
511
548
|
let mismatched: GateResolutionRecord | undefined;
|
|
512
549
|
for (const r of records) {
|
|
@@ -534,20 +571,20 @@ export function tallyGateApprovals(
|
|
|
534
571
|
permit = r;
|
|
535
572
|
}
|
|
536
573
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
const prior = byActor.get(
|
|
541
|
-
if (!prior || at(r) >= at(prior)) byActor.set(r
|
|
574
|
+
if (approverOf(r).kind !== "human") continue;
|
|
575
|
+
const key = r.resolvedBy.normalize("NFKC").trim().toLowerCase();
|
|
576
|
+
const proven = rules.some(ruleNeedsProof) && judge(r).proven;
|
|
577
|
+
const prior = byActor.get(key);
|
|
578
|
+
if (!prior || (proven && !prior.proven) || (proven === prior.proven && at(r) >= at(prior.record))) byActor.set(key, { record: r, proven });
|
|
542
579
|
}
|
|
543
580
|
|
|
544
|
-
const
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
};
|
|
581
|
+
const humans = [...byActor.values()].map((v) => v.record).sort((a, b) => at(a) - at(b));
|
|
582
|
+
const extra = { ...(permit ? { permit } : {}), ...(mismatched ? { mismatched } : {}) };
|
|
583
|
+
if (rules.length === 0) return { counted: humans, need: 1, met: humans.length >= 1, ...extra };
|
|
584
|
+
const approvers = humans.map((r) => ({ who: r.resolvedBy, at: r.timestamp, judged: judge(r) }));
|
|
585
|
+
const progress = rules.map((rule) => quorumRuleProgress(rule, approvers));
|
|
586
|
+
const counted = humans.filter((r) => progress.some((p) => p.approvers.some((a) => samePrincipal(a, r.resolvedBy))));
|
|
587
|
+
return { counted, need: quorumNeed(rules), met: progress.every((p) => p.met), rules: progress, ...extra };
|
|
551
588
|
}
|
|
552
589
|
|
|
553
590
|
/**
|
|
@@ -807,11 +844,15 @@ export async function evaluateGate(port: GateLedgerPort, input: GateCheckInput):
|
|
|
807
844
|
standing.approval?.policy?.version === input.approval.policy?.version
|
|
808
845
|
? humanQuorumSince(pending, standing)
|
|
809
846
|
: since;
|
|
810
|
-
|
|
847
|
+
// #4290: who an approval proves, and their roles at base, for rules that read them.
|
|
848
|
+
const judge = quorumRules(input.approval.quorum).some(ruleNeedsProof)
|
|
849
|
+
? (input.judge ?? (await port.quorumJudge?.(input.gate)) ?? UNPROVEN_JUDGE)
|
|
850
|
+
: UNPROVEN_JUDGE;
|
|
851
|
+
const tally = tallyGateApprovals(resolutions, input.gate, since, input.planDigest, input.approval, humansSince, judge);
|
|
811
852
|
if (tally.permit) {
|
|
812
853
|
return { satisfied: true, resolution: tally.permit, via: "policy", approvals: tally.counted };
|
|
813
854
|
}
|
|
814
|
-
if (tally.
|
|
855
|
+
if (tally.met) {
|
|
815
856
|
return {
|
|
816
857
|
satisfied: true,
|
|
817
858
|
resolution: tally.counted[tally.counted.length - 1]!,
|
|
@@ -820,19 +861,19 @@ export async function evaluateGate(port: GateLedgerPort, input: GateCheckInput):
|
|
|
820
861
|
};
|
|
821
862
|
}
|
|
822
863
|
mismatched = tally.mismatched;
|
|
823
|
-
quorum = { approvers: tally.counted.map((r) => r.resolvedBy), need: tally.need };
|
|
864
|
+
quorum = { approvers: tally.counted.map((r) => r.resolvedBy), need: tally.need, ...(tally.rules ? { rules: tally.rules } : {}) };
|
|
824
865
|
// #3949: old and new approvals add up toward one quorum, each approver once.
|
|
825
866
|
const counted = new Set(tally.counted.map((r) => r.resolvedBy));
|
|
826
867
|
const extra = fromAliases.filter((r) => !counted.has(r.resolvedBy));
|
|
827
868
|
if (extra.length > 0) {
|
|
828
869
|
const asCarried = new Map(extra.map((r) => [{ version: 1, ...carriedApproval(r, input.op, input.gate, now) } as GateResolutionRecord, r]));
|
|
829
|
-
const combined = tallyGateApprovals([...resolutions, ...asCarried.keys()], input.gate, since, input.planDigest, input.approval, humansSince);
|
|
830
|
-
if (combined.
|
|
870
|
+
const combined = tallyGateApprovals([...resolutions, ...asCarried.keys()], input.gate, since, input.planDigest, input.approval, humansSince, judge);
|
|
871
|
+
if (combined.met) {
|
|
831
872
|
const written = await carry(combined.counted.filter((r) => asCarried.has(r)).map((r) => asCarried.get(r)!));
|
|
832
873
|
const approvals = [...combined.counted.filter((r) => !asCarried.has(r)), ...written];
|
|
833
874
|
return { satisfied: true, resolution: written[written.length - 1]!, via: "alias", approvals, carried: written };
|
|
834
875
|
}
|
|
835
|
-
quorum = { approvers: combined.counted.map((r) => r.resolvedBy), need: combined.need };
|
|
876
|
+
quorum = { approvers: combined.counted.map((r) => r.resolvedBy), need: combined.need, ...(combined.rules ? { rules: combined.rules } : {}) };
|
|
836
877
|
}
|
|
837
878
|
} else {
|
|
838
879
|
const found = latestResolutionForPlan(resolutions, input.gate, since, input.planDigest);
|
package/src/op/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { Op, phase, activity, gate, effect, build, kubectlApply, helmInstall, helmInstallPinned, waitForStack, waitForReady,
|
|
2
|
-
gitlabPipeline, lifecycleSnapshot, shell, sourceArchive, releasePlan, releaseRecord, composeChangeSet, lifecyclePlanChangeSet, readChangeSetPart, releaseRollbackPlan, releaseRollbackRecord, decide, ensureSecret, teardown, envTeardown, k3dUp, k3dDown,
|
|
2
|
+
gitlabPipeline, lifecycleSnapshot, shell, sourceArchive, releasePlan, releaseRecord, composeChangeSet, lifecyclePlanChangeSet, readChangeSetPart, reviewAgent, releaseRollbackPlan, releaseRollbackRecord, decide, ensureSecret, teardown, envTeardown, k3dUp, k3dDown,
|
|
3
3
|
k3sInstall, k3sUninstall, flociUp, flociDown,
|
|
4
4
|
flociAzUp, flociAzDown, flociGcpUp, flociGcpDown, httpCheck,
|
|
5
5
|
azGroupEnsure, azGroupDelete, azApply, azDelete, awsApply, awsDelete, gcpApply, gcpDelete, policyGate,
|
|
@@ -57,10 +57,13 @@ export {
|
|
|
57
57
|
OP_WAVE_DEFAULT_GATE_NAME,
|
|
58
58
|
OP_WAVE_GATE_POLICIES,
|
|
59
59
|
OP_WAVE_HOOK_MOMENTS,
|
|
60
|
+
PR_LOOP_HOOK_MOMENTS,
|
|
60
61
|
assertOpWaveHooks,
|
|
61
62
|
assertOpWavesSpec,
|
|
63
|
+
assertPrLoopHooks,
|
|
62
64
|
opWaveApplyHooksRunBy,
|
|
63
65
|
opWaveGateName,
|
|
66
|
+
opWaveHookRuns,
|
|
64
67
|
opWaveHooksAt,
|
|
65
68
|
opWaveJobs,
|
|
66
69
|
opWaveRecordPath,
|
|
@@ -71,6 +74,7 @@ export {
|
|
|
71
74
|
OP_WAVE_APPLY_OUTCOMES,
|
|
72
75
|
OP_WAVE_MAX_REPLANS,
|
|
73
76
|
opWaveHooksAtBase,
|
|
77
|
+
runStageHooks,
|
|
74
78
|
opWaveNeedsApproval,
|
|
75
79
|
opWavePlansPath,
|
|
76
80
|
opWaveRunsPath,
|
|
@@ -102,6 +106,8 @@ export type {
|
|
|
102
106
|
OpWaveShowAtBase,
|
|
103
107
|
RecordHeadPlansOptions,
|
|
104
108
|
RunOpWaveApplyHooksOptions,
|
|
109
|
+
RunStageHooksOptions,
|
|
110
|
+
StageHooksRun,
|
|
105
111
|
RunOpWaveOptions,
|
|
106
112
|
RunOpWaveResult,
|
|
107
113
|
} from "./op-waves-run";
|
|
@@ -130,6 +136,12 @@ export type {
|
|
|
130
136
|
GateApproval, GateApprovalMode, GateApprover, GateContextValue, GatePolicyAnswer, GatePolicyDecision,
|
|
131
137
|
GatePolicyEvaluator, GatePolicyRef, GatePolicyRequest, GateQuorum, ResolvedGateApproval,
|
|
132
138
|
} from "./gate-approval";
|
|
139
|
+
export {
|
|
140
|
+
quorumRules, quorumRulesFor, quorumNeed, quorumProblems, quorumRuleProgress, quorumRuleLine, quorumWaiting, rolesOf, trustQuorumJudge, UNPROVEN_JUDGE,
|
|
141
|
+
} from "./gate-quorum";
|
|
142
|
+
export type {
|
|
143
|
+
GateQuorumRule, GateQuorumSpec, GateQuorumJudge, GateQuorumRuleProgress, QuorumJudgement, QuorumApprover, TrustQuorumInput,
|
|
144
|
+
} from "./gate-quorum";
|
|
133
145
|
export {
|
|
134
146
|
gatePlanSummary, gatePlanSummaryOfResult, changeSetOfResult, GATE_PLAN_CONTEXT_KEY, GATE_PLAN_ADDRESS_LIMIT,
|
|
135
147
|
} from "./gate-plan-context";
|
package/src/op/local-executor.ts
CHANGED
|
@@ -41,6 +41,7 @@ import { resolveGateAliases } from "./gate-alias";
|
|
|
41
41
|
import { gatePointOf } from "./gate-point";
|
|
42
42
|
import { evaluatePointGate, workspaceGatePointAsker, type GatePointAsker } from "./gate-point-run";
|
|
43
43
|
import type { ResolvedGateApproval } from "./gate-approval";
|
|
44
|
+
import { quorumRules, quorumRulesFor } from "./gate-quorum";
|
|
44
45
|
import { GATE_PLAN_CONTEXT_KEY, gatePlanSummaryOfResult } from "./gate-plan-context";
|
|
45
46
|
import { withOpRunContext, type OpRunContext, type PassedGate } from "./run-context";
|
|
46
47
|
import type { GateAnswerRef, PendingGateRecord } from "../lifecycle/gate-ledger";
|
|
@@ -732,6 +733,7 @@ function gateFn(step: GateStep): string {
|
|
|
732
733
|
function resolveGateApproval(
|
|
733
734
|
step: GateStep,
|
|
734
735
|
resultsById: ReadonlyMap<string, unknown>,
|
|
736
|
+
env?: string,
|
|
735
737
|
): ResolvedGateApproval | undefined {
|
|
736
738
|
const authored = step.approval;
|
|
737
739
|
if (!authored) return undefined;
|
|
@@ -750,8 +752,13 @@ function resolveGateApproval(
|
|
|
750
752
|
const summary = gatePlanSummaryOfResult(resultsById.get(step.plan.step));
|
|
751
753
|
if (summary) context = { ...(context ?? {}), [GATE_PLAN_CONTEXT_KEY]: summary };
|
|
752
754
|
}
|
|
755
|
+
// #4290: the rules this run's gate takes, by its environment. A rule with
|
|
756
|
+
// `targets` never covers a run with no environment. One rule with no
|
|
757
|
+
// `targets` keeps its own shape, so a standing pending fact still matches.
|
|
758
|
+
const single = authored.quorum !== undefined && !Array.isArray(authored.quorum) && (authored.quorum as { targets?: unknown }).targets === undefined;
|
|
759
|
+
const rules = authored.quorum ? quorumRulesFor(quorumRules(authored.quorum), env !== undefined ? [env] : []) : [];
|
|
753
760
|
return {
|
|
754
|
-
...(
|
|
761
|
+
...(single ? { quorum: authored.quorum } : rules.length > 0 ? { quorum: rules } : {}),
|
|
755
762
|
...(authored.policy ? { policy: authored.policy } : {}),
|
|
756
763
|
mode: authored.mode ?? "log-only",
|
|
757
764
|
...(context ? { context } : {}),
|
|
@@ -779,7 +786,7 @@ async function runGateStep(
|
|
|
779
786
|
// a run over a missing digest.
|
|
780
787
|
const resolvedPlan = resolveStepOutputRefs(step.plan, resultsById);
|
|
781
788
|
const planDigest = typeof resolvedPlan === "string" && resolvedPlan !== "" ? resolvedPlan : undefined;
|
|
782
|
-
const approval = resolveGateApproval(step, resultsById);
|
|
789
|
+
const approval = resolveGateApproval(step, resultsById, gates.env);
|
|
783
790
|
// #3947: a gate that resumes stores the outputs the steps after it read.
|
|
784
791
|
const resumeAt = gates.resume?.gates.get(step);
|
|
785
792
|
let checkpoint: GateCheckpoint | undefined;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook `targets` and the `before.observe` moment (#4288), in an Op waves
|
|
3
|
+
* job and through `runStageHooks`.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, test } from "vitest";
|
|
6
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { assertOpWaveHooks, assertPrLoopHooks, opWaveHookRuns, opWaveHooksAt, OP_WAVE_HOOK_MOMENTS, type OpWavesSpec } from "./op-waves";
|
|
10
|
+
import { runOpWave, runStageHooks, type OpWaveExec, type OpWaveShowAtBase } from "./op-waves-run";
|
|
11
|
+
import { memoryGateLedgerPort } from "./gate";
|
|
12
|
+
|
|
13
|
+
const digest = (n: string) => `sha256:${n.repeat(64)}`;
|
|
14
|
+
|
|
15
|
+
describe("hook targets and before.observe in the spec (#4288)", () => {
|
|
16
|
+
test("before.observe is the first moment, and opWaveHooksAt reads it", () => {
|
|
17
|
+
expect(OP_WAVE_HOOK_MOMENTS[0]).toBe("before-observe");
|
|
18
|
+
const hooks = opWaveHooksAt({ hooks: { before: { observe: [{ command: ["init"] }] } }, waves: [{ name: "prod", runs: [] }] }, "prod");
|
|
19
|
+
expect(hooks["before-observe"]).toEqual([{ command: ["init"] }]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("the spec refuses after.observe, {plan} before the observe and targets that are not a list of names", () => {
|
|
23
|
+
expect(() => assertOpWaveHooks("w", { after: { observe: [{ command: ["x"] }] } } as never)).toThrow(/hooks.after.observe is not a moment/);
|
|
24
|
+
expect(() => assertOpWaveHooks("w", { before: { observe: [{ command: ["x", "{plan}"], perRun: true }] } })).toThrow(/no \{plan\} yet/);
|
|
25
|
+
expect(() => assertOpWaveHooks("w", { before: { plan: [{ command: ["x"], targets: [] }] } })).toThrow(/targets must be a non-empty list/);
|
|
26
|
+
expect(() => assertOpWaveHooks("w", { before: { plan: [{ command: ["x"], targets: ["prod/*", 3] as never }] } })).toThrow(/targets must be a non-empty list/);
|
|
27
|
+
expect(() => assertOpWaveHooks("w", { before: { observe: [{ command: ["x"], targets: ["prod/**"], onFailure: "approve" }] } })).not.toThrow();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("a pull-request job refuses after.gate and apply.runBy", () => {
|
|
31
|
+
expect(() => assertPrLoopHooks("ci.hooks", { after: { gate: [{ command: ["x"] }] } })).toThrow(/after.gate runs in an Op waves job only/);
|
|
32
|
+
expect(() => assertPrLoopHooks("ci.hooks", { apply: { runBy: "apply" } })).toThrow(/runBy is for an Op waves spec/);
|
|
33
|
+
expect(() => assertPrLoopHooks("ci.hooks", { before: { observe: [{ command: ["x"] }], apply: [{ command: ["y"] }] } })).not.toThrow();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("targets match a run by name or by a pattern, as a job group's runs do", () => {
|
|
37
|
+
const runs = [{ target: "prod/eu" }, { target: "prod/us/east" }, { target: "dev" }];
|
|
38
|
+
expect(opWaveHookRuns({}, runs).map((r) => r.target)).toEqual(["prod/eu", "prod/us/east", "dev"]);
|
|
39
|
+
expect(opWaveHookRuns({ targets: ["prod/*"] }, runs).map((r) => r.target)).toEqual(["prod/eu"]);
|
|
40
|
+
expect(opWaveHookRuns({ targets: ["prod/**", "dev"] }, runs).map((r) => r.target)).toEqual(["prod/eu", "prod/us/east", "dev"]);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("runOpWave with targets and before.observe (#4288)", () => {
|
|
45
|
+
const spec: OpWavesSpec = {
|
|
46
|
+
name: "roots",
|
|
47
|
+
op: "tf",
|
|
48
|
+
plan: ["tool", "plan", "{target}", "{plan}"],
|
|
49
|
+
apply: ["tool", "apply", "{target}", "{plan}"],
|
|
50
|
+
hooks: {
|
|
51
|
+
before: {
|
|
52
|
+
observe: [{ name: "init", command: ["hook", "init", "{target}"], perRun: true, targets: ["eu/*"] }],
|
|
53
|
+
plan: [{ name: "us-only", command: ["hook", "us", "{runs}"], targets: ["us/*"] }],
|
|
54
|
+
},
|
|
55
|
+
after: { plan: [{ name: "eu-check", command: ["hook", "eu-check", "{runs}"], targets: ["eu/*"] }] },
|
|
56
|
+
},
|
|
57
|
+
waves: [{ name: "prod", runs: [{ target: "eu/a" }, { target: "eu/b" }, { target: "asia/c" }], gate: "never" }],
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function harness(fails: string[] = []) {
|
|
61
|
+
const cwd = mkdtempSync(join(tmpdir(), "op-waves-targets-"));
|
|
62
|
+
const calls: Array<{ argv: string; env?: Record<string, string> }> = [];
|
|
63
|
+
const exec: OpWaveExec = (argv, dir, env) => {
|
|
64
|
+
calls.push({ argv: argv.join(" "), ...(env ? { env } : {}) });
|
|
65
|
+
if (argv[1] === "plan") writeFileSync(join(dir, argv[3]!), JSON.stringify({ planDigest: digest(argv[2]!.endsWith("a") ? "1" : "2") }));
|
|
66
|
+
return fails.some((f) => argv.join(" ").startsWith(f)) ? 1 : 0;
|
|
67
|
+
};
|
|
68
|
+
const show: OpWaveShowAtBase = () => ({ sha: "b".repeat(40), text: JSON.stringify(spec) });
|
|
69
|
+
return { cwd, calls, exec, show, argv: () => calls.map((c) => c.argv), done: () => rmSync(cwd, { recursive: true, force: true }) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
test("before-observe runs ahead of before-plan, each hook only for the runs its targets match", async () => {
|
|
73
|
+
const h = harness();
|
|
74
|
+
const logs: string[] = [];
|
|
75
|
+
const result = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort(), log: (l) => logs.push(l) });
|
|
76
|
+
expect(result.exitCode).toBe(0);
|
|
77
|
+
expect(h.argv().filter((c) => c.startsWith("hook") || c.startsWith("tool plan"))).toEqual([
|
|
78
|
+
"hook init eu/a",
|
|
79
|
+
"hook init eu/b",
|
|
80
|
+
"tool plan eu/a .chant/op-waves/roots/wave-1/eu_a.plan.json",
|
|
81
|
+
"tool plan eu/b .chant/op-waves/roots/wave-1/eu_b.plan.json",
|
|
82
|
+
"tool plan asia/c .chant/op-waves/roots/wave-1/asia_c.plan.json",
|
|
83
|
+
"hook eu-check .chant/op-waves/roots/wave-1/runs.json",
|
|
84
|
+
]);
|
|
85
|
+
expect(h.calls[0]!.env).toMatchObject({ CHANT_HOOK: "before-observe", CHANT_TARGET: "eu/a" });
|
|
86
|
+
expect(logs).toContain("before-plan hook us-only: no run matches its targets (us/*), so it did not run");
|
|
87
|
+
expect(result.hooks.map((o) => `${o.moment} ${o.name}${o.target ? ` ${o.target}` : ""} ${o.outcome}`)).toEqual([
|
|
88
|
+
"before-observe init eu/a passed",
|
|
89
|
+
"before-observe init eu/b passed",
|
|
90
|
+
"after-plan eu-check passed",
|
|
91
|
+
]);
|
|
92
|
+
h.done();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("a failed before-observe hook stops the wave before anything plans", async () => {
|
|
96
|
+
const h = harness(["hook init eu/b"]);
|
|
97
|
+
const result = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort(), log: () => {} });
|
|
98
|
+
expect(result.exitCode).toBe(1);
|
|
99
|
+
expect(h.argv().some((c) => c.startsWith("tool"))).toBe(false);
|
|
100
|
+
h.done();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("runStageHooks (#4288)", () => {
|
|
105
|
+
test("runs one moment's hooks for the runs given, with targets, and counts an approve failure as a hold", () => {
|
|
106
|
+
const cwd = mkdtempSync(join(tmpdir(), "stage-hooks-"));
|
|
107
|
+
const calls: string[] = [];
|
|
108
|
+
const exec: OpWaveExec = (argv) => {
|
|
109
|
+
calls.push(argv.join(" "));
|
|
110
|
+
return argv[1] === "gate" ? 1 : 0;
|
|
111
|
+
};
|
|
112
|
+
const ran = runStageHooks(
|
|
113
|
+
"before-plan",
|
|
114
|
+
[
|
|
115
|
+
{ name: "each", command: ["hook", "each", "{target}", "{op}"], perRun: true, targets: ["api"] },
|
|
116
|
+
{ name: "gate", command: ["hook", "gate", "{wave}"], onFailure: "approve" },
|
|
117
|
+
],
|
|
118
|
+
{ name: "pr-7", op: "pr-7", wave: "prod", runs: [{ target: "api", op: "pr-7" }, { target: "web", op: "pr-7" }], runsFile: "runs.json", holdFile: "hold.json", cwd, exec, log: () => {} },
|
|
119
|
+
);
|
|
120
|
+
expect(calls).toEqual(["hook each api pr-7", "hook gate prod"]);
|
|
121
|
+
expect(ran.failed).toBeUndefined();
|
|
122
|
+
expect(ran.holds).toEqual(["before-plan hook gate"]);
|
|
123
|
+
expect(ran.outcomes.map((o) => o.outcome)).toEqual(["passed", "held"]);
|
|
124
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A plan the product's policy holds (#4294): the wave waits for a person's
|
|
3
|
+
* approval of its set digest whatever its gate policy says, and applies
|
|
4
|
+
* once one stands.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "vitest";
|
|
8
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import type { OpWavesSpec } from "./op-waves";
|
|
12
|
+
import { runOpWave, type OpWaveExec, type OpWaveShowAtBase } from "./op-waves-run";
|
|
13
|
+
import { memoryGateLedgerPort } from "./gate";
|
|
14
|
+
import { waveSetDigest } from "../gated-waves";
|
|
15
|
+
|
|
16
|
+
const spec: OpWavesSpec = {
|
|
17
|
+
name: "roots",
|
|
18
|
+
op: "tf",
|
|
19
|
+
plan: ["tool", "plan", "{target}", "--out", "{plan}"],
|
|
20
|
+
apply: ["tool", "apply", "{target}", "--plan", "{plan}"],
|
|
21
|
+
waves: [{ name: "dev", runs: [{ target: "vpc" }], gate: "never" }],
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const digest = (n: string) => `sha256:${n.repeat(64)}`;
|
|
25
|
+
|
|
26
|
+
function harness(plan: Record<string, unknown>) {
|
|
27
|
+
const cwd = mkdtempSync(join(tmpdir(), "op-waves-hold-"));
|
|
28
|
+
const calls: string[] = [];
|
|
29
|
+
const exec: OpWaveExec = (argv, dir) => {
|
|
30
|
+
calls.push(argv.join(" "));
|
|
31
|
+
if (argv[1] === "plan") writeFileSync(join(dir, argv[4]!), JSON.stringify(plan));
|
|
32
|
+
return 0;
|
|
33
|
+
};
|
|
34
|
+
const show: OpWaveShowAtBase = () => ({ sha: "b".repeat(40), text: JSON.stringify(spec) });
|
|
35
|
+
return { cwd, calls, exec, show, done: () => rmSync(cwd, { recursive: true, force: true }) };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("a policy hold on an Op waves plan (#4294)", () => {
|
|
39
|
+
test("a held run makes a gate: never wave wait, and an approval of its digest lets it apply", async () => {
|
|
40
|
+
const h = harness({ planDigest: digest("1"), held: { rules: ["main.hold_iam", "main.hold_iam"], reason: "an IAM change" } });
|
|
41
|
+
const waiting = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort() });
|
|
42
|
+
expect(waiting.exitCode).toBe(3);
|
|
43
|
+
expect(waiting.decision).toMatchObject({ status: "waiting", policy: "never", heldBy: ["policy on vpc (main.hold_iam): an IAM change"] });
|
|
44
|
+
expect(waiting.decision!.members[0]).toMatchObject({ member: "vpc", held: { rules: ["main.hold_iam"] } });
|
|
45
|
+
expect(h.calls.some((c) => c.startsWith("tool apply"))).toBe(false);
|
|
46
|
+
|
|
47
|
+
const set = waveSetDigest([{ member: "vpc", planDigest: digest("1") }]);
|
|
48
|
+
const gates = memoryGateLedgerPort({ resolutions: [{ version: 1, op: "roots", gate: "roots-wave-1", resolvedBy: "alice", timestamp: "2026-10-10T00:00:00Z", planDigest: set }] });
|
|
49
|
+
const approved = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates });
|
|
50
|
+
expect(approved).toMatchObject({ exitCode: 0, applied: ["vpc"], decision: { status: "approved", approvedBy: "alice" } });
|
|
51
|
+
h.done();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("a run with no hold under gate: never needs no approval", async () => {
|
|
55
|
+
const h = harness({ planDigest: digest("2") });
|
|
56
|
+
const free = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort() });
|
|
57
|
+
expect(free).toMatchObject({ exitCode: 0, applied: ["vpc"], decision: { status: "not-required" } });
|
|
58
|
+
h.done();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("a hold that names no rule is refused", async () => {
|
|
62
|
+
const h = harness({ planDigest: digest("3"), held: { rules: [] } });
|
|
63
|
+
await expect(runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort() })).rejects.toThrow(/a hold is \{ "rules"/);
|
|
64
|
+
h.done();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { assertOpWavesSpec, type OpWavesSpec } from "./op-waves";
|
|
6
|
+
import { runOpWave, type OpWaveExec, type OpWaveShowAtBase } from "./op-waves-run";
|
|
7
|
+
import { memoryGateLedgerPort } from "./gate";
|
|
8
|
+
import { waveSetDigest } from "../gated-waves";
|
|
9
|
+
import type { TrustPolicy } from "../workspace/trust/policy";
|
|
10
|
+
import type { MergedReview, ReviewSource } from "./gate-review";
|
|
11
|
+
|
|
12
|
+
const digest = (n: string) => `sha256:${n.repeat(64)}`;
|
|
13
|
+
|
|
14
|
+
const spec: OpWavesSpec = {
|
|
15
|
+
name: "migrations",
|
|
16
|
+
op: "migrate",
|
|
17
|
+
plan: ["tool", "plan", "{target}", "--out", "{plan}"],
|
|
18
|
+
apply: ["tool", "apply", "{target}", "--plan", "{plan}"],
|
|
19
|
+
waves: [{ name: "prod", runs: [{ target: "prod-eu" }, { target: "prod-us" }] }],
|
|
20
|
+
};
|
|
21
|
+
const set = waveSetDigest([
|
|
22
|
+
{ member: "prod-eu", planDigest: digest("e") },
|
|
23
|
+
{ member: "prod-us", planDigest: digest("u") },
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
function harness(atBase: OpWavesSpec) {
|
|
27
|
+
const cwd = mkdtempSync(join(tmpdir(), "op-waves-quorum-"));
|
|
28
|
+
const calls: string[] = [];
|
|
29
|
+
const exec: OpWaveExec = (argv, dir) => {
|
|
30
|
+
calls.push(argv.join(" "));
|
|
31
|
+
if (argv[1] === "plan") writeFileSync(join(dir, argv[4]!), JSON.stringify({ planDigest: digest(argv[2] === "prod-eu" ? "e" : "u") }));
|
|
32
|
+
return 0;
|
|
33
|
+
};
|
|
34
|
+
const show: OpWaveShowAtBase = () => ({ sha: "b".repeat(40), text: JSON.stringify(atBase) });
|
|
35
|
+
return { cwd, exec, show, applies: () => calls.filter((c) => c.startsWith("tool apply")).length, done: () => rmSync(cwd, { recursive: true, force: true }) };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const trust = (roles: Record<string, string[]>): (() => TrustPolicy) => () =>
|
|
39
|
+
({ base: "b".repeat(40), signersPath: ".chant/allowed_signers", active: false, signers: [], excluded: [], roles, adopted: [], runners: [], excludedRunners: [], admitted: {}, problems: [] }) as TrustPolicy;
|
|
40
|
+
|
|
41
|
+
const reviews = (merged: MergedReview | null): ReviewSource => ({ kind: "github", mergedReview: async () => merged });
|
|
42
|
+
const approval = (who: string, at: string) => ({ version: 1 as const, op: "migrations", gate: "migrations-wave-1", resolvedBy: who, timestamp: at, planDigest: set });
|
|
43
|
+
|
|
44
|
+
describe("Op waves approval rules (#4290)", () => {
|
|
45
|
+
test("the spec checks approvals.quorum, and refuses it beside a decision point", () => {
|
|
46
|
+
expect(() => assertOpWavesSpec({ ...spec, approvals: { quorum: [{ targets: ["prod-*"], count: 2, roles: ["platform"], notAuthor: true }] } })).not.toThrow();
|
|
47
|
+
expect(() => assertOpWavesSpec({ ...spec, approvals: { quorum: [{ count: 0 }] } })).toThrow(/approvals.quorum\[0\].count/);
|
|
48
|
+
expect(() => assertOpWavesSpec({ ...spec, approvals: { quorum: { count: 1 } } } as unknown as OpWavesSpec)).toThrow(/must be a list of rules/);
|
|
49
|
+
expect(() => assertOpWavesSpec({ ...spec, point: "ship", approvals: { quorum: [{ count: 1 }] } })).toThrow(/both set/);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("a rule at base whose glob matches a run holds the wave until two people approve", async () => {
|
|
53
|
+
const atBase: OpWavesSpec = { ...spec, approvals: { quorum: [{ targets: ["prod-*"], count: 2 }, { targets: ["dev"], count: 5 }] } };
|
|
54
|
+
const h = harness(atBase);
|
|
55
|
+
const gates = memoryGateLedgerPort({ resolutions: [approval("alice", "2026-10-22T01:00:00Z")] });
|
|
56
|
+
const logs: string[] = [];
|
|
57
|
+
// The change drops the rule; the one at base holds.
|
|
58
|
+
const one = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates, now: "2026-10-22T02:00:00Z", log: (l) => logs.push(l) });
|
|
59
|
+
expect(one.exitCode).toBe(3);
|
|
60
|
+
expect(one.decision?.quorum).toEqual([{ targets: ["prod-*"], count: 2 }]);
|
|
61
|
+
expect(one.decision?.quorumProgress?.[0]).toMatchObject({ approvers: ["alice"], met: false });
|
|
62
|
+
expect(logs.some((l) => /the ones at base hold/.test(l))).toBe(true);
|
|
63
|
+
// Approvals made while the wave waits count, each person once.
|
|
64
|
+
const two = memoryGateLedgerPort({ resolutions: [approval("alice", "2026-10-22T02:30:00Z"), approval("alice", "2026-10-22T02:45:00Z"), approval("bob", "2026-10-22T03:00:00Z")], pending: gates.appended });
|
|
65
|
+
const done = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: two, now: "2026-10-22T04:00:00Z" });
|
|
66
|
+
expect(done.exitCode).toBe(0);
|
|
67
|
+
expect(done.decision?.approvedBy).toBe("alice, bob");
|
|
68
|
+
expect(h.applies()).toBe(2);
|
|
69
|
+
h.done();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("a claimed role never counts; a role from trust.json at base counts for a reviewer of the merged pull request", async () => {
|
|
73
|
+
const atBase: OpWavesSpec = { ...spec, waves: [{ ...spec.waves[0]!, approval: "pr-review" }], approvals: { quorum: [{ count: 1, roles: ["platform"], notAuthor: true }] } };
|
|
74
|
+
const h = harness(atBase);
|
|
75
|
+
const claimed = memoryGateLedgerPort({ resolutions: [{ ...approval("github:carol", "2026-10-22T01:00:00Z"), approver: { kind: "human", roles: ["platform"] } }] });
|
|
76
|
+
const merged: MergedReview = { pr: 7, head: "h".repeat(40), author: "github:dana", approvers: ["github:alice"], refused: [] };
|
|
77
|
+
const headPlans = async () => ({ version: 1 as const, name: "migrations", head: merged.head, timestamp: "2026-10-22T00:00:00Z", waves: [{ wave: 1, name: "prod", digest: set }] });
|
|
78
|
+
const base = { spec: atBase, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, head: "m".repeat(40), headPlans, now: "2026-10-22T02:00:00Z" };
|
|
79
|
+
// carol claims the role and is not proven; nobody reviewed.
|
|
80
|
+
const waiting = await runOpWave({ ...base, gates: claimed, reviews: reviews({ ...merged, approvers: [] }), trust: trust({ platform: ["github:alice", "github:carol"] }) });
|
|
81
|
+
expect(waiting.exitCode).toBe(3);
|
|
82
|
+
// alice reviewed and holds the role at base; dana wrote the change.
|
|
83
|
+
const gates = memoryGateLedgerPort();
|
|
84
|
+
const passed = await runOpWave({ ...base, gates, reviews: reviews(merged), trust: trust({ platform: ["github:alice"] }) });
|
|
85
|
+
expect(passed.exitCode).toBe(0);
|
|
86
|
+
expect(passed.decision).toMatchObject({ via: "pr-review", approvedBy: "github:alice" });
|
|
87
|
+
expect(gates.resolved.map((r) => r.resolvedBy)).toEqual(["github:alice"]);
|
|
88
|
+
// The author reviewing their own change never meets notAuthor.
|
|
89
|
+
const own = await runOpWave({ ...base, gates: memoryGateLedgerPort(), reviews: reviews({ ...merged, author: "github:alice" }), trust: trust({ platform: ["github:alice"] }) });
|
|
90
|
+
expect(own.exitCode).toBe(3);
|
|
91
|
+
h.done();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("rules at base that cannot be read fail the wave", async () => {
|
|
95
|
+
const h = harness({ ...spec, approvals: { quorum: [{ count: 0 }] } });
|
|
96
|
+
const result = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort() });
|
|
97
|
+
expect(result.exitCode).toBe(1);
|
|
98
|
+
expect(h.applies()).toBe(0);
|
|
99
|
+
h.done();
|
|
100
|
+
});
|
|
101
|
+
});
|