@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/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);
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scheduled watch and reconcile Ops that stopped running (#4289): the UTC
|
|
3
|
+
* cron count, the verdict, the forge clients' `scheduledRuns`, the check the
|
|
4
|
+
* plan job runs, and the warning line in the plan note.
|
|
5
|
+
*/
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { afterEach, describe, expect, test } from "vitest";
|
|
11
|
+
import { cronFiresUtc } from "./cron";
|
|
12
|
+
import { checkOverdueOps, opPipelineFile, overdueVerdict, scheduledWatches } from "./schedule-overdue";
|
|
13
|
+
import { forgejoForge, githubForge, gitlabForge, type ForgeFetch, type PrForge } from "../pr-forge";
|
|
14
|
+
import { renderPrNote, type PrReport } from "../pr-loop";
|
|
15
|
+
import type { OpConfig } from "./types";
|
|
16
|
+
|
|
17
|
+
function fakeFetch(route: (method: string, url: URL) => unknown): ForgeFetch {
|
|
18
|
+
return async (url, init) => {
|
|
19
|
+
const found = route(init.method, new URL(url));
|
|
20
|
+
if (found === undefined) return { ok: false, status: 404, text: async () => '{"message":"Not Found"}' };
|
|
21
|
+
if (typeof found === "number") return { ok: false, status: found, text: async () => "" };
|
|
22
|
+
return { ok: true, status: 200, text: async () => JSON.stringify(found) };
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("cronFiresUtc", () => {
|
|
27
|
+
test("counts fires in UTC, after the start and up to the end", () => {
|
|
28
|
+
const fires = cronFiresUtc("0 6 * * *", new Date("2026-10-01T06:00:00Z"), new Date("2026-10-04T05:59:00Z"), 5);
|
|
29
|
+
expect(fires.map((d) => d.toISOString())).toEqual(["2026-10-02T06:00:00.000Z", "2026-10-03T06:00:00.000Z"]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("stops at max, reads weekday names as numbers and 7 as Sunday", () => {
|
|
33
|
+
expect(cronFiresUtc("*/15 * * * *", new Date("2026-10-01T00:00:00Z"), new Date("2026-10-02T00:00:00Z"), 2)).toHaveLength(2);
|
|
34
|
+
const sundays = cronFiresUtc("0 0 * * 7", new Date("2026-10-01T00:00:00Z"), new Date("2026-10-31T00:00:00Z"), 10);
|
|
35
|
+
expect(sundays.every((d) => d.getUTCDay() === 0)).toBe(true);
|
|
36
|
+
expect(sundays).toHaveLength(4);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("an expression that is not 5 or 6 fields fires never", () => {
|
|
40
|
+
expect(cronFiresUtc("0 6 *", new Date("2026-01-01T00:00:00Z"), new Date("2026-12-31T00:00:00Z"), 2)).toEqual([]);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("scheduledWatches", () => {
|
|
45
|
+
const op = (name: string, labels?: Record<string, string>, cron?: string): OpConfig =>
|
|
46
|
+
({ name, overview: "", phases: [], ...(labels ? { labels } : {}), ...(cron ? { schedule: { cron } } : {}) }) as OpConfig;
|
|
47
|
+
|
|
48
|
+
test("keeps scheduled Ops labelled Watch or Reconcile, sorted", () => {
|
|
49
|
+
const got = scheduledWatches([
|
|
50
|
+
op("z-watch", { Watch: "true" }, "0 * * * *"),
|
|
51
|
+
op("a-reconcile", { Reconcile: "true" }, "0 6 * * *"),
|
|
52
|
+
op("unscheduled", { Watch: "true" }),
|
|
53
|
+
op("deploy", undefined, "0 6 * * *"),
|
|
54
|
+
]);
|
|
55
|
+
expect(got).toEqual([
|
|
56
|
+
{ op: "a-reconcile", cron: "0 6 * * *" },
|
|
57
|
+
{ op: "z-watch", cron: "0 * * * *" },
|
|
58
|
+
]);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("overdueVerdict", () => {
|
|
63
|
+
const watch = { op: "drift", cron: "0 6 * * *" };
|
|
64
|
+
const now = new Date("2026-10-22T12:00:00Z");
|
|
65
|
+
|
|
66
|
+
test("one missed fire is on time; two is overdue", () => {
|
|
67
|
+
expect(overdueVerdict(watch, "github", { last: "2026-10-21T06:01:00Z" }, undefined, now)).toBeUndefined();
|
|
68
|
+
const late = overdueVerdict(watch, "github", { last: "2026-10-20T06:01:00Z" }, undefined, now);
|
|
69
|
+
expect(late?.since).toBe("2026-10-20T06:01:00Z");
|
|
70
|
+
expect(late?.message).toContain("Op `drift` is overdue");
|
|
71
|
+
expect(late?.message).toContain("its last run was on 2026-10-20");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("a job that never ran counts from its pipeline file's commit", () => {
|
|
75
|
+
const v = overdueVerdict(watch, "forgejo", {}, "2026-10-01T00:00:00Z", now);
|
|
76
|
+
expect(v?.neverRan).toBe(true);
|
|
77
|
+
expect(v?.message).toContain("no run is on record since its pipeline was added on 2026-10-01");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("nothing to count from is no verdict", () => {
|
|
81
|
+
expect(overdueVerdict(watch, "github", {}, undefined, now)).toBeUndefined();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("names a disabled workflow and a missing GitLab schedule", () => {
|
|
85
|
+
expect(overdueVerdict(watch, "github", { last: "2026-09-01T00:00:00Z", disabled: "disabled_inactivity" }, undefined, now)?.message).toContain("disabled_inactivity");
|
|
86
|
+
expect(overdueVerdict(watch, "gitlab", { noSchedule: true }, "2026-09-01T00:00:00Z", now)?.message).toContain("No active pipeline schedule sets CHANT_SCHEDULED_OP to drift");
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("scheduledRuns", () => {
|
|
91
|
+
test("github: the newest schedule or dispatch run, and a disabled workflow", async () => {
|
|
92
|
+
const fetch = fakeFetch((_m, u) => {
|
|
93
|
+
if (u.pathname === "/repos/acme/infra/actions/workflows/drift.yml") return { state: "disabled_inactivity" };
|
|
94
|
+
if (u.pathname === "/repos/acme/infra/actions/workflows/drift.yml/runs") {
|
|
95
|
+
return { workflow_runs: [{ created_at: u.searchParams.get("event") === "schedule" ? "2026-08-01T06:00:00Z" : "2026-08-03T10:00:00Z" }] };
|
|
96
|
+
}
|
|
97
|
+
return undefined;
|
|
98
|
+
});
|
|
99
|
+
const forge = githubForge({ apiBase: "https://api.github.com", repo: "acme/infra", token: "t", fetch });
|
|
100
|
+
expect(await forge.scheduledRuns!({ op: "drift", workflow: "drift.yml" })).toEqual({ last: "2026-08-03T10:00:00Z", disabled: "disabled_inactivity" });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("forgejo: only this workflow's schedule and dispatch runs", async () => {
|
|
104
|
+
const fetch = fakeFetch((_m, u) =>
|
|
105
|
+
u.pathname === "/api/v1/repos/acme/infra/actions/runs"
|
|
106
|
+
? {
|
|
107
|
+
workflow_runs: [
|
|
108
|
+
{ event: "schedule", workflow_id: "other.yml", created: "2026-10-20T00:00:00Z" },
|
|
109
|
+
{ event: "push", workflow_id: "drift.yml", created: "2026-10-19T00:00:00Z" },
|
|
110
|
+
{ event: "schedule", workflow_id: "drift.yml", created: "2026-10-10T00:00:00Z" },
|
|
111
|
+
],
|
|
112
|
+
}
|
|
113
|
+
: undefined,
|
|
114
|
+
);
|
|
115
|
+
const forge = forgejoForge({ apiBase: "https://code.example/api/v1", repo: "acme/infra", host: "code.example", token: "t", fetch });
|
|
116
|
+
expect(await forge.scheduledRuns!({ op: "drift", workflow: "drift.yml" })).toEqual({ last: "2026-10-10T00:00:00Z" });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("gitlab: the schedules that select the Op, and none", async () => {
|
|
120
|
+
const fetch = fakeFetch((_m, u) => {
|
|
121
|
+
const p = u.pathname.replace("/api/v4/projects/42", "");
|
|
122
|
+
if (p === "/pipeline_schedules") return [{ id: 1 }, { id: 2 }];
|
|
123
|
+
if (p === "/pipeline_schedules/1") return { id: 1, variables: [{ key: "CHANT_SCHEDULED_OP", value: "drift" }], last_pipeline: { id: 900 } };
|
|
124
|
+
if (p === "/pipeline_schedules/2") return { id: 2, variables: [{ key: "CHANT_SCHEDULED_OP", value: "other" }], last_pipeline: { id: 901 } };
|
|
125
|
+
if (p === "/pipelines/900") return { id: 900, created_at: "2026-10-01T06:00:00Z" };
|
|
126
|
+
return undefined;
|
|
127
|
+
});
|
|
128
|
+
const forge = gitlabForge({ apiBase: "https://gitlab.com/api/v4", project: "42", token: "t", fetch });
|
|
129
|
+
expect(await forge.scheduledRuns!({ op: "drift", workflow: "ops.gitlab-ci.yml" })).toEqual({ last: "2026-10-01T06:00:00Z" });
|
|
130
|
+
expect(await forge.scheduledRuns!({ op: "nobody", workflow: "ops.gitlab-ci.yml" })).toEqual({ noSchedule: true });
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("checkOverdueOps", () => {
|
|
135
|
+
let dir: string | undefined;
|
|
136
|
+
afterEach(() => {
|
|
137
|
+
if (dir) rmSync(dir, { recursive: true, force: true });
|
|
138
|
+
dir = undefined;
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
function repo(files: Record<string, string>, date: string): string {
|
|
142
|
+
dir = mkdtempSync(join(tmpdir(), "chant-overdue-"));
|
|
143
|
+
const git = (...args: string[]) =>
|
|
144
|
+
execFileSync("git", ["-C", dir!, ...args], { env: { ...process.env, GIT_COMMITTER_DATE: date, GIT_AUTHOR_DATE: date }, stdio: "pipe" });
|
|
145
|
+
git("init", "-q");
|
|
146
|
+
for (const [path, body] of Object.entries(files)) {
|
|
147
|
+
mkdirSync(join(dir, path, ".."), { recursive: true });
|
|
148
|
+
writeFileSync(join(dir, path), body);
|
|
149
|
+
}
|
|
150
|
+
git("add", ".");
|
|
151
|
+
git("-c", "user.name=t", "-c", "user.email=t@example.com", "commit", "-q", "-m", "add");
|
|
152
|
+
return dir;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const forgeWith = (kind: PrForge["kind"], scheduledRuns: PrForge["scheduledRuns"]): PrForge => ({ kind, host: "example", scheduledRuns }) as PrForge;
|
|
156
|
+
|
|
157
|
+
test("warns for an Op whose job never ran since its workflow was added, and skips one with no pipeline file", async () => {
|
|
158
|
+
const root = repo({ ".github/workflows/drift.yml": "on: schedule" }, "2026-10-01T00:00:00Z");
|
|
159
|
+
const asked: string[] = [];
|
|
160
|
+
const lines: string[] = [];
|
|
161
|
+
const found = await checkOverdueOps({
|
|
162
|
+
watches: [
|
|
163
|
+
{ op: "drift", cron: "0 6 * * *" },
|
|
164
|
+
{ op: "operator-only", cron: "0 6 * * *" },
|
|
165
|
+
],
|
|
166
|
+
forge: forgeWith("github", async (q) => {
|
|
167
|
+
asked.push(q.workflow);
|
|
168
|
+
return {};
|
|
169
|
+
}),
|
|
170
|
+
repoRoot: root,
|
|
171
|
+
now: new Date("2026-10-22T00:00:00Z"),
|
|
172
|
+
log: (l) => lines.push(l),
|
|
173
|
+
});
|
|
174
|
+
expect(asked).toEqual(["drift.yml"]);
|
|
175
|
+
expect(found.map((o) => [o.op, o.neverRan, o.since.slice(0, 10)])).toEqual([["drift", true, "2026-10-01"]]);
|
|
176
|
+
expect(lines).toHaveLength(1);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("a forge that will not answer is a log line and no verdict", async () => {
|
|
180
|
+
const root = repo({ ".github/workflows/web-drift.yml": "on: schedule" }, "2026-10-01T00:00:00Z");
|
|
181
|
+
const lines: string[] = [];
|
|
182
|
+
const found = await checkOverdueOps({
|
|
183
|
+
watches: [{ op: "drift", cron: "0 6 * * *" }],
|
|
184
|
+
forge: forgeWith("github", async () => {
|
|
185
|
+
throw new Error("GET answered 403");
|
|
186
|
+
}),
|
|
187
|
+
repoRoot: root,
|
|
188
|
+
member: "web",
|
|
189
|
+
now: new Date("2026-10-22T00:00:00Z"),
|
|
190
|
+
log: (l) => lines.push(l),
|
|
191
|
+
});
|
|
192
|
+
expect(found).toEqual([]);
|
|
193
|
+
expect(lines[0]).toContain("did not say when Op drift last ran");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("gitlab: the root file whose rules select the Op", () => {
|
|
197
|
+
const root = repo({ "ops.gitlab-ci.yml": 'rules:\n - if: $CI_PIPELINE_SOURCE == "schedule" && $CHANT_SCHEDULED_OP == "drift"\n' }, "2026-10-01T00:00:00Z");
|
|
198
|
+
expect(opPipelineFile(root, "gitlab", "drift")).toBe("ops.gitlab-ci.yml");
|
|
199
|
+
expect(opPipelineFile(root, "gitlab", "other")).toBeUndefined();
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe("the plan note", () => {
|
|
204
|
+
test("prints each overdue Op as a warning", () => {
|
|
205
|
+
const report = {
|
|
206
|
+
stage: "plan",
|
|
207
|
+
env: "prod",
|
|
208
|
+
base: "b".repeat(40),
|
|
209
|
+
head: "a".repeat(40),
|
|
210
|
+
status: "nothing",
|
|
211
|
+
digest: "",
|
|
212
|
+
members: [],
|
|
213
|
+
selection: { changed: [], dependents: [], unclaimed: [], indeterminate: [], waves: [] },
|
|
214
|
+
changeSet: { members: [], entries: [], summary: { byMember: {} } },
|
|
215
|
+
approval: { status: "pending" },
|
|
216
|
+
overdue: [{ op: "drift", cron: "0 6 * * *", since: "2026-10-01T00:00:00Z", message: "Op `drift` is overdue: test." }],
|
|
217
|
+
} as unknown as PrReport;
|
|
218
|
+
expect(renderPrNote(report)).toContain("Warning: Op `drift` is overdue: test.");
|
|
219
|
+
});
|
|
220
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scheduled watch and reconcile Ops that stopped running (#4289).
|
|
3
|
+
*
|
|
4
|
+
* A schedule can stop with no error anywhere. GitHub turns a scheduled
|
|
5
|
+
* workflow off after 60 days with no activity in the repository and drops
|
|
6
|
+
* scheduled runs under load. A GitLab schedule exists only once someone
|
|
7
|
+
* creates it. A watch Op cannot report its own absence, so the pull-request
|
|
8
|
+
* plan job does. For each discovered Op with a `schedule` and the `Watch` or
|
|
9
|
+
* `Reconcile` label, whose generated pipeline file is in the checkout, it
|
|
10
|
+
* asks the forge when the Op's job last ran. When the cron has fired at
|
|
11
|
+
* least twice since then, or since the pipeline file was added for a job
|
|
12
|
+
* that never ran, the job log and the plan note say the Op is overdue.
|
|
13
|
+
*
|
|
14
|
+
* One late or dropped run is not enough: two fires are. A forge that will
|
|
15
|
+
* not answer is a line in the log and no verdict.
|
|
16
|
+
*/
|
|
17
|
+
import { spawnSync } from "node:child_process";
|
|
18
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import type { OpConfig } from "./types";
|
|
21
|
+
import { cronFiresUtc } from "./cron";
|
|
22
|
+
import type { ForgeKind, PrForge, ScheduledRuns } from "../pr-forge";
|
|
23
|
+
|
|
24
|
+
/** One scheduled watch or reconcile Op the plan job checks. */
|
|
25
|
+
export interface ScheduledWatch {
|
|
26
|
+
op: string;
|
|
27
|
+
cron: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** An Op the plan note warns about. */
|
|
31
|
+
export interface OverdueOp {
|
|
32
|
+
op: string;
|
|
33
|
+
cron: string;
|
|
34
|
+
/** The time the count starts from: the job's last run, or the pipeline file's first commit (ISO 8601). */
|
|
35
|
+
since: string;
|
|
36
|
+
/** True when no run is on record and `since` is the pipeline file's first commit. */
|
|
37
|
+
neverRan?: boolean;
|
|
38
|
+
/** The line for the job log and the plan note. */
|
|
39
|
+
message: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The discovered Ops that are scheduled watch or reconcile Ops: a
|
|
44
|
+
* `schedule`, and the `Watch` label `WatchOp` sets (./composites/watch-op.ts)
|
|
45
|
+
* or the `Reconcile` label `ReconcileOp` sets (./composites/reconcile-op.ts).
|
|
46
|
+
* Sorted by name.
|
|
47
|
+
*/
|
|
48
|
+
export function scheduledWatches(ops: Iterable<OpConfig>): ScheduledWatch[] {
|
|
49
|
+
const out: ScheduledWatch[] = [];
|
|
50
|
+
for (const op of ops) {
|
|
51
|
+
if (!op.schedule?.cron) continue;
|
|
52
|
+
if (op.labels?.Watch !== "true" && op.labels?.Reconcile !== "true") continue;
|
|
53
|
+
out.push({ op: op.name, cron: op.schedule.cron });
|
|
54
|
+
}
|
|
55
|
+
return out.sort((a, b) => a.op.localeCompare(b.op));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The file `chant run --generate <forge>` writes for an Op, relative to the
|
|
60
|
+
* repository root: `<dir>/<op>.yml` on GitHub and Forgejo, `<member>-<op>.yml`
|
|
61
|
+
* in a workspace member. GitLab puts every Op in one file, so the root's
|
|
62
|
+
* `*.gitlab-ci.yml` files are read for the one whose rules select the Op.
|
|
63
|
+
* Undefined when the checkout has no such file, so an Op that some other
|
|
64
|
+
* scheduler runs is never checked.
|
|
65
|
+
*/
|
|
66
|
+
export function opPipelineFile(repoRoot: string, forge: ForgeKind, op: string, member?: string): string | undefined {
|
|
67
|
+
const base = member ? `${member}-${op}` : op;
|
|
68
|
+
if (forge === "github" || forge === "forgejo") {
|
|
69
|
+
const path = `.${forge}/workflows/${base}.yml`;
|
|
70
|
+
return existsSync(join(repoRoot, path)) ? path : undefined;
|
|
71
|
+
}
|
|
72
|
+
// The gitlab lexicon's cron rule: `$CHANT_SCHEDULED_OP == "<op>"`.
|
|
73
|
+
const selector = `$CHANT_SCHEDULED_OP == "${op}"`;
|
|
74
|
+
let names: string[];
|
|
75
|
+
try {
|
|
76
|
+
names = readdirSync(repoRoot).filter((n) => n.endsWith(".gitlab-ci.yml")).sort();
|
|
77
|
+
} catch {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
return names.find((n) => {
|
|
81
|
+
try {
|
|
82
|
+
return readFileSync(join(repoRoot, n), "utf8").includes(selector);
|
|
83
|
+
} catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** When `path` was first committed (ISO 8601), if the checkout's history has it. */
|
|
90
|
+
export function fileAdded(repoRoot: string, path: string): string | undefined {
|
|
91
|
+
const r = spawnSync("git", ["-C", repoRoot, "log", "--format=%cI", "--diff-filter=A", "--", path], { encoding: "utf-8" });
|
|
92
|
+
if (r.status !== 0) return undefined;
|
|
93
|
+
return r.stdout.trim().split("\n").filter(Boolean).pop();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const HINT: Record<ForgeKind, string> = {
|
|
97
|
+
github: "GitHub turns a scheduled workflow off after 60 days with no activity in the repository. Turn the workflow on again under Actions, or run it by hand",
|
|
98
|
+
gitlab: "A GitLab project runs a scheduled Op only from a pipeline schedule. Check its schedule under CI/CD > Schedules",
|
|
99
|
+
forgejo: "Check that Actions is on for the repository and a runner is up, or run the workflow by hand",
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Whether one Op is overdue: its cron has fired at least twice since its
|
|
104
|
+
* last run, or since `added` when no run is on record. Undefined when it is
|
|
105
|
+
* on time, or when nothing says when it should have run.
|
|
106
|
+
*/
|
|
107
|
+
export function overdueVerdict(watch: ScheduledWatch, forge: ForgeKind, runs: ScheduledRuns, added: string | undefined, now: Date): OverdueOp | undefined {
|
|
108
|
+
const since = runs.last ?? added;
|
|
109
|
+
if (!since) return undefined;
|
|
110
|
+
if (cronFiresUtc(watch.cron, new Date(since), now, 2).length < 2) return undefined;
|
|
111
|
+
const day = since.slice(0, 10);
|
|
112
|
+
const what = runs.last ? `its last run was on ${day}` : `no run is on record since its pipeline was added on ${day}`;
|
|
113
|
+
const why = runs.disabled
|
|
114
|
+
? `GitHub has turned the workflow off (${runs.disabled}). Turn it on again under Actions`
|
|
115
|
+
: forge === "gitlab" && runs.noSchedule
|
|
116
|
+
? `No active pipeline schedule sets CHANT_SCHEDULED_OP to ${watch.op}. Add one with the cron \`${watch.cron}\` under CI/CD > Schedules`
|
|
117
|
+
: HINT[forge];
|
|
118
|
+
return {
|
|
119
|
+
op: watch.op,
|
|
120
|
+
cron: watch.cron,
|
|
121
|
+
since,
|
|
122
|
+
...(runs.last ? {} : { neverRan: true }),
|
|
123
|
+
message: `Op \`${watch.op}\` is overdue: its schedule \`${watch.cron}\` has fired at least twice and ${what}. ${why}.`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The plan job's check over every scheduled watch or reconcile Op that has a
|
|
129
|
+
* generated pipeline file. Each Op the forge will not answer for is a line
|
|
130
|
+
* in `log` and no verdict. A forge client with no `scheduledRuns` checks
|
|
131
|
+
* nothing.
|
|
132
|
+
*/
|
|
133
|
+
export async function checkOverdueOps(input: {
|
|
134
|
+
watches: readonly ScheduledWatch[];
|
|
135
|
+
forge: PrForge;
|
|
136
|
+
repoRoot: string;
|
|
137
|
+
member?: string;
|
|
138
|
+
now: Date;
|
|
139
|
+
log: (line: string) => void;
|
|
140
|
+
}): Promise<OverdueOp[]> {
|
|
141
|
+
const { forge } = input;
|
|
142
|
+
if (!forge.scheduledRuns) return [];
|
|
143
|
+
const out: OverdueOp[] = [];
|
|
144
|
+
for (const watch of input.watches) {
|
|
145
|
+
const file = opPipelineFile(input.repoRoot, forge.kind, watch.op, input.member);
|
|
146
|
+
if (!file) continue;
|
|
147
|
+
let runs: ScheduledRuns;
|
|
148
|
+
try {
|
|
149
|
+
runs = await forge.scheduledRuns({ op: watch.op, workflow: file.split("/").pop()! });
|
|
150
|
+
} catch (err) {
|
|
151
|
+
input.log(`schedule check: ${forge.kind} did not say when Op ${watch.op} last ran, so it is not checked: ${(err instanceof Error ? err.message : String(err)).split("\n")[0]}`);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const found = overdueVerdict(watch, forge.kind, runs, runs.last ? undefined : fileAdded(input.repoRoot, file), input.now);
|
|
155
|
+
if (found) {
|
|
156
|
+
input.log(found.message);
|
|
157
|
+
out.push(found);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|