@intentius/chant 0.124.40 → 0.124.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (281) 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 +15 -1
  15. package/dist/cli/handlers/fan-out.js +16 -3
  16. package/dist/cli/handlers/gate-resume.d.ts +3 -0
  17. package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
  18. package/dist/cli/handlers/gate-resume.js +15 -2
  19. package/dist/cli/handlers/lock.d.ts +8 -0
  20. package/dist/cli/handlers/lock.d.ts.map +1 -0
  21. package/dist/cli/handlers/lock.js +197 -0
  22. package/dist/cli/handlers/pr-env.d.ts +34 -0
  23. package/dist/cli/handlers/pr-env.d.ts.map +1 -0
  24. package/dist/cli/handlers/pr-env.js +436 -0
  25. package/dist/cli/handlers/pr.d.ts +74 -0
  26. package/dist/cli/handlers/pr.d.ts.map +1 -1
  27. package/dist/cli/handlers/pr.js +503 -53
  28. package/dist/cli/handlers/promote.d.ts.map +1 -1
  29. package/dist/cli/handlers/promote.js +9 -0
  30. package/dist/cli/handlers/query.d.ts +6 -0
  31. package/dist/cli/handlers/query.d.ts.map +1 -0
  32. package/dist/cli/handlers/query.js +80 -0
  33. package/dist/cli/main.d.ts.map +1 -1
  34. package/dist/cli/main.js +41 -2
  35. package/dist/cli/parse-args.d.ts.map +1 -1
  36. package/dist/cli/parse-args.js +31 -2
  37. package/dist/cli/registry.d.ts +12 -2
  38. package/dist/cli/registry.d.ts.map +1 -1
  39. package/dist/components/fan-out-run.d.ts +28 -0
  40. package/dist/components/fan-out-run.d.ts.map +1 -1
  41. package/dist/components/fan-out-run.js +27 -3
  42. package/dist/components/pr-environment.d.ts +298 -0
  43. package/dist/components/pr-environment.d.ts.map +1 -0
  44. package/dist/components/pr-environment.js +406 -0
  45. package/dist/components/pr-hooks.d.ts +66 -0
  46. package/dist/components/pr-hooks.d.ts.map +1 -0
  47. package/dist/components/pr-hooks.js +119 -0
  48. package/dist/components/pr-pipeline.d.ts +72 -2
  49. package/dist/components/pr-pipeline.d.ts.map +1 -1
  50. package/dist/components/pr-pipeline.js +96 -5
  51. package/dist/components/pr-policy.d.ts +109 -0
  52. package/dist/components/pr-policy.d.ts.map +1 -0
  53. package/dist/components/pr-policy.js +150 -0
  54. package/dist/components/pr-run.d.ts.map +1 -1
  55. package/dist/components/pr-run.js +5 -3
  56. package/dist/gate-decision.d.ts +15 -2
  57. package/dist/gate-decision.d.ts.map +1 -1
  58. package/dist/gate-decision.js +17 -2
  59. package/dist/gate-policy.d.ts +140 -0
  60. package/dist/gate-policy.d.ts.map +1 -0
  61. package/dist/gate-policy.js +242 -0
  62. package/dist/lifecycle/gate-checkpoint.d.ts +1 -1
  63. package/dist/lifecycle/gate-checkpoint.js +1 -1
  64. package/dist/{workspace/trust → lifecycle}/gate-seal.d.ts +1 -1
  65. package/dist/lifecycle/gate-seal.d.ts.map +1 -0
  66. package/dist/lifecycle/query.d.ts +68 -0
  67. package/dist/lifecycle/query.d.ts.map +1 -0
  68. package/dist/lifecycle/query.js +246 -0
  69. package/dist/lifecycle/run-ledger.d.ts +13 -0
  70. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  71. package/dist/lifecycle/run-ledger.js +16 -0
  72. package/dist/lifecycle/target-lock.d.ts +97 -0
  73. package/dist/lifecycle/target-lock.d.ts.map +1 -0
  74. package/dist/lifecycle/target-lock.js +286 -0
  75. package/dist/op/activities/activity-contracts.d.ts +30 -0
  76. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  77. package/dist/op/activities/activity-contracts.js +26 -0
  78. package/dist/op/activities/index.d.ts +2 -0
  79. package/dist/op/activities/index.d.ts.map +1 -1
  80. package/dist/op/activities/index.js +4 -0
  81. package/dist/op/activities/review-agent.d.ts +137 -0
  82. package/dist/op/activities/review-agent.d.ts.map +1 -0
  83. package/dist/op/activities/review-agent.js +266 -0
  84. package/dist/op/builders.d.ts +9 -0
  85. package/dist/op/builders.d.ts.map +1 -1
  86. package/dist/op/builders.js +11 -0
  87. package/dist/op/cron.d.ts +8 -0
  88. package/dist/op/cron.d.ts.map +1 -1
  89. package/dist/op/cron.js +51 -0
  90. package/dist/op/gate-approval.d.ts +20 -13
  91. package/dist/op/gate-approval.d.ts.map +1 -1
  92. package/dist/op/gate-approval.js +13 -18
  93. package/dist/op/gate-quorum.d.ts +131 -0
  94. package/dist/op/gate-quorum.d.ts.map +1 -0
  95. package/dist/op/gate-quorum.js +183 -0
  96. package/dist/op/gate-resume.d.ts +4 -2
  97. package/dist/op/gate-resume.d.ts.map +1 -1
  98. package/dist/op/gate-resume.js +5 -4
  99. package/dist/op/gate-review.js +1 -1
  100. package/dist/op/gate.d.ts +32 -4
  101. package/dist/op/gate.d.ts.map +1 -1
  102. package/dist/op/gate.js +40 -24
  103. package/dist/op/index.d.ts +6 -4
  104. package/dist/op/index.d.ts.map +1 -1
  105. package/dist/op/index.js +4 -3
  106. package/dist/op/local-executor.d.ts.map +1 -1
  107. package/dist/op/local-executor.js +9 -3
  108. package/dist/op/op-waves-run.d.ts +62 -1
  109. package/dist/op/op-waves-run.d.ts.map +1 -1
  110. package/dist/op/op-waves-run.js +190 -11
  111. package/dist/op/op-waves.d.ts +55 -3
  112. package/dist/op/op-waves.d.ts.map +1 -1
  113. package/dist/op/op-waves.js +63 -8
  114. package/dist/op/schedule-overdue.d.ts +57 -0
  115. package/dist/op/schedule-overdue.d.ts.map +1 -0
  116. package/dist/op/schedule-overdue.js +138 -0
  117. package/dist/override-gate.d.ts +2 -2
  118. package/dist/override-gate.d.ts.map +1 -1
  119. package/dist/override-gate.js +1 -1
  120. package/dist/pr-forge.d.ts +36 -0
  121. package/dist/pr-forge.d.ts.map +1 -1
  122. package/dist/pr-forge.js +82 -0
  123. package/dist/pr-loop.d.ts +90 -1
  124. package/dist/pr-loop.d.ts.map +1 -1
  125. package/dist/pr-loop.js +77 -3
  126. package/dist/pr-post.d.ts +72 -0
  127. package/dist/pr-post.d.ts.map +1 -0
  128. package/dist/pr-post.js +134 -0
  129. package/dist/wave-blocked.d.ts +79 -0
  130. package/dist/wave-blocked.d.ts.map +1 -0
  131. package/dist/wave-blocked.js +245 -0
  132. package/dist/wave-engine.d.ts +6 -0
  133. package/dist/wave-engine.d.ts.map +1 -1
  134. package/dist/wave-engine.js +2 -0
  135. package/dist/wave-runtime.mjs +242 -242
  136. package/dist/workspace/__fixtures__/two-member-workspace.d.ts +1 -0
  137. package/dist/workspace/__fixtures__/two-member-workspace.d.ts.map +1 -1
  138. package/dist/workspace/__fixtures__/two-member-workspace.js +1 -1
  139. package/dist/workspace/ci-branches.d.ts +51 -0
  140. package/dist/workspace/ci-branches.d.ts.map +1 -0
  141. package/dist/workspace/ci-branches.js +69 -0
  142. package/dist/workspace/ci-cloud-identity.d.ts +11 -0
  143. package/dist/workspace/ci-cloud-identity.d.ts.map +1 -0
  144. package/dist/workspace/ci-cloud-identity.js +24 -0
  145. package/dist/workspace/ci-locks.d.ts +12 -0
  146. package/dist/workspace/ci-locks.d.ts.map +1 -0
  147. package/dist/workspace/ci-locks.js +6 -0
  148. package/dist/workspace/ci-run.d.ts +17 -1
  149. package/dist/workspace/ci-run.d.ts.map +1 -1
  150. package/dist/workspace/ci-run.js +64 -3
  151. package/dist/workspace/declaration.d.ts +63 -0
  152. package/dist/workspace/declaration.d.ts.map +1 -1
  153. package/dist/workspace/declaration.js +72 -1
  154. package/dist/workspace/declaration.schema.json +147 -0
  155. package/dist/workspace/identity-gate-rule.d.ts +1 -1
  156. package/dist/workspace/identity-gate-rule.d.ts.map +1 -1
  157. package/dist/workspace/identity-gate-rule.js +1 -1
  158. package/dist/workspace/identity.d.ts +1 -1
  159. package/dist/workspace/identity.d.ts.map +1 -1
  160. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  161. package/dist/workspace/lineage-upgrade.js +8 -5
  162. package/dist/workspace/member-pipeline.d.ts +6 -0
  163. package/dist/workspace/member-pipeline.d.ts.map +1 -1
  164. package/dist/workspace/member-pipeline.js +23 -0
  165. package/dist/workspace/pr-report.schema.json +73 -2
  166. package/dist/workspace/security.d.ts.map +1 -1
  167. package/dist/workspace/security.js +4 -0
  168. package/dist/workspace/status-gates.d.ts +3 -2
  169. package/dist/workspace/status-gates.d.ts.map +1 -1
  170. package/dist/workspace/status-gates.js +8 -7
  171. package/dist/workspace/status.d.ts.map +1 -1
  172. package/dist/workspace/status.js +4 -1
  173. package/dist/workspace/trust/seal.d.ts +2 -2
  174. package/dist/workspace/trust/seal.d.ts.map +1 -1
  175. package/dist/workspace/trust/seal.js +2 -2
  176. package/package.json +1 -1
  177. package/src/__fixtures__/oidc-emulator.ts +118 -0
  178. package/src/ci-handover.test.ts +209 -0
  179. package/src/ci-handover.ts +237 -0
  180. package/src/ci-identity.test.ts +201 -0
  181. package/src/ci-identity.ts +310 -0
  182. package/src/cli/handlers/approve.ts +18 -5
  183. package/src/cli/handlers/build.ts +15 -1
  184. package/src/cli/handlers/fan-out.ts +17 -4
  185. package/src/cli/handlers/gate-resume.ts +16 -2
  186. package/src/cli/handlers/lock.test.ts +98 -0
  187. package/src/cli/handlers/lock.ts +202 -0
  188. package/src/cli/handlers/operator.test.ts +3 -1
  189. package/src/cli/handlers/pr-env.ts +462 -0
  190. package/src/cli/handlers/pr-waves-blocked.test.ts +247 -0
  191. package/src/cli/handlers/pr.test.ts +184 -0
  192. package/src/cli/handlers/pr.ts +496 -59
  193. package/src/cli/handlers/promote.ts +9 -0
  194. package/src/cli/handlers/query.ts +78 -0
  195. package/src/cli/main.ts +41 -2
  196. package/src/cli/parse-args.ts +23 -2
  197. package/src/cli/registry.ts +12 -2
  198. package/src/components/fan-out-run.ts +39 -2
  199. package/src/components/fan-out-waves.test.ts +45 -1
  200. package/src/components/pr-environment.test.ts +249 -0
  201. package/src/components/pr-environment.ts +538 -0
  202. package/src/components/pr-hooks.test.ts +118 -0
  203. package/src/components/pr-hooks.ts +157 -0
  204. package/src/components/pr-pipeline.ts +130 -5
  205. package/src/components/pr-policy.test.ts +145 -0
  206. package/src/components/pr-policy.ts +211 -0
  207. package/src/components/pr-run.ts +5 -2
  208. package/src/gate-decision-bundle.test.ts +4 -3
  209. package/src/gate-decision.ts +39 -2
  210. package/src/gate-policy-bundle.test.ts +28 -0
  211. package/src/gate-policy.test.ts +162 -0
  212. package/src/gate-policy.ts +298 -0
  213. package/src/lifecycle/gate-checkpoint.ts +1 -1
  214. package/src/{workspace/trust → lifecycle}/gate-seal.ts +1 -1
  215. package/src/lifecycle/query.test.ts +91 -0
  216. package/src/lifecycle/query.ts +315 -0
  217. package/src/lifecycle/run-ledger.ts +18 -0
  218. package/src/lifecycle/target-lock.test.ts +195 -0
  219. package/src/lifecycle/target-lock.ts +347 -0
  220. package/src/op/activities/activity-contracts.ts +31 -0
  221. package/src/op/activities/index.ts +6 -0
  222. package/src/op/activities/review-agent.test.ts +240 -0
  223. package/src/op/activities/review-agent.ts +355 -0
  224. package/src/op/builders.ts +13 -0
  225. package/src/op/cron.ts +44 -0
  226. package/src/op/gate-alias.test.ts +2 -2
  227. package/src/op/gate-approval.test.ts +30 -9
  228. package/src/op/gate-approval.ts +23 -26
  229. package/src/op/gate-quorum.test.ts +139 -0
  230. package/src/op/gate-quorum.ts +248 -0
  231. package/src/op/gate-resume.ts +6 -3
  232. package/src/op/gate-review.ts +1 -1
  233. package/src/op/gate.ts +64 -23
  234. package/src/op/index.ts +13 -1
  235. package/src/op/local-executor.ts +9 -2
  236. package/src/op/op-waves-hook-targets.test.ts +126 -0
  237. package/src/op/op-waves-policy-hold.test.ts +66 -0
  238. package/src/op/op-waves-quorum.test.ts +101 -0
  239. package/src/op/op-waves-run.ts +233 -11
  240. package/src/op/op-waves.ts +83 -10
  241. package/src/op/schedule-overdue.test.ts +220 -0
  242. package/src/op/schedule-overdue.ts +161 -0
  243. package/src/override-gate-bundle.test.ts +1 -1
  244. package/src/override-gate.ts +2 -2
  245. package/src/pr-forge-bundle.test.ts +2 -2
  246. package/src/pr-forge.test.ts +33 -0
  247. package/src/pr-forge.ts +114 -0
  248. package/src/pr-loop-bundle.test.ts +2 -2
  249. package/src/pr-loop.test.ts +58 -0
  250. package/src/pr-loop.ts +146 -4
  251. package/src/pr-post.test.ts +141 -0
  252. package/src/pr-post.ts +159 -0
  253. package/src/wave-blocked-bundle.test.ts +25 -0
  254. package/src/wave-blocked.test.ts +146 -0
  255. package/src/wave-blocked.ts +271 -0
  256. package/src/wave-engine.ts +8 -0
  257. package/src/workspace/__fixtures__/two-member-workspace.ts +2 -2
  258. package/src/workspace/ci-branches.test.ts +62 -0
  259. package/src/workspace/ci-branches.ts +79 -0
  260. package/src/workspace/ci-cloud-identity.test.ts +53 -0
  261. package/src/workspace/ci-cloud-identity.ts +23 -0
  262. package/src/workspace/ci-locks.ts +16 -0
  263. package/src/workspace/ci-run-index.test.ts +2 -1
  264. package/src/workspace/ci-run.test.ts +107 -8
  265. package/src/workspace/ci-run.ts +72 -4
  266. package/src/workspace/declaration.schema.json +147 -0
  267. package/src/workspace/declaration.test.ts +76 -0
  268. package/src/workspace/declaration.ts +134 -2
  269. package/src/workspace/identity-gate-rule.ts +1 -1
  270. package/src/workspace/identity.ts +1 -1
  271. package/src/workspace/lineage-upgrade.ts +11 -8
  272. package/src/workspace/member-pipeline.test.ts +29 -0
  273. package/src/workspace/member-pipeline.ts +30 -0
  274. package/src/workspace/pr-report.schema.json +73 -2
  275. package/src/workspace/security.ts +4 -0
  276. package/src/workspace/status-gates.ts +9 -6
  277. package/src/workspace/status.ts +4 -1
  278. package/src/workspace/trust/gate-seal.test.ts +1 -1
  279. package/src/workspace/trust/seal.ts +2 -2
  280. package/dist/workspace/trust/gate-seal.d.ts.map +0 -1
  281. /package/dist/{workspace/trust → lifecycle}/gate-seal.js +0 -0
