@intentius/chant 0.124.40 → 0.124.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (346) hide show
  1. package/dist/__fixtures__/oidc-emulator.d.ts +49 -0
  2. package/dist/__fixtures__/oidc-emulator.d.ts.map +1 -0
  3. package/dist/__fixtures__/oidc-emulator.js +89 -0
  4. package/dist/ci-handover.d.ts +117 -0
  5. package/dist/ci-handover.d.ts.map +1 -0
  6. package/dist/ci-handover.js +217 -0
  7. package/dist/ci-identity.d.ts +190 -0
  8. package/dist/ci-identity.d.ts.map +1 -0
  9. package/dist/ci-identity.js +219 -0
  10. package/dist/cli/handlers/approve.d.ts +2 -1
  11. package/dist/cli/handlers/approve.d.ts.map +1 -1
  12. package/dist/cli/handlers/approve.js +20 -6
  13. package/dist/cli/handlers/build.d.ts.map +1 -1
  14. package/dist/cli/handlers/build.js +18 -1
  15. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  16. package/dist/cli/handlers/fan-out.js +42 -5
  17. package/dist/cli/handlers/gate-resume.d.ts +3 -0
  18. package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
  19. package/dist/cli/handlers/gate-resume.js +15 -2
  20. package/dist/cli/handlers/lock.d.ts +8 -0
  21. package/dist/cli/handlers/lock.d.ts.map +1 -0
  22. package/dist/cli/handlers/lock.js +197 -0
  23. package/dist/cli/handlers/pr-env.d.ts +34 -0
  24. package/dist/cli/handlers/pr-env.d.ts.map +1 -0
  25. package/dist/cli/handlers/pr-env.js +436 -0
  26. package/dist/cli/handlers/pr.d.ts +76 -0
  27. package/dist/cli/handlers/pr.d.ts.map +1 -1
  28. package/dist/cli/handlers/pr.js +615 -55
  29. package/dist/cli/handlers/promote.d.ts.map +1 -1
  30. package/dist/cli/handlers/promote.js +9 -0
  31. package/dist/cli/handlers/query.d.ts +6 -0
  32. package/dist/cli/handlers/query.d.ts.map +1 -0
  33. package/dist/cli/handlers/query.js +80 -0
  34. package/dist/cli/handlers/run.d.ts.map +1 -1
  35. package/dist/cli/handlers/run.js +11 -0
  36. package/dist/cli/main.d.ts.map +1 -1
  37. package/dist/cli/main.js +49 -4
  38. package/dist/cli/parse-args.d.ts.map +1 -1
  39. package/dist/cli/parse-args.js +46 -2
  40. package/dist/cli/registry.d.ts +31 -2
  41. package/dist/cli/registry.d.ts.map +1 -1
  42. package/dist/components/capability.d.ts +47 -0
  43. package/dist/components/capability.d.ts.map +1 -1
  44. package/dist/components/capability.js +44 -0
  45. package/dist/components/cli-support.d.ts +6 -0
  46. package/dist/components/cli-support.d.ts.map +1 -1
  47. package/dist/components/cli-support.js +8 -1
  48. package/dist/components/driver.d.ts +10 -0
  49. package/dist/components/driver.d.ts.map +1 -1
  50. package/dist/components/driver.js +52 -2
  51. package/dist/components/fan-out-run.d.ts +37 -0
  52. package/dist/components/fan-out-run.d.ts.map +1 -1
  53. package/dist/components/fan-out-run.js +44 -4
  54. package/dist/components/gate-plan.d.ts +2 -0
  55. package/dist/components/gate-plan.d.ts.map +1 -1
  56. package/dist/components/gate-plan.js +1 -0
  57. package/dist/components/pr-environment.d.ts +298 -0
  58. package/dist/components/pr-environment.d.ts.map +1 -0
  59. package/dist/components/pr-environment.js +406 -0
  60. package/dist/components/pr-hooks.d.ts +66 -0
  61. package/dist/components/pr-hooks.d.ts.map +1 -0
  62. package/dist/components/pr-hooks.js +119 -0
  63. package/dist/components/pr-pipeline.d.ts +92 -2
  64. package/dist/components/pr-pipeline.d.ts.map +1 -1
  65. package/dist/components/pr-pipeline.js +150 -21
  66. package/dist/components/pr-policy.d.ts +109 -0
  67. package/dist/components/pr-policy.d.ts.map +1 -0
  68. package/dist/components/pr-policy.js +150 -0
  69. package/dist/components/pr-run.d.ts +6 -0
  70. package/dist/components/pr-run.d.ts.map +1 -1
  71. package/dist/components/pr-run.js +8 -3
  72. package/dist/components/wave-plan.d.ts +14 -1
  73. package/dist/components/wave-plan.d.ts.map +1 -1
  74. package/dist/components/wave-plan.js +34 -2
  75. package/dist/gate-decision.d.ts +15 -2
  76. package/dist/gate-decision.d.ts.map +1 -1
  77. package/dist/gate-decision.js +17 -2
  78. package/dist/gate-policy.d.ts +140 -0
  79. package/dist/gate-policy.d.ts.map +1 -0
  80. package/dist/gate-policy.js +242 -0
  81. package/dist/lifecycle/gate-checkpoint.d.ts +1 -1
  82. package/dist/lifecycle/gate-checkpoint.js +1 -1
  83. package/dist/{workspace/trust → lifecycle}/gate-seal.d.ts +1 -1
  84. package/dist/lifecycle/gate-seal.d.ts.map +1 -0
  85. package/dist/lifecycle/query.d.ts +68 -0
  86. package/dist/lifecycle/query.d.ts.map +1 -0
  87. package/dist/lifecycle/query.js +246 -0
  88. package/dist/lifecycle/run-ledger.d.ts +13 -0
  89. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  90. package/dist/lifecycle/run-ledger.js +16 -0
  91. package/dist/lifecycle/target-lock.d.ts +97 -0
  92. package/dist/lifecycle/target-lock.d.ts.map +1 -0
  93. package/dist/lifecycle/target-lock.js +286 -0
  94. package/dist/op/activities/activity-contracts.d.ts +30 -0
  95. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  96. package/dist/op/activities/activity-contracts.js +26 -0
  97. package/dist/op/activities/index.d.ts +2 -0
  98. package/dist/op/activities/index.d.ts.map +1 -1
  99. package/dist/op/activities/index.js +4 -0
  100. package/dist/op/activities/pr-refs.d.ts +10 -0
  101. package/dist/op/activities/pr-refs.d.ts.map +1 -0
  102. package/dist/op/activities/pr-refs.js +26 -0
  103. package/dist/op/activities/predict-behaviour.d.ts +2 -4
  104. package/dist/op/activities/predict-behaviour.d.ts.map +1 -1
  105. package/dist/op/activities/predict-behaviour.js +2 -21
  106. package/dist/op/activities/review-agent.d.ts +137 -0
  107. package/dist/op/activities/review-agent.d.ts.map +1 -0
  108. package/dist/op/activities/review-agent.js +266 -0
  109. package/dist/op/builders.d.ts +9 -0
  110. package/dist/op/builders.d.ts.map +1 -1
  111. package/dist/op/builders.js +11 -0
  112. package/dist/op/cron.d.ts +8 -0
  113. package/dist/op/cron.d.ts.map +1 -1
  114. package/dist/op/cron.js +51 -0
  115. package/dist/op/gate-approval.d.ts +20 -13
  116. package/dist/op/gate-approval.d.ts.map +1 -1
  117. package/dist/op/gate-approval.js +13 -18
  118. package/dist/op/gate-quorum.d.ts +131 -0
  119. package/dist/op/gate-quorum.d.ts.map +1 -0
  120. package/dist/op/gate-quorum.js +183 -0
  121. package/dist/op/gate-resume.d.ts +4 -2
  122. package/dist/op/gate-resume.d.ts.map +1 -1
  123. package/dist/op/gate-resume.js +5 -4
  124. package/dist/op/gate-review.js +1 -1
  125. package/dist/op/gate.d.ts +32 -4
  126. package/dist/op/gate.d.ts.map +1 -1
  127. package/dist/op/gate.js +40 -24
  128. package/dist/op/index.d.ts +6 -4
  129. package/dist/op/index.d.ts.map +1 -1
  130. package/dist/op/index.js +4 -3
  131. package/dist/op/local-executor.d.ts.map +1 -1
  132. package/dist/op/local-executor.js +9 -3
  133. package/dist/op/op-waves-run.d.ts +28 -43
  134. package/dist/op/op-waves-run.d.ts.map +1 -1
  135. package/dist/op/op-waves-run.js +170 -120
  136. package/dist/op/op-waves.d.ts +55 -3
  137. package/dist/op/op-waves.d.ts.map +1 -1
  138. package/dist/op/op-waves.js +63 -8
  139. package/dist/op/schedule-overdue.d.ts +57 -0
  140. package/dist/op/schedule-overdue.d.ts.map +1 -0
  141. package/dist/op/schedule-overdue.js +138 -0
  142. package/dist/op/stage-hooks.d.ts +132 -0
  143. package/dist/op/stage-hooks.d.ts.map +1 -0
  144. package/dist/op/stage-hooks.js +142 -0
  145. package/dist/override-gate.d.ts +2 -2
  146. package/dist/override-gate.d.ts.map +1 -1
  147. package/dist/override-gate.js +1 -1
  148. package/dist/pr-forge.d.ts +64 -0
  149. package/dist/pr-forge.d.ts.map +1 -1
  150. package/dist/pr-forge.js +331 -44
  151. package/dist/pr-loop.d.ts +107 -1
  152. package/dist/pr-loop.d.ts.map +1 -1
  153. package/dist/pr-loop.js +84 -3
  154. package/dist/pr-post.d.ts +72 -0
  155. package/dist/pr-post.d.ts.map +1 -0
  156. package/dist/pr-post.js +134 -0
  157. package/dist/pr-requires.d.ts +38 -0
  158. package/dist/pr-requires.d.ts.map +1 -0
  159. package/dist/pr-requires.js +48 -0
  160. package/dist/wave-blocked.d.ts +79 -0
  161. package/dist/wave-blocked.d.ts.map +1 -0
  162. package/dist/wave-blocked.js +245 -0
  163. package/dist/wave-engine.d.ts +6 -0
  164. package/dist/wave-engine.d.ts.map +1 -1
  165. package/dist/wave-engine.js +2 -0
  166. package/dist/wave-runtime.mjs +243 -243
  167. package/dist/workspace/__fixtures__/two-member-workspace.d.ts +1 -0
  168. package/dist/workspace/__fixtures__/two-member-workspace.d.ts.map +1 -1
  169. package/dist/workspace/__fixtures__/two-member-workspace.js +1 -1
  170. package/dist/workspace/checks/ci-workflow.d.ts.map +1 -1
  171. package/dist/workspace/checks/ci-workflow.js +19 -4
  172. package/dist/workspace/ci-branches.d.ts +51 -0
  173. package/dist/workspace/ci-branches.d.ts.map +1 -0
  174. package/dist/workspace/ci-branches.js +69 -0
  175. package/dist/workspace/ci-cloud-identity.d.ts +11 -0
  176. package/dist/workspace/ci-cloud-identity.d.ts.map +1 -0
  177. package/dist/workspace/ci-cloud-identity.js +24 -0
  178. package/dist/workspace/ci-init.d.ts.map +1 -1
  179. package/dist/workspace/ci-init.js +5 -4
  180. package/dist/workspace/ci-locks.d.ts +12 -0
  181. package/dist/workspace/ci-locks.d.ts.map +1 -0
  182. package/dist/workspace/ci-locks.js +6 -0
  183. package/dist/workspace/ci-run.d.ts +17 -1
  184. package/dist/workspace/ci-run.d.ts.map +1 -1
  185. package/dist/workspace/ci-run.js +66 -4
  186. package/dist/workspace/ci-workflow-at.d.ts +14 -0
  187. package/dist/workspace/ci-workflow-at.d.ts.map +1 -0
  188. package/dist/workspace/ci-workflow-at.js +17 -0
  189. package/dist/workspace/ci-workflow.d.ts +11 -3
  190. package/dist/workspace/ci-workflow.d.ts.map +1 -1
  191. package/dist/workspace/ci-workflow.js +10 -2
  192. package/dist/workspace/declaration.d.ts +69 -3
  193. package/dist/workspace/declaration.d.ts.map +1 -1
  194. package/dist/workspace/declaration.js +72 -1
  195. package/dist/workspace/declaration.schema.json +149 -2
  196. package/dist/workspace/identity-gate-rule.d.ts +1 -1
  197. package/dist/workspace/identity-gate-rule.d.ts.map +1 -1
  198. package/dist/workspace/identity-gate-rule.js +1 -1
  199. package/dist/workspace/identity.d.ts +1 -1
  200. package/dist/workspace/identity.d.ts.map +1 -1
  201. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  202. package/dist/workspace/lineage-upgrade.js +8 -5
  203. package/dist/workspace/member-pipeline.d.ts +6 -0
  204. package/dist/workspace/member-pipeline.d.ts.map +1 -1
  205. package/dist/workspace/member-pipeline.js +23 -0
  206. package/dist/workspace/pr-report.schema.json +94 -3
  207. package/dist/workspace/reason-codes.d.ts +1 -1
  208. package/dist/workspace/reason-codes.d.ts.map +1 -1
  209. package/dist/workspace/reason-codes.js +1 -1
  210. package/dist/workspace/security.d.ts.map +1 -1
  211. package/dist/workspace/security.js +4 -0
  212. package/dist/workspace/status-gates.d.ts +3 -2
  213. package/dist/workspace/status-gates.d.ts.map +1 -1
  214. package/dist/workspace/status-gates.js +8 -7
  215. package/dist/workspace/status.d.ts.map +1 -1
  216. package/dist/workspace/status.js +4 -1
  217. package/dist/workspace/trust/seal.d.ts +2 -2
  218. package/dist/workspace/trust/seal.d.ts.map +1 -1
  219. package/dist/workspace/trust/seal.js +2 -2
  220. package/package.json +1 -1
  221. package/src/__fixtures__/oidc-emulator.ts +118 -0
  222. package/src/ci-handover.test.ts +209 -0
  223. package/src/ci-handover.ts +237 -0
  224. package/src/ci-identity.test.ts +201 -0
  225. package/src/ci-identity.ts +310 -0
  226. package/src/cli/handlers/approve.ts +18 -5
  227. package/src/cli/handlers/build.ts +18 -1
  228. package/src/cli/handlers/fan-out.ts +43 -6
  229. package/src/cli/handlers/gate-resume.ts +16 -2
  230. package/src/cli/handlers/lock.test.ts +98 -0
  231. package/src/cli/handlers/lock.ts +202 -0
  232. package/src/cli/handlers/operator.test.ts +3 -1
  233. package/src/cli/handlers/pr-before-merge.test.ts +276 -0
  234. package/src/cli/handlers/pr-env.ts +462 -0
  235. package/src/cli/handlers/pr-waves-blocked.test.ts +247 -0
  236. package/src/cli/handlers/pr.test.ts +184 -0
  237. package/src/cli/handlers/pr.ts +605 -61
  238. package/src/cli/handlers/promote.ts +9 -0
  239. package/src/cli/handlers/query.ts +78 -0
  240. package/src/cli/handlers/run.ts +10 -0
  241. package/src/cli/main.ts +49 -4
  242. package/src/cli/parse-args.ts +34 -2
  243. package/src/cli/registry.ts +31 -2
  244. package/src/components/capability-targets.test.ts +162 -0
  245. package/src/components/capability.ts +71 -0
  246. package/src/components/cli-support.ts +14 -1
  247. package/src/components/driver.ts +54 -3
  248. package/src/components/fan-out-run.ts +58 -3
  249. package/src/components/fan-out-waves.test.ts +45 -1
  250. package/src/components/gate-plan.ts +3 -0
  251. package/src/components/pr-environment.test.ts +249 -0
  252. package/src/components/pr-environment.ts +538 -0
  253. package/src/components/pr-hooks.test.ts +118 -0
  254. package/src/components/pr-hooks.ts +157 -0
  255. package/src/components/pr-pipeline.ts +194 -21
  256. package/src/components/pr-policy.test.ts +145 -0
  257. package/src/components/pr-policy.ts +211 -0
  258. package/src/components/pr-run.ts +14 -2
  259. package/src/components/wave-plan.ts +38 -3
  260. package/src/gate-decision-bundle.test.ts +4 -3
  261. package/src/gate-decision.ts +39 -2
  262. package/src/gate-policy-bundle.test.ts +28 -0
  263. package/src/gate-policy.test.ts +162 -0
  264. package/src/gate-policy.ts +298 -0
  265. package/src/lifecycle/gate-checkpoint.ts +1 -1
  266. package/src/{workspace/trust → lifecycle}/gate-seal.ts +1 -1
  267. package/src/lifecycle/query.test.ts +91 -0
  268. package/src/lifecycle/query.ts +315 -0
  269. package/src/lifecycle/run-ledger.ts +18 -0
  270. package/src/lifecycle/target-lock.test.ts +195 -0
  271. package/src/lifecycle/target-lock.ts +347 -0
  272. package/src/op/activities/activity-contracts.ts +31 -0
  273. package/src/op/activities/index.ts +6 -0
  274. package/src/op/activities/pr-refs.ts +28 -0
  275. package/src/op/activities/predict-behaviour.ts +3 -23
  276. package/src/op/activities/review-agent.test.ts +240 -0
  277. package/src/op/activities/review-agent.ts +355 -0
  278. package/src/op/builders.ts +13 -0
  279. package/src/op/cron.ts +44 -0
  280. package/src/op/gate-alias.test.ts +2 -2
  281. package/src/op/gate-approval.test.ts +30 -9
  282. package/src/op/gate-approval.ts +23 -26
  283. package/src/op/gate-quorum.test.ts +139 -0
  284. package/src/op/gate-quorum.ts +248 -0
  285. package/src/op/gate-resume.ts +6 -3
  286. package/src/op/gate-review.ts +1 -1
  287. package/src/op/gate.ts +64 -23
  288. package/src/op/index.ts +13 -1
  289. package/src/op/local-executor.ts +9 -2
  290. package/src/op/op-waves-hook-targets.test.ts +126 -0
  291. package/src/op/op-waves-policy-hold.test.ts +66 -0
  292. package/src/op/op-waves-quorum.test.ts +101 -0
  293. package/src/op/op-waves-run.ts +197 -183
  294. package/src/op/op-waves.ts +83 -10
  295. package/src/op/schedule-overdue.test.ts +220 -0
  296. package/src/op/schedule-overdue.ts +161 -0
  297. package/src/op/stage-hooks.ts +251 -0
  298. package/src/override-gate-bundle.test.ts +1 -1
  299. package/src/override-gate.ts +2 -2
  300. package/src/pr-forge-bundle.test.ts +2 -2
  301. package/src/pr-forge.test.ts +33 -0
  302. package/src/pr-forge.ts +347 -35
  303. package/src/pr-loop-bundle.test.ts +2 -2
  304. package/src/pr-loop.test.ts +58 -0
  305. package/src/pr-loop.ts +170 -4
  306. package/src/pr-post.test.ts +141 -0
  307. package/src/pr-post.ts +159 -0
  308. package/src/pr-requires.test.ts +184 -0
  309. package/src/pr-requires.ts +79 -0
  310. package/src/review-agent-bundle.test.ts +35 -0
  311. package/src/stage-hooks-bundle.test.ts +38 -0
  312. package/src/wave-blocked-bundle.test.ts +25 -0
  313. package/src/wave-blocked.test.ts +146 -0
  314. package/src/wave-blocked.ts +271 -0
  315. package/src/wave-engine.ts +8 -0
  316. package/src/workspace/__fixtures__/two-member-workspace.ts +2 -2
  317. package/src/workspace/checks/ci-workflow.test.ts +9 -0
  318. package/src/workspace/checks/ci-workflow.ts +18 -4
  319. package/src/workspace/ci-branches.test.ts +62 -0
  320. package/src/workspace/ci-branches.ts +79 -0
  321. package/src/workspace/ci-cloud-identity.test.ts +53 -0
  322. package/src/workspace/ci-cloud-identity.ts +23 -0
  323. package/src/workspace/ci-init.ts +5 -4
  324. package/src/workspace/ci-locks.ts +16 -0
  325. package/src/workspace/ci-run-index.test.ts +2 -1
  326. package/src/workspace/ci-run.test.ts +107 -8
  327. package/src/workspace/ci-run.ts +74 -5
  328. package/src/workspace/ci-workflow-at.ts +23 -0
  329. package/src/workspace/ci-workflow.ts +13 -2
  330. package/src/workspace/declaration.schema.json +149 -2
  331. package/src/workspace/declaration.test.ts +76 -0
  332. package/src/workspace/declaration.ts +140 -5
  333. package/src/workspace/identity-gate-rule.ts +1 -1
  334. package/src/workspace/identity.ts +1 -1
  335. package/src/workspace/lineage-upgrade.ts +11 -8
  336. package/src/workspace/member-pipeline.test.ts +29 -0
  337. package/src/workspace/member-pipeline.ts +30 -0
  338. package/src/workspace/pr-report.schema.json +94 -3
  339. package/src/workspace/reason-codes.ts +1 -1
  340. package/src/workspace/security.ts +4 -0
  341. package/src/workspace/status-gates.ts +9 -6
  342. package/src/workspace/status.ts +4 -1
  343. package/src/workspace/trust/gate-seal.test.ts +1 -1
  344. package/src/workspace/trust/seal.ts +2 -2
  345. package/dist/workspace/trust/gate-seal.d.ts.map +0 -1
  346. /package/dist/{workspace/trust → lifecycle}/gate-seal.js +0 -0
