@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.
Files changed (346) hide show
  1. package/dist/__fixtures__/oidc-emulator.d.ts +49 -0
  2. package/dist/__fixtures__/oidc-emulator.d.ts.map +1 -0
  3. package/dist/__fixtures__/oidc-emulator.js +89 -0
  4. package/dist/ci-handover.d.ts +117 -0
  5. package/dist/ci-handover.d.ts.map +1 -0
  6. package/dist/ci-handover.js +217 -0
  7. package/dist/ci-identity.d.ts +190 -0
  8. package/dist/ci-identity.d.ts.map +1 -0
  9. package/dist/ci-identity.js +219 -0
  10. package/dist/cli/handlers/approve.d.ts +2 -1
  11. package/dist/cli/handlers/approve.d.ts.map +1 -1
  12. package/dist/cli/handlers/approve.js +20 -6
  13. package/dist/cli/handlers/build.d.ts.map +1 -1
  14. package/dist/cli/handlers/build.js +18 -1
  15. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  16. package/dist/cli/handlers/fan-out.js +42 -5
  17. package/dist/cli/handlers/gate-resume.d.ts +3 -0
  18. package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
  19. package/dist/cli/handlers/gate-resume.js +15 -2
  20. package/dist/cli/handlers/lock.d.ts +8 -0
  21. package/dist/cli/handlers/lock.d.ts.map +1 -0
  22. package/dist/cli/handlers/lock.js +197 -0
  23. package/dist/cli/handlers/pr-env.d.ts +34 -0
  24. package/dist/cli/handlers/pr-env.d.ts.map +1 -0
  25. package/dist/cli/handlers/pr-env.js +436 -0
  26. package/dist/cli/handlers/pr.d.ts +76 -0
  27. package/dist/cli/handlers/pr.d.ts.map +1 -1
  28. package/dist/cli/handlers/pr.js +615 -55
  29. package/dist/cli/handlers/promote.d.ts.map +1 -1
  30. package/dist/cli/handlers/promote.js +9 -0
  31. package/dist/cli/handlers/query.d.ts +6 -0
  32. package/dist/cli/handlers/query.d.ts.map +1 -0
  33. package/dist/cli/handlers/query.js +80 -0
  34. package/dist/cli/handlers/run.d.ts.map +1 -1
  35. package/dist/cli/handlers/run.js +11 -0
  36. package/dist/cli/main.d.ts.map +1 -1
  37. package/dist/cli/main.js +49 -4
  38. package/dist/cli/parse-args.d.ts.map +1 -1
  39. package/dist/cli/parse-args.js +46 -2
  40. package/dist/cli/registry.d.ts +31 -2
  41. package/dist/cli/registry.d.ts.map +1 -1
  42. package/dist/components/capability.d.ts +47 -0
  43. package/dist/components/capability.d.ts.map +1 -1
  44. package/dist/components/capability.js +44 -0
  45. package/dist/components/cli-support.d.ts +6 -0
  46. package/dist/components/cli-support.d.ts.map +1 -1
  47. package/dist/components/cli-support.js +8 -1
  48. package/dist/components/driver.d.ts +10 -0
  49. package/dist/components/driver.d.ts.map +1 -1
  50. package/dist/components/driver.js +52 -2
  51. package/dist/components/fan-out-run.d.ts +37 -0
  52. package/dist/components/fan-out-run.d.ts.map +1 -1
  53. package/dist/components/fan-out-run.js +44 -4
  54. package/dist/components/gate-plan.d.ts +2 -0
  55. package/dist/components/gate-plan.d.ts.map +1 -1
  56. package/dist/components/gate-plan.js +1 -0
  57. package/dist/components/pr-environment.d.ts +298 -0
  58. package/dist/components/pr-environment.d.ts.map +1 -0
  59. package/dist/components/pr-environment.js +406 -0
  60. package/dist/components/pr-hooks.d.ts +66 -0
  61. package/dist/components/pr-hooks.d.ts.map +1 -0
  62. package/dist/components/pr-hooks.js +119 -0
  63. package/dist/components/pr-pipeline.d.ts +92 -2
  64. package/dist/components/pr-pipeline.d.ts.map +1 -1
  65. package/dist/components/pr-pipeline.js +150 -21
  66. package/dist/components/pr-policy.d.ts +109 -0
  67. package/dist/components/pr-policy.d.ts.map +1 -0
  68. package/dist/components/pr-policy.js +150 -0
  69. package/dist/components/pr-run.d.ts +6 -0
  70. package/dist/components/pr-run.d.ts.map +1 -1
  71. package/dist/components/pr-run.js +8 -3
  72. package/dist/components/wave-plan.d.ts +14 -1
  73. package/dist/components/wave-plan.d.ts.map +1 -1
  74. package/dist/components/wave-plan.js +34 -2
  75. package/dist/gate-decision.d.ts +15 -2
  76. package/dist/gate-decision.d.ts.map +1 -1
  77. package/dist/gate-decision.js +17 -2
  78. package/dist/gate-policy.d.ts +140 -0
  79. package/dist/gate-policy.d.ts.map +1 -0
  80. package/dist/gate-policy.js +242 -0
  81. package/dist/lifecycle/gate-checkpoint.d.ts +1 -1
  82. package/dist/lifecycle/gate-checkpoint.js +1 -1
  83. package/dist/{workspace/trust → lifecycle}/gate-seal.d.ts +1 -1
  84. package/dist/lifecycle/gate-seal.d.ts.map +1 -0
  85. package/dist/lifecycle/query.d.ts +68 -0
  86. package/dist/lifecycle/query.d.ts.map +1 -0
  87. package/dist/lifecycle/query.js +246 -0
  88. package/dist/lifecycle/run-ledger.d.ts +13 -0
  89. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  90. package/dist/lifecycle/run-ledger.js +16 -0
  91. package/dist/lifecycle/target-lock.d.ts +97 -0
  92. package/dist/lifecycle/target-lock.d.ts.map +1 -0
  93. package/dist/lifecycle/target-lock.js +286 -0
  94. package/dist/op/activities/activity-contracts.d.ts +30 -0
  95. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  96. package/dist/op/activities/activity-contracts.js +26 -0
  97. package/dist/op/activities/index.d.ts +2 -0
  98. package/dist/op/activities/index.d.ts.map +1 -1
  99. package/dist/op/activities/index.js +4 -0
  100. package/dist/op/activities/pr-refs.d.ts +10 -0
  101. package/dist/op/activities/pr-refs.d.ts.map +1 -0
  102. package/dist/op/activities/pr-refs.js +26 -0
  103. package/dist/op/activities/predict-behaviour.d.ts +2 -4
  104. package/dist/op/activities/predict-behaviour.d.ts.map +1 -1
  105. package/dist/op/activities/predict-behaviour.js +2 -21
  106. package/dist/op/activities/review-agent.d.ts +137 -0
  107. package/dist/op/activities/review-agent.d.ts.map +1 -0
  108. package/dist/op/activities/review-agent.js +266 -0
  109. package/dist/op/builders.d.ts +9 -0
  110. package/dist/op/builders.d.ts.map +1 -1
  111. package/dist/op/builders.js +11 -0
  112. package/dist/op/cron.d.ts +8 -0
  113. package/dist/op/cron.d.ts.map +1 -1
  114. package/dist/op/cron.js +51 -0
  115. package/dist/op/gate-approval.d.ts +20 -13
  116. package/dist/op/gate-approval.d.ts.map +1 -1
  117. package/dist/op/gate-approval.js +13 -18
  118. package/dist/op/gate-quorum.d.ts +131 -0
  119. package/dist/op/gate-quorum.d.ts.map +1 -0
  120. package/dist/op/gate-quorum.js +183 -0
  121. package/dist/op/gate-resume.d.ts +4 -2
  122. package/dist/op/gate-resume.d.ts.map +1 -1
  123. package/dist/op/gate-resume.js +5 -4
  124. package/dist/op/gate-review.js +1 -1
  125. package/dist/op/gate.d.ts +32 -4
  126. package/dist/op/gate.d.ts.map +1 -1
  127. package/dist/op/gate.js +40 -24
  128. package/dist/op/index.d.ts +6 -4
  129. package/dist/op/index.d.ts.map +1 -1
  130. package/dist/op/index.js +4 -3
  131. package/dist/op/local-executor.d.ts.map +1 -1
  132. package/dist/op/local-executor.js +9 -3
  133. package/dist/op/op-waves-run.d.ts +28 -43
  134. package/dist/op/op-waves-run.d.ts.map +1 -1
  135. package/dist/op/op-waves-run.js +170 -120
  136. package/dist/op/op-waves.d.ts +55 -3
  137. package/dist/op/op-waves.d.ts.map +1 -1
  138. package/dist/op/op-waves.js +63 -8
  139. package/dist/op/schedule-overdue.d.ts +57 -0
  140. package/dist/op/schedule-overdue.d.ts.map +1 -0
  141. package/dist/op/schedule-overdue.js +138 -0
  142. package/dist/op/stage-hooks.d.ts +132 -0
  143. package/dist/op/stage-hooks.d.ts.map +1 -0
  144. package/dist/op/stage-hooks.js +142 -0
  145. package/dist/override-gate.d.ts +2 -2
  146. package/dist/override-gate.d.ts.map +1 -1
  147. package/dist/override-gate.js +1 -1
  148. package/dist/pr-forge.d.ts +64 -0
  149. package/dist/pr-forge.d.ts.map +1 -1
  150. package/dist/pr-forge.js +331 -44
  151. package/dist/pr-loop.d.ts +107 -1
  152. package/dist/pr-loop.d.ts.map +1 -1
  153. package/dist/pr-loop.js +84 -3
  154. package/dist/pr-post.d.ts +72 -0
  155. package/dist/pr-post.d.ts.map +1 -0
  156. package/dist/pr-post.js +134 -0
  157. package/dist/pr-requires.d.ts +38 -0
  158. package/dist/pr-requires.d.ts.map +1 -0
  159. package/dist/pr-requires.js +48 -0
  160. package/dist/wave-blocked.d.ts +79 -0
  161. package/dist/wave-blocked.d.ts.map +1 -0
  162. package/dist/wave-blocked.js +245 -0
  163. package/dist/wave-engine.d.ts +6 -0
  164. package/dist/wave-engine.d.ts.map +1 -1
  165. package/dist/wave-engine.js +2 -0
  166. package/dist/wave-runtime.mjs +243 -243
  167. package/dist/workspace/__fixtures__/two-member-workspace.d.ts +1 -0
  168. package/dist/workspace/__fixtures__/two-member-workspace.d.ts.map +1 -1
  169. package/dist/workspace/__fixtures__/two-member-workspace.js +1 -1
  170. package/dist/workspace/checks/ci-workflow.d.ts.map +1 -1
  171. package/dist/workspace/checks/ci-workflow.js +19 -4
  172. package/dist/workspace/ci-branches.d.ts +51 -0
  173. package/dist/workspace/ci-branches.d.ts.map +1 -0
  174. package/dist/workspace/ci-branches.js +69 -0
  175. package/dist/workspace/ci-cloud-identity.d.ts +11 -0
  176. package/dist/workspace/ci-cloud-identity.d.ts.map +1 -0
  177. package/dist/workspace/ci-cloud-identity.js +24 -0
  178. package/dist/workspace/ci-init.d.ts.map +1 -1
  179. package/dist/workspace/ci-init.js +5 -4
  180. package/dist/workspace/ci-locks.d.ts +12 -0
  181. package/dist/workspace/ci-locks.d.ts.map +1 -0
  182. package/dist/workspace/ci-locks.js +6 -0
  183. package/dist/workspace/ci-run.d.ts +17 -1
  184. package/dist/workspace/ci-run.d.ts.map +1 -1
  185. package/dist/workspace/ci-run.js +66 -4
  186. package/dist/workspace/ci-workflow-at.d.ts +14 -0
  187. package/dist/workspace/ci-workflow-at.d.ts.map +1 -0
  188. package/dist/workspace/ci-workflow-at.js +17 -0
  189. package/dist/workspace/ci-workflow.d.ts +11 -3
  190. package/dist/workspace/ci-workflow.d.ts.map +1 -1
  191. package/dist/workspace/ci-workflow.js +10 -2
  192. package/dist/workspace/declaration.d.ts +69 -3
  193. package/dist/workspace/declaration.d.ts.map +1 -1
  194. package/dist/workspace/declaration.js +72 -1
  195. package/dist/workspace/declaration.schema.json +149 -2
  196. package/dist/workspace/identity-gate-rule.d.ts +1 -1
  197. package/dist/workspace/identity-gate-rule.d.ts.map +1 -1
  198. package/dist/workspace/identity-gate-rule.js +1 -1
  199. package/dist/workspace/identity.d.ts +1 -1
  200. package/dist/workspace/identity.d.ts.map +1 -1
  201. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  202. package/dist/workspace/lineage-upgrade.js +8 -5
  203. package/dist/workspace/member-pipeline.d.ts +6 -0
  204. package/dist/workspace/member-pipeline.d.ts.map +1 -1
  205. package/dist/workspace/member-pipeline.js +23 -0
  206. package/dist/workspace/pr-report.schema.json +94 -3
  207. package/dist/workspace/reason-codes.d.ts +1 -1
  208. package/dist/workspace/reason-codes.d.ts.map +1 -1
  209. package/dist/workspace/reason-codes.js +1 -1
  210. package/dist/workspace/security.d.ts.map +1 -1
  211. package/dist/workspace/security.js +4 -0
  212. package/dist/workspace/status-gates.d.ts +3 -2
  213. package/dist/workspace/status-gates.d.ts.map +1 -1
  214. package/dist/workspace/status-gates.js +8 -7
  215. package/dist/workspace/status.d.ts.map +1 -1
  216. package/dist/workspace/status.js +4 -1
  217. package/dist/workspace/trust/seal.d.ts +2 -2
  218. package/dist/workspace/trust/seal.d.ts.map +1 -1
  219. package/dist/workspace/trust/seal.js +2 -2
  220. package/package.json +1 -1
  221. package/src/__fixtures__/oidc-emulator.ts +118 -0
  222. package/src/ci-handover.test.ts +209 -0
  223. package/src/ci-handover.ts +237 -0
  224. package/src/ci-identity.test.ts +201 -0
  225. package/src/ci-identity.ts +310 -0
  226. package/src/cli/handlers/approve.ts +18 -5
  227. package/src/cli/handlers/build.ts +18 -1
  228. package/src/cli/handlers/fan-out.ts +43 -6
  229. package/src/cli/handlers/gate-resume.ts +16 -2
  230. package/src/cli/handlers/lock.test.ts +98 -0
  231. package/src/cli/handlers/lock.ts +202 -0
  232. package/src/cli/handlers/operator.test.ts +3 -1
  233. package/src/cli/handlers/pr-before-merge.test.ts +276 -0
  234. package/src/cli/handlers/pr-env.ts +462 -0
  235. package/src/cli/handlers/pr-waves-blocked.test.ts +247 -0
  236. package/src/cli/handlers/pr.test.ts +184 -0
  237. package/src/cli/handlers/pr.ts +605 -61
  238. package/src/cli/handlers/promote.ts +9 -0
  239. package/src/cli/handlers/query.ts +78 -0
  240. package/src/cli/handlers/run.ts +10 -0
  241. package/src/cli/main.ts +49 -4
  242. package/src/cli/parse-args.ts +34 -2
  243. package/src/cli/registry.ts +31 -2
  244. package/src/components/capability-targets.test.ts +162 -0
  245. package/src/components/capability.ts +71 -0
  246. package/src/components/cli-support.ts +14 -1
  247. package/src/components/driver.ts +54 -3
  248. package/src/components/fan-out-run.ts +58 -3
  249. package/src/components/fan-out-waves.test.ts +45 -1
  250. package/src/components/gate-plan.ts +3 -0
  251. package/src/components/pr-environment.test.ts +249 -0
  252. package/src/components/pr-environment.ts +538 -0
  253. package/src/components/pr-hooks.test.ts +118 -0
  254. package/src/components/pr-hooks.ts +157 -0
  255. package/src/components/pr-pipeline.ts +194 -21
  256. package/src/components/pr-policy.test.ts +145 -0
  257. package/src/components/pr-policy.ts +211 -0
  258. package/src/components/pr-run.ts +14 -2
  259. package/src/components/wave-plan.ts +38 -3
  260. package/src/gate-decision-bundle.test.ts +4 -3
  261. package/src/gate-decision.ts +39 -2
  262. package/src/gate-policy-bundle.test.ts +28 -0
  263. package/src/gate-policy.test.ts +162 -0
  264. package/src/gate-policy.ts +298 -0
  265. package/src/lifecycle/gate-checkpoint.ts +1 -1
  266. package/src/{workspace/trust → lifecycle}/gate-seal.ts +1 -1
  267. package/src/lifecycle/query.test.ts +91 -0
  268. package/src/lifecycle/query.ts +315 -0
  269. package/src/lifecycle/run-ledger.ts +18 -0
  270. package/src/lifecycle/target-lock.test.ts +195 -0
  271. package/src/lifecycle/target-lock.ts +347 -0
  272. package/src/op/activities/activity-contracts.ts +31 -0
  273. package/src/op/activities/index.ts +6 -0
  274. package/src/op/activities/pr-refs.ts +28 -0
  275. package/src/op/activities/predict-behaviour.ts +3 -23
  276. package/src/op/activities/review-agent.test.ts +240 -0
  277. package/src/op/activities/review-agent.ts +355 -0
  278. package/src/op/builders.ts +13 -0
  279. package/src/op/cron.ts +44 -0
  280. package/src/op/gate-alias.test.ts +2 -2
  281. package/src/op/gate-approval.test.ts +30 -9
  282. package/src/op/gate-approval.ts +23 -26
  283. package/src/op/gate-quorum.test.ts +139 -0
  284. package/src/op/gate-quorum.ts +248 -0
  285. package/src/op/gate-resume.ts +6 -3
  286. package/src/op/gate-review.ts +1 -1
  287. package/src/op/gate.ts +64 -23
  288. package/src/op/index.ts +13 -1
  289. package/src/op/local-executor.ts +9 -2
  290. package/src/op/op-waves-hook-targets.test.ts +126 -0
  291. package/src/op/op-waves-policy-hold.test.ts +66 -0
  292. package/src/op/op-waves-quorum.test.ts +101 -0
  293. package/src/op/op-waves-run.ts +197 -183
  294. package/src/op/op-waves.ts +83 -10
  295. package/src/op/schedule-overdue.test.ts +220 -0
  296. package/src/op/schedule-overdue.ts +161 -0
  297. package/src/op/stage-hooks.ts +251 -0
  298. package/src/override-gate-bundle.test.ts +1 -1
  299. package/src/override-gate.ts +2 -2
  300. package/src/pr-forge-bundle.test.ts +2 -2
  301. package/src/pr-forge.test.ts +33 -0
  302. package/src/pr-forge.ts +347 -35
  303. package/src/pr-loop-bundle.test.ts +2 -2
  304. package/src/pr-loop.test.ts +58 -0
  305. package/src/pr-loop.ts +170 -4
  306. package/src/pr-post.test.ts +141 -0
  307. package/src/pr-post.ts +159 -0
  308. package/src/pr-requires.test.ts +184 -0
  309. package/src/pr-requires.ts +79 -0
  310. package/src/review-agent-bundle.test.ts +35 -0
  311. package/src/stage-hooks-bundle.test.ts +38 -0
  312. package/src/wave-blocked-bundle.test.ts +25 -0
  313. package/src/wave-blocked.test.ts +146 -0
  314. package/src/wave-blocked.ts +271 -0
  315. package/src/wave-engine.ts +8 -0
  316. package/src/workspace/__fixtures__/two-member-workspace.ts +2 -2
  317. package/src/workspace/checks/ci-workflow.test.ts +9 -0
  318. package/src/workspace/checks/ci-workflow.ts +18 -4
  319. package/src/workspace/ci-branches.test.ts +62 -0
  320. package/src/workspace/ci-branches.ts +79 -0
  321. package/src/workspace/ci-cloud-identity.test.ts +53 -0
  322. package/src/workspace/ci-cloud-identity.ts +23 -0
  323. package/src/workspace/ci-init.ts +5 -4
  324. package/src/workspace/ci-locks.ts +16 -0
  325. package/src/workspace/ci-run-index.test.ts +2 -1
  326. package/src/workspace/ci-run.test.ts +107 -8
  327. package/src/workspace/ci-run.ts +74 -5
  328. package/src/workspace/ci-workflow-at.ts +23 -0
  329. package/src/workspace/ci-workflow.ts +13 -2
  330. package/src/workspace/declaration.schema.json +149 -2
  331. package/src/workspace/declaration.test.ts +76 -0
  332. package/src/workspace/declaration.ts +140 -5
  333. package/src/workspace/identity-gate-rule.ts +1 -1
  334. package/src/workspace/identity.ts +1 -1
  335. package/src/workspace/lineage-upgrade.ts +11 -8
  336. package/src/workspace/member-pipeline.test.ts +29 -0
  337. package/src/workspace/member-pipeline.ts +30 -0
  338. package/src/workspace/pr-report.schema.json +94 -3
  339. package/src/workspace/reason-codes.ts +1 -1
  340. package/src/workspace/security.ts +4 -0
  341. package/src/workspace/status-gates.ts +9 -6
  342. package/src/workspace/status.ts +4 -1
  343. package/src/workspace/trust/gate-seal.test.ts +1 -1
  344. package/src/workspace/trust/seal.ts +2 -2
  345. package/dist/workspace/trust/gate-seal.d.ts.map +0 -1
  346. /package/dist/{workspace/trust → lifecycle}/gate-seal.js +0 -0
@@ -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. `after-gate` (#3821) runs
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?: { plan?: OpWaveHook[]; apply?: OpWaveHook[] };
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 !== "spend") throw new Error(`${where}: approvals.${key} is not a setting; the one there is approvals.spend.`);
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 !== "plan" && stage !== "apply" && stage !== "gate") throw new Error(`${where}: ${at} is not a stage; a hook runs before or after plan or apply, or after the gate.`);
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
+ }