@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
@@ -436,6 +436,15 @@ describe("retention (#4036)", () => {
436
436
  });
437
437
  });
438
438
 
439
+ describe("ci.locks (#4293)", () => {
440
+ test("apply or plan is read; absent leaves it out; anything else is declaration-invalid", () => {
441
+ expect(parse(base([], { ci: { locks: "plan" } })).ci).toEqual({ green: null, locks: "plan", reportUrl: null });
442
+ expect(parse(base([], { ci: { locks: "apply" } })).ci?.locks).toBe("apply");
443
+ expect(parse(base([], { ci: {} })).ci?.locks).toBeUndefined();
444
+ for (const locks of ["merge", true, ""]) expect(failure(base([], { ci: { locks } })).code).toBe("declaration-invalid");
445
+ });
446
+ });
447
+
439
448
  describe("ci.reportUrl (#4160)", () => {
440
449
  test("an s3:// or file:// URL is read without its trailing slash, beside green or alone, and is null when absent", () => {
441
450
  expect(parse(base([])).ci).toBeNull();
@@ -489,3 +498,70 @@ describe("metrics (#4039)", () => {
489
498
  }
490
499
  });
491
500
  });
501
+
502
+ describe("ci.branches and ci.labels (#4292)", () => {
503
+ const members = [
504
+ { name: "prod-eu", dir: "prod-eu", kind: "chant" },
505
+ { name: "api", dir: "api", kind: "chant" },
506
+ ];
507
+
508
+ test("both maps are read without their x- keys, and stay absent when unset", () => {
509
+ const d = parse(base(members, { ci: { branches: { release: ["prod-*"], "x-note": "ops" }, labels: { "plan:prod": ["prod-*"] } } }));
510
+ expect(d.ci?.branches).toEqual({ release: ["prod-*"] });
511
+ expect(d.ci?.labels).toEqual({ "plan:prod": ["prod-*"] });
512
+ expect(parse(base(members, { ci: {} })).ci).toEqual({ green: null, reportUrl: null });
513
+ });
514
+
515
+ test("a member two branches route is declaration-invalid at the second branch", () => {
516
+ const err = failure(base(members, { ci: { branches: { release: ["prod-*"], hotfix: ["prod-eu"] } } }));
517
+ expect(err.code).toBe("declaration-invalid");
518
+ expect(err.message).toBe("ci.branches routes member prod-eu to both release and hotfix; a member applies from one branch");
519
+ });
520
+
521
+ test("an empty glob list or a glob that is not a member-name glob is refused", () => {
522
+ expect(failure(base(members, { ci: { branches: { release: [] } } })).code).toBe("declaration-invalid");
523
+ expect(failure(base(members, { ci: { branches: { release: ["prod/eu"] } } })).code).toBe("declaration-invalid");
524
+ expect(failure(base(members, { ci: { labels: { "plan:prod": "prod-*" } } })).code).toBe("declaration-invalid");
525
+ });
526
+ });
527
+
528
+ describe("ci.identity.cloud (#4296)", () => {
529
+ const members = [{ name: "net", dir: "net", kind: "chant" }];
530
+ const aws = { region: "eu-west-1", "x-note": "ops", rules: [{ environments: ["prod"], members: ["net"], plan: "arn:aws:iam::1:role/plan", apply: "arn:aws:iam::1:role/apply", "x-why": "split" }] };
531
+
532
+ test("each cloud is read without its x- keys", () => {
533
+ const d = parse(base(members, { ci: { identity: { cloud: { aws, azure: { tenantId: "t", rules: [{ plan: "p", apply: "a" }] } } } } }));
534
+ expect(d.ci?.identity).toEqual({
535
+ cloud: {
536
+ aws: { region: "eu-west-1", rules: [{ environments: ["prod"], members: ["net"], plan: "arn:aws:iam::1:role/plan", apply: "arn:aws:iam::1:role/apply" }] },
537
+ azure: { tenantId: "t", rules: [{ plan: "p", apply: "a" }] },
538
+ },
539
+ });
540
+ });
541
+
542
+ test("a rule that plans as an identity some rule applies as is declaration-invalid at that rule", () => {
543
+ const err = failure(base(members, { ci: { identity: { cloud: { aws: { rules: [{ plan: "r", apply: "r" }] } } } } }));
544
+ expect(err.code).toBe("declaration-invalid");
545
+ expect(err.message).toContain("ci.identity.cloud.aws.rules[0] plans and applies as r");
546
+ });
547
+
548
+ test("the schema refuses a rule with no apply, an unknown cloud and a GCP provider that is not a provider's name", () => {
549
+ expect(failure(base(members, { ci: { identity: { cloud: { aws: { rules: [{ plan: "r" }] } } } } })).code).toBe("declaration-invalid");
550
+ expect(failure(base(members, { ci: { identity: { cloud: { oci: { rules: [] } } } } })).code).toBe("declaration-invalid");
551
+ expect(failure(base(members, { ci: { identity: { cloud: { gcp: { provider: "forge", rules: [{ plan: "p", apply: "a" }] } } } } })).code).toBe("declaration-invalid");
552
+ });
553
+ });
554
+
555
+ describe("ci.prEnvironment (#4298)", () => {
556
+ const members = [{ name: "api", dir: "api", kind: "chant" }];
557
+
558
+ test("is read with its template and a 72h TTL by default", () => {
559
+ expect(parse(base(members, { ci: { prEnvironment: { template: "staging" } } })).ci?.prEnvironment).toEqual({ template: "staging", ttl: "72h" });
560
+ expect(parse(base(members, { ci: { prEnvironment: { template: "staging", ttl: "7d" } } })).ci?.prEnvironment).toEqual({ template: "staging", ttl: "7d" });
561
+ });
562
+
563
+ test("a block with no template, or a TTL in another form, is declaration-invalid", () => {
564
+ expect(failure(base(members, { ci: { prEnvironment: {} } })).code).toBe("declaration-invalid");
565
+ expect(failure(base(members, { ci: { prEnvironment: { template: "staging", ttl: "1w" } } })).code).toBe("declaration-invalid");
566
+ });
567
+ });
@@ -19,6 +19,8 @@
19
19
  import { RETENTION_KINDS, type RetentionOverride } from "../lifecycle/retention-policy";
20
20
  import { parseReportUrl } from "../pr-report-url";
21
21
  import schema from "./declaration.schema.json" with { type: "json" };
22
+ import { routeConflicts } from "./ci-branches";
23
+ import { cloudIdentityProblems, CLOUDS, type CloudIdentityDeclaration } from "../ci-identity";
22
24
  import { expandGlob } from "./glob";
23
25
  import { EXAMPLES_KIND, holdsChantProject } from "./kinds";
24
26
  import { parseJsonText, pointerToken, type TextLocation } from "./jsonc";
@@ -27,6 +29,8 @@ import type { SecurityGroup } from "./security";
27
29
  import { joinPath, type WorkspaceTree } from "./tree";
28
30
  import { loadAjv2020 } from "./ajv2020";
29
31
  import { readChantPackageVersion } from "../package-version";
32
+ import { assertPrLoopHooks, type OpWaveHooks } from "../op/op-waves";
33
+ import { assertGatePolicySettings, type GatePolicySettings } from "../gate-policy";
30
34
 
31
35
  export const DECLARATION_SCHEMA_ID = schema.$id;
32
36
  export const DECLARATION_FILES = ["chant.workspace.json", "chant.workspace.jsonc"] as const;
@@ -254,16 +258,30 @@ export type CiSource = "checks" | "runs";
254
258
  * A prescribed delivery workflow (#4168 section 7). `trunk-curated`: a merge
255
259
  * records the plan and deploys nothing, and releases are cut from green
256
260
  * commits and promoted through the environments. `trunk-curated+apply-on-merge`
257
- * adds the apply on merge as an opt-in. `chant workspace check` reports what a
258
- * member under one still lacks (./checks/ci-workflow.ts, WSP151).
261
+ * adds the apply on merge as an opt-in. `apply-before-merge` (#4301, ws-106)
262
+ * is `trunk-curated` with an open pull request's plan applied from its head
263
+ * on request, once it is approved, mergeable, undiverged and green. `chant
264
+ * workspace check` reports what a member under one still lacks
265
+ * (./checks/ci-workflow.ts, WSP151).
259
266
  */
260
- export type CiWorkflow = "trunk-curated" | "trunk-curated+apply-on-merge";
267
+ export type CiWorkflow = "trunk-curated" | "trunk-curated+apply-on-merge" | "apply-before-merge";
268
+
269
+ /**
270
+ * When a pull request takes its target locks (#4293, ../lifecycle/target-lock.ts).
271
+ * `apply`: its apply takes them, and `chant lock` does by hand. `plan`: its
272
+ * plan takes them too, through `chant lock --stage plan` in a job that runs
273
+ * the target branch's code. Under either, an apply refuses a target another
274
+ * open pull request holds.
275
+ */
276
+ export type CiLocks = "apply" | "plan";
261
277
 
262
278
  /** The declaration's `ci` block (#3573). */
263
279
  export interface CiDeclaration {
264
280
  green: CiGreen | null;
265
281
  /** `ci.workflow` (#4168 step 6): the workflow every member follows unless it names its own; absent when the declaration sets none. */
266
282
  workflow?: CiWorkflow;
283
+ /** `ci.locks` (#4293): when a pull request takes its target locks; absent when the declaration sets none, and then nothing is locked. */
284
+ locks?: CiLocks;
267
285
  /**
268
286
  * `ci.reportUrl` (#4160): where the pull-request jobs copy their reports
269
287
  * and `chant ci run` keeps the run index, `<reportUrl>/runs/index.json`.
@@ -272,8 +290,56 @@ export interface CiDeclaration {
272
290
  * the declaration sets none.
273
291
  */
274
292
  reportUrl: string | null;
293
+ /**
294
+ * `ci.hooks` (#4288): the stage hooks the pull-request jobs run, in the
295
+ * shape an Op waves spec's `hooks` takes. `chant components pr-plan`,
296
+ * `pr-record` and `pr-apply` read them from the declaration at base.
297
+ * Absent when the declaration sets none.
298
+ */
299
+ hooks?: OpWaveHooks;
300
+ /**
301
+ * `ci.branches` (#4292): branch name to member-name globs. A member a glob
302
+ * matches applies from that branch alone; the others apply from every
303
+ * branch the map does not name (./ci-branches.ts). Absent when unset.
304
+ */
305
+ branches?: Record<string, string[]>;
306
+ /**
307
+ * `ci.labels` (#4292): pull request label to member-name globs. A labelled
308
+ * pull request plans only the members its labels match. Never a gate.
309
+ * Absent when unset.
310
+ */
311
+ labels?: Record<string, string[]>;
312
+ /**
313
+ * `ci.policy` (#4294): the policy engine the pull-request jobs run over
314
+ * the change set, read with the policy from the declaration at base.
315
+ * Absent when the declaration sets none.
316
+ */
317
+ policy?: GatePolicySettings;
318
+ /**
319
+ * `ci.identity` (#4296): `cloud` gives each cloud's plan and apply
320
+ * identities by environment and member globs (../ci-identity.ts).
321
+ * Absent when unset.
322
+ */
323
+ identity?: { cloud: CloudIdentityDeclaration };
324
+ /**
325
+ * `ci.prEnvironment` (#4298): an environment per open pull request,
326
+ * derived from `template`, torn down on close or after `ttl`
327
+ * (../components/pr-environment.ts). Absent when unset.
328
+ */
329
+ prEnvironment?: CiPrEnvironment;
275
330
  }
276
331
 
332
+ /** The `ci.prEnvironment` block (#4298), with its default TTL. */
333
+ export interface CiPrEnvironment {
334
+ /** The declared environment `pr-<n>` takes its endpoint from. */
335
+ template: string;
336
+ /** How long the environment lives after its last apply: `72h` when absent. */
337
+ ttl: string;
338
+ }
339
+
340
+ /** The default `ci.prEnvironment.ttl`. */
341
+ export const DEFAULT_PR_ENVIRONMENT_TTL = "72h";
342
+
277
343
  export interface MemberRole {
278
344
  name: string;
279
345
  /** Relative to the member's directory, or null for the whole member. */
@@ -592,6 +658,8 @@ export interface Member {
592
658
  security: SecurityGroup[];
593
659
  /** The member's own `ci.workflow` (#4168 step 6), which wins over the top level's; null when it names none. */
594
660
  ciWorkflow: CiWorkflow | null;
661
+ /** The member's own `ci.hooks` (#4288), run after the top level's; null when it names none. */
662
+ ciHooks: OpWaveHooks | null;
595
663
  suppress: Suppression[];
596
664
  /** The entry's JSON Pointer in the file, for messages. */
597
665
  pointer: string;
@@ -889,6 +957,7 @@ export function parseDeclaration(text: string, file: string, reader: string = re
889
957
  travel: e.travel === true,
890
958
  security: [...((e.security as SecurityGroup[] | undefined) ?? [])],
891
959
  ciWorkflow: ((e.ci as { workflow?: CiWorkflow } | undefined)?.workflow) ?? null,
960
+ ciHooks: ciHooksOf((e.ci as { hooks?: unknown } | undefined)?.hooks, `${pointer}/ci/hooks`, at),
892
961
  suppress,
893
962
  pointer,
894
963
  };
@@ -1013,6 +1082,15 @@ export function parseDeclaration(text: string, file: string, reader: string = re
1013
1082
  const tierSession = factoryTierSessionProblem(members, agents);
1014
1083
  if (tierSession) throw new WorkspaceReadError("declaration-invalid", tierSession.message, at(tierSession.pointer));
1015
1084
  const ci = ciOf(obj.ci, at);
1085
+ // #4292: a member applies from one branch, so two branches may not both route it.
1086
+ const [conflict] = routeConflicts(ci?.branches, members.map((m) => m.name));
1087
+ if (conflict) {
1088
+ throw new WorkspaceReadError(
1089
+ "declaration-invalid",
1090
+ `ci.branches routes member ${conflict.member} to both ${conflict.branches.join(" and ")}; a member applies from one branch`,
1091
+ at(`/ci/branches/${pointerToken(conflict.branches[1]!)}`),
1092
+ );
1093
+ }
1016
1094
 
1017
1095
  const pins = ((obj.pins as Record<string, string>[] | undefined) ?? []).map((p) => ({
1018
1096
  package: p.package ?? null,
@@ -1134,10 +1212,67 @@ function retentionOf(raw: unknown): RetentionOverride | null {
1134
1212
  return out;
1135
1213
  }
1136
1214
 
1215
+ /** A `ci.hooks` block (#4288), refused when a pull-request job could not run it. Null when absent. */
1216
+ function ciHooksOf(raw: unknown, pointer: string, at: (pointer: string, key?: boolean) => ErrorLocation): OpWaveHooks | null {
1217
+ if (raw === undefined) return null;
1218
+ try {
1219
+ assertPrLoopHooks(pointer.slice(1).replace(/\//g, "."), raw as OpWaveHooks);
1220
+ } catch (err) {
1221
+ throw new WorkspaceReadError("declaration-invalid", err instanceof Error ? err.message : String(err), at(pointer));
1222
+ }
1223
+ return structuredClone(raw as OpWaveHooks);
1224
+ }
1225
+
1226
+ /** A `ci.branches` or `ci.labels` map, copied without its `x-` keys. */
1227
+ function globMap(raw: Record<string, string[]>): Record<string, string[]> {
1228
+ return Object.fromEntries(Object.entries(raw).filter(([k]) => !k.startsWith("x-")).map(([k, v]) => [k, [...v]]));
1229
+ }
1230
+
1231
+ /** A `ci.policy` block (#4294), refused when the pull-request jobs could not run it. Null when absent. */
1232
+ function ciPolicyOf(raw: unknown, at: (pointer: string, key?: boolean) => ErrorLocation): GatePolicySettings | null {
1233
+ if (raw === undefined) return null;
1234
+ try {
1235
+ assertGatePolicySettings("ci.policy", raw);
1236
+ } catch (err) {
1237
+ throw new WorkspaceReadError("declaration-invalid", err instanceof Error ? err.message : String(err), at("/ci/policy"));
1238
+ }
1239
+ return structuredClone(raw);
1240
+ }
1241
+
1242
+ /**
1243
+ * `ci.identity.cloud` (#4296), copied without its `x-` keys. A rule that
1244
+ * plans as an identity some rule applies as is refused: the plan runs the
1245
+ * pull request's code.
1246
+ */
1247
+ function cloudIdentityOf(raw: CloudIdentityDeclaration, at: (pointer: string, key?: boolean) => ErrorLocation): CloudIdentityDeclaration {
1248
+ const out: CloudIdentityDeclaration = {};
1249
+ for (const cloud of CLOUDS) {
1250
+ const c = raw[cloud];
1251
+ if (c === undefined) continue;
1252
+ const kept = Object.fromEntries(Object.entries(c).filter(([k]) => !k.startsWith("x-")));
1253
+ const rules = c.rules.map((r) => Object.fromEntries(Object.entries(r).filter(([k]) => !k.startsWith("x-")).map(([k, v]) => [k, Array.isArray(v) ? [...v] : v])));
1254
+ (out as Record<string, unknown>)[cloud] = { ...kept, rules };
1255
+ }
1256
+ const [problem] = cloudIdentityProblems(out);
1257
+ if (problem) throw new WorkspaceReadError("declaration-invalid", problem.message, at(problem.pointer));
1258
+ return out;
1259
+ }
1260
+
1137
1261
  function ciOf(raw: unknown, at: (pointer: string, key?: boolean) => ErrorLocation): CiDeclaration | null {
1138
1262
  if (raw === undefined) return null;
1139
- const block = raw as { reportUrl?: string; workflow?: CiWorkflow; green?: { branch: string; window?: string; phases: Record<string, string[] | { runs: string[]; skipped?: CiSkipped; build?: CiBuild }>; require: string[]; preMerge?: string[]; signedSince?: string; source?: CiSource } };
1140
- const workflow = block.workflow !== undefined ? { workflow: block.workflow } : {};
1263
+ const block = raw as { reportUrl?: string; workflow?: CiWorkflow; hooks?: unknown; identity?: { cloud?: CloudIdentityDeclaration }; branches?: Record<string, string[]>; labels?: Record<string, string[]>; locks?: CiLocks; policy?: unknown; prEnvironment?: { template: string; ttl?: string }; green?: { branch: string; window?: string; phases: Record<string, string[] | { runs: string[]; skipped?: CiSkipped; build?: CiBuild }>; require: string[]; preMerge?: string[]; signedSince?: string; source?: CiSource } };
1264
+ const hooks = ciHooksOf(block.hooks, "/ci/hooks", at);
1265
+ const policy = ciPolicyOf(block.policy, at);
1266
+ const workflow = {
1267
+ ...(block.workflow !== undefined ? { workflow: block.workflow } : {}),
1268
+ ...(hooks ? { hooks } : {}),
1269
+ ...(block.branches !== undefined ? { branches: globMap(block.branches) } : {}),
1270
+ ...(block.labels !== undefined ? { labels: globMap(block.labels) } : {}),
1271
+ ...(block.locks !== undefined ? { locks: block.locks } : {}),
1272
+ ...(policy ? { policy } : {}),
1273
+ ...(block.identity?.cloud !== undefined ? { identity: { cloud: cloudIdentityOf(block.identity.cloud, at) } } : {}),
1274
+ ...(block.prEnvironment !== undefined ? { prEnvironment: { template: block.prEnvironment.template, ttl: block.prEnvironment.ttl ?? DEFAULT_PR_ENVIRONMENT_TTL } } : {}),
1275
+ };
1141
1276
  let reportUrl: string | null = null;
1142
1277
  if (block.reportUrl !== undefined) {
1143
1278
  // #4160: refused here as pr-plan and ci run refuse the flag, so a reader never gets a URL no job can write to.
@@ -14,7 +14,7 @@
14
14
  * else: no zod, no ajv, no package loading, so it bundles small.
15
15
  */
16
16
 
17
- import { GATE_SEAL_NAMESPACE, gateSealPayload, namespaceAllowed, sshKeyFingerprint, sshSignatureKey, verifySshSignatureWith, type SealedGateApproval } from "./trust/gate-seal";
17
+ import { GATE_SEAL_NAMESPACE, gateSealPayload, namespaceAllowed, sshKeyFingerprint, sshSignatureKey, verifySshSignatureWith, type SealedGateApproval } from "../lifecycle/gate-seal";
18
18
  import type { Signer } from "./trust/trust-files";
19
19
 
20
20
  /** The core classes, in the order a principal's roles are tried; `human` holds no role and is the rest. */
@@ -31,7 +31,7 @@ import { classesOf, coreClassRegistry, roleGrants, type ClassRegistry } from "./
31
31
  import type { ReasonCode } from "./reason-codes";
32
32
  import { normalisePrincipal } from "./records";
33
33
  import type { TrustPolicy } from "./trust/policy";
34
- import type { SealedGateApproval } from "./trust/gate-seal";
34
+ import type { SealedGateApproval } from "../lifecycle/gate-seal";
35
35
  import { scopeSource } from "./write-scope";
36
36
 
37
37
  /** The forges a forge identity can name. */
@@ -42,6 +42,7 @@ import { tmpdir } from "node:os";
42
42
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
43
43
  import { computePlanDigest } from "../lifecycle/plan-digest";
44
44
  import type { ResolvedGateApproval } from "../op/gate-approval";
45
+ import { quorumRules, type GateQuorumSpec } from "../op/gate-quorum";
45
46
  import { checkLineage, findingKey, type CheckFinding } from "./lineage-check";
46
47
  import { dirLabel, git, parseDirSpec, parseTemplateSource, portableUrl, readTemplateDir, readTemplateTree, recordedDirPath } from "./lineage-init";
47
48
  import {
@@ -589,11 +590,13 @@ async function rulesAtHead(repo: string, paths: string[]): Promise<ResolvedGateA
589
590
  for (const { config, filePath } of ops.values()) {
590
591
  const rel = relative(repo, filePath).split(sep).join("/");
591
592
  if (!opFiles.has(rel)) continue;
593
+ // #4290: a quorum may be a list of rules; the strictest count is asked for.
592
594
  for (const approval of gateApprovals(config)) {
593
- const need = approval.quorum?.count ?? 1;
594
- if (need > count) {
595
- count = need;
596
- roles = approval.quorum?.roles;
595
+ for (const rule of quorumRules(approval.quorum)) {
596
+ if (rule.count > count) {
597
+ count = rule.count;
598
+ roles = rule.roles;
599
+ }
597
600
  }
598
601
  }
599
602
  }
@@ -602,10 +605,10 @@ async function rulesAtHead(repo: string, paths: string[]): Promise<ResolvedGateA
602
605
  return { mode: "log-only", quorum: { count, ...(roles && roles.length > 0 ? { roles } : {}) } };
603
606
  }
604
607
 
605
- type StepLike = { kind: string; approval?: { quorum?: { count: number; roles?: string[] } }; steps?: StepLike[] };
608
+ type StepLike = { kind: string; approval?: { quorum?: GateQuorumSpec }; steps?: StepLike[] };
606
609
 
607
- function gateApprovals(config: { phases?: Array<{ steps: StepLike[] }>; onFailure?: Array<{ steps: StepLike[] }> }): Array<{ quorum?: { count: number; roles?: string[] } }> {
608
- const out: Array<{ quorum?: { count: number; roles?: string[] } }> = [];
610
+ function gateApprovals(config: { phases?: Array<{ steps: StepLike[] }>; onFailure?: Array<{ steps: StepLike[] }> }): Array<{ quorum?: GateQuorumSpec }> {
611
+ const out: Array<{ quorum?: GateQuorumSpec }> = [];
609
612
  const visit = (step: StepLike): void => {
610
613
  if (step.kind === "gate") out.push(step.approval ?? {});
611
614
  if (step.kind === "effect") (step.steps ?? []).forEach(visit);
@@ -1042,7 +1045,7 @@ export function describeStaged(staged: StagedUpgrade): string[] {
1042
1045
  }
1043
1046
  for (const s of staged.manualSteps) lines.push(` manual step: ${at(s.path)} (${s.reason})`);
1044
1047
  if (staged.governance) {
1045
- const q = staged.governance.approval.quorum!;
1048
+ const q = quorumRules(staged.governance.approval.quorum)[0]!;
1046
1049
  lines.push(
1047
1050
  ` governance: ${staged.governance.paths.join(", ")}; needs ${q.count} human approval(s)${q.roles ? ` with a role in ${q.roles.join(", ")}` : ""}, from the rules at ${staged.governance.rules.slice(0, 12)}`,
1048
1051
  );
@@ -178,3 +178,32 @@ describe("the generated-file record", () => {
178
178
  expect(readGeneratedRecord(dir).files.map((f) => f.path)).toEqual(["a.yml", "b.yml"]);
179
179
  });
180
180
  });
181
+
182
+ describe("ci.branches routes a member's pull-request pipeline (#4292)", () => {
183
+ const routed = () => {
184
+ rmSync(repo, { recursive: true, force: true });
185
+ repo = twoMemberWorkspace({ extra: { ci: { branches: { release: ["api"] } } } });
186
+ };
187
+ const plan = (dir: string, branch?: string) =>
188
+ planMemberComponentPipeline({ projectDir: dir, found: findWorkspaceRoot(dir)!, lexicon: "github", env: "staging", prLoop: branch ? { branch } : {} })!;
189
+
190
+ test("a routed member's pipeline targets its branch, with or without --branch naming it", () => {
191
+ routed();
192
+ const dir = join(repo, "services/api");
193
+ expect(plan(dir).branch).toBe("release");
194
+ expect(plan(dir, "release").branch).toBe("release");
195
+ expect(plan(dir).command).toBe("chant build --components --generate github --env staging --pr-loop");
196
+ });
197
+
198
+ test("--branch may not move a routed member, nor put another member on a routed branch", () => {
199
+ routed();
200
+ expect(() => plan(join(repo, "services/api"), "main")).toThrow("--branch main: ci.branches routes member api to release, so its pull-request pipeline targets release");
201
+ expect(() => plan(join(repo, "apps/web"), "release")).toThrow("--branch release: ci.branches applies only the members its globs match from release, and member web is not one");
202
+ });
203
+
204
+ test("an unrouted member keeps --branch, or the generator's default", () => {
205
+ routed();
206
+ expect(plan(join(repo, "apps/web")).branch).toBeUndefined();
207
+ expect(plan(join(repo, "apps/web"), "trunk").branch).toBe("trunk");
208
+ });
209
+ });
@@ -26,6 +26,7 @@ import { GENERATED_MARKER } from "../discovery/files";
26
26
  import type { PipelineMember } from "../lexicon";
27
27
  import type { WorkspaceRootSearch } from "../project-root";
28
28
  import { ownerOf, readDeclaration, resolveGroups, rootExclusions, WorkspaceReadError, type Declaration, type Member } from "./declaration";
29
+ import { routedBranch } from "./ci-branches";
29
30
  import { gitTop, workingTree } from "./tree";
30
31
 
31
32
  /**
@@ -196,6 +197,12 @@ export interface MemberComponentPlan {
196
197
  /** The command that regenerates it, run in the member's directory. */
197
198
  command: string;
198
199
  env: string;
200
+ /**
201
+ * With `--pr-loop`, the branch the pipeline targets: `--branch`, else the
202
+ * branch `ci.branches` routes the member to (#4292). Undefined for the
203
+ * generator's default.
204
+ */
205
+ branch?: string;
199
206
  }
200
207
 
201
208
  export interface ComponentPlanInput {
@@ -234,6 +241,7 @@ export function planMemberComponentPipeline(input: ComponentPlanInput): MemberCo
234
241
  const ctx = resolveMemberContext(input.projectDir, input.found);
235
242
  if (!ctx) return undefined;
236
243
  const env = input.env ?? DEFAULT_ENV;
244
+ const branch = input.prLoop ? prLoopBranch(ctx, input.prLoop.branch) : undefined;
237
245
  let target: string;
238
246
  if (input.output) {
239
247
  target = resolve(input.output);
@@ -273,9 +281,31 @@ export function planMemberComponentPipeline(input: ComponentPlanInput): MemberCo
273
281
  target,
274
282
  command: parts.map(shellArg).join(" "),
275
283
  env,
284
+ ...(branch !== undefined ? { branch } : {}),
276
285
  };
277
286
  }
278
287
 
288
+ /**
289
+ * The branch a member's pull-request pipeline targets (#4292): the one
290
+ * `ci.branches` routes it to, which `--branch` may repeat but not change.
291
+ * Without a route, `--branch` as given, which may not name a routed branch
292
+ * whose globs leave the member out.
293
+ */
294
+ function prLoopBranch(ctx: MemberContext, flag: string | undefined): string | undefined {
295
+ const branches = ctx.declaration.ci?.branches;
296
+ const own = routedBranch(branches, ctx.member.name);
297
+ if (own !== null) {
298
+ if (flag !== undefined && flag !== own) {
299
+ throw new Error(`--branch ${flag}: ci.branches routes member ${ctx.member.name} to ${own}, so its pull-request pipeline targets ${own}`);
300
+ }
301
+ return own;
302
+ }
303
+ if (flag !== undefined && Object.prototype.hasOwnProperty.call(branches ?? {}, flag)) {
304
+ throw new Error(`--branch ${flag}: ci.branches applies only the members its globs match from ${flag}, and member ${ctx.member.name} is not one`);
305
+ }
306
+ return flag;
307
+ }
308
+
279
309
  /** Write a member's pipeline with its header and record it as the member's generated file. */
280
310
  export function writeMemberComponentPipeline(plan: MemberComponentPlan, yaml: string): void {
281
311
  mkdirSync(dirname(plan.target), { recursive: true });
@@ -8,7 +8,7 @@
8
8
  "properties": {
9
9
  "$schema": { "const": "https://intentius.io/chant/schemas/workspace/pr-report/v1/pr-report.schema.json" },
10
10
  "contract": { "const": 1 },
11
- "stage": { "enum": ["plan", "apply", "record"], "description": "plan: on the pull request. record (#4168, chant 0.124.33 and newer): on merge, the default; it plans the merged commit and writes the merge record, applying nothing. apply: on merge with --apply-on-merge." },
11
+ "stage": { "enum": ["plan", "apply", "record"], "description": "plan: on the pull request. record (#4168, chant 0.124.33 and newer): on merge, the default; it plans the merged commit and writes the merge record, applying nothing. apply: on merge with --apply-on-merge, or (#4301, chant 0.124.42 and newer) at an open pull request's head with --before-merge, when beforeMerge is set." },
12
12
  "pr": {
13
13
  "type": ["integer", "null"],
14
14
  "minimum": 1,
@@ -37,8 +37,8 @@
37
37
  "description": "planned: every member planned (plan stage). recorded (#4168, chant 0.124.33 and newer): every member planned on the merged commit and the merge record was written (record stage); approval says whether the plan is the approved one, and nothing applied. plan-failed: a member failed to plan, so nothing can apply. nothing: no component deploys what the change touches. applied: every member applied. failed: a member failed to apply and its dependents did not run. wave-applied (#4003, chant 0.124.32 and newer): with --wave-gate and --wave, the wave applied and a later wave is still to run. refused: nothing applied, see refusal. waiting (#3999): the approved plans applied except members held back after an upstream applied, which wait on an approval of the plan the last of rounds made."
38
38
  },
39
39
  "refusal": {
40
- "enum": ["not-approved", "plan-changed", "not-a-reviewer", "author-approved", "no-pull-request", "wave-not-approved", "wave-plan-changed", "earlier-wave"],
41
- "description": "Why an apply refused. plan-changed: an approval stands for another digest, so the plan changed after review. author-approved (#4000): under --require-review, an approval names the pull request's author or an author or committer of one of its commits. With --wave-gate (#4003, chant 0.124.32 and newer): wave-not-approved, a wave's new plan is not covered by the approved one and no approval of its gate stands for it; wave-plan-changed, an approval of its gate stands for another digest; earlier-wave, an earlier wave has work left or no record says it applied. The waves that applied before a wave refused stay applied; waves says which."
40
+ "enum": ["not-approved", "plan-changed", "not-a-reviewer", "author-approved", "no-pull-request", "wave-not-approved", "wave-plan-changed", "earlier-wave", "locked", "policy-denied", "policy-error", "requires-unmet", "before-merge-off"],
41
+ "description": "Why an apply refused. plan-changed: an approval stands for another digest, so the plan changed after review. author-approved (#4000): under --require-review, an approval names the pull request's author or an author or committer of one of its commits. With --wave-gate (#4003, chant 0.124.32 and newer): wave-not-approved, a wave's new plan is not covered by the approved one and no approval of its gate stands for it; wave-plan-changed, an approval of its gate stands for another digest; earlier-wave, an earlier wave has work left or no record says it applied. The waves that applied before a wave refused stay applied; waves says which. locked (#4293, chant 0.124.41 and newer): under ci.locks, another open pull request holds a lock on a target the apply reaches, and nothing applied. With ci.policy (#4294, chant 0.124.41 and newer): policy-denied, the policy at base denied the plan and no override counts; policy-error, the policy could not be run or gave no readable answer. With pr-apply --before-merge (#4301, chant 0.124.42 and newer): requires-unmet, the pull request is not approved, mergeable, undiverged or green, and beforeMerge says which; before-merge-off, the declaration at base does not set ci.workflow to apply-before-merge for the member. Nothing applied."
42
42
  },
43
43
  "message": { "type": "string", "description": "The refusal or failure as printed." },
44
44
  "resumed": {
@@ -77,6 +77,21 @@
77
77
  }
78
78
  }
79
79
  },
80
+ "overdue": {
81
+ "type": "array",
82
+ "description": "Added within contract version 1 by #4289, written by chant 0.124.41 and newer. Plan stage: the scheduled watch and reconcile Ops whose cron has fired at least twice since their job last ran, or since their pipeline file was added when no run is on record. Absent when none is overdue or the forge was not asked.",
83
+ "items": {
84
+ "type": "object",
85
+ "required": ["op", "cron", "since", "message"],
86
+ "properties": {
87
+ "op": { "type": "string" },
88
+ "cron": { "type": "string" },
89
+ "since": { "type": "string", "description": "The job's last run, or the pipeline file's first commit when neverRan, ISO 8601." },
90
+ "neverRan": { "type": "boolean" },
91
+ "message": { "type": "string", "description": "The warning as the plan note prints it." }
92
+ }
93
+ }
94
+ },
80
95
  "plannedOutputs": {
81
96
  "type": "object",
82
97
  "description": "Added within contract version 1 by #3999, written by chant 0.124.32 and newer. Per dependency in the change that a member was planned against, the names of the outputs its plan knows, sorted. An output a member reads that is not listed is known only after apply, and the member lists it under unknownInputs. Absent when no member read one.",
@@ -175,6 +190,82 @@
175
190
  "changeSet": {
176
191
  "type": "object",
177
192
  "description": "The change-set document over every member (change-set.schema.json). Its digest equals digest."
193
+ },
194
+ "hooks": {
195
+ "type": "array",
196
+ "description": "Added within contract version 1 by #4288, written by chant 0.124.41 and newer. The stage hooks the job ran, in order, from ci.hooks in the workspace declaration at base. Absent when it ran none.",
197
+ "items": {
198
+ "type": "object",
199
+ "required": ["moment", "name", "exit", "outcome"],
200
+ "additionalProperties": false,
201
+ "properties": {
202
+ "moment": { "enum": ["before-observe", "before-plan", "after-plan", "before-apply", "after-apply"] },
203
+ "name": { "type": "string" },
204
+ "target": { "type": "string", "description": "The component, for a perRun hook." },
205
+ "exit": { "type": "integer" },
206
+ "outcome": { "enum": ["passed", "failed", "held"], "description": "held: an onFailure approve hook failed, which the pull request's gate answers." }
207
+ }
208
+ }
209
+ },
210
+ "beforeMerge": {
211
+ "type": "object",
212
+ "required": ["requires"],
213
+ "description": "Added within contract version 1 by #4301, written by chant 0.124.42 and newer. pr-apply --before-merge: the requires checks the forge answered before anything planned (ws-106). Absent on every other stage.",
214
+ "properties": {
215
+ "requires": {
216
+ "type": "array",
217
+ "items": {
218
+ "type": "object",
219
+ "required": ["name", "passed", "reason"],
220
+ "properties": {
221
+ "name": { "enum": ["approved", "mergeable", "undiverged", "checks"] },
222
+ "passed": { "type": "boolean" },
223
+ "pending": { "type": "boolean", "description": "The forge had not settled it when asked: a check still ran, or it had not worked out whether the pull request merges." },
224
+ "reason": { "type": "string" }
225
+ }
226
+ }
227
+ }
228
+ }
229
+ },
230
+ "policy": {
231
+ "type": "object",
232
+ "description": "Added within contract version 1 by #4294, written by chant 0.124.41 and newer. What ci.policy in the workspace declaration at base said about the change set. Absent when the base sets no policy, or the stage reached no plan to judge.",
233
+ "required": ["result", "engine", "base", "denials", "holds", "warnings"],
234
+ "additionalProperties": false,
235
+ "properties": {
236
+ "result": { "enum": ["passed", "denied", "held", "error"], "description": "denied wins over held. error: the policy did not run or gave no readable answer, and nothing applies." },
237
+ "engine": { "enum": ["conftest", "opa"], "description": "The result shape read." },
238
+ "base": { "type": "string", "description": "The commit the settings and the policy were read at." },
239
+ "denials": { "type": "array", "items": { "$ref": "#/$defs/policyFinding" } },
240
+ "rules": { "type": "array", "items": { "type": "string" }, "description": "The ids of the rules that denied, sorted: what an override names." },
241
+ "holds": { "type": "array", "items": { "$ref": "#/$defs/policyFinding" }, "description": "Rules that hold the plan for a person's approval of its digest." },
242
+ "warnings": { "type": "array", "items": { "type": "string" } },
243
+ "error": { "type": "string" },
244
+ "override": {
245
+ "type": "object",
246
+ "required": ["by", "at", "reason", "digest"],
247
+ "additionalProperties": false,
248
+ "description": "The override on _gates/policy-override.jsonl that lets the denied plan through.",
249
+ "properties": {
250
+ "by": { "type": "string" },
251
+ "at": { "type": "string" },
252
+ "reason": { "type": "string" },
253
+ "digest": { "type": "string", "description": "The override digest: the op, the plan digest and the rules." }
254
+ }
255
+ },
256
+ "overrideCommand": { "type": "string", "description": "While denied: the command that overrides the denial." }
257
+ }
258
+ }
259
+ },
260
+ "$defs": {
261
+ "policyFinding": {
262
+ "type": "object",
263
+ "required": ["rule", "message"],
264
+ "additionalProperties": false,
265
+ "properties": {
266
+ "rule": { "type": "string", "description": "<package>.<rule>, such as main.deny_public_bucket." },
267
+ "message": { "type": "string" }
268
+ }
178
269
  }
179
270
  }
180
271
  }
@@ -255,7 +255,7 @@ export const REASONS = {
255
255
  "ci-workflow-no-environments": "Under ci.workflow, a member's chant.config.ts declares no environments, so a release has nowhere to be promoted to.",
256
256
  "ci-workflow-ungated-env": "Under ci.workflow, a component has no gate, so a promote to the last environment waits on no approval.",
257
257
  "ci-workflow-pipeline-missing": "Under ci.workflow, no pipeline file runs the plan job, the merge job or chant ci tick.",
258
- "ci-workflow-pipeline-differs": "Under ci.workflow, the merge job does not match the workflow: it applies under trunk-curated, or records under trunk-curated+apply-on-merge.",
258
+ "ci-workflow-pipeline-differs": "Under ci.workflow, the pipeline does not match the workflow: it applies on merge under trunk-curated, records under trunk-curated+apply-on-merge, or applies before merge, or fails to, against apply-before-merge.",
259
259
  "ci-workflow-publish-missing": "Under ci.workflow, no pipeline file runs chant release publish.",
260
260
  // A box that holds a credential (check, #2726). Each is also a WSP finding.
261
261
  "box-credential-declared": "A file in a box member's directory carries a literal secret: a credential's shape, or a literal where a credential goes. A variable or secret-manager reference is not one.",
@@ -109,6 +109,10 @@ export const SECURITY_FEATURES: readonly SecurityFeature[] = [
109
109
  { feature: "Apply jobs require a runner-start statement", byDefault: "off", setting: "`--require-runner-start`", group: "isolated-runners", codes: ["security-runner-start-no-key", "security-runner-start-no-role", "security-trust-unreadable"] },
110
110
  { feature: "Secrets file", byDefault: "off", setting: "`--secrets`", group: null, codes: [] },
111
111
  { feature: "Env allowlist and log redaction", byDefault: "on: `ci run`'s contract, a listed exception", setting: "`--pass-env`", group: null, codes: [] },
112
+ // #4295: the plan job's steps never hold a forge token; the plan-note job posts. Part of the generated pipeline, so nothing turns it on.
113
+ { feature: "Plan steps hold no forge token", byDefault: "on: the plan job's contract, a listed exception", setting: "regenerate the pipeline", group: null, codes: [] },
114
+ // #4296: the plan job trades its OIDC token for the plan identity before the checkout, and the apply jobs take the apply identity.
115
+ { feature: "Separate plan and apply cloud identities", byDefault: "off: no job requests an OIDC token", setting: "`ci.identity.cloud`", group: null, codes: [] },
112
116
  ];
113
117
 
114
118
  /** The reference page's table of {@link SECURITY_FEATURES}, as markdown. */