@@ -0,0 +1,271 @@
1
+ /**
2
+ * A failed member blocks only the members that depend on it (#4291).
3
+ *
4
+ * A run applied a wave at a time stops a member when something it reads did
5
+ * not apply, and lets every other member go on. This module holds the rule
6
+ * and the records it reads, with no runner in it, so a product that runs its
7
+ * waves in its own CI stages (terragucci's roots, lifted from its
8
+ * `blocked.ts`) and chant's own wave jobs (`pr-apply --wave-gate`,
9
+ * `chant components fan-out --wave-gate`) block the same members for the
10
+ * same reasons. It imports `node:fs` and `node:path` and nothing else, so it
11
+ * bundles small (`wave-blocked-bundle.test.ts`).
12
+ *
13
+ * ## Outcomes
14
+ *
15
+ * Each member of an earlier wave has an outcome. `applied` and `unchanged`
16
+ * let a dependent through. Every other status holds it:
17
+ *
18
+ * - `failed`: its plan or its apply failed.
19
+ * - `waiting`: its wave waits for an approval.
20
+ * - `refused`: its wave was refused, held or stood down.
21
+ * - `decided`: its wave's deciding job decided it, and a share job applies it.
22
+ * - `not-applied`: its wave applied nothing because another member of it failed to plan.
23
+ * - `blocked`: something it reads did not apply. `by` names the member whose
24
+ * outcome held it, past any member blocked in between.
25
+ *
26
+ * ## Outcome files
27
+ *
28
+ * A wave job that hands the next job its outcomes as files writes one per
29
+ * job, `outcome-wave-<k>[-group-<g>][-share-<i>].json`:
30
+ *
31
+ * {
32
+ * "version": 1, "wave": 2, "at": "2026-10-18T10:00:09.000Z",
33
+ * "members": {
34
+ * "cluster-b": { "status": "applied" },
35
+ * "cluster-a": { "status": "failed" },
36
+ * "app-01": { "status": "blocked", "by": "cluster-a" }
37
+ * }
38
+ * }
39
+ *
40
+ * A wave job runs again on a re-run, so the newest outcome of a member wins.
41
+ * A file written with `roots` in place of `members` (terragucci's, before
42
+ * this module) reads the same. `holds_all` marks a wave that holds every
43
+ * later member, such as a wave of state migrations that did not apply: each
44
+ * later member is blocked by `wave-<k>`.
45
+ */
46
+ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
47
+ import { join } from "node:path";
48
+
49
+ export const WAVE_OUTCOME_VERSION = 1;
50
+
51
+ export type WaveMemberStatus = "applied" | "unchanged" | "failed" | "waiting" | "refused" | "decided" | "not-applied" | "blocked";
52
+
53
+ export interface WaveMemberOutcome {
54
+ status: WaveMemberStatus;
55
+ /** A blocked member's: the member whose outcome held it, past any member blocked in between. */
56
+ by?: string;
57
+ }
58
+
59
+ export interface WaveOutcomeFile {
60
+ version: typeof WAVE_OUTCOME_VERSION;
61
+ wave: number;
62
+ /** When the job wrote it: the newest outcome of a member wins. */
63
+ at: string;
64
+ members: Record<string, WaveMemberOutcome>;
65
+ /** The wave holds every later member, each blocked by `wave-<k>`. */
66
+ holds_all?: true;
67
+ }
68
+
69
+ const THROUGH: ReadonlySet<WaveMemberStatus> = new Set(["applied", "unchanged"]);
70
+
71
+ /** Whether `outcome` lets a dependent through: it applied, or had nothing to apply. */
72
+ export function passesThrough(outcome: WaveMemberOutcome | undefined): boolean {
73
+ return outcome !== undefined && THROUGH.has(outcome.status);
74
+ }
75
+
76
+ /**
77
+ * The members of `members` an upstream that did not apply holds, each with
78
+ * the member whose outcome holds it. `upstreams` is each member's upstreams;
79
+ * only those in `earlier` count (a member of this wave or a later one is no
80
+ * upstream it waits for here). An upstream with no outcome did not apply as
81
+ * far as this run knows, so it holds its dependents and names itself. A
82
+ * blocked upstream passes on the member that blocked it. With `holdsAll`,
83
+ * every member is blocked by `wave-<holdsAll>`.
84
+ */
85
+ export function blockedMembers(
86
+ members: readonly string[],
87
+ upstreams: ReadonlyMap<string, Iterable<string>>,
88
+ earlier: ReadonlySet<string>,
89
+ outcomes: ReadonlyMap<string, WaveMemberOutcome>,
90
+ holdsAll?: number,
91
+ ): Map<string, string> {
92
+ if (holdsAll !== undefined) return new Map(members.map((m) => [m, `wave-${holdsAll}`]));
93
+ const out = new Map<string, string>();
94
+ for (const member of members) {
95
+ const by = [...(upstreams.get(member) ?? [])]
96
+ .filter((u) => earlier.has(u))
97
+ .sort()
98
+ .map((u) => {
99
+ const o = outcomes.get(u);
100
+ if (passesThrough(o)) return undefined;
101
+ return o?.status === "blocked" && o.by ? o.by : u;
102
+ })
103
+ .find((b): b is string => b !== undefined);
104
+ if (by !== undefined) out.set(member, by);
105
+ }
106
+ return out;
107
+ }
108
+
109
+ /**
110
+ * Walk waves 1 to `before - 1` and give each member that has no outcome the
111
+ * one it ends with: `blocked` behind an upstream that did not apply. A member
112
+ * with no outcome that nothing blocks has not run yet: it is in `unsettled`,
113
+ * and wave `before` cannot run until it has. `outcomes` is what the records
114
+ * say: `applied` for what applied, and `failed`, `waiting` or `not-applied`
115
+ * for what did not. Returns those outcomes with the blocked members added.
116
+ */
117
+ export function settleEarlierWaves(
118
+ waves: readonly (readonly string[])[],
119
+ before: number,
120
+ upstreams: ReadonlyMap<string, Iterable<string>>,
121
+ outcomes: ReadonlyMap<string, WaveMemberOutcome>,
122
+ ): { outcomes: Map<string, WaveMemberOutcome>; unsettled: string[] } {
123
+ const out = new Map(outcomes);
124
+ const unsettled: string[] = [];
125
+ const earlier = new Set<string>();
126
+ for (let k = 1; k < before && k <= waves.length; k++) {
127
+ const wave = waves[k - 1]!;
128
+ const open = wave.filter((m) => !out.has(m));
129
+ const blocked = blockedMembers(open, upstreams, earlier, out);
130
+ for (const m of open) {
131
+ const by = blocked.get(m);
132
+ if (by !== undefined) out.set(m, { status: "blocked", by });
133
+ else unsettled.push(m);
134
+ }
135
+ for (const m of wave) earlier.add(m);
136
+ }
137
+ return { outcomes: out, unsettled };
138
+ }
139
+
140
+ /**
141
+ * Whether every member that did not apply waits for an approval, or is
142
+ * blocked behind one that does: the run waits rather than failed, so the
143
+ * job ends as waiting (exit 3), and the approval's re-run applies them.
144
+ */
145
+ export function onlyWaiting(outcomes: ReadonlyMap<string, WaveMemberOutcome>): boolean {
146
+ let held = false;
147
+ for (const o of outcomes.values()) {
148
+ if (THROUGH.has(o.status)) continue;
149
+ held = true;
150
+ if (o.status === "waiting") continue;
151
+ if (o.status === "blocked" && o.by !== undefined && outcomes.get(o.by)?.status === "waiting") continue;
152
+ return false;
153
+ }
154
+ return held;
155
+ }
156
+
157
+ const byName = ([a]: [string, unknown], [b]: [string, unknown]): number => (a < b ? -1 : a > b ? 1 : 0);
158
+
159
+ /** The blocked members grouped by what blocked them, as a log and a note list them: `app-01, app-02 (blocked by cluster-a)`. */
160
+ export function blockedWords(blocked: ReadonlyMap<string, string>): string {
161
+ const byRoot = new Map<string, string[]>();
162
+ for (const [member, by] of [...blocked].sort(byName)) byRoot.set(by, [...(byRoot.get(by) ?? []), member]);
163
+ return [...byRoot].map(([by, ms]) => `${ms.join(", ")} (blocked by ${by})`).join("; ");
164
+ }
165
+
166
+ /**
167
+ * How the run came out, from every member's newest outcome: "8 applied, 1
168
+ * failed, 6 blocked", with waiting, refused and the rest named when there
169
+ * are any, and undefined when every member applied or had nothing to apply.
170
+ */
171
+ export function runSummary(outcomes: Iterable<WaveMemberOutcome>): string | undefined {
172
+ const counts = new Map<WaveMemberStatus, number>();
173
+ for (const o of outcomes) counts.set(o.status, (counts.get(o.status) ?? 0) + 1);
174
+ if (![...counts.keys()].some((s) => !THROUGH.has(s))) return undefined;
175
+ const parts = [`${counts.get("applied") ?? 0} applied`, `${counts.get("failed") ?? 0} failed`, `${counts.get("blocked") ?? 0} blocked`];
176
+ for (const s of ["waiting", "refused", "decided", "not-applied"] as const) {
177
+ if (counts.get(s)) parts.push(`${counts.get(s)} ${s === "decided" ? "not reported by their share" : s}`);
178
+ }
179
+ if (counts.get("unchanged")) parts.push(`${counts.get("unchanged")} unchanged`);
180
+ return parts.join(", ");
181
+ }
182
+
183
+ /** The outcome file of a wave's job, or of one group or share of it. */
184
+ export function waveOutcomeFileName(wave: number, part: { group?: string; share?: number } = {}): string {
185
+ const group = part.group !== undefined ? `-group-${part.group.replace(/[^A-Za-z0-9._-]/g, "_")}` : "";
186
+ const share = part.share !== undefined ? `-share-${part.share}` : "";
187
+ return `outcome-wave-${wave}${group}${share}.json`;
188
+ }
189
+
190
+ /** Write a wave job's outcome file in `dir`, members sorted by name. Returns its path. */
191
+ export function writeWaveOutcomeFile(
192
+ dir: string,
193
+ wave: number,
194
+ part: { group?: string; share?: number },
195
+ members: Record<string, WaveMemberOutcome>,
196
+ at = new Date().toISOString(),
197
+ holdsAll = false,
198
+ ): string {
199
+ const file = join(dir, waveOutcomeFileName(wave, part));
200
+ const body: WaveOutcomeFile = {
201
+ version: WAVE_OUTCOME_VERSION,
202
+ wave,
203
+ at,
204
+ members: Object.fromEntries(Object.entries(members).sort(byName)),
205
+ ...(holdsAll ? { holds_all: true as const } : {}),
206
+ };
207
+ writeFileSync(file, JSON.stringify(body, null, 2) + "\n");
208
+ return file;
209
+ }
210
+
211
+ /** Every outcome file under `dirs`, four directories deep at most: downloaded artifacts keep each job's in a directory of its own. */
212
+ function outcomeFiles(dirs: readonly string[]): string[] {
213
+ const out: string[] = [];
214
+ const walk = (dir: string, depth: number): void => {
215
+ let names: string[];
216
+ try {
217
+ names = readdirSync(dir);
218
+ } catch {
219
+ return;
220
+ }
221
+ for (const name of names) {
222
+ const abs = join(dir, name);
223
+ let isDir = false;
224
+ try {
225
+ isDir = statSync(abs).isDirectory();
226
+ } catch {
227
+ continue;
228
+ }
229
+ if (isDir) {
230
+ if (depth < 4) walk(abs, depth + 1);
231
+ } else if (/^outcome-wave-\d+.*\.json$/.test(name)) out.push(abs);
232
+ }
233
+ };
234
+ for (const d of dirs) if (existsSync(d)) walk(d, 0);
235
+ return out.sort();
236
+ }
237
+
238
+ /**
239
+ * The outcome of each member of the waves before `wave`, as their jobs wrote
240
+ * them under `dirs`, and the earliest such wave that holds every later
241
+ * member, if any. For a member more than one job wrote (a re-run, or a split
242
+ * wave's deciding job and the share that applied it), the newest. A file
243
+ * that does not parse, or is of another version, is skipped.
244
+ */
245
+ export function earlierWaveOutcomes(
246
+ dirs: readonly string[],
247
+ wave: number,
248
+ ): { members: Map<string, WaveMemberOutcome & { wave: number; at: string }>; holdsAll?: number } {
249
+ const out = new Map<string, WaveMemberOutcome & { wave: number; at: string }>();
250
+ const newest = new Map<number, { at: string; holdsAll: boolean }>();
251
+ for (const file of outcomeFiles(dirs)) {
252
+ let f: Partial<WaveOutcomeFile> & { roots?: unknown };
253
+ try {
254
+ f = JSON.parse(readFileSync(file, "utf-8"));
255
+ } catch {
256
+ continue;
257
+ }
258
+ const members = (f?.members ?? f?.roots) as Record<string, WaveMemberOutcome> | undefined;
259
+ if (f?.version !== WAVE_OUTCOME_VERSION || !Number.isInteger(f.wave) || f.wave! >= wave || typeof f.at !== "string") continue;
260
+ if (typeof members !== "object" || members === null) continue;
261
+ for (const [member, o] of Object.entries(members)) {
262
+ const had = out.get(member);
263
+ if (had && had.at > f.at) continue;
264
+ out.set(member, { ...o, wave: f.wave!, at: f.at });
265
+ }
266
+ const n = newest.get(f.wave!);
267
+ if (!n || n.at <= f.at) newest.set(f.wave!, { at: f.at, holdsAll: f.holds_all === true });
268
+ }
269
+ const holding = [...newest].filter(([, n]) => n.holdsAll).map(([k]) => k).sort((a, b) => a - b);
270
+ return { members: out, ...(holding.length ? { holdsAll: holding[0] } : {}) };
271
+ }
@@ -30,6 +30,8 @@ import { dirname } from "node:path";
30
30
  import { mkdirSync, renameSync, writeFileSync } from "node:fs";
