@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
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Stage hooks on the pull-request path (#4288): `ci.hooks` in the workspace
3
+ * declaration, run by `chant components pr-plan`, `pr-record` and
4
+ * `pr-apply` with the runner an Op waves job uses (`runStageHooks`).
5
+ *
6
+ * The hooks are read from the declaration at the base the job measures the
7
+ * change from, never from the change itself, so a pull request cannot add,
8
+ * edit or remove a hook its own jobs run. The top level's hooks run first,
9
+ * then those of the member the job runs in. When the base has no workspace
10
+ * declaration no hook runs. When the declaration at base cannot be read and
11
+ * the working tree's names hooks, the job refuses to run rather than guess.
12
+ *
13
+ * The runs a hook sees are the components the change selects, in apply
14
+ * order: `{target}` and `CHANT_TARGET` name one, and `targets` match their
15
+ * names. After the plan, a `perRun` hook's `{plan}` is a JSON file with that
16
+ * component's members and the plans its steps hand the apply.
17
+ */
18
+
19
+ import { mkdirSync, writeFileSync } from "node:fs";
20
+ import { join, relative, resolve } from "node:path";
21
+ import type { OpWaveHook, OpWaveHookMoment, OpWaveHooks } from "../op/op-waves";
22
+ import { PR_LOOP_HOOK_MOMENTS } from "../op/op-waves";
23
+ import { runStageHooks, type OpWaveExec, type OpWaveHookRun, type StageHooksRun } from "../op/op-waves-run";
24
+ import { readDeclaration, WorkspaceReadError, type Declaration } from "../workspace/declaration";
25
+ import { memberAtCwd } from "../workspace/security";
26
+ import { locateWorkspace } from "../workspace/which-chant";
27
+ import type { PrHookOutcome, PrMember } from "../pr-loop";
28
+
29
+ /** The hooks a pull-request job runs, by moment, and where they were read. */
30
+ export interface PrLoopHooks {
31
+ hooks: Partial<Record<OpWaveHookMoment, OpWaveHook[]>>;
32
+ /** The commit they were read at. */
33
+ base: string;
34
+ /** A line for the log when none run because the base has no declaration, or could not be read. */
35
+ note?: string;
36
+ }
37
+
38
+ /** The hooks of one `ci.hooks` block at `moment`. */
39
+ function at(hooks: OpWaveHooks | null | undefined, moment: OpWaveHookMoment): OpWaveHook[] {
40
+ const [side, stage] = moment.split("-") as ["before" | "after", string];
41
+ return ((hooks?.[side] as Record<string, OpWaveHook[] | undefined> | undefined)?.[stage]) ?? [];
42
+ }
43
+
44
+ /** The hooks `declaration` gives a job run in `cwd`: the top level's, then the member's. */
45
+ export function prLoopHooksOf(declaration: Pick<Declaration, "ci" | "members">, member: string | null): Partial<Record<OpWaveHookMoment, OpWaveHook[]>> {
46
+ const own = member === null ? null : (declaration.members.find((m) => m.name === member)?.ciHooks ?? null);
47
+ const out: Partial<Record<OpWaveHookMoment, OpWaveHook[]>> = {};
48
+ for (const moment of PR_LOOP_HOOK_MOMENTS) {
49
+ const list = [...at(declaration.ci?.hooks, moment), ...at(own, moment)];
50
+ if (list.length > 0) out[moment] = list;
51
+ }
52
+ return out;
53
+ }
54
+
55
+ /** Whether the working tree's declaration names any `ci.hooks`. */
56
+ function checkoutNamesHooks(cwd: string): boolean {
57
+ try {
58
+ const located = locateWorkspace(cwd);
59
+ const d = readDeclaration(located.tree);
60
+ return Object.keys(prLoopHooksOf(d, memberAtCwd({ declaration: d, rootOnDisk: located.rootOnDisk }, cwd))).length > 0;
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * The hooks a pull-request job in `cwd` runs, read from the workspace
68
+ * declaration at `base`. Throws when the declaration at base cannot be read
69
+ * and the working tree's names hooks.
70
+ */
71
+ export function prLoopHooksAt(cwd: string, base: string): PrLoopHooks {
72
+ let declaration: Declaration;
73
+ let rootOnDisk: string;
74
+ try {
75
+ const located = locateWorkspace(cwd, base);
76
+ declaration = readDeclaration(located.tree);
77
+ rootOnDisk = located.rootOnDisk;
78
+ } catch (err) {
79
+ const missing = err instanceof WorkspaceReadError && (err.code === "declaration-missing" || err.code === "not-a-git-repository");
80
+ const why = err instanceof Error ? err.message : String(err);
81
+ if (checkoutNamesHooks(cwd)) {
82
+ if (missing) return { hooks: {}, base, note: `ci.hooks: the base ${base.slice(0, 12)} has no workspace declaration, so the hooks this change declares do not run until it merges` };
83
+ throw new Error(`ci.hooks are read from the workspace declaration at ${base.slice(0, 12)}, and it could not be read (${why}), so no hook can be trusted to run`);
84
+ }
85
+ return missing ? { hooks: {}, base } : { hooks: {}, base, note: `ci.hooks: the declaration at ${base.slice(0, 12)} could not be read (${why}); this checkout names no hooks, so none run` };
86
+ }
87
+ return { hooks: prLoopHooksOf(declaration, memberAtCwd({ declaration, rootOnDisk }, cwd)), base };
88
+ }
89
+
90
+ /** Where a pull-request job's hooks run, and what they are told. */
91
+ export interface PrHookPlace {
92
+ /** The gate's op, `pr-<n>`: `{op}` and `CHANT_OP_WAVES`. */
93
+ op: string;
94
+ /** The environment: `{wave}`, `CHANT_WAVE` and `CHANT_ENV`. */
95
+ env: string;
96
+ stage: "plan" | "apply" | "record";
97
+ /** The report directory, relative to `cwd`; the runs and plan files go under `<dir>/hooks`. */
98
+ dir: string;
99
+ cwd: string;
100
+ exec?: OpWaveExec;
101
+ log?: (line: string) => void;
102
+ }
103
+
104
+ /** What one moment's hooks came to on the pull-request path. */
105
+ export interface PrHooksRun {
106
+ outcomes: PrHookOutcome[];
107
+ failed?: string;
108
+ holds: string[];
109
+ }
110
+
111
+ /**
112
+ * Run the hooks of `moment` for `components` (#4288). `plans`, after the
113
+ * plan, gives each component's members and step plans, written where a
114
+ * `perRun` hook's `{plan}` points.
115
+ */
116
+ export function runPrHooks(
117
+ read: PrLoopHooks,
118
+ moment: OpWaveHookMoment,
119
+ place: PrHookPlace,
120
+ components: readonly string[],
121
+ planned?: { members: readonly PrMember[]; plans: Record<string, Record<string, unknown>> },
122
+ ): PrHooksRun {
123
+ const hooks = read.hooks[moment];
124
+ if (!hooks || hooks.length === 0) return { outcomes: [], holds: [] };
125
+ // Relative to cwd, as an Op waves job's paths are, whether --output was given relative or absolute.
126
+ const dir = relative(place.cwd, resolve(place.cwd, place.dir, "hooks")) || ".";
127
+ mkdirSync(join(place.cwd, dir, "plans"), { recursive: true });
128
+ const runs: OpWaveHookRun[] = components.map((component) => {
129
+ if (!planned) return { target: component, op: place.op };
130
+ const plan = join(dir, "plans", `${component.replace(/[^A-Za-z0-9._-]/g, "_")}.json`);
131
+ const body = { component, members: planned.members.filter((m) => m.component === component), plans: planned.plans[component] ?? {} };
132
+ writeFileSync(join(place.cwd, plan), JSON.stringify(body, null, 2) + "\n");
133
+ return { target: component, op: place.op, plan };
134
+ });
135
+ const runsFile = join(dir, "runs.json");
136
+ writeFileSync(join(place.cwd, runsFile), JSON.stringify({ version: 1, op: place.op, env: place.env, stage: place.stage, base: read.base, hook: moment, runs }, null, 2) + "\n");
137
+ const ran: StageHooksRun = runStageHooks(moment, hooks, {
138
+ name: place.op,
139
+ op: place.op,
140
+ wave: place.env,
141
+ runs,
142
+ runsFile,
143
+ holdFile: join(dir, "hold.json"),
144
+ env: { CHANT_ENV: place.env, CHANT_PR_STAGE: place.stage, CHANT_HOOKS_BASE: read.base },
145
+ cwd: place.cwd,
146
+ ...(place.exec ? { exec: place.exec } : {}),
147
+ ...(place.log ? { log: place.log } : {}),
148
+ });
149
+ const outcomes = ran.outcomes.map((o): PrHookOutcome => ({
150
+ moment: o.moment,
151
+ name: o.name,
152
+ ...(o.target ? { target: o.target } : {}),
153
+ exit: o.exit,
154
+ outcome: o.outcome === "failed" ? "failed" : o.outcome === "held" ? "held" : "passed",
155
+ }));
156
+ return { outcomes, ...(ran.failed ? { failed: ran.failed } : {}), holds: ran.holds };
157
+ }
@@ -8,8 +8,11 @@
8
8
  * values they read from the forge live here so the three cannot disagree.
9
9
  *
10
10
  * - The plan job runs `chant components pr-plan` against the pull request's
11
- * target commit. It posts the note and the `chant/plan` status and writes
12
- * nothing to `chant/lifecycle`, since it runs the pull request's code.
11
+ * target commit. It writes nothing to `chant/lifecycle` and holds no forge
12
+ * token, since it runs the pull request's code (#4295): it writes the note
13
+ * and the status into its report with `--no-status`, and the `plan-note`
14
+ * job after it posts them with `chant components pr-post`
15
+ * ({@link prLoopPostJob}, ../ci-handover.ts).
13
16
  * - The record job (#4168 step 1, the default) runs `chant components
14
17
  * pr-record` against the commit the push replaced. It plans the same
15
18
  * members on the merged commit, stores the change set and writes the merge
@@ -81,10 +84,12 @@
81
84
  import type { ForgeKind } from "../pr-forge";
82
85
  import { PR_APPLY_GATE } from "../pr-loop";
83
86
  import { CI_IMAGE, GITHUB_JOB_TOKEN } from "../ci-jobs";
87
+ import { withoutForgeTokens } from "../ci-handover";
84
88
  import { EMPTY_TREE_SHA } from "./changed-files";
85
89
  import { layerWaves } from "../gated-waves";
86
90
  import type { DriverComponent } from "./driver";
87
91
  import type { GatedWavePipelineOptions } from "./gated-wave-pipeline";
92
+ import type { PipelineCloudIdentity } from "../ci-identity";
88
93
 
89
94
  /** The variable `chant ci run` reads its runner key from (`RUNNER_KEY_ENV` in ../workspace/ci-green.ts). */
90
95
  const RUNNER_KEY_ENV = "CHANT_RUNNER_KEY";
@@ -143,6 +148,16 @@ export interface PrLoopPipelineOptions {
143
148
  * false, so a merge deploys nothing. `waveGate` needs it.
144
149
  */
145
150
  applyOnMerge?: boolean;
151
+ /**
152
+ * Apply an open pull request before it merges (#4301, ws-106): the apply
153
+ * job runs `pr-apply --before-merge` at the pull request's head, started
154
+ * by a `/chant apply` comment on GitHub and Forgejo, or on GitLab by a
155
+ * pipeline of the target branch with `CHANT_APPLY_PR` set. A merge still
156
+ * records. `pr-apply` refuses unless the declaration at base sets
157
+ * `ci.workflow: "apply-before-merge"`. It takes neither `applyOnMerge`,
158
+ * `waveGate` nor `build`.
159
+ */
160
+ applyBeforeMerge?: boolean;
146
161
  /**
147
162
  * Stream each job's run events (#4007): `--events-json` on both commands,
148
163
  * and `CHANT_RUN_EVENTS_DIR` set to {@link PR_LOOP_EVENTS_DIR}. Default false.
@@ -175,11 +190,27 @@ export interface PrLoopPipelineOptions {
175
190
  * wave. Set from `--wave-gate` beside `--pr-loop` ({@link prLoopWithWaves}).
176
191
  */
177
192
  waveGate?: string;
193
+ /**
194
+ * The plan and apply cloud identities (#4296), from `ci.identity.cloud`
195
+ * resolved for the pipeline's environment and member
196
+ * (`resolveCloudIdentity` in ../ci-identity.ts). The plan job trades its
197
+ * OIDC token for the plan identity before the checkout, the record job
198
+ * for the plan identity, and the apply jobs for the apply identity.
199
+ * Absent: no job requests an OIDC token.
200
+ */
201
+ identity?: PipelineCloudIdentity;
178
202
  /**
179
203
  * Which GitHub-shaped forge the workflow is for. Set by the forgejo
180
204
  * generator, which reuses the github one; a caller never needs to.
181
205
  */
182
206
  forge?: "github" | "forgejo";
207
+ /**
208
+ * An environment per open pull request (#4298, `ci.prEnvironment`): an
209
+ * `env-up` job on each opened, updated or reopened pull request, an
210
+ * `env-down` job when it closes, and an `env-sweep` job on a schedule
211
+ * ({@link prEnvJobs}). Set from `--pr-env` beside `--pr-loop`. Default false.
212
+ */
213
+ prEnvironment?: boolean;
183
214
  }
184
215
 
185
216
  /** One job's command and the forge values it reads, as environment variables for its step. */
@@ -238,6 +269,10 @@ export function prLoopBuildLines(env: string, options: PrLoopPipelineOptions = {
238
269
  */
239
270
  export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelineOptions = {}, member?: string): PrLoopJob[] {
240
271
  const gate = options.gate ?? PR_APPLY_GATE;
272
+ const early = options.applyBeforeMerge === true;
273
+ if (early && options.applyOnMerge) throw new Error("--apply-before-merge applies an open pull request and a merge records, so it takes no --apply-on-merge; pass one of them");
274
+ if (early && options.waveGate) throw new Error("--apply-before-merge applies the pull request's plan in one job, so it takes no --wave-gate");
275
+ if (early && (options.build || options.buildCommand)) throw new Error("--apply-before-merge runs no build before its requires checks, so it takes no --pr-build or --pr-build-command yet");
241
276
  const common = [
242
277
  "--env", env, "--gate", gate, "--output", PR_LOOP_REPORT_DIR, "--forge", forge,
243
278
  ...(member ? ["--member", member] : []),
@@ -254,7 +289,8 @@ export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelin
254
289
  // #4156: the flags `chant ci run` rebuilds `command` from. It finds the member from the directory it runs in.
255
290
  const ciRun = (job: PrLoopJob["jobName"]): string[] => [
256
291
  "chant", "ci", "run", job, "--in-place", ...(job === "plan" ? ["--no-push"] : []),
257
- ...(job === "apply" ? [] : ["--base", '"$BASE_SHA"']), ...(job === "plan" ? ["--pr", '"$PR_NUMBER"'] : []),
292
+ ...(job === "apply" && !early ? [] : ["--base", '"$BASE_SHA"']), ...(job === "plan" || (job === "apply" && early) ? ["--pr", '"$PR_NUMBER"'] : []),
293
+ ...(job === "apply" && early ? ["--apply-before-merge"] : []),
258
294
  "--env", env, "--gate", gate, "--forge", forge,
259
295
  ...(job !== "record" && options.waveGate ? ["--wave-gate", options.waveGate] : []),
260
296
  ...(job !== "plan" && options.branch ? ["--branch", JSON.stringify(options.branch)] : []),
@@ -264,35 +300,54 @@ export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelin
264
300
  ...(options.reportUrl ? ["--report-url", JSON.stringify(options.reportUrl)] : []),
265
301
  ];
266
302
  const runEnv = (env: Record<string, string>): Record<string, string> => (github ? { ...env, [RUNNER_KEY_ENV]: `\${{ secrets.${RUNNER_KEY_ENV} }}` } : env);
303
+ // #4295: the plan runs the pull request's code, so it gets no forge token. It writes the note and the
304
+ // status into its report (--no-status), and the plan-note job posts them (prLoopPostJob).
267
305
  const planEnv = github
268
- ? { BASE_SHA: "${{ github.event.pull_request.base.sha }}", PR_NUMBER: "${{ github.event.pull_request.number }}", ...jobEnv }
269
- : { BASE_SHA: "$CI_MERGE_REQUEST_DIFF_BASE_SHA", PR_NUMBER: "$CI_MERGE_REQUEST_IID", ...jobEnv };
306
+ ? { BASE_SHA: "${{ github.event.pull_request.base.sha }}", PR_NUMBER: "${{ github.event.pull_request.number }}", ...withoutForgeTokens(jobEnv) }
307
+ : { BASE_SHA: "$CI_MERGE_REQUEST_DIFF_BASE_SHA", PR_NUMBER: "$CI_MERGE_REQUEST_IID", ...withoutForgeTokens(jobEnv) };
270
308
  const greenBranch = github ? JSON.stringify(options.branch ?? "main") : options.branch ? JSON.stringify(options.branch) : "$CI_DEFAULT_BRANCH";
271
309
  const applyEnv = github ? { BASE_SHA: "${{ github.event.before }}", ...jobEnv } : { BASE_SHA: "$CI_COMMIT_BEFORE_SHA", ...jobEnv };
310
+ // #4301: the apply before merge runs in a job of the target branch, whose commit is the base, at the pull request's head.
311
+ const earlyEnv = github
312
+ ? { BASE_SHA: "${{ github.sha }}", PR_NUMBER: "${{ github.event.issue.number }}", ...jobEnv }
313
+ : { BASE_SHA: "$CI_COMMIT_SHA", PR_NUMBER: `$${GITLAB_APPLY_PR_VAR}`, ...jobEnv };
314
+ const greenApply: PrLoopJob = {
315
+ // #4168 step 9: the apply applies the newest green commit, measured from the one applied before it.
316
+ jobName: "apply",
317
+ command: [
318
+ "chant", "components", "pr-apply", "--green", ...common,
319
+ ...(options.requireReview === true ? ["--require-review"] : []),
320
+ ...(options.waveGate ? ["--wave-gate", options.waveGate] : []),
321
+ "--resume", PR_APPLY_RECORD,
322
+ ].join(" "),
323
+ env: jobEnv,
324
+ checkout: [greenCheckout(greenBranch)],
325
+ greenBranch,
326
+ ...build,
327
+ run: { command: ciRun("apply").join(" "), env: runEnv(jobEnv) },
328
+ };
329
+ const earlyApply: PrLoopJob = {
330
+ // #4301: the requires checks run inside pr-apply, before any of the head's code loads.
331
+ jobName: "apply",
332
+ command: [
333
+ "chant", "components", "pr-apply", "--before-merge", "--base", '"$BASE_SHA"', "--pr", '"$PR_NUMBER"', ...common,
334
+ ...(options.requireReview === true ? ["--require-review"] : []),
335
+ "--resume", PR_APPLY_RECORD,
336
+ ].join(" "),
337
+ env: earlyEnv,
338
+ checkout: [pullRequestCheckout(forge)],
339
+ run: { command: ciRun("apply").join(" "), env: runEnv(earlyEnv) },
340
+ };
272
341
  return [
273
342
  {
274
343
  jobName: "plan",
275
344
  // With `waveGate` the plan's note says the later waves may stop at their own gates (#4003).
276
- command: ["chant", "components", "pr-plan", "--base", '"$BASE_SHA"', "--pr", '"$PR_NUMBER"', ...common, ...(options.waveGate ? ["--wave-gate", options.waveGate] : [])].join(" "),
345
+ command: ["chant", "components", "pr-plan", "--base", '"$BASE_SHA"', "--pr", '"$PR_NUMBER"', ...common, ...(options.waveGate ? ["--wave-gate", options.waveGate] : []), "--no-status"].join(" "),
277
346
  env: planEnv,
278
347
  ...build,
279
348
  run: { command: ciRun("plan").join(" "), env: runEnv(planEnv) },
280
349
  },
281
- {
282
- // #4168 step 9: the apply applies the newest green commit, measured from the one applied before it.
283
- jobName: "apply",
284
- command: [
285
- "chant", "components", "pr-apply", "--green", ...common,
286
- ...(options.requireReview === true ? ["--require-review"] : []),
287
- ...(options.waveGate ? ["--wave-gate", options.waveGate] : []),
288
- "--resume", PR_APPLY_RECORD,
289
- ].join(" "),
290
- env: jobEnv,
291
- checkout: [greenCheckout(greenBranch)],
292
- greenBranch,
293
- ...build,
294
- run: { command: ciRun("apply").join(" "), env: runEnv(jobEnv) },
295
- },
350
+ early ? earlyApply : greenApply,
296
351
  {
297
352
  jobName: "record",
298
353
  command: ["chant", "components", "pr-record", "--base", '"$BASE_SHA"', ...common].join(" "),
@@ -304,6 +359,35 @@ export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelin
304
359
  ];
305
360
  }
306
361
 
362
+ /** The name of the job that posts the plan's note and status (#4295). */
363
+ export const PR_PLAN_NOTE_JOB = "plan-note";
364
+
365
+ /**
366
+ * The plan-note job's command and the values it reads (#4295). It runs in a
367
+ * fresh job after the plan, at the target branch's commit, and posts the
368
+ * note and the `chant/plan` status from the report the plan job handed on,
369
+ * with the forge token the plan job never held. `HEAD_SHA` is the commit
370
+ * the plan job checked out, `PR_NUMBER` the pull request, and on GitHub and
371
+ * Forgejo `PLAN_RESULT` the plan job's result, which fails the status when
372
+ * the plan job failed. `BASE_SHA` is the commit the note job checks out on
373
+ * GitLab, whose runner checks out the pipeline's own commit.
374
+ */
375
+ export function prLoopPostJob(forge: ForgeKind, env: string, member?: string): { command: string; env: Record<string, string> } {
376
+ const github = forge !== "gitlab";
377
+ const command = [
378
+ "chant", "components", "pr-post", "--pr", '"$PR_NUMBER"', "--head", '"$HEAD_SHA"',
379
+ "--env", env, "--output", PR_LOOP_REPORT_DIR, "--forge", forge,
380
+ ...(member ? ["--member", member] : []),
381
+ ...(github ? ["--result", '"$PLAN_RESULT"'] : []),
382
+ ].join(" ");
383
+ return {
384
+ command,
385
+ env: github
386
+ ? { PR_NUMBER: "${{ github.event.pull_request.number }}", HEAD_SHA: "${{ github.sha }}", PLAN_RESULT: "${{ needs.plan.result }}", GITHUB_TOKEN: GITHUB_JOB_TOKEN }
387
+ : { PR_NUMBER: "$CI_MERGE_REQUEST_IID", HEAD_SHA: "$CI_COMMIT_SHA", BASE_SHA: "$CI_MERGE_REQUEST_DIFF_BASE_SHA" },
388
+ };
389
+ }
390
+
307
391
  /**
308
392
  * What starts the apply under apply on merge (#4168 step 9): on GitHub the
309
393
  * tick's workflow finishing (`workflow_run`, since tags its token pushes start
@@ -408,6 +492,22 @@ function pushBaseFallback(target: string, commit: string, diffBase?: string): st
408
492
  );
409
493
  }
410
494
 
495
+ /** The GitLab variable a person sets on a pipeline of the target branch to apply a merge request before it merges (#4301). */
496
+ export const GITLAB_APPLY_PR_VAR = "CHANT_APPLY_PR";
497
+
498
+ /** The comment that starts the apply before merge on GitHub and Forgejo (#4301). */
499
+ export const APPLY_COMMENT = "/chant apply";
500
+
501
+ /**
502
+ * The line that checks out pull request `$PR_NUMBER`'s head (#4301) in a job
503
+ * of the target branch: the forge's `refs/pull/<n>/head`, or GitLab's
504
+ * `refs/merge-requests/<n>/head`. A number that is not one fails the job.
505
+ */
506
+ export function pullRequestCheckout(forge: ForgeKind): string {
507
+ const ref = forge === "gitlab" ? 'refs/merge-requests/$PR_NUMBER/head' : 'refs/pull/$PR_NUMBER/head';
508
+ return `echo "$PR_NUMBER" | grep -Eq '^[0-9]+$' && git fetch --quiet origin "+${ref}:refs/remotes/origin/chant-apply/$PR_NUMBER" && git checkout --quiet --detach "refs/remotes/origin/chant-apply/$PR_NUMBER"`;
509
+ }
510
+
411
511
  /**
412
512
  * The line that checks out the newest green commit of `branch` (#4168 step
413
513
  * 9), after fetching the branch and its tags, for the apply job. `chant ci
@@ -450,3 +550,76 @@ export function gitlabBaseFallback(branch?: string): string {
450
550
  export function githubBaseFallback(branch?: string): string {
451
551
  return pushBaseFallback(JSON.stringify(branch ?? "main"), "$GITHUB_SHA");
452
552
  }
553
+
554
+ /** Where the pull request environment jobs (#4298) write their reports, kept as a CI artifact. */
555
+ export const PR_ENV_REPORT_DIR = ".chant/pr-env";
556
+
557
+ /** The cron the generated `env-sweep` job runs on (#4298): hourly, at a minute off the hour. */
558
+ export const PR_ENV_SWEEP_CRON = "17 * * * *";
559
+
560
+ /** One job of a pull request's environment (#4298): its command and the forge values it reads. */
561
+ export interface PrEnvJob {
562
+ jobName: "env-up" | "env-post" | "env-down" | "env-sweep";
563
+ /** The command line. `env-up` reads `$BASE_SHA` and `$PR_NUMBER`, `env-down` `$PR_NUMBER`. */
564
+ command: string;
565
+ /** Values the command reads, as the forge spells them. `CHANT_ENV` is the pull request's environment. */
566
+ env: Record<string, string>;
567
+ /** With `build`: the build and lint lines, run before the command. */
568
+ build?: string[];
569
+ }
570
+
571
+ /** The name of the job that keeps an environment's record and posts its note (#4298, #4295). */
572
+ export const PR_ENV_POST_JOB = "env-post";
573
+
574
+ /**
575
+ * The jobs of a pull request's environment (#4298), when `prEnvironment` is
576
+ * on, in this order:
577
+ *
578
+ * - `env-up` runs `chant components pr-env up --no-push`. It runs the
579
+ * change's code, so it gets no forge token (#4295): it passes no
580
+ * `--forge`, and it hands its report and its record on.
581
+ * - `env-post` runs `pr-env post` in a fresh job at the target branch's
582
+ * commit, with the token: it keeps the record on chant/lifecycle and posts
583
+ * the note and the `chant/pr-env` status. On GitHub and Forgejo it reads
584
+ * the up job's result as `UP_RESULT`.
585
+ * - `env-down` runs `pr-env down --reason closed`, and `env-sweep` runs
586
+ * `pr-env sweep`. Neither builds the change's code: a teardown reads the
587
+ * markers off the live resources.
588
+ *
589
+ * Each writes its report under {@link PR_ENV_REPORT_DIR}.
590
+ */
591
+ export function prEnvJobs(forge: ForgeKind, options: PrLoopPipelineOptions = {}, member?: string): PrEnvJob[] {
592
+ const github = forge !== "gitlab";
593
+ const out = ["--output", PR_ENV_REPORT_DIR, ...(member ? ["--member", member] : [])];
594
+ const common = ["--forge", forge, ...out];
595
+ const token: Record<string, string> = github ? { GITHUB_TOKEN: GITHUB_JOB_TOKEN } : {};
596
+ const prNumber = github ? "${{ github.event.pull_request.number }}" : "$CI_MERGE_REQUEST_IID";
597
+ const prEnv = { CHANT_ENV: `pr-${prNumber}` };
598
+ const buildLines = prLoopBuildLines('"$CHANT_ENV"', options);
599
+ return [
600
+ {
601
+ jobName: "env-up",
602
+ // #4295: no --forge and no token; the record stays in the report directory for env-post.
603
+ command: ["chant", "components", "pr-env", "up", "--base", '"$BASE_SHA"', "--pr", '"$PR_NUMBER"', ...out, "--no-push"].join(" "),
604
+ env: { BASE_SHA: github ? "${{ github.event.pull_request.base.sha }}" : "$CI_MERGE_REQUEST_DIFF_BASE_SHA", PR_NUMBER: prNumber, ...prEnv },
605
+ ...(buildLines.length > 0 ? { build: buildLines } : {}),
606
+ },
607
+ {
608
+ jobName: PR_ENV_POST_JOB,
609
+ command: ["chant", "components", "pr-env", "post", "--pr", '"$PR_NUMBER"', "--head", '"$HEAD_SHA"', ...common, ...(github ? ["--result", '"$UP_RESULT"'] : [])].join(" "),
610
+ env: github
611
+ ? { PR_NUMBER: prNumber, HEAD_SHA: "${{ github.sha }}", UP_RESULT: "${{ needs.env-up.result }}", ...token }
612
+ : { PR_NUMBER: prNumber, HEAD_SHA: "$CI_COMMIT_SHA", BASE_SHA: "$CI_MERGE_REQUEST_DIFF_BASE_SHA" },
613
+ },
614
+ {
615
+ jobName: "env-down",
616
+ command: ["chant", "components", "pr-env", "down", "--pr", '"$PR_NUMBER"', "--reason", "closed", ...common].join(" "),
617
+ env: { PR_NUMBER: prNumber, ...prEnv, ...token },
618
+ },
619
+ {
620
+ jobName: "env-sweep",
621
+ command: ["chant", "components", "pr-env", "sweep", ...common].join(" "),
622
+ env: { ...token },
623
+ },
624
+ ];
625
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * `ci.policy` on the pull-request path (#4294): the settings and the policy
3
+ * read at base, the engine run over the change set, and a denial decided
4
+ * against the override ledger. The engine is a fake that prints opa's
5
+ * result shape; nothing reaches a network.
6
+ */
7
+ import { afterAll, describe, expect, test } from "vitest";
8
+ import { execFileSync } from "node:child_process";
9
+ import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { tmpdir } from "node:os";
12
+ import { parseDeclaration, WorkspaceReadError } from "../workspace/declaration";
13
+ import { composeChangeSet } from "../change-set";
14
+ import { memoryGateLedgerPort } from "../op/gate";
15
+ import { overrideDigest } from "../override-gate";
16
+ import type { GatePolicyExec } from "../gate-policy";
17
+ import { decidePrPolicyOverride, prLoopPolicyAt, prPolicyReport, runPrPolicy, spendPrPolicyOverride, type PrPolicyInput } from "./pr-policy";
18
+
19
+ const scratch: string[] = [];
20
+ afterAll(() => {
21
+ for (const d of scratch) rmSync(d, { recursive: true, force: true });
22
+ });
23
+ const tmp = (): string => {
24
+ const d = realpathSync(mkdtempSync(join(tmpdir(), "pr-policy-")));
25
+ scratch.push(d);
26
+ return d;
27
+ };
28
+ const git = (cwd: string, ...args: string[]): string => execFileSync("git", args, { cwd, encoding: "utf-8" }).trim();
29
+
30
+ const declaration = (policy: unknown) => ({
31
+ name: "acme",
32
+ schema: 1,
33
+ members: [{ name: "infra", dir: "infra", kind: "other", because: "test" }],
34
+ ...(policy ? { ci: { policy } } : {}),
35
+ });
36
+
37
+ /** A repo whose base declares `ci.policy` with an opa policy and one overrider. */
38
+ function repoWithPolicy(): { repo: string; base: string } {
39
+ const repo = tmp();
40
+ git(repo, "init", "-q", "-b", "main");
41
+ git(repo, "config", "user.email", "t@example.com");
42
+ git(repo, "config", "user.name", "t");
43
+ mkdirSync(join(repo, "infra"));
44
+ mkdirSync(join(repo, "policy"));
45
+ mkdirSync(join(repo, ".chant"));
46
+ writeFileSync(join(repo, "chant.workspace.json"), JSON.stringify(declaration({ engine: "opa", path: "policy" })));
47
+ writeFileSync(join(repo, "policy", "main.rego"), "package main\n# base\n");
48
+ writeFileSync(join(repo, ".chant", "overriders"), "github:carol\n");
49
+ git(repo, "add", ".");
50
+ git(repo, "commit", "-q", "-m", "base");
51
+ return { repo, base: git(repo, "rev-parse", "HEAD") };
52
+ }
53
+
54
+ const doc = composeChangeSet([]);
55
+ const input = (base: string): PrPolicyInput => ({ version: 1, stage: "apply", op: "pr-7", gate: "pr-apply", env: "prod", pr: 7, base, head: "h".repeat(40), changeSet: doc });
56
+ const opaOut = (value: unknown): string => JSON.stringify({ result: [{ expressions: [{ value }] }] });
57
+
58
+ describe("ci.policy in the declaration (#4294)", () => {
59
+ test("parses at the top level, and refuses an unattended outcome", () => {
60
+ const read = (policy: unknown) => parseDeclaration(JSON.stringify(declaration(policy)), "chant.workspace.json", "0.124.41");
61
+ expect(read({ engine: "opa", path: "rules" }).ci?.policy).toEqual({ engine: "opa", path: "rules" });
62
+ expect(() => read({ outcomes: ["allow_unattended"] })).toThrow(WorkspaceReadError);
63
+ expect(() => read({ engine: "rego" })).toThrow(/conftest or opa/);
64
+ });
65
+ });
66
+
67
+ describe("prLoopPolicyAt and runPrPolicy (#4294)", () => {
68
+ test("the settings and the policy come from the base: the change's edits to either do not run", () => {
69
+ const { repo, base } = repoWithPolicy();
70
+ writeFileSync(join(repo, "chant.workspace.json"), JSON.stringify(declaration({ engine: "opa", path: "policy", command: ["true", "{input}"] })));
71
+ writeFileSync(join(repo, "policy", "main.rego"), "package main\n# the change loosens it\n");
72
+ const read = prLoopPolicyAt(join(repo, "infra"), base);
73
+ expect(read.settings).toEqual({ engine: "opa", path: "policy" });
74
+ let seen = "";
75
+ let fed: unknown;
76
+ const exec: GatePolicyExec = (argv) => {
77
+ seen = readFileSync(join(argv[argv.indexOf("--data") + 1]!, "main.rego"), "utf-8");
78
+ fed = JSON.parse(readFileSync(argv[argv.indexOf("--input") + 1]!, "utf-8"));
79
+ return { status: 0, stdout: opaOut({ deny: ["public bucket"] }), stderr: "" };
80
+ };
81
+ const verdict = runPrPolicy(read, input(base), exec)!;
82
+ expect(seen).toBe("package main\n# base\n");
83
+ expect(fed).toMatchObject({ op: "pr-7", stage: "apply", changeSet: { digest: doc.digest } });
84
+ expect(prPolicyReport(verdict, base)).toEqual({ result: "denied", engine: "opa", base, denials: [{ rule: "main.deny", message: "public bucket" }], rules: ["main.deny"], holds: [], warnings: [] });
85
+ });
86
+
87
+ test("no policy at base runs none, and says so when the change declares one", () => {
88
+ const repo = tmp();
89
+ git(repo, "init", "-q", "-b", "main");
90
+ git(repo, "config", "user.email", "t@example.com");
91
+ git(repo, "config", "user.name", "t");
92
+ writeFileSync(join(repo, "README"), "x");
93
+ git(repo, "add", ".");
94
+ git(repo, "commit", "-q", "-m", "base");
95
+ const base = git(repo, "rev-parse", "HEAD");
96
+ expect(prLoopPolicyAt(repo, base)).toEqual({ base });
97
+ writeFileSync(join(repo, "chant.workspace.json"), JSON.stringify(declaration({ engine: "opa" })));
98
+ const read = prLoopPolicyAt(repo, base);
99
+ expect(read.settings).toBeUndefined();
100
+ expect(read.note).toMatch(/runs once it merges/);
101
+ expect(runPrPolicy(read, input(base))).toBeUndefined();
102
+ });
103
+
104
+ test("a policy directory the base lacks is an error, which stops the apply", () => {
105
+ const { repo, base } = repoWithPolicy();
106
+ const read = { ...prLoopPolicyAt(repo, base), settings: { engine: "opa" as const, path: "missing" } };
107
+ expect(runPrPolicy(read, input(base), () => ({ status: 0, stdout: opaOut({}), stderr: "" }))).toMatchObject({ result: "error", error: "the policy directory missing does not exist at base" });
108
+ });
109
+ });
110
+
111
+ describe("a denial and its override (#4294)", () => {
112
+ test("no override records the denial once; a listed overrider's override counts; spent, it counts no more", async () => {
113
+ const { repo, base } = repoWithPolicy();
114
+ const port = memoryGateLedgerPort();
115
+ const ask = (record: boolean) =>
116
+ decidePrPolicyOverride({ port, subject: "pr-7", digest: doc.digest, rules: ["main.deny"], base, cwd: repo, sealed: false, record, now: "2026-10-22T10:00:00Z" });
117
+
118
+ const plan = await ask(false);
119
+ expect(plan).toMatchObject({ status: "none", recorded: false, overriders: ["github:carol"], command: 'chant approve policy-override pr-7 --rule main.deny --reason "<why>"' });
120
+ expect(port.appended).toHaveLength(0);
121
+
122
+ expect(await ask(true)).toMatchObject({ status: "none", recorded: true });
123
+ expect(port.appended[0]).toMatchObject({ op: "policy-override", gate: "pr-7", plan: doc.digest, rules: ["main.deny"] });
124
+ // The denial stands, so a second refusal records no other.
125
+ expect(await ask(true)).toMatchObject({ status: "none", recorded: false });
126
+
127
+ const od = overrideDigest("pr-7", doc.digest, ["main.deny"]);
128
+ await port.appendResolution!({ op: "policy-override", gate: "pr-7", resolvedBy: "github:dave", timestamp: "2026-10-22T11:00:00Z", planDigest: od, note: "not listed" });
129
+ expect(await ask(true)).toMatchObject({ status: "none", refusals: [expect.stringContaining("github:dave is not listed")] });
130
+
131
+ await port.appendResolution!({ op: "policy-override", gate: "pr-7", resolvedBy: "github:carol", timestamp: "2026-10-22T12:00:00Z", planDigest: od, note: "the bucket is the public docs site" });
132
+ const counted = await ask(true);
133
+ expect(counted).toEqual({ status: "overridden", override: { by: "github:carol", at: "2026-10-22T12:00:00Z", reason: "the bucket is the public docs site", digest: od } });
134
+
135
+ if (counted.status !== "overridden") throw new Error("expected an override");
136
+ await spendPrPolicyOverride(port, "pr-7", counted.override, { now: "2026-10-22T12:30:00Z", commit: "c".repeat(40) });
137
+ expect(port.applied).toEqual([expect.objectContaining({ op: "policy-override", gate: "pr-7", planDigest: od, approvedBy: "github:carol", commit: "c".repeat(40) })]);
138
+ expect(await ask(true)).toMatchObject({ status: "none", refusals: expect.arrayContaining([expect.stringContaining("is spent")]) });
139
+ });
140
+
141
+ test("an applied record that does not reach the remote throws, so nothing applies", async () => {
142
+ const port = { ...memoryGateLedgerPort(), appendApplied: async () => ({ pushed: false, pushWarning: 'the push of chant/lifecycle to "origin" failed' }) };
143
+ await expect(spendPrPolicyOverride(port, "pr-7", { by: "github:carol", at: "2026-10-22T12:00:00Z", reason: "ok", digest: "sha256:1" })).rejects.toThrow(/did not reach the remote/);
144
+ });
145
+ });