@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
@@ -31,7 +31,8 @@ import schema from "./ci-run-index.schema.json";
31
31
  afterAll(cleanScratch);
32
32
 
33
33
  const JOB_IMAGE = "chant-ci-run-index-test:local";
34
- const HOST = { PATH: process.env.PATH, HOME: process.env.HOME, GITHUB_TOKEN: "t", GITHUB_REPOSITORY: "acme/estate" };
34
+ // No forge token: a plan job given one posts its note from the report (#4295), which these tests do not look at.
35
+ const HOST = { PATH: process.env.PATH, HOME: process.env.HOME, GITHUB_REPOSITORY: "acme/estate" };
35
36
 
36
37
  /** The repository every test starts from, built once: one member, and two commits. */
37
38
  const repoTemplate = repoFixture(() => {
@@ -18,7 +18,8 @@ import { afterAll, beforeAll, describe, expect, test } from "vitest";
18
18
  import { cleanScratch, declaration, git, repoFixture, scratchDir, writeFiles } from "./__fixtures__/contract-repo";
19
19
  import { checkRunEventLines } from "./__fixtures__/run-event-check";
20
20
  import { ciJobCommand, ciJobEnv, ciJobFailure, ciJobOp, ciLoopOptions, ciRun, CiRunError, ciStatusContext, ciStatusFromEnv, ciStatusUrl, formatCiRun } from "./ci-run";
21
- import type { CommitStatus } from "../pr-forge";
21
+ import type { CommitStatus, ForgeStatus, PrForge } from "../pr-forge";
22
+ import { FORGE_TOKEN_VARS } from "../ci-handover";
22
23
  import { ciList, ciTick, evaluateCommit, readOpRunSource, revokedAnnotation } from "./ci-green";
23
24
  import type { StepRecord } from "../op/local-executor";
24
25
  import { sshFromEd25519 } from "./trust/dsse";
@@ -136,6 +137,28 @@ const HOST = {
136
137
  GITHUB_RUN_ID: "999",
137
138
  };
138
139
 
140
+ /** A plan report for pull request 7 at `sha` in member `app`, which the fake chant writes with FAKE_REPORT (#4295). */
141
+ const PLAN_FIXTURE = JSON.parse(readFileSync(join(import.meta.dirname, "..", "__fixtures__", "pr-loop", "pr-plan.golden.json"), "utf-8")) as Record<string, unknown>;
142
+ const planReport = (sha: string): string => JSON.stringify({ ...PLAN_FIXTURE, pr: 7, head: sha, member: "app" });
143
+
144
+ /** A forge client that records the plan's status and note the runner posts from the report (#4295). */
145
+ function recordingForge(): { forge: PrForge; statuses: Array<{ sha: string; status: ForgeStatus }>; notes: Array<{ pr: number; marker: string }> } {
146
+ const statuses: Array<{ sha: string; status: ForgeStatus }> = [];
147
+ const notes: Array<{ pr: number; marker: string }> = [];
148
+ const forge = {
149
+ kind: "forgejo",
150
+ host: "forgejo.localhost",
151
+ async setStatus(sha: string, status: ForgeStatus) {
152
+ statuses.push({ sha, status });
153
+ },
154
+ async upsertNote(pr: number, marker: string) {
155
+ notes.push({ pr, marker });
156
+ return "1";
157
+ },
158
+ } as unknown as PrForge;
159
+ return { forge, statuses, notes };
160
+ }
161
+
139
162
  const envFile = (dir: string, stage: string): Record<string, string> =>
140
163
  Object.fromEntries(
141
164
  readFileSync(join(dir, `${stage}.env`), "utf-8")
@@ -235,6 +258,7 @@ describe("chant ci run, then ci tick from runs (#4094)", () => {
235
258
  let events: string[];
236
259
  let plan: Awaited<ReturnType<typeof ciRun>>;
237
260
  let apply: Awaited<ReturnType<typeof ciRun>>;
261
+ const posted = recordingForge();
238
262
 
239
263
  beforeAll(async () => {
240
264
  w = workspace();
@@ -250,8 +274,9 @@ describe("chant ci run, then ci tick from runs (#4094)", () => {
250
274
  base: w.base,
251
275
  pr: 7,
252
276
  chant,
253
- hostEnv: HOST,
254
- passEnv: ["AWS_ACCESS_KEY_ID"],
277
+ hostEnv: { ...HOST, FAKE_REPORT: planReport(w.sha) },
278
+ passEnv: ["AWS_ACCESS_KEY_ID", "FAKE_REPORT"],
279
+ planForge: posted.forge,
255
280
  runnerKey: BOT.pem,
256
281
  output: join(reports, "plan"),
257
282
  runId: "chant-plan-1",
@@ -271,20 +296,24 @@ describe("chant ci run, then ci tick from runs (#4094)", () => {
271
296
  const out = join(reports, "plan");
272
297
  const [job] = prLoopJobs("forgejo", "prod", {}, "app");
273
298
  const argv = readFileSync(join(out, "pr-plan.args"), "utf-8").trimEnd().split("\n");
274
- expect(argv).toEqual(["components", "pr-plan", "--base", base, "--pr", "7", "--env", "prod", "--gate", "pr-apply", "--output", ".chant/pr", "--forge", "forgejo", "--member", "app"]);
299
+ expect(argv).toEqual(["components", "pr-plan", "--base", base, "--pr", "7", "--env", "prod", "--gate", "pr-apply", "--output", ".chant/pr", "--forge", "forgejo", "--member", "app", "--no-status"]);
275
300
  expect(job!.command.split(" ").slice(1).map((a) => a.replace(/"/g, ""))).toEqual(argv.map((a) => (a === base ? "$BASE_SHA" : a === "7" ? "$PR_NUMBER" : a)));
276
301
  const pwd = readFileSync(join(out, "pr-plan.pwd"), "utf-8").trim();
277
302
  expect(pwd).toBe(join(plan.checkout, "app"));
278
303
  expect(pwd.startsWith(realpathSync(root))).toBe(false);
279
304
  expect(existsSync(plan.checkout)).toBe(false);
280
305
  expect(git(root, "worktree", "list", "--porcelain")).toBe(worktrees);
281
- expect(JSON.parse(readFileSync(join(out, "pr-plan.json"), "utf-8"))).toEqual({ stage: "pr-plan" });
306
+ expect(JSON.parse(readFileSync(join(out, "pr-plan.json"), "utf-8"))).toMatchObject({ stage: "plan", pr: 7, head: sha });
282
307
 
283
308
  // The environment is clean: the job's variables and the few the runner hands on. The copied
284
309
  // report shows the values of the variables whose names look secret as *** (#4096).
285
310
  const env = envFile(out, "pr-plan");
286
- expect(env).toMatchObject({ BASE_SHA: base, PR_NUMBER: "7", GITHUB_SHA: sha, CHANT_ENV: "prod", GITHUB_TOKEN: "***", AWS_ACCESS_KEY_ID: "***" });
311
+ expect(env).toMatchObject({ BASE_SHA: base, PR_NUMBER: "7", GITHUB_SHA: sha, CHANT_ENV: "prod", AWS_ACCESS_KEY_ID: "***" });
287
312
  for (const name of ["NOT_FOR_JOBS", "CHANT_RUNNER_KEY", "GITHUB_RUN_ID"]) expect(env).not.toHaveProperty(name);
313
+ // #4295: the plan runs the pull request's code, so its step got no forge token; the runner posted from its report.
314
+ for (const name of FORGE_TOKEN_VARS) expect(env).not.toHaveProperty(name);
315
+ expect(posted.statuses).toEqual([{ sha, status: expect.objectContaining({ context: "chant/plan/app", state: "success" }) }]);
316
+ expect(posted.notes).toEqual([{ pr: 7, marker: "<!-- chant-pr:prod:app -->" }]);
288
317
  });
289
318
 
290
319
  test("the plan and the apply are recorded signed, with a pipeline run's events", async () => {
@@ -508,7 +537,9 @@ describe("a commit status per job (#4097)", () => {
508
537
 
509
538
  async function job(over: Record<string, unknown>) {
510
539
  const { root, app, base, sha } = workspace();
511
- const r = await ciRun({ image: JOB_IMAGE, cwd: app, job: "plan", env: "prod", forge: "forgejo", base, pr: 7, chant: fakeChant(), hostEnv: HOST, push: false, output: join(scratchDir("chant-ci-status-"), "pr"), ...over } as Parameters<typeof ciRun>[0]);
540
+ // #4295: a plan job's report is a real plan report, and the runner posts it to a recording forge.
541
+ const plan = { hostEnv: { ...HOST, FAKE_REPORT: planReport(sha) }, passEnv: ["FAKE_REPORT"], planForge: recordingForge().forge };
542
+ const r = await ciRun({ image: JOB_IMAGE, cwd: app, job: "plan", env: "prod", forge: "forgejo", base, pr: 7, chant: fakeChant(), push: false, output: join(scratchDir("chant-ci-status-"), "pr"), ...plan, ...over } as Parameters<typeof ciRun>[0]);
512
543
  return { r, sha };
513
544
  }
514
545
 
@@ -818,7 +849,8 @@ describe("a generated forge job runs ci run --in-place (#4156)", () => {
818
849
  test("with no key the job runs, warns of nothing, and its unsigned record counts in ci list and the run index", async () => {
819
850
  const { app, base, sha } = workspace();
820
851
  const reports = scratchDir("chant-ci-run-index-");
821
- const { CHANT_RUNNER_KEY: _key, ...keyless } = HOST;
852
+ // #4295: a plan job generated now holds no forge token; its plan-note job posts.
853
+ const { CHANT_RUNNER_KEY: _key, GITHUB_TOKEN: _token, ...keyless } = HOST;
822
854
  const r = await ciRun({ cwd: app, job: "plan", env: "prod", forge: "forgejo", base, pr: 7, chant: fakeChant(), hostEnv: { ...keyless, ...FORGE, GITHUB_JOB: "plan" }, inPlace: true, push: false, runId: "forge-plan-1", loop: { reportUrl: `file://${reports}` } });
823
855
  expect(r.status).toBe("ok");
824
856
  expect(r.warnings).toEqual([]);
@@ -940,3 +972,70 @@ describe("ci run --local (#4161)", () => {
940
972
  expect(parseArgs(["ci", "run", "plan", "--local"]).local).toBe(true);
941
973
  });
942
974
  });
975
+
976
+ describe("the plan's steps hold no forge token (#4295)", () => {
977
+ test("on the runner: no token reaches a step from the runner, --pass-env or a forge name, and the runner posts the note from the report", async () => {
978
+ const { app, base, sha } = workspace();
979
+ const output = join(scratchDir("chant-ci-handover-"), "pr");
980
+ const forge = recordingForge();
981
+ const tokens = { GH_TOKEN: "gh-token-4295", FORGEJO_TOKEN: "fj-token-4295", CHANT_FORGE_TOKEN: "chant-token-4295", ACTIONS_RUNTIME_TOKEN: "rt-token-4295" };
982
+ const r = await ciRun({
983
+ image: JOB_IMAGE,
984
+ cwd: app,
985
+ job: "plan",
986
+ env: "prod",
987
+ forge: "forgejo",
988
+ base,
989
+ pr: 7,
990
+ chant: fakeChant(),
991
+ hostEnv: { ...HOST, ...tokens, FAKE_REPORT: planReport(sha) },
992
+ passEnv: ["FAKE_REPORT", ...Object.keys(tokens)],
993
+ beforeScript: ["mkdir -p .chant/pr && env | sort > .chant/pr/before.env"],
994
+ planForge: forge.forge,
995
+ push: false,
996
+ output,
997
+ });
998
+ expect(r.status).toBe("ok");
999
+ expect(r.warnings).toEqual([]);
1000
+ const env = readFileSync(join(output, "pr-plan.env"), "utf-8");
1001
+ for (const value of ["forge-token", ...Object.values(tokens)]) expect(env).not.toContain(value);
1002
+ // The build phase's lines run the change's code too.
1003
+ expect(r.record.phases.map((p) => p.name)).toEqual(["build", "plan"]);
1004
+ const before = readFileSync(join(output, "before.env"), "utf-8");
1005
+ for (const value of ["forge-token", ...Object.values(tokens)]) expect(before).not.toContain(value);
1006
+ for (const name of FORGE_TOKEN_VARS) expect(env).not.toMatch(new RegExp(`^${name}=`, "m"));
1007
+ expect(readFileSync(join(output, "pr-plan.args"), "utf-8").trimEnd().split("\n").at(-1)).toBe("--no-status");
1008
+ expect(forge.statuses.map((p) => [p.sha, p.status.context, p.status.state])).toEqual([[sha, "chant/plan/app", "success"]]);
1009
+ expect(forge.notes).toEqual([{ pr: 7, marker: "<!-- chant-pr:prod:app -->" }]);
1010
+ });
1011
+
1012
+ test("a report the change forged for another head posts a failed status and no note", async () => {
1013
+ const { app, base } = workspace();
1014
+ const forge = recordingForge();
1015
+ const r = await ciRun({ image: JOB_IMAGE, cwd: app, job: "plan", env: "prod", forge: "forgejo", base, pr: 7, chant: fakeChant(), hostEnv: { ...HOST, FAKE_REPORT: planReport("3".repeat(40)) }, passEnv: ["FAKE_REPORT"], planForge: forge.forge, push: false, output: join(scratchDir("chant-ci-handover-"), "pr") });
1016
+ expect(r.warnings).toEqual([expect.stringMatching(/the plan report was not posted: the report is for head/)]);
1017
+ expect(forge.statuses.map((p) => p.status.state)).toEqual(["failure"]);
1018
+ expect(forge.notes).toEqual([]);
1019
+ });
1020
+
1021
+ test("in place, a job generated before #4295 still holds a token: its step gets none, the runner posts, and says to regenerate", async () => {
1022
+ const { app, base, sha } = workspace();
1023
+ const forge = recordingForge();
1024
+ const r = await ciRun({ cwd: app, job: "plan", env: "prod", forge: "forgejo", base, pr: 7, chant: fakeChant(), hostEnv: { ...HOST, GITHUB_JOB: "plan", FAKE_REPORT: planReport(sha) }, inPlace: true, push: false, planForge: forge.forge });
1025
+ expect(r.warnings).toEqual([expect.stringMatching(/regenerate the pipeline, whose plan-note job posts the note/)]);
1026
+ const env = envFile(join(app, ".chant/pr"), "pr-plan");
1027
+ expect(env).not.toHaveProperty("GITHUB_TOKEN");
1028
+ expect(env.NOT_FOR_JOBS).toBe("leak");
1029
+ expect(forge.notes).toHaveLength(1);
1030
+ });
1031
+
1032
+ test("the apply and the record keep their token: they run the target branch's code and post themselves", () => {
1033
+ const [, apply, record] = prLoopJobs("forgejo", "prod");
1034
+ expect(apply!.env.GITHUB_TOKEN).toBe("${{ github.token }}");
1035
+ expect(record!.env.GITHUB_TOKEN).toBe("${{ github.token }}");
1036
+ const [plan] = prLoopJobs("forgejo", "prod");
1037
+ expect(plan!.env).not.toHaveProperty("GITHUB_TOKEN");
1038
+ expect(plan!.run.env).not.toHaveProperty("GITHUB_TOKEN");
1039
+ expect(plan!.command.endsWith(" --no-status")).toBe(true);
1040
+ });
1041
+ });
@@ -45,6 +45,16 @@
45
45
  * commit: `pending` when it starts, `success` or `failure` when it settles.
46
46
  * A status the forge refuses is a warning and never fails the job.
47
47
  *
48
+ * The plan runs the pull request's code, so no step of the plan job gets a
49
+ * forge token (#4295, ../ci-handover.ts): every name in `FORGE_TOKEN_VARS`
50
+ * is left out of its steps' environment, from the runner, a secret or the
51
+ * forge job alike, and `pr-plan` runs with `--no-status`. Once the steps
52
+ * settle, the runner posts the note and the `chant/plan` status from the
53
+ * report with its own token (`postPlanReport`, ../pr-post.ts). In place, a
54
+ * generated job holds no token and its `plan-note` job posts; a job that
55
+ * still holds one was generated before #4295, and the runner posts for it
56
+ * with a warning to regenerate.
57
+ *
48
58
  * Signing is opt-in (#4156). With no runner key the record is unsigned, and
49
59
  * a reader counts it with no signer. A key `.chant/trust.json` does not list
50
60
  * leaves it unsigned with a note `--verbose` prints, unless the declaration
@@ -75,7 +85,9 @@ import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync,
75
85
  import { tmpdir } from "node:os";
76
86
  import { dirname, join, relative, resolve, sep } from "node:path";
77
87
  import { PR_APPLY_RECORD, PR_LOOP_REPORT_DIR, prLoopJobs, prLoopSetup, PR_LOOP_IMAGE, type PrLoopJob, type PrLoopPipelineOptions } from "../components/pr-pipeline";
78
- import { detectForge, ForgeEnvironmentError, forgeFromEnv, FORGE_KINDS, type CommitStatus, type ForgeKind } from "../pr-forge";
88
+ import { detectForge, ForgeEnvironmentError, forgeFromEnv, FORGE_KINDS, type CommitStatus, type ForgeKind, type PrForge } from "../pr-forge";
89
+ import { holdsForgeToken, withoutForgeTokens } from "../ci-handover";
90
+ import { postPlanReport } from "../pr-post";
79
91
  import { PR_APPLY_GATE } from "../pr-loop";
80
92
  import type { CommandContext } from "../cli/registry";
81
93
  import { formatError, formatWarning } from "../cli/format";
@@ -458,6 +470,12 @@ export interface CiRunRequest {
458
470
  secrets?: CiSecrets;
459
471
  /** `--output`: where the job's report is copied, from `cwd`. Default `.chant/pr`. */
460
472
  output?: string;
473
+ /**
474
+ * The forge client that posts the plan's note and `chant/plan` status from
475
+ * its report once the steps settle (#4295). Default: one built from the
476
+ * runner's environment when it holds a forge token.
477
+ */
478
+ planForge?: PrForge;
461
479
  /** `--run-index-limit`: how many runs the index at `loop.reportUrl` keeps (#4098). Default 200. */
462
480
  runIndexLimit?: number;
463
481
  /** A runner key, PEM, that signs the run's record. */
@@ -675,7 +693,8 @@ export async function ciRun(req: CiRunRequest): Promise<CiRunResult> {
675
693
  const started = req.requireRunnerStart || isolated ? runnerStartFor(req, { top, commit, hostEnv }) : undefined;
676
694
  // A generated wave job's command, as prLoopWaveJobs writes it. #4161: a local job's command posts no status or note.
677
695
  const forgeCommand = req.noForge ? found.command.replace(` --forge ${req.forge}`, "") : found.command;
678
- const command = `${forgeCommand}${req.wave !== undefined ? ` --wave ${req.wave}` : ""}${req.local ? " --no-status" : ""}`;
696
+ const noStatus = req.local && !/ --no-status(?: |$)/.test(forgeCommand);
697
+ const command = `${forgeCommand}${req.wave !== undefined ? ` --wave ${req.wave}` : ""}${noStatus ? " --no-status" : ""}`;
679
698
  const job = { ...found, command, ...(req.wave !== undefined ? { wave: req.wave } : {}) };
680
699
  const own = ciJobEnv(job, {
681
700
  forge: req.forge,
@@ -686,14 +705,19 @@ export async function ciRun(req: CiRunRequest): Promise<CiRunResult> {
686
705
  secrets: ciSecretsFor(req.secrets, req.job),
687
706
  });
688
707
  // #4156: in place, the forge job's environment is the job's, less the runner key.
689
- const jobEnv = req.inPlace ? { ...definedEnv(hostEnv), ...own } : own;
708
+ // #4295: the plan runs the pull request's code, so no step of it gets a forge token, whatever gave it one.
709
+ const merged = req.inPlace ? { ...definedEnv(hostEnv), ...own } : own;
710
+ const jobEnv = req.job === "plan" ? withoutForgeTokens(merged) : merged;
690
711
  delete jobEnv[RUNNER_KEY_ENV];
712
+ const poster = req.job === "plan" ? planPoster(req, hostEnv) : undefined;
691
713
  // #4096: the secrets and the job's secret-looking variables, redacted from everything the run writes and returns.
692
714
  // In place the forge's own variables come too; one whose value is short, such as a flag, is left out, so it can't blank common words.
693
715
  const redact = ciRedactor(req.inPlace ? { ...longValues(jobEnv), ...own } : jobEnv, req.secrets);
694
716
  try {
695
717
  const ran = await runCiJob(req, { cwd, top, commit, place, job, jobEnv, ...(redact ? { redact } : {}) });
696
- const result = started ? { ...ran, notes: [started, ...ran.notes] } : ran;
718
+ const posted = poster ? await postFromReport(req, poster, ran, { commit, ...(place.member ? { member: place.member } : {}) }) : [];
719
+ const withPosts = posted.length > 0 ? { ...ran, warnings: [...ran.warnings, ...posted] } : ran;
720
+ const result = started ? { ...withPosts, notes: [started, ...withPosts.notes] } : withPosts;
697
721
  return redact ? { ...result, warnings: result.warnings.map(redact), notes: result.notes.map(redact), ...(result.pushWarning ? { pushWarning: redact(result.pushWarning) } : {}) } : result;
698
722
  } catch (err) {
699
723
  if (redact && err instanceof Error) err.message = redact(err.message);
@@ -701,6 +725,50 @@ export async function ciRun(req: CiRunRequest): Promise<CiRunResult> {
701
725
  }
702
726
  }
703
727
 
728
+ /**
729
+ * Who posts the plan's note and `chant/plan` status (#4295): the runner,
730
+ * from the report, once the plan's steps settled, with the forge token the
731
+ * steps never got. None on `--local`, for a plain git server (`noForge`), or
732
+ * without a token. In place, a token means the job was generated before the
733
+ * plan-note job existed: the runner still posts, and says to regenerate.
734
+ */
735
+ function planPoster(req: CiRunRequest, hostEnv: Record<string, string | undefined>): { forge: PrForge; warning?: string } | { error: string } | undefined {
736
+ if (req.local || req.noForge || req.pr === undefined) return undefined;
737
+ const warning = req.inPlace
738
+ ? "this plan job holds a forge token, which the pull request's code runs beside in the same job; regenerate the pipeline, whose plan-note job posts the note from a fresh job (#4295)"
739
+ : undefined;
740
+ if (req.planForge) return { forge: req.planForge, ...(warning ? { warning } : {}) };
741
+ if (!holdsForgeToken(hostEnv)) return undefined;
742
+ try {
743
+ return { forge: forgeFromEnv(req.forge, hostEnv), ...(warning ? { warning } : {}) };
744
+ } catch (err) {
745
+ return { error: `the plan's note and status will not be posted: ${message(err)}` };
746
+ }
747
+ }
748
+
749
+ /** Post the plan's note and status from the report the job wrote (#4295), and return the warnings. */
750
+ async function postFromReport(
751
+ req: CiRunRequest,
752
+ poster: NonNullable<ReturnType<typeof planPoster>>,
753
+ ran: CiRunResult,
754
+ at: { commit: string; member?: string },
755
+ ): Promise<string[]> {
756
+ if ("error" in poster) return [poster.error];
757
+ const warnings = poster.warning ? [poster.warning] : [];
758
+ const dir = ran.report ?? resolve(req.cwd, req.output ?? PR_LOOP_REPORT_DIR);
759
+ const posted = await postPlanReport({
760
+ forge: poster.forge,
761
+ dir,
762
+ pr: req.pr!,
763
+ head: at.commit,
764
+ env: req.env,
765
+ ...(at.member ? { member: at.member } : {}),
766
+ result: ran.status === "ok" ? "success" : "failure",
767
+ });
768
+ if (posted.refused) warnings.push(`the plan report was not posted: ${posted.refused}`);
769
+ return [...warnings, ...posted.warnings];
770
+ }
771
+
704
772
  /**
705
773
  * `--require-runner-start` (#4147): the runner-start statement for this
706
774
  * forge job, checked against `.chant/trust.json` at `--base` (the commit
@@ -972,13 +1040,14 @@ export function ciLoopOptions(args: CommandContext["args"]): PrLoopPipelineOptio
972
1040
  ...(args.prBuildCommand ? { buildCommand: args.prBuildCommand } : {}),
973
1041
  ...(args.waveGate ? { waveGate: args.waveGate } : {}),
974
1042
  ...(args.requireReview === true ? { requireReview: true } : {}),
1043
+ ...(args.applyBeforeMerge ? { applyBeforeMerge: true } : {}),
975
1044
  };
976
1045
  }
977
1046
 
978
1047
  const USAGE =
979
1048
  "chant ci run plan|record|apply --env <env> --base <sha> [--pr <n>] [--commit <sha>] [--forge github|gitlab|forgejo] " +
980
1049
  "[--gate <name>] [--wave-gate <name>] [--wave <k>] [--branch <name>] [--events-json] [--report-url <url>] [--run-index-limit <n>] [--image <image>] [--before-script <line>]... " +
981
- "[--pr-build] [--pr-build-command <line>] [--require-review] [--no-status] [--status-url <url template>] " +
1050
+ "[--pr-build] [--pr-build-command <line>] [--require-review] [--apply-before-merge] [--no-status] [--status-url <url template>] " +
982
1051
  "[--chant <command>] [--pass-env <NAME>]... [--secrets <file>] [--key <runner key.pem>] [-o <dir>] [--in-place | --local] [--no-push] [--verbose] | chant ci run --at <sha> | chant ci run --setup-key [--trust [--principal <name>]] [--state <dir>] [--branch <name>]";
983
1052
 
984
1053
  const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * `ci.workflow` read at base (#4301, ws-106), as `ci.locks` is
3
+ * (./ci-locks.ts): the target branch's tip, so a pull request that sets
4
+ * `apply-before-merge` in its own copy of the declaration gets nothing from it.
5
+ */
6
+ import type { CiWorkflow } from "./declaration";
7
+ import { workflowOf } from "./ci-workflow";
8
+ import { memberAtCwd } from "./security";
9
+ import { scopeSource } from "./write-scope";
10
+
11
+ /**
12
+ * The workflow the member holding `cwd` follows, or `member` when given:
13
+ * its own `ci.workflow`, else the top level's, read at `at` when given, else
14
+ * at base, else from the working tree. Null outside a workspace and when
15
+ * neither sets one.
16
+ */
17
+ export function ciWorkflowAt(cwd: string, at?: string, member?: string): CiWorkflow | null {
18
+ const source = scopeSource(cwd, at);
19
+ const d = source.declaration;
20
+ if (!d) return null;
21
+ const name = member ?? memberAtCwd(source, cwd);
22
+ return workflowOf(d, d.members.find((m) => m.name === name) ?? null);
23
+ }
@@ -7,6 +7,8 @@
7
7
  * from a green commit and promoted through the member's environments, gated
8
8
  * in the last (4.2). `trunk-curated+apply-on-merge` is the same, and each
9
9
  * newest green commit is also applied (section 5, step 9).
10
+ * `apply-before-merge` (#4301, ws-106) is `trunk-curated`, and a person may
11
+ * also apply an open pull request's plan from its head before it merges.
10
12
  *
11
13
  * A workflow requires no security group: those are opt-in on their own
12
14
  * (./security.ts). `chant workspace check` reports what a member under a
@@ -19,13 +21,22 @@ import type { CiWorkflow, Declaration, Member } from "./declaration";
19
21
  /** The marker a component stub `chant ci init` writes carries until someone fills it in. WSP151 reports a member whose only components carry it. */
20
22
  export const STUB_MARKER = "TODO(chant ci init)";
21
23
 
22
- export const CI_WORKFLOWS = ["trunk-curated", "trunk-curated+apply-on-merge"] as const satisfies readonly CiWorkflow[];
24
+ export const CI_WORKFLOWS = ["trunk-curated", "trunk-curated+apply-on-merge", "apply-before-merge"] as const satisfies readonly CiWorkflow[];
23
25
 
24
26
  /** Whether `workflow` turns the apply on merge back on. */
25
27
  export function appliesOnMerge(workflow: CiWorkflow): boolean {
26
28
  return workflow === "trunk-curated+apply-on-merge";
27
29
  }
28
30
 
31
+ /**
32
+ * Whether `workflow` lets an open pull request's plan apply from its head
33
+ * before it merges (#4301, ws-106). Only `apply-before-merge` does, so with
34
+ * no workflow or either trunk-curated one nothing applies before merge.
35
+ */
36
+ export function appliesBeforeMerge(workflow: CiWorkflow | null): boolean {
37
+ return workflow === "apply-before-merge";
38
+ }
39
+
29
40
  /** The workflow `member` follows: its own `ci.workflow`, else the top level's, else null. */
30
41
  export function workflowOf(declaration: Pick<Declaration, "ci">, member: Pick<Member, "ciWorkflow"> | null): CiWorkflow | null {
31
42
  return member?.ciWorkflow ?? declaration.ci?.workflow ?? null;
@@ -56,7 +67,7 @@ export const CI_WORKFLOW_REQUIREMENTS = [
56
67
  { code: "ci-workflow-no-environments", requirement: "the member's `chant.config.ts` declares `environments`, the promotion order", fix: "`environments` in `chant.config.ts`", perMember: true },
57
68
  { code: "ci-workflow-ungated-env", requirement: "each component has a gate, so a promote to the last environment waits on an approval", fix: "a `gate()` step in the component", perMember: true },
58
69
  { code: "ci-workflow-pipeline-missing", requirement: "a pipeline file runs the plan job, the record job (`ci run record`) and `chant ci tick`", fix: "`chant build --components --generate <forge> --pr-loop` and `chant ci workflow`", perMember: false },
59
- { code: "ci-workflow-pipeline-differs", requirement: "the pipeline matches the workflow: no apply job under `trunk-curated`, and under `trunk-curated+apply-on-merge` an apply job that applies the newest green commit, not each merge", fix: "regenerate the pull-request pipeline, with `--apply-on-merge` only under apply on merge", perMember: false },
70
+ { code: "ci-workflow-pipeline-differs", requirement: "the pipeline matches the workflow: no apply job under `trunk-curated`, and under `trunk-curated+apply-on-merge` an apply job that applies the newest green commit, not each merge; a job that applies before merge only, and always, under `apply-before-merge`", fix: "regenerate the pull-request pipeline, with `--apply-on-merge` only under apply on merge and `--apply-before-merge` only under apply before merge", perMember: false },
60
71
  { code: "ci-workflow-publish-missing", requirement: "a pipeline file runs `chant release publish`", fix: "`chant release workflow`", perMember: false },
61
72
  ] as const satisfies readonly CiWorkflowRequirement[];
62
73
 
@@ -181,10 +181,116 @@
181
181
  "workflow": {
182
182
  "$ref": "#/$defs/ciWorkflow"
183
183
  },
184
+ "hooks": {
185
+ "$ref": "#/$defs/ciHooks"
186
+ },
187
+ "locks": {
188
+ "enum": ["apply", "plan"],
189
+ "description": "When a pull request takes its target locks, _locks/<gate>.json on chant/lifecycle (#4293). apply: the pull request's apply takes them, and chant lock takes them by hand. plan: its plan takes them too, through chant lock --stage plan in a job that runs the target branch's code. Under either, chant components pr-apply refuses a target that another open pull request holds, and a lock whose pull request merged or closed reads as released. Absent: nothing is locked. Read from the declaration at base. Added in schema 1 by chant 0.124.41, so a declaration that uses it sets minReader to 0.124.41 or newer."
190
+ },
191
+ "policy": {
192
+ "$ref": "#/$defs/ciPolicy"
193
+ },
184
194
  "reportUrl": {
185
195
  "type": "string",
186
196
  "minLength": 1,
187
197
  "description": "Where the pull-request jobs copy their reports and events, and where chant ci run keeps the run index, <reportUrl>/runs/index.json (#4160): an s3://<bucket>/<prefix> or file://<directory> URL, as --report-url takes. chant build --pr-loop, chant ci run, chant ci serve and chant ci runs use it when --report-url is not given, and the flag wins. Readers find it in chant workspace ls --json as ci.reportUrl. Added in schema 1 by chant 0.124.32, so a declaration that uses it sets minReader to 0.124.32 or newer."
198
+ },
199
+ "branches": {
200
+ "type": "object",
201
+ "description": "Branch routing (#4292): a branch name to member-name globs. A member a glob matches applies from that branch alone: chant build --pr-loop targets its pipeline at the branch, and its record and apply jobs plan nothing on a push to any other branch. A member no glob matches applies from every branch the map does not name. One member may not be matched by two branches. Added in schema 1 by chant 0.124.41, so a declaration that uses it sets minReader to 0.124.41 or newer.",
202
+ "patternProperties": {
203
+ "^x-": true,
204
+ "^(?!x-)[^\\s]+$": { "$ref": "#/$defs/ciMemberGlobs" }
205
+ },
206
+ "additionalProperties": false
207
+ },
208
+ "labels": {
209
+ "type": "object",
210
+ "description": "Label filters (#4292): a pull request label to member-name globs. When a pull request carries one of these labels, its plan jobs plan only the members those labels match. A label never gates: the record and apply jobs never read labels, and an apply still needs an approval of the digest it plans. Added in schema 1 by chant 0.124.41, so a declaration that uses it sets minReader to 0.124.41 or newer.",
211
+ "patternProperties": {
212
+ "^x-": true,
213
+ "^(?!x-).+$": { "$ref": "#/$defs/ciMemberGlobs" }
214
+ },
215
+ "additionalProperties": false
216
+ },
217
+ "identity": {
218
+ "type": "object",
219
+ "additionalProperties": false,
220
+ "patternProperties": { "^x-": true },
221
+ "description": "Cloud identities for the pull-request jobs (#4296). Added in schema 1 by chant 0.124.41, so a declaration that uses it sets minReader to 0.124.41 or newer.",
222
+ "properties": {
223
+ "cloud": {
224
+ "type": "object",
225
+ "additionalProperties": false,
226
+ "patternProperties": { "^x-": true },
227
+ "description": "Each cloud's plan and apply identities, as rules over environment and member globs. chant build --pr-loop takes the first rule that matches the pipeline's environment and member. The plan job trades its OIDC token for the plan identity before the change is checked out, the record job takes the plan identity, and the apply jobs the apply identity. A rule may not plan as an identity any rule applies as.",
228
+ "properties": {
229
+ "aws": {
230
+ "type": "object",
231
+ "additionalProperties": false,
232
+ "patternProperties": { "^x-": true },
233
+ "required": ["rules"],
234
+ "description": "AWS roles, assumed through STS AssumeRoleWithWebIdentity.",
235
+ "properties": {
236
+ "region": { "type": "string", "minLength": 1, "description": "The STS region. Default us-east-1." },
237
+ "audience": { "type": "string", "minLength": 1, "description": "The OIDC token's audience. Default sts.amazonaws.com." },
238
+ "endpoint": { "type": "string", "pattern": "^https?://", "description": "The STS endpoint, for an emulator. Default the region's, or AWS_ENDPOINT_URL_STS in the job." },
239
+ "rules": { "$ref": "#/$defs/ciCloudIdentityRules" }
240
+ }
241
+ },
242
+ "gcp": {
243
+ "type": "object",
244
+ "additionalProperties": false,
245
+ "patternProperties": { "^x-": true },
246
+ "required": ["provider", "rules"],
247
+ "description": "GCP service accounts, reached through a workload identity provider and impersonated with IAM generateAccessToken.",
248
+ "properties": {
249
+ "provider": { "type": "string", "pattern": "^(//iam\\.googleapis\\.com/)?projects/[^/]+/locations/global/workloadIdentityPools/[^/]+/providers/[^/]+$", "description": "The workload identity provider: projects/<number>/locations/global/workloadIdentityPools/<pool>/providers/<provider>." },
250
+ "audience": { "type": "string", "minLength": 1, "description": "The OIDC token's audience. Default https://iam.googleapis.com/<provider>." },
251
+ "stsEndpoint": { "type": "string", "pattern": "^https?://", "description": "Google's STS, for an emulator. Default https://sts.googleapis.com." },
252
+ "iamEndpoint": { "type": "string", "pattern": "^https?://", "description": "IAM credentials, for an emulator. Default https://iamcredentials.googleapis.com." },
253
+ "rules": { "$ref": "#/$defs/ciCloudIdentityRules" }
254
+ }
255
+ },
256
+ "azure": {
257
+ "type": "object",
258
+ "additionalProperties": false,
259
+ "patternProperties": { "^x-": true },
260
+ "required": ["tenantId", "rules"],
261
+ "description": "Entra application clients, each with a federated credential for the forge's tokens. The rules' identities are client ids.",
262
+ "properties": {
263
+ "tenantId": { "type": "string", "minLength": 1, "description": "The Entra tenant." },
264
+ "subscriptionId": { "type": "string", "minLength": 1, "description": "Set as AZURE_SUBSCRIPTION_ID and ARM_SUBSCRIPTION_ID when given." },
265
+ "audience": { "type": "string", "minLength": 1, "description": "The OIDC token's audience. Default api://AzureADTokenExchange." },
266
+ "authority": { "type": "string", "pattern": "^https?://", "description": "The Entra authority host, for an emulator. Default https://login.microsoftonline.com." },
267
+ "rules": { "$ref": "#/$defs/ciCloudIdentityRules" }
268
+ }
269
+ }
270
+ }
271
+ }
272
+ }
273
+ },
274
+ "prEnvironment": {
275
+ "type": "object",
276
+ "description": "An environment per open pull request (#4298): chant components pr-env up applies the pull request's components to pr-<number>, which takes the template environment's declared endpoint, and pr-env down tears down only what carries that environment's ownership marker. A pull request's environment is never a release target. Read from the declaration at base, so a pull request cannot change its own template or TTL. Added in schema 1 by chant 0.124.42, so a declaration that uses it sets minReader to 0.124.42 or newer.",
277
+ "required": ["template"],
278
+ "additionalProperties": false,
279
+ "patternProperties": {
280
+ "^x-": true
281
+ },
282
+ "properties": {
283
+ "template": {
284
+ "type": "string",
285
+ "pattern": "^[a-z0-9][a-z0-9-]*$",
286
+ "description": "The declared environment a pull request's environment is derived from: pr-<number> takes its endpoint unless a pr-* entry declares one."
287
+ },
288
+ "ttl": {
289
+ "type": "string",
290
+ "pattern": "^[1-9][0-9]*(m|h|d)$",
291
+ "description": "How long a pull request's environment lives after its last apply, such as 72h, 90m or 7d. chant components pr-env sweep tears down one whose TTL passed. 72h when absent."
292
+ }
293
+ }
188
294
  }
189
295
  }
190
296
  },
@@ -254,6 +360,36 @@
254
360
  },
255
361
  "additionalProperties": false,
256
362
  "$defs": {
363
+ "ciCloudIdentityRules": {
364
+ "type": "array",
365
+ "minItems": 1,
366
+ "description": "Rules, first match wins: a rule matches a pipeline when its environments match the pipeline's environment and its members match the pipeline's member. A rule with no environments matches every environment, and one with no members every member and a pipeline outside a member.",
367
+ "items": {
368
+ "type": "object",
369
+ "additionalProperties": false,
370
+ "patternProperties": { "^x-": true },
371
+ "required": ["plan", "apply"],
372
+ "properties": {
373
+ "environments": {
374
+ "type": "array",
375
+ "minItems": 1,
376
+ "uniqueItems": true,
377
+ "items": { "type": "string", "minLength": 1 },
378
+ "description": "Environment-name globs, such as prod or staging-*."
379
+ },
380
+ "members": { "$ref": "#/$defs/ciMemberGlobs" },
381
+ "plan": { "type": "string", "minLength": 1, "description": "The identity the plan and record jobs take: a role ARN, a service account email or a client id." },
382
+ "apply": { "type": "string", "minLength": 1, "description": "The identity the apply jobs take." }
383
+ }
384
+ }
385
+ },
386
+ "ciMemberGlobs": {
387
+ "type": "array",
388
+ "minItems": 1,
389
+ "uniqueItems": true,
390
+ "items": { "type": "string", "minLength": 1, "pattern": "^[a-z0-9*?\\[\\]{},!-]+$" },
391
+ "description": "Globs over member names, such as prod-* or [\"api\", \"web\"]: * and ? within the name, [...] classes, {a,b} alternatives."
392
+ },
257
393
  "retentionDays": {
258
394
  "type": "integer",
259
395
  "minimum": 0,
@@ -504,6 +640,9 @@
504
640
  "properties": {
505
641
  "workflow": {
506
642
  "$ref": "#/$defs/ciWorkflow"
643
+ },
644
+ "hooks": {
645
+ "$ref": "#/$defs/ciHooks"
507
646
  }
508
647
  }
509
648
  },
@@ -1579,8 +1718,16 @@
1579
1718
  "additionalProperties": false
1580
1719
  },
1581
1720
  "ciWorkflow": {
1582
- "enum": ["trunk-curated", "trunk-curated+apply-on-merge"],
1583
- "description": "A prescribed delivery workflow (#4168). trunk-curated: a merge records the plan and deploys nothing; releases are cut from green commits and promoted through each member's environments, gated in the last. trunk-curated+apply-on-merge: the same, with the apply on merge turned back on. chant workspace check reports what a member under one still lacks (WSP151), and chant ci init writes it. At the top level for every member, or on a member, which wins. Added in schema 1 by chant 0.124.34, so a declaration that uses it sets minReader to 0.124.34 or newer."
1721
+ "enum": ["trunk-curated", "trunk-curated+apply-on-merge", "apply-before-merge"],
1722
+ "description": "A prescribed delivery workflow (#4168). trunk-curated: a merge records the plan and deploys nothing; releases are cut from green commits and promoted through each member's environments, gated in the last. trunk-curated+apply-on-merge: the same, with the apply on merge turned back on. apply-before-merge (#4301, ws-106): the same as trunk-curated, and a person may also apply an open pull request's plan from its head under its gate, once it is approved, mergeable, undiverged and its checks passed (chant components pr-apply --before-merge); added by chant 0.124.42, so a declaration that uses it sets minReader to 0.124.42 or newer. chant workspace check reports what a member under one still lacks (WSP151), and chant ci init writes it. At the top level for every member, or on a member, which wins. Added in schema 1 by chant 0.124.34, so a declaration that uses it sets minReader to 0.124.34 or newer."
1723
+ },
1724
+ "ciHooks": {
1725
+ "type": "object",
1726
+ "description": "Stage hooks the pull-request jobs run (#4288), in the shape an Op waves spec's hooks take: before.observe, before.plan, after.plan, before.apply and after.apply, each a list of { command, name?, onFailure?, perRun?, targets? }. targets are component names or patterns. chant components pr-plan, pr-record and pr-apply read them from the declaration at base, the top level's first and then the member's. after.gate and apply.runBy are refused. Added in schema 1 by chant 0.124.41, so a declaration that uses it sets minReader to 0.124.41 or newer."
1727
+ },
1728
+ "ciPolicy": {
1729
+ "type": "object",
1730
+ "description": "Gate-time policy on the pull-request jobs (#4294): a policy engine run over the change set, with the policy read at base. engine names the result shape the command prints, conftest (the default) or opa. path is the policy directory, relative to the declaration (default policy). namespace is the package to read. command replaces the engine's own argv, with {policy} for the policy directory exported from base and {input} for the input file. deny rules refuse the apply unless an override counts; hold rules make the plan wait for a person's approval; warn rules are advice. There is no unattended outcome. Added in schema 1 by chant 0.124.41, so a declaration that uses it sets minReader to 0.124.41 or newer."
1584
1731
  },
1585
1732
  "ciPhaseName": {
1586
1733
  "type": "string",