package/src/op/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { Op, phase, activity, gate, effect, build, kubectlApply, helmInstall, helmInstallPinned, waitForStack, waitForReady,
2
- gitlabPipeline, lifecycleSnapshot, shell, sourceArchive, releasePlan, releaseRecord, composeChangeSet, lifecyclePlanChangeSet, readChangeSetPart, releaseRollbackPlan, releaseRollbackRecord, decide, ensureSecret, teardown, envTeardown, k3dUp, k3dDown,
2
+ gitlabPipeline, lifecycleSnapshot, shell, sourceArchive, releasePlan, releaseRecord, composeChangeSet, lifecyclePlanChangeSet, readChangeSetPart, reviewAgent, releaseRollbackPlan, releaseRollbackRecord, decide, ensureSecret, teardown, envTeardown, k3dUp, k3dDown,
3
3
  k3sInstall, k3sUninstall, flociUp, flociDown,
4
4
  flociAzUp, flociAzDown, flociGcpUp, flociGcpDown, httpCheck,
5
5
  azGroupEnsure, azGroupDelete, azApply, azDelete, awsApply, awsDelete, gcpApply, gcpDelete, policyGate,
@@ -57,10 +57,13 @@ export {
57
57
  OP_WAVE_DEFAULT_GATE_NAME,
58
58
  OP_WAVE_GATE_POLICIES,
59
59
  OP_WAVE_HOOK_MOMENTS,
60
+ PR_LOOP_HOOK_MOMENTS,
60
61
  assertOpWaveHooks,
61
62
  assertOpWavesSpec,
63
+ assertPrLoopHooks,
62
64
  opWaveApplyHooksRunBy,
63
65
  opWaveGateName,
66
+ opWaveHookRuns,
64
67
  opWaveHooksAt,
65
68
  opWaveJobs,
66
69
  opWaveRecordPath,
@@ -71,6 +74,7 @@ export {
71
74
  OP_WAVE_APPLY_OUTCOMES,
72
75
  OP_WAVE_MAX_REPLANS,
73
76
  opWaveHooksAtBase,
77
+ runStageHooks,
74
78
  opWaveNeedsApproval,
75
79
  opWavePlansPath,
76
80
  opWaveRunsPath,
@@ -102,6 +106,8 @@ export type {
102
106
  OpWaveShowAtBase,
103
107
  RecordHeadPlansOptions,
104
108
  RunOpWaveApplyHooksOptions,
109
+ RunStageHooksOptions,
110
+ StageHooksRun,
105
111
  RunOpWaveOptions,
106
112
  RunOpWaveResult,
107
113
  } from "./op-waves-run";
@@ -130,6 +136,12 @@ export type {
130
136
  GateApproval, GateApprovalMode, GateApprover, GateContextValue, GatePolicyAnswer, GatePolicyDecision,
131
137
  GatePolicyEvaluator, GatePolicyRef, GatePolicyRequest, GateQuorum, ResolvedGateApproval,
132
138
  } from "./gate-approval";
139
+ export {
140
+ quorumRules, quorumRulesFor, quorumNeed, quorumProblems, quorumRuleProgress, quorumRuleLine, quorumWaiting, rolesOf, trustQuorumJudge, UNPROVEN_JUDGE,
141
+ } from "./gate-quorum";
142
+ export type {
143
+ GateQuorumRule, GateQuorumSpec, GateQuorumJudge, GateQuorumRuleProgress, QuorumJudgement, QuorumApprover, TrustQuorumInput,
144
+ } from "./gate-quorum";
133
145
  export {
134
146
  gatePlanSummary, gatePlanSummaryOfResult, changeSetOfResult, GATE_PLAN_CONTEXT_KEY, GATE_PLAN_ADDRESS_LIMIT,
135
147
  } from "./gate-plan-context";
@@ -41,6 +41,7 @@ import { resolveGateAliases } from "./gate-alias";
41
41
  import { gatePointOf } from "./gate-point";
42
42
  import { evaluatePointGate, workspaceGatePointAsker, type GatePointAsker } from "./gate-point-run";
43
43
  import type { ResolvedGateApproval } from "./gate-approval";
44
+ import { quorumRules, quorumRulesFor } from "./gate-quorum";
44
45
  import { GATE_PLAN_CONTEXT_KEY, gatePlanSummaryOfResult } from "./gate-plan-context";
45
46
  import { withOpRunContext, type OpRunContext, type PassedGate } from "./run-context";
46
47
  import type { GateAnswerRef, PendingGateRecord } from "../lifecycle/gate-ledger";
@@ -732,6 +733,7 @@ function gateFn(step: GateStep): string {
732
733
  function resolveGateApproval(
733
734
  step: GateStep,
734
735
  resultsById: ReadonlyMap<string, unknown>,
736
+ env?: string,
735
737
  ): ResolvedGateApproval | undefined {
736
738
  const authored = step.approval;
737
739
  if (!authored) return undefined;
@@ -750,8 +752,13 @@ function resolveGateApproval(
750
752
  const summary = gatePlanSummaryOfResult(resultsById.get(step.plan.step));
751
753
  if (summary) context = { ...(context ?? {}), [GATE_PLAN_CONTEXT_KEY]: summary };
752
754
  }
755
+ // #4290: the rules this run's gate takes, by its environment. A rule with
756
+ // `targets` never covers a run with no environment. One rule with no
757
+ // `targets` keeps its own shape, so a standing pending fact still matches.
758
+ const single = authored.quorum !== undefined && !Array.isArray(authored.quorum) && (authored.quorum as { targets?: unknown }).targets === undefined;
759
+ const rules = authored.quorum ? quorumRulesFor(quorumRules(authored.quorum), env !== undefined ? [env] : []) : [];
753
760
  return {
754
- ...(authored.quorum ? { quorum: authored.quorum } : {}),
761
+ ...(single ? { quorum: authored.quorum } : rules.length > 0 ? { quorum: rules } : {}),
755
762
  ...(authored.policy ? { policy: authored.policy } : {}),
756
763
  mode: authored.mode ?? "log-only",
757
764
  ...(context ? { context } : {}),
@@ -779,7 +786,7 @@ async function runGateStep(
779
786
  // a run over a missing digest.
780
787
  const resolvedPlan = resolveStepOutputRefs(step.plan, resultsById);
781
788
  const planDigest = typeof resolvedPlan === "string" && resolvedPlan !== "" ? resolvedPlan : undefined;
782
- const approval = resolveGateApproval(step, resultsById);
789
+ const approval = resolveGateApproval(step, resultsById, gates.env);
783
790
  // #3947: a gate that resumes stores the outputs the steps after it read.
784
791
  const resumeAt = gates.resume?.gates.get(step);
785
792
  let checkpoint: GateCheckpoint | undefined;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Hook `targets` and the `before.observe` moment (#4288), in an Op waves
3
+ * job and through `runStageHooks`.
4
+ */
5
+ import { describe, expect, test } from "vitest";
6
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { tmpdir } from "node:os";
9
+ import { assertOpWaveHooks, assertPrLoopHooks, opWaveHookRuns, opWaveHooksAt, OP_WAVE_HOOK_MOMENTS, type OpWavesSpec } from "./op-waves";
10
+ import { runOpWave, runStageHooks, type OpWaveExec, type OpWaveShowAtBase } from "./op-waves-run";
11
+ import { memoryGateLedgerPort } from "./gate";
12
+
13
+ const digest = (n: string) => `sha256:${n.repeat(64)}`;
14
+
15
+ describe("hook targets and before.observe in the spec (#4288)", () => {
16
+ test("before.observe is the first moment, and opWaveHooksAt reads it", () => {
17
+ expect(OP_WAVE_HOOK_MOMENTS[0]).toBe("before-observe");
18
+ const hooks = opWaveHooksAt({ hooks: { before: { observe: [{ command: ["init"] }] } }, waves: [{ name: "prod", runs: [] }] }, "prod");
19
+ expect(hooks["before-observe"]).toEqual([{ command: ["init"] }]);
20
+ });
21
+
22
+ test("the spec refuses after.observe, {plan} before the observe and targets that are not a list of names", () => {
23
+ expect(() => assertOpWaveHooks("w", { after: { observe: [{ command: ["x"] }] } } as never)).toThrow(/hooks.after.observe is not a moment/);
24
+ expect(() => assertOpWaveHooks("w", { before: { observe: [{ command: ["x", "{plan}"], perRun: true }] } })).toThrow(/no \{plan\} yet/);
25
+ expect(() => assertOpWaveHooks("w", { before: { plan: [{ command: ["x"], targets: [] }] } })).toThrow(/targets must be a non-empty list/);
26
+ expect(() => assertOpWaveHooks("w", { before: { plan: [{ command: ["x"], targets: ["prod/*", 3] as never }] } })).toThrow(/targets must be a non-empty list/);
27
+ expect(() => assertOpWaveHooks("w", { before: { observe: [{ command: ["x"], targets: ["prod/**"], onFailure: "approve" }] } })).not.toThrow();
28
+ });
29
+
30
+ test("a pull-request job refuses after.gate and apply.runBy", () => {
31
+ expect(() => assertPrLoopHooks("ci.hooks", { after: { gate: [{ command: ["x"] }] } })).toThrow(/after.gate runs in an Op waves job only/);
32
+ expect(() => assertPrLoopHooks("ci.hooks", { apply: { runBy: "apply" } })).toThrow(/runBy is for an Op waves spec/);
33
+ expect(() => assertPrLoopHooks("ci.hooks", { before: { observe: [{ command: ["x"] }], apply: [{ command: ["y"] }] } })).not.toThrow();
34
+ });
35
+
36
+ test("targets match a run by name or by a pattern, as a job group's runs do", () => {
37
+ const runs = [{ target: "prod/eu" }, { target: "prod/us/east" }, { target: "dev" }];
38
+ expect(opWaveHookRuns({}, runs).map((r) => r.target)).toEqual(["prod/eu", "prod/us/east", "dev"]);
39
+ expect(opWaveHookRuns({ targets: ["prod/*"] }, runs).map((r) => r.target)).toEqual(["prod/eu"]);
40
+ expect(opWaveHookRuns({ targets: ["prod/**", "dev"] }, runs).map((r) => r.target)).toEqual(["prod/eu", "prod/us/east", "dev"]);
41
+ });
42
+ });
43
+
44
+ describe("runOpWave with targets and before.observe (#4288)", () => {
45
+ const spec: OpWavesSpec = {
46
+ name: "roots",
47
+ op: "tf",
48
+ plan: ["tool", "plan", "{target}", "{plan}"],
49
+ apply: ["tool", "apply", "{target}", "{plan}"],
50
+ hooks: {
51
+ before: {
52
+ observe: [{ name: "init", command: ["hook", "init", "{target}"], perRun: true, targets: ["eu/*"] }],
53
+ plan: [{ name: "us-only", command: ["hook", "us", "{runs}"], targets: ["us/*"] }],
54
+ },
55
+ after: { plan: [{ name: "eu-check", command: ["hook", "eu-check", "{runs}"], targets: ["eu/*"] }] },
56
+ },
57
+ waves: [{ name: "prod", runs: [{ target: "eu/a" }, { target: "eu/b" }, { target: "asia/c" }], gate: "never" }],
58
+ };
59
+
60
+ function harness(fails: string[] = []) {
61
+ const cwd = mkdtempSync(join(tmpdir(), "op-waves-targets-"));
62
+ const calls: Array<{ argv: string; env?: Record<string, string> }> = [];
63
+ const exec: OpWaveExec = (argv, dir, env) => {
64
+ calls.push({ argv: argv.join(" "), ...(env ? { env } : {}) });
65
+ if (argv[1] === "plan") writeFileSync(join(dir, argv[3]!), JSON.stringify({ planDigest: digest(argv[2]!.endsWith("a") ? "1" : "2") }));
66
+ return fails.some((f) => argv.join(" ").startsWith(f)) ? 1 : 0;
67
+ };
68
+ const show: OpWaveShowAtBase = () => ({ sha: "b".repeat(40), text: JSON.stringify(spec) });
69
+ return { cwd, calls, exec, show, argv: () => calls.map((c) => c.argv), done: () => rmSync(cwd, { recursive: true, force: true }) };
70
+ }
71
+
72
+ test("before-observe runs ahead of before-plan, each hook only for the runs its targets match", async () => {
73
+ const h = harness();
74
+ const logs: string[] = [];
75
+ const result = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort(), log: (l) => logs.push(l) });
76
+ expect(result.exitCode).toBe(0);
77
+ expect(h.argv().filter((c) => c.startsWith("hook") || c.startsWith("tool plan"))).toEqual([
78
+ "hook init eu/a",
79
+ "hook init eu/b",
80
+ "tool plan eu/a .chant/op-waves/roots/wave-1/eu_a.plan.json",
81
+ "tool plan eu/b .chant/op-waves/roots/wave-1/eu_b.plan.json",
82
+ "tool plan asia/c .chant/op-waves/roots/wave-1/asia_c.plan.json",
83
+ "hook eu-check .chant/op-waves/roots/wave-1/runs.json",
84
+ ]);
85
+ expect(h.calls[0]!.env).toMatchObject({ CHANT_HOOK: "before-observe", CHANT_TARGET: "eu/a" });
86
+ expect(logs).toContain("before-plan hook us-only: no run matches its targets (us/*), so it did not run");
87
+ expect(result.hooks.map((o) => `${o.moment} ${o.name}${o.target ? ` ${o.target}` : ""} ${o.outcome}`)).toEqual([
88
+ "before-observe init eu/a passed",
89
+ "before-observe init eu/b passed",
90
+ "after-plan eu-check passed",
91
+ ]);
92
+ h.done();
93
+ });
94
+
95
+ test("a failed before-observe hook stops the wave before anything plans", async () => {
96
+ const h = harness(["hook init eu/b"]);
97
+ const result = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort(), log: () => {} });
98
+ expect(result.exitCode).toBe(1);
99
+ expect(h.argv().some((c) => c.startsWith("tool"))).toBe(false);
100
+ h.done();
101
+ });
102
+ });
103
+
104
+ describe("runStageHooks (#4288)", () => {
105
+ test("runs one moment's hooks for the runs given, with targets, and counts an approve failure as a hold", () => {
106
+ const cwd = mkdtempSync(join(tmpdir(), "stage-hooks-"));
107
+ const calls: string[] = [];
108
+ const exec: OpWaveExec = (argv) => {
109
+ calls.push(argv.join(" "));
110
+ return argv[1] === "gate" ? 1 : 0;
111
+ };
112
+ const ran = runStageHooks(
113
+ "before-plan",
114
+ [
115
+ { name: "each", command: ["hook", "each", "{target}", "{op}"], perRun: true, targets: ["api"] },
116
+ { name: "gate", command: ["hook", "gate", "{wave}"], onFailure: "approve" },
117
+ ],
118
+ { name: "pr-7", op: "pr-7", wave: "prod", runs: [{ target: "api", op: "pr-7" }, { target: "web", op: "pr-7" }], runsFile: "runs.json", holdFile: "hold.json", cwd, exec, log: () => {} },
119
+ );
120
+ expect(calls).toEqual(["hook each api pr-7", "hook gate prod"]);
121
+ expect(ran.failed).toBeUndefined();
122
+ expect(ran.holds).toEqual(["before-plan hook gate"]);
123
+ expect(ran.outcomes.map((o) => o.outcome)).toEqual(["passed", "held"]);
124
+ rmSync(cwd, { recursive: true, force: true });
125
+ });
126
+ });
@@ -0,0 +1,66 @@
1
+ /**
2
+ * A plan the product's policy holds (#4294): the wave waits for a person's
3
+ * approval of its set digest whatever its gate policy says, and applies
4
+ * once one stands.
5
+ */
6
+
7
+ import { describe, expect, test } from "vitest";
8
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { tmpdir } from "node:os";
11
+ import type { OpWavesSpec } from "./op-waves";
12
+ import { runOpWave, type OpWaveExec, type OpWaveShowAtBase } from "./op-waves-run";
13
+ import { memoryGateLedgerPort } from "./gate";
14
+ import { waveSetDigest } from "../gated-waves";
15
+
16
+ const spec: OpWavesSpec = {
17
+ name: "roots",
18
+ op: "tf",
19
+ plan: ["tool", "plan", "{target}", "--out", "{plan}"],
20
+ apply: ["tool", "apply", "{target}", "--plan", "{plan}"],
21
+ waves: [{ name: "dev", runs: [{ target: "vpc" }], gate: "never" }],
22
+ };
23
+
24
+ const digest = (n: string) => `sha256:${n.repeat(64)}`;
25
+
26
+ function harness(plan: Record<string, unknown>) {
27
+ const cwd = mkdtempSync(join(tmpdir(), "op-waves-hold-"));
28
+ const calls: string[] = [];
29
+ const exec: OpWaveExec = (argv, dir) => {
30
+ calls.push(argv.join(" "));
31
+ if (argv[1] === "plan") writeFileSync(join(dir, argv[4]!), JSON.stringify(plan));
32
+ return 0;
33
+ };
34
+ const show: OpWaveShowAtBase = () => ({ sha: "b".repeat(40), text: JSON.stringify(spec) });
35
+ return { cwd, calls, exec, show, done: () => rmSync(cwd, { recursive: true, force: true }) };
36
+ }
37
+
38
+ describe("a policy hold on an Op waves plan (#4294)", () => {
39
+ test("a held run makes a gate: never wave wait, and an approval of its digest lets it apply", async () => {
40
+ const h = harness({ planDigest: digest("1"), held: { rules: ["main.hold_iam", "main.hold_iam"], reason: "an IAM change" } });
41
+ const waiting = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort() });
42
+ expect(waiting.exitCode).toBe(3);
43
+ expect(waiting.decision).toMatchObject({ status: "waiting", policy: "never", heldBy: ["policy on vpc (main.hold_iam): an IAM change"] });
44
+ expect(waiting.decision!.members[0]).toMatchObject({ member: "vpc", held: { rules: ["main.hold_iam"] } });
45
+ expect(h.calls.some((c) => c.startsWith("tool apply"))).toBe(false);
46
+
47
+ const set = waveSetDigest([{ member: "vpc", planDigest: digest("1") }]);
48
+ const gates = memoryGateLedgerPort({ resolutions: [{ version: 1, op: "roots", gate: "roots-wave-1", resolvedBy: "alice", timestamp: "2026-10-10T00:00:00Z", planDigest: set }] });
49
+ const approved = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates });
50
+ expect(approved).toMatchObject({ exitCode: 0, applied: ["vpc"], decision: { status: "approved", approvedBy: "alice" } });
51
+ h.done();
52
+ });
53
+
54
+ test("a run with no hold under gate: never needs no approval", async () => {
55
+ const h = harness({ planDigest: digest("2") });
56
+ const free = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort() });
57
+ expect(free).toMatchObject({ exitCode: 0, applied: ["vpc"], decision: { status: "not-required" } });
58
+ h.done();
59
+ });
60
+
61
+ test("a hold that names no rule is refused", async () => {
62
+ const h = harness({ planDigest: digest("3"), held: { rules: [] } });
63
+ await expect(runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort() })).rejects.toThrow(/a hold is \{ "rules"/);
64
+ h.done();
65
+ });
66
+ });
@@ -0,0 +1,101 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { assertOpWavesSpec, type OpWavesSpec } from "./op-waves";
6
+ import { runOpWave, type OpWaveExec, type OpWaveShowAtBase } from "./op-waves-run";
7
+ import { memoryGateLedgerPort } from "./gate";
8
+ import { waveSetDigest } from "../gated-waves";
9
+ import type { TrustPolicy } from "../workspace/trust/policy";
10
+ import type { MergedReview, ReviewSource } from "./gate-review";
11
+
12
+ const digest = (n: string) => `sha256:${n.repeat(64)}`;
13
+
14
+ const spec: OpWavesSpec = {
15
+ name: "migrations",
16
+ op: "migrate",
17
+ plan: ["tool", "plan", "{target}", "--out", "{plan}"],
18
+ apply: ["tool", "apply", "{target}", "--plan", "{plan}"],
19
+ waves: [{ name: "prod", runs: [{ target: "prod-eu" }, { target: "prod-us" }] }],
20
+ };
21
+ const set = waveSetDigest([
22
+ { member: "prod-eu", planDigest: digest("e") },
23
+ { member: "prod-us", planDigest: digest("u") },
24
+ ]);
25
+
26
+ function harness(atBase: OpWavesSpec) {
27
+ const cwd = mkdtempSync(join(tmpdir(), "op-waves-quorum-"));
28
+ const calls: string[] = [];
29
+ const exec: OpWaveExec = (argv, dir) => {
30
+ calls.push(argv.join(" "));
31
+ if (argv[1] === "plan") writeFileSync(join(dir, argv[4]!), JSON.stringify({ planDigest: digest(argv[2] === "prod-eu" ? "e" : "u") }));
32
+ return 0;
33
+ };
34
+ const show: OpWaveShowAtBase = () => ({ sha: "b".repeat(40), text: JSON.stringify(atBase) });
35
+ return { cwd, exec, show, applies: () => calls.filter((c) => c.startsWith("tool apply")).length, done: () => rmSync(cwd, { recursive: true, force: true }) };
36
+ }
37
+
38
+ const trust = (roles: Record<string, string[]>): (() => TrustPolicy) => () =>
39
+ ({ base: "b".repeat(40), signersPath: ".chant/allowed_signers", active: false, signers: [], excluded: [], roles, adopted: [], runners: [], excludedRunners: [], admitted: {}, problems: [] }) as TrustPolicy;
40
+
41
+ const reviews = (merged: MergedReview | null): ReviewSource => ({ kind: "github", mergedReview: async () => merged });
42
+ const approval = (who: string, at: string) => ({ version: 1 as const, op: "migrations", gate: "migrations-wave-1", resolvedBy: who, timestamp: at, planDigest: set });
43
+
44
+ describe("Op waves approval rules (#4290)", () => {
45
+ test("the spec checks approvals.quorum, and refuses it beside a decision point", () => {
46
+ expect(() => assertOpWavesSpec({ ...spec, approvals: { quorum: [{ targets: ["prod-*"], count: 2, roles: ["platform"], notAuthor: true }] } })).not.toThrow();
47
+ expect(() => assertOpWavesSpec({ ...spec, approvals: { quorum: [{ count: 0 }] } })).toThrow(/approvals.quorum\[0\].count/);
48
+ expect(() => assertOpWavesSpec({ ...spec, approvals: { quorum: { count: 1 } } } as unknown as OpWavesSpec)).toThrow(/must be a list of rules/);
49
+ expect(() => assertOpWavesSpec({ ...spec, point: "ship", approvals: { quorum: [{ count: 1 }] } })).toThrow(/both set/);
50
+ });
51
+
52
+ test("a rule at base whose glob matches a run holds the wave until two people approve", async () => {
53
+ const atBase: OpWavesSpec = { ...spec, approvals: { quorum: [{ targets: ["prod-*"], count: 2 }, { targets: ["dev"], count: 5 }] } };
54
+ const h = harness(atBase);
55
+ const gates = memoryGateLedgerPort({ resolutions: [approval("alice", "2026-10-22T01:00:00Z")] });
56
+ const logs: string[] = [];
57
+ // The change drops the rule; the one at base holds.
58
+ const one = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates, now: "2026-10-22T02:00:00Z", log: (l) => logs.push(l) });
59
+ expect(one.exitCode).toBe(3);
60
+ expect(one.decision?.quorum).toEqual([{ targets: ["prod-*"], count: 2 }]);
61
+ expect(one.decision?.quorumProgress?.[0]).toMatchObject({ approvers: ["alice"], met: false });
62
+ expect(logs.some((l) => /the ones at base hold/.test(l))).toBe(true);
63
+ // Approvals made while the wave waits count, each person once.
64
+ const two = memoryGateLedgerPort({ resolutions: [approval("alice", "2026-10-22T02:30:00Z"), approval("alice", "2026-10-22T02:45:00Z"), approval("bob", "2026-10-22T03:00:00Z")], pending: gates.appended });
65
+ const done = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: two, now: "2026-10-22T04:00:00Z" });
66
+ expect(done.exitCode).toBe(0);
67
+ expect(done.decision?.approvedBy).toBe("alice, bob");
68
+ expect(h.applies()).toBe(2);
69
+ h.done();
70
+ });
71
+
72
+ test("a claimed role never counts; a role from trust.json at base counts for a reviewer of the merged pull request", async () => {
73
+ const atBase: OpWavesSpec = { ...spec, waves: [{ ...spec.waves[0]!, approval: "pr-review" }], approvals: { quorum: [{ count: 1, roles: ["platform"], notAuthor: true }] } };
74
+ const h = harness(atBase);
75
+ const claimed = memoryGateLedgerPort({ resolutions: [{ ...approval("github:carol", "2026-10-22T01:00:00Z"), approver: { kind: "human", roles: ["platform"] } }] });
76
+ const merged: MergedReview = { pr: 7, head: "h".repeat(40), author: "github:dana", approvers: ["github:alice"], refused: [] };
77
+ const headPlans = async () => ({ version: 1 as const, name: "migrations", head: merged.head, timestamp: "2026-10-22T00:00:00Z", waves: [{ wave: 1, name: "prod", digest: set }] });
78
+ const base = { spec: atBase, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, head: "m".repeat(40), headPlans, now: "2026-10-22T02:00:00Z" };
79
+ // carol claims the role and is not proven; nobody reviewed.
80
+ const waiting = await runOpWave({ ...base, gates: claimed, reviews: reviews({ ...merged, approvers: [] }), trust: trust({ platform: ["github:alice", "github:carol"] }) });
81
+ expect(waiting.exitCode).toBe(3);
82
+ // alice reviewed and holds the role at base; dana wrote the change.
83
+ const gates = memoryGateLedgerPort();
84
+ const passed = await runOpWave({ ...base, gates, reviews: reviews(merged), trust: trust({ platform: ["github:alice"] }) });
85
+ expect(passed.exitCode).toBe(0);
86
+ expect(passed.decision).toMatchObject({ via: "pr-review", approvedBy: "github:alice" });
87
+ expect(gates.resolved.map((r) => r.resolvedBy)).toEqual(["github:alice"]);
88
+ // The author reviewing their own change never meets notAuthor.
89
+ const own = await runOpWave({ ...base, gates: memoryGateLedgerPort(), reviews: reviews({ ...merged, author: "github:alice" }), trust: trust({ platform: ["github:alice"] }) });
90
+ expect(own.exitCode).toBe(3);
91
+ h.done();
92
+ });
93
+
94
+ test("rules at base that cannot be read fail the wave", async () => {
95
+ const h = harness({ ...spec, approvals: { quorum: [{ count: 0 }] } });
96
+ const result = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort() });
97
+ expect(result.exitCode).toBe(1);
98
+ expect(h.applies()).toBe(0);
99
+ h.done();
100
+ });
101
+ });