31
31
  import { evaluateGate, type GateCheck, type GateLedgerPort } from "./op/gate";
32
32
  import type { AppliedGateRecord } from "./lifecycle/gate-applied";
33
+ import type { ResolvedGateApproval } from "./op/gate-approval";
34
+ import type { GateQuorumJudge } from "./op/gate-quorum";
33
35
 
34
36
  export { WAVE_GATE_NAME_PATTERN, waveGateNameFrom, waveShare } from "./wave-layout";
35
37
  export {
@@ -66,6 +68,10 @@ export interface WaveGateRequest {
66
68
  fields?: Record<string, unknown>;
67
69
  /** The pending fact carries `neverOverMcp` (#3821). */
68
70
  neverOverMcp?: boolean;
71
+ /** The approval rules the wave takes (#4290), as an `approval` block with a quorum. */
72
+ approval?: ResolvedGateApproval;
73
+ /** Judges approvals for those rules (#4290). */
74
+ judge?: GateQuorumJudge;
69
75
  }
70
76
 
71
77
  /**
@@ -85,6 +91,8 @@ export function decideWaveGate(port: GateLedgerPort, request: WaveGateRequest):
85
91
  ...(request.spent ? { spent: request.spent } : {}),
86
92
  ...(request.fields ? { fields: request.fields } : {}),
87
93
  ...(request.neverOverMcp ? { neverOverMcp: true } : {}),
94
+ ...(request.approval ? { approval: request.approval } : {}),
95
+ ...(request.judge ? { judge: request.judge } : {}),
88
96
  });
89
97
  }
90
98
 
@@ -10,7 +10,7 @@ import { tmpdir } from "node:os";
10
10
  import { dirname, join } from "node:path";
11
11
 
12
12
  /** A git repository holding a workspace with two members, a root member and an example group. */
13
- export function twoMemberWorkspace(options: { root?: boolean; prefix?: string } = {}): string {
13
+ export function twoMemberWorkspace(options: { root?: boolean; prefix?: string; extra?: Record<string, unknown> } = {}): string {
14
14
  const repo = realpathSync(mkdtempSync(join(tmpdir(), "chant-member-pipeline-")));
15
15
  execFileSync("git", ["init", "-q"], { cwd: repo });
16
16
  const ws = options.prefix ? join(repo, options.prefix) : repo;
@@ -21,7 +21,7 @@ export function twoMemberWorkspace(options: { root?: boolean; prefix?: string }
21
21
  ];
22
22
  if (options.root) members.unshift({ name: "platform", dir: ".", kind: "chant" });
23
23
  const files: Record<string, string> = {
24
- "chant.workspace.json": JSON.stringify({ name: "shop", schema: 1, members }, null, 2),
24
+ "chant.workspace.json": JSON.stringify({ name: "shop", schema: 1, members, ...options.extra }, null, 2),
25
25
  "services/api/chant.config.ts": "export default {};\n",
26
26
  "services/api/src/infra.ts": "\n",
27
27
  "apps/web/chant.config.ts": "export default {};\n",
@@ -120,6 +120,15 @@ describe("ci.workflow (WSP151)", () => {
120
120
  expect(await found(repo(complete({ "chant.workspace.json": decl, ".github/workflows/chant-pr.yml": noRecord })))).toEqual([["ci-workflow-pipeline-missing", null]]);
121
121
  });
122
122
 
123
+ test("apply-before-merge needs a job that applies before merge, and the other workflows refuse one (#4301)", async () => {
124
+ const early = `${PIPELINE} apply:\n steps:\n - run: chant ci run apply --in-place --base "$BASE_SHA" --pr "$PR_NUMBER" --apply-before-merge --env prod --gate pr-apply --forge github\n`;
125
+ const decl = declaration({ ci: { workflow: "apply-before-merge", green: green() } });
126
+ expect(await found(repo(complete({ "chant.workspace.json": decl })))).toEqual([["ci-workflow-pipeline-differs", null]]);
127
+ expect(await found(repo(complete({ "chant.workspace.json": decl, ".github/workflows/chant-pr.yml": early })))).toEqual([]);
128
+ expect(await found(repo(complete({ ".github/workflows/chant-pr.yml": early })))).toEqual([["ci-workflow-pipeline-differs", null]]);
129
+ expect(workflowOf({ ci: { green: null, reportUrl: null, workflow: "apply-before-merge" } } as never, null)).toBe("apply-before-merge");
130
+ });
131
+
123
132
  test("a member's own workflow wins, and a top-level workflow skips members that don't build with chant", async () => {
124
133
  const root = repo({
125
134
  ...complete(),
@@ -14,7 +14,7 @@
14
14
 
15
15
  import type { WorkspaceCheck, WorkspaceDiagnostic } from "../checks";
16
16
  import type { CiWorkflow, Member } from "../declaration";
17
- import { appliesOnMerge, STUB_MARKER, workflowOf, type CiWorkflowCode } from "../ci-workflow";
17
+ import { appliesBeforeMerge, appliesOnMerge, STUB_MARKER, workflowOf, type CiWorkflowCode } from "../ci-workflow";
18
18
  import { matchesCheckRun } from "../ci-green";
19
19
  import { staticPromotionOrder } from "../promotion-order";
20
20
  import { joinPath } from "../tree";
@@ -27,6 +27,8 @@ const memberDir = (dir: string) => (dir === "." ? "" : dir);
27
27
  const PLAN = /\bci\s+run\s+plan\b|\bcomponents\s+pr-plan\b/;
28
28
  const RECORD = /\bci\s+run\s+record\b|\bcomponents\s+pr-record\b/;
29
29
  const APPLY = /\bci\s+run\s+apply\b|\bcomponents\s+pr-apply\b/;
30
+ /** An apply before merge (#4301): `pr-apply --before-merge`, or `ci run apply --apply-before-merge`. */
31
+ const BEFORE_MERGE = /--(?:apply-)?before-merge\b/;
30
32
  const TICK = /\bci\s+tick\b/;
31
33
  const PUBLISH = /\brelease\s+publish\b/;
32
34
 
@@ -113,16 +115,28 @@ export const CI_WORKFLOW_CHECKS: readonly WorkspaceCheck[] = [
113
115
  if (missing.length > 0) {
114
116
  finding("ci-workflow-pipeline-missing", `no pipeline file runs ${missing.join(", ")}; generate them with chant build --components --generate <forge> --pr-loop and chant ci workflow`);
115
117
  }
118
+ // #4301: an apply before merge is its own job, allowed only under apply-before-merge.
119
+ const beforeMerge = under.some((u) => appliesBeforeMerge(u.workflow)) || appliesBeforeMerge(d.ci?.workflow ?? null);
120
+ const earlyLines = commandLines(ctx, APPLY).filter((c) => BEFORE_MERGE.test(c.text));
121
+ if (!beforeMerge) {
122
+ for (const l of earlyLines) {
123
+ finding("ci-workflow-pipeline-differs", `${l.path} applies before merge, and no member's workflow is apply-before-merge; regenerate without --apply-before-merge, or set ci.workflow to apply-before-merge`, { treeFile: l.path, line: l.line });
124
+ }
125
+ } else if (earlyLines.length === 0 && has(PLAN)) {
126
+ const l = commandLines(ctx, PLAN)[0]!;
127
+ finding("ci-workflow-pipeline-differs", `${l.path} has no job that applies before merge, and the workflow is apply-before-merge; regenerate with --apply-before-merge`, { treeFile: l.path, line: l.line });
128
+ }
129
+ const mergeApply = (l: { text: string }) => !BEFORE_MERGE.test(l.text);
116
130
  if (!applyOnMerge) {
117
- for (const l of commandLines(ctx, APPLY)) {
131
+ for (const l of commandLines(ctx, APPLY).filter(mergeApply)) {
118
132
  finding("ci-workflow-pipeline-differs", `${l.path} applies on merge, and the workflow is trunk-curated, where a merge records the plan and deploys nothing; regenerate without --apply-on-merge, or set ci.workflow to trunk-curated+apply-on-merge`, { treeFile: l.path, line: l.line });
119
133
  }
120
- } else if (!has(APPLY) && has(RECORD)) {
134
+ } else if (!commandLines(ctx, APPLY).some(mergeApply) && has(RECORD)) {
121
135
  const l = commandLines(ctx, RECORD)[0]!;
122
136
  finding("ci-workflow-pipeline-differs", `${l.path} records on merge, and the workflow is trunk-curated+apply-on-merge; regenerate with --apply-on-merge`, { treeFile: l.path, line: l.line });
123
137
  } else {
124
138
  // #4168 step 9: an apply measured from a push's base applies each merge; the workflow applies green commits.
125
- for (const l of commandLines(ctx, APPLY).filter((c) => /--base\b/.test(c.text) || (/\bcomponents\s+pr-apply\b/.test(c.text) && !/--green\b/.test(c.text)))) {
139
+ for (const l of commandLines(ctx, APPLY).filter(mergeApply).filter((c) => /--base\b/.test(c.text) || (/\bcomponents\s+pr-apply\b/.test(c.text) && !/--green\b/.test(c.text)))) {
126
140
  finding("ci-workflow-pipeline-differs", `${l.path} applies each merge, and under trunk-curated+apply-on-merge the apply applies the newest green commit; regenerate with --apply-on-merge`, { treeFile: l.path, line: l.line });
127
141
  }
128
142
  }
@@ -0,0 +1,62 @@
1
+ /** Branch routing and label filters for the pull-request loop (#4292). */
2
+
3
+ import { describe, expect, test } from "vitest";
4
+ import { appliesFrom, plansUnderLabels, pushedBranchFromEnv, routeConflicts, routedBranch } from "./ci-branches";
5
+
6
+ const branches = { release: ["prod-*", "dr"], staging: ["staging-*"] };
7
+
8
+ describe("ci.branches", () => {
9
+ test("a member routes to the first branch whose globs match it, or to none", () => {
10
+ expect(routedBranch(branches, "prod-eu")).toBe("release");
11
+ expect(routedBranch(branches, "dr")).toBe("release");
12
+ expect(routedBranch(branches, "staging-a")).toBe("staging");
13
+ expect(routedBranch(branches, "api")).toBeNull();
14
+ expect(routedBranch(undefined, "api")).toBeNull();
15
+ });
16
+
17
+ test("a routed member applies from its branch alone", () => {
18
+ expect(appliesFrom(branches, "prod-eu", "release")).toEqual({ applies: true, note: "ci.branches: release applies member prod-eu" });
19
+ expect(appliesFrom(branches, "prod-eu", "main")).toEqual({ applies: false, note: "ci.branches: member prod-eu applies from release, not main" });
20
+ expect(appliesFrom(branches, "prod-eu", "staging").applies).toBe(false);
21
+ });
22
+
23
+ test("an unrouted member applies from every branch the map does not name", () => {
24
+ expect(appliesFrom(branches, "api", "main").applies).toBe(true);
25
+ expect(appliesFrom(branches, "api", "hotfix").applies).toBe(true);
26
+ expect(appliesFrom(branches, "api", "release")).toEqual({
27
+ applies: false,
28
+ note: "ci.branches: release applies only the members its globs match, and member api is not one",
29
+ });
30
+ expect(appliesFrom(undefined, "api", "release").applies).toBe(true);
31
+ });
32
+
33
+ test("a member two branches match is a conflict", () => {
34
+ expect(routeConflicts({ release: ["prod-*"], hotfix: ["prod-eu"] }, ["prod-eu", "prod-us", "api"])).toEqual([{ member: "prod-eu", branches: ["release", "hotfix"] }]);
35
+ expect(routeConflicts(branches, ["prod-eu", "dr", "api"])).toEqual([]);
36
+ });
37
+
38
+ test("the pushed branch comes from the forge's variables, never from a tag ref", () => {
39
+ expect(pushedBranchFromEnv({ GITHUB_REF_NAME: "release", GITHUB_REF_TYPE: "branch" })).toBe("release");
40
+ expect(pushedBranchFromEnv({ GITHUB_REF_NAME: "v1.2.0", GITHUB_REF_TYPE: "tag" })).toBeUndefined();
41
+ expect(pushedBranchFromEnv({ CI_COMMIT_BRANCH: "release" })).toBe("release");
42
+ expect(pushedBranchFromEnv({})).toBeUndefined();
43
+ });
44
+ });
45
+
46
+ describe("ci.labels", () => {
47
+ const labels = { "plan:prod": ["prod-*"], "plan:web": ["web"] };
48
+
49
+ test("with none of the labels on the pull request, every member plans", () => {
50
+ expect(plansUnderLabels(labels, "api", [])).toEqual({ plans: true });
51
+ expect(plansUnderLabels(labels, "api", ["bug"])).toEqual({ plans: true });
52
+ expect(plansUnderLabels(undefined, "api", ["plan:prod"])).toEqual({ plans: true });
53
+ });
54
+
55
+ test("with some, only the members their globs match plan", () => {
56
+ expect(plansUnderLabels(labels, "prod-eu", ["plan:prod"])).toEqual({ plans: true, note: "ci.labels: plan:prod plans member prod-eu" });
57
+ expect(plansUnderLabels(labels, "web", ["plan:prod", "plan:web"]).plans).toBe(true);
58
+ const out = plansUnderLabels(labels, "api", ["plan:prod", "bug"]);
59
+ expect(out.plans).toBe(false);
60
+ expect(out.note).toContain("its record and apply on merge still plan it");
61
+ });
62
+ });
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Branch routing and label filters for the pull-request loop (#4292).
3
+ *
4
+ * `ci.branches` maps a branch to member-name globs. A member a glob matches
5
+ * applies from that branch: its generated pull-request pipeline targets it,
6
+ * and its record or apply job does nothing on a push to any other branch. A
7
+ * member no glob matches applies from every branch but those, the default
8
+ * branch included. terragucci's `apply.branches` (`branchLayers`) works the
9
+ * same way over Terraform roots.
10
+ *
11
+ * `ci.labels` maps a pull request label to member-name globs. When a pull
12
+ * request carries one of the labels, its plan job plans only the members
13
+ * those labels' globs match, and every other member's plan job plans
14
+ * nothing. A label never gates: the record and apply jobs on the push never
15
+ * read labels, and an apply still waits for an approval of the digest it
16
+ * plans. Anyone with triage access can set a label, so a label may narrow
17
+ * what a reviewer sees ahead of the merge and no more.
18
+ */
19
+
20
+ // @ts-ignore: picomatch has no types declaration
21
+ import picomatch from "picomatch";
22
+
23
+ /** Branch or label name to member-name globs. */
24
+ export type MemberGlobs = Readonly<Record<string, readonly string[]>>;
25
+
26
+ const matches = (globs: readonly string[], member: string): boolean => globs.some((g) => (picomatch(g) as (s: string) => boolean)(member));
27
+
28
+ /** The keys of `map` whose globs match `member`, in declaration order. */
29
+ export function keysMatching(map: MemberGlobs | undefined, member: string): string[] {
30
+ return Object.keys(map ?? {}).filter((k) => matches(map![k]!, member));
31
+ }
32
+
33
+ /** The branch `ci.branches` routes `member` to, or null when no glob matches it. */
34
+ export function routedBranch(branches: MemberGlobs | undefined, member: string): string | null {
35
+ return keysMatching(branches, member)[0] ?? null;
36
+ }
37
+
38
+ /** Each member that two branches' globs match, with the branches. The declaration refuses these. */
39
+ export function routeConflicts(branches: MemberGlobs | undefined, members: readonly string[]): Array<{ member: string; branches: string[] }> {
40
+ return members.map((member) => ({ member, branches: keysMatching(branches, member) })).filter((c) => c.branches.length > 1);
41
+ }
42
+
43
+ /**
44
+ * Whether a push to `pushed` applies `member`, with a line that says why.
45
+ * A routed member applies only from its branch. Any other member applies
46
+ * from every branch `ci.branches` does not name.
47
+ */
48
+ export function appliesFrom(branches: MemberGlobs | undefined, member: string, pushed: string): { applies: boolean; note: string } {
49
+ const own = routedBranch(branches, member);
50
+ if (own !== null) {
51
+ return own === pushed
52
+ ? { applies: true, note: `ci.branches: ${pushed} applies member ${member}` }
53
+ : { applies: false, note: `ci.branches: member ${member} applies from ${own}, not ${pushed}` };
54
+ }
55
+ if (Object.prototype.hasOwnProperty.call(branches ?? {}, pushed)) {
56
+ return { applies: false, note: `ci.branches: ${pushed} applies only the members its globs match, and member ${member} is not one` };
57
+ }
58
+ return { applies: true, note: `ci.branches: member ${member} is routed to no branch, so ${pushed} applies it` };
59
+ }
60
+
61
+ /**
62
+ * Whether a pull request's plan job plans `member`, given the labels the pull
63
+ * request carries. With none of `ci.labels` on the pull request every member
64
+ * plans. With some, a member plans only when one of those labels' globs
65
+ * matches it.
66
+ */
67
+ export function plansUnderLabels(labels: MemberGlobs | undefined, member: string, carried: readonly string[]): { plans: boolean; note?: string } {
68
+ const set = carried.filter((l) => Object.prototype.hasOwnProperty.call(labels ?? {}, l));
69
+ if (set.length === 0) return { plans: true };
70
+ const by = set.filter((l) => matches(labels![l]!, member));
71
+ return by.length > 0
72
+ ? { plans: true, note: `ci.labels: ${by.join(", ")} plan${by.length === 1 ? "s" : ""} member ${member}` }
73
+ : { plans: false, note: `ci.labels: the pull request's labels ${set.join(", ")} plan other members, so member ${member} is not planned here; its record and apply on merge still plan it` };
74
+ }
75
+
76
+ /** The branch the job runs on, from the forge's own variables: GitHub and Forgejo's GITHUB_REF_NAME, GitLab's CI_COMMIT_BRANCH. */
77
+ export function pushedBranchFromEnv(env: Record<string, string | undefined> = process.env): string | undefined {
78
+ return env.CI_COMMIT_BRANCH || (env.GITHUB_REF_TYPE === undefined || env.GITHUB_REF_TYPE === "branch" ? env.GITHUB_REF_NAME : undefined) || undefined;
79
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * #4296: `chant build --pr-loop` reads `ci.identity.cloud` from the working
3
+ * tree of the workspace that holds the project, and resolves it for the
4
+ * pipeline's environment and member.
5
+ */
6
+
7
+ import { join } from "node:path";
8
+ import { afterEach, describe, expect, test } from "vitest";
9
+ import { cleanScratch, declaration, git, scratchDir, writeFiles } from "./__fixtures__/contract-repo";
10
+ import { declaredCloudIdentity } from "./ci-cloud-identity";
11
+
12
+ afterEach(() => cleanScratch());
13
+
14
+ const MEMBERS = [
15
+ { name: "net", dir: "net", kind: "other", because: "the network" },
16
+ { name: "app", dir: "app", kind: "other", because: "the app" },
17
+ ];
18
+
19
+ function workspace(ci?: Record<string, unknown>): string {
20
+ const root = scratchDir("chant-cloud-identity-");
21
+ git(root, "init", "-q");
22
+ writeFiles(root, { "chant.workspace.json": declaration(MEMBERS, ci ? { ci } : {}), "net/a.txt": "a\n", "app/a.txt": "a\n" });
23
+ return root;
24
+ }
25
+
26
+ describe("declaredCloudIdentity (#4296)", () => {
27
+ test("each member's pipeline takes the first rule for its environment and member", async () => {
28
+ const root = workspace({
29
+ identity: {
30
+ cloud: {
31
+ aws: {
32
+ rules: [
33
+ { members: ["net"], plan: "arn:aws:iam::1:role/net-plan", apply: "arn:aws:iam::1:role/net-apply" },
34
+ { plan: "arn:aws:iam::1:role/plan", apply: "arn:aws:iam::1:role/apply" },
35
+ ],
36
+ },
37
+ },
38
+ },
39
+ });
40
+ expect((await declaredCloudIdentity(join(root, "net"), "production", "net"))!.apply.aws!.role).toBe("arn:aws:iam::1:role/net-apply");
41
+ expect((await declaredCloudIdentity(join(root, "app"), "production", "app"))!.apply.aws!.role).toBe("arn:aws:iam::1:role/apply");
42
+ });
43
+
44
+ test("a workspace with no block, and a directory outside any workspace, read nothing", async () => {
45
+ expect(await declaredCloudIdentity(workspace(), "production", "net")).toBeUndefined();
46
+ expect(await declaredCloudIdentity(scratchDir("chant-cloud-identity-none-"), "production")).toBeUndefined();
47
+ });
48
+
49
+ test("a declaration that plans as an apply identity is an error naming its code", async () => {
50
+ const root = workspace({ identity: { cloud: { aws: { rules: [{ plan: "r", apply: "r" }] } } } });
51
+ await expect(declaredCloudIdentity(root, "production")).rejects.toThrow(/plans and applies as r.*\[declaration-invalid\]/);
52
+ });
53
+ });
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The cloud identities a generated pull-request pipeline takes (#4296): the
3
+ * workspace declaration's `ci.identity.cloud`, resolved for the pipeline's
4
+ * environment and member (../ci-identity.ts). Read from the working tree of
5
+ * the workspace that holds `cwd`, as `ci.reportUrl` is (./ci-report-url.ts).
6
+ * A directory outside a workspace reads nothing.
7
+ */
8
+
9
+ import { findWorkspaceRoot } from "../project-root";
10
+ import { resolveCloudIdentity, type PipelineCloudIdentity } from "../ci-identity";
11
+
12
+ /** The plan and apply identities of the pipeline for `env` and `member`, or undefined. */
13
+ export async function declaredCloudIdentity(cwd: string, env: string, member?: string): Promise<PipelineCloudIdentity | undefined> {
14
+ const found = findWorkspaceRoot(cwd);
15
+ if (!found) return undefined;
16
+ const [{ readDeclaration, WorkspaceReadError }, { workingTree }] = await Promise.all([import("./declaration"), import("./tree")]);
17
+ try {
18
+ return resolveCloudIdentity(readDeclaration(workingTree(found.dir)).ci?.identity?.cloud, env, member);
19
+ } catch (err) {
20
+ if (!(err instanceof WorkspaceReadError)) throw err;
21
+ throw Object.assign(new Error(`${err.describe()} [${err.code}]`), { code: err.code });
22
+ }
23
+ }
@@ -37,7 +37,7 @@ import { formatError, formatSuccess } from "../cli/format";
37
37
  import { parseYAMLDocument } from "../yaml";
38
38
  import { CI_WORKFLOW_CHECKS } from "./checks/ci-workflow";
39
39
  import type { WorkspaceDiagnostic } from "./checks";
40
- import { CI_WORKFLOWS, STUB_MARKER, appliesOnMerge } from "./ci-workflow";
40
+ import { CI_WORKFLOWS, STUB_MARKER, appliesBeforeMerge, appliesOnMerge } from "./ci-workflow";
41
41
 
42
42
  export { STUB_MARKER };
43
43
  import { CI_GREEN_WORKFLOW_FILE, jobCheckRuns, readWorkflowSources, workflowsHolding } from "./ci-green-workflow";
@@ -48,7 +48,7 @@ import { SECURITY_GROUPS, type SecurityGroup } from "./security";
48
48
  import { gitTop, workingTree, type WorkspaceTree } from "./tree";
49
49
  import { RELEASE_WORKFLOW_FORGES, type ReleaseWorkflowForge } from "./release-workflow";
50
50
 
51
- const USAGE = "chant ci init --workflow trunk-curated|trunk-curated+apply-on-merge [--forge github|gitlab|forgejo] [--security <group>]... [--yes]";
51
+ const USAGE = "chant ci init --workflow trunk-curated|trunk-curated+apply-on-merge|apply-before-merge [--forge github|gitlab|forgejo] [--security <group>]... [--yes]";
52
52
 
53
53
 
54
54
  /** The environments a config with none is given. */
@@ -280,11 +280,12 @@ export function proposeCiInit(root: string, options: CiInitOptions): CiInitPropo
280
280
  if (reported("ci-workflow-pipeline-missing") || reported("ci-workflow-pipeline-differs")) {
281
281
  for (const m of releasing) {
282
282
  const env = firstEnv.get(m.name);
283
+ const workflow = m.ciWorkflow ?? proposed.ci!.workflow!;
283
284
  commands.push({
284
285
  cwd: memberDir(m.dir) || ".",
285
286
  // The root member builds alone: at a workspace root, chant build refuses without --root-only (WSP000).
286
- args: ["build", "--components", "--generate", forge, "--pr-loop", ...(m.dir === "." ? ["--root-only"] : []), ...(env ? ["--env", env] : []), ...(appliesOnMerge(m.ciWorkflow ?? proposed.ci!.workflow!) ? ["--apply-on-merge"] : [])],
287
- why: `the pull-request pipeline for ${m.name}: plan on a pull request, ${appliesOnMerge(m.ciWorkflow ?? proposed.ci!.workflow!) ? "apply" : "record"} on merge`,
287
+ args: ["build", "--components", "--generate", forge, "--pr-loop", ...(m.dir === "." ? ["--root-only"] : []), ...(env ? ["--env", env] : []), ...(appliesOnMerge(workflow) ? ["--apply-on-merge"] : []), ...(appliesBeforeMerge(workflow) ? ["--apply-before-merge"] : [])],
288
+ why: `the pull-request pipeline for ${m.name}: plan on a pull request, ${appliesBeforeMerge(workflow) ? "apply on request before merge, record" : appliesOnMerge(workflow) ? "apply" : "record"} on merge`,
288
289
  });
289
290
  }
290
291
  if (forge === "github") {
@@ -0,0 +1,16 @@
1
+ /**
2
+ * `ci.locks` (#4293): when a pull request takes its target locks
3
+ * (../lifecycle/target-lock.ts). Read from the declaration at base, the
4
+ * target branch's tip, as the security groups are (./security.ts), so a
5
+ * pull request cannot turn its own locks off. Null outside a workspace and
6
+ * when the declaration sets none.
7
+ */
8
+ import type { CiLocks } from "./declaration";
9
+ import { scopeSource } from "./write-scope";
10
+
11
+ export const CI_LOCKS = ["apply", "plan"] as const satisfies readonly CiLocks[];
12
+
13
+ /** The `ci.locks` of the workspace holding `cwd`, read at `at` when given, else at base, else from the working tree. */
14
+ export function ciLocksAt(cwd: string, at?: string): CiLocks | null {
15
+ return scopeSource(cwd, at).declaration?.ci?.locks ?? null;
16
+ }