@intentius/chant 0.124.39 → 0.124.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (337) 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/component-endpoint.d.ts +20 -0
  11. package/dist/cli/component-endpoint.d.ts.map +1 -0
  12. package/dist/cli/component-endpoint.js +36 -0
  13. package/dist/cli/handlers/approve.d.ts +2 -1
  14. package/dist/cli/handlers/approve.d.ts.map +1 -1
  15. package/dist/cli/handlers/approve.js +20 -6
  16. package/dist/cli/handlers/build.d.ts.map +1 -1
  17. package/dist/cli/handlers/build.js +15 -1
  18. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  19. package/dist/cli/handlers/fan-out.js +26 -4
  20. package/dist/cli/handlers/gate-resume.d.ts +3 -0
  21. package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
  22. package/dist/cli/handlers/gate-resume.js +15 -2
  23. package/dist/cli/handlers/lock.d.ts +8 -0
  24. package/dist/cli/handlers/lock.d.ts.map +1 -0
  25. package/dist/cli/handlers/lock.js +197 -0
  26. package/dist/cli/handlers/pr-env.d.ts +34 -0
  27. package/dist/cli/handlers/pr-env.d.ts.map +1 -0
  28. package/dist/cli/handlers/pr-env.js +436 -0
  29. package/dist/cli/handlers/pr.d.ts +74 -0
  30. package/dist/cli/handlers/pr.d.ts.map +1 -1
  31. package/dist/cli/handlers/pr.js +514 -58
  32. package/dist/cli/handlers/promote.d.ts.map +1 -1
  33. package/dist/cli/handlers/promote.js +16 -2
  34. package/dist/cli/handlers/query.d.ts +6 -0
  35. package/dist/cli/handlers/query.d.ts.map +1 -0
  36. package/dist/cli/handlers/query.js +80 -0
  37. package/dist/cli/handlers/run.d.ts +4 -1
  38. package/dist/cli/handlers/run.d.ts.map +1 -1
  39. package/dist/cli/handlers/run.js +17 -6
  40. package/dist/cli/main.d.ts.map +1 -1
  41. package/dist/cli/main.js +41 -2
  42. package/dist/cli/parse-args.d.ts.map +1 -1
  43. package/dist/cli/parse-args.js +31 -2
  44. package/dist/cli/registry.d.ts +12 -2
  45. package/dist/cli/registry.d.ts.map +1 -1
  46. package/dist/components/auto-release.d.ts +35 -3
  47. package/dist/components/auto-release.d.ts.map +1 -1
  48. package/dist/components/auto-release.js +29 -2
  49. package/dist/components/cli-support.d.ts +2 -0
  50. package/dist/components/cli-support.d.ts.map +1 -1
  51. package/dist/components/cli-support.js +2 -0
  52. package/dist/components/deploy-units.d.ts.map +1 -1
  53. package/dist/components/deploy-units.js +5 -0
  54. package/dist/components/fan-out-run.d.ts +28 -0
  55. package/dist/components/fan-out-run.d.ts.map +1 -1
  56. package/dist/components/fan-out-run.js +27 -3
  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 +72 -2
  64. package/dist/components/pr-pipeline.d.ts.map +1 -1
  65. package/dist/components/pr-pipeline.js +96 -5
  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.map +1 -1
  70. package/dist/components/pr-run.js +5 -3
  71. package/dist/components/promote.d.ts.map +1 -1
  72. package/dist/components/promote.js +39 -16
  73. package/dist/gate-decision.d.ts +15 -2
  74. package/dist/gate-decision.d.ts.map +1 -1
  75. package/dist/gate-decision.js +17 -2
  76. package/dist/gate-policy.d.ts +140 -0
  77. package/dist/gate-policy.d.ts.map +1 -0
  78. package/dist/gate-policy.js +242 -0
  79. package/dist/lifecycle/__fixtures__/publish-fakes.d.ts.map +1 -1
  80. package/dist/lifecycle/__fixtures__/publish-fakes.js +6 -1
  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/publish-declaration.d.ts.map +1 -1
  86. package/dist/lifecycle/publish-declaration.js +4 -3
  87. package/dist/lifecycle/publish-npm.d.ts +2 -2
  88. package/dist/lifecycle/publish-npm.d.ts.map +1 -1
  89. package/dist/lifecycle/publish-npm.js +31 -8
  90. package/dist/lifecycle/query.d.ts +68 -0
  91. package/dist/lifecycle/query.d.ts.map +1 -0
  92. package/dist/lifecycle/query.js +246 -0
  93. package/dist/lifecycle/release-ledger.d.ts +31 -0
  94. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  95. package/dist/lifecycle/release-ledger.js +19 -1
  96. package/dist/lifecycle/run-ledger.d.ts +13 -0
  97. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  98. package/dist/lifecycle/run-ledger.js +16 -0
  99. package/dist/lifecycle/status.d.ts +5 -1
  100. package/dist/lifecycle/status.d.ts.map +1 -1
  101. package/dist/lifecycle/status.js +10 -5
  102. package/dist/lifecycle/target-lock.d.ts +97 -0
  103. package/dist/lifecycle/target-lock.d.ts.map +1 -0
  104. package/dist/lifecycle/target-lock.js +286 -0
  105. package/dist/live-endpoint.d.ts +18 -0
  106. package/dist/live-endpoint.d.ts.map +1 -1
  107. package/dist/live-endpoint.js +27 -0
  108. package/dist/op/activities/activity-contracts.d.ts +30 -0
  109. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  110. package/dist/op/activities/activity-contracts.js +26 -0
  111. package/dist/op/activities/index.d.ts +2 -0
  112. package/dist/op/activities/index.d.ts.map +1 -1
  113. package/dist/op/activities/index.js +4 -0
  114. package/dist/op/activities/review-agent.d.ts +137 -0
  115. package/dist/op/activities/review-agent.d.ts.map +1 -0
  116. package/dist/op/activities/review-agent.js +266 -0
  117. package/dist/op/builders.d.ts +9 -0
  118. package/dist/op/builders.d.ts.map +1 -1
  119. package/dist/op/builders.js +11 -0
  120. package/dist/op/cron.d.ts +8 -0
  121. package/dist/op/cron.d.ts.map +1 -1
  122. package/dist/op/cron.js +51 -0
  123. package/dist/op/gate-approval.d.ts +20 -13
  124. package/dist/op/gate-approval.d.ts.map +1 -1
  125. package/dist/op/gate-approval.js +13 -18
  126. package/dist/op/gate-quorum.d.ts +131 -0
  127. package/dist/op/gate-quorum.d.ts.map +1 -0
  128. package/dist/op/gate-quorum.js +183 -0
  129. package/dist/op/gate-resume.d.ts +4 -2
  130. package/dist/op/gate-resume.d.ts.map +1 -1
  131. package/dist/op/gate-resume.js +5 -4
  132. package/dist/op/gate-review.js +1 -1
  133. package/dist/op/gate.d.ts +32 -4
  134. package/dist/op/gate.d.ts.map +1 -1
  135. package/dist/op/gate.js +40 -24
  136. package/dist/op/index.d.ts +6 -4
  137. package/dist/op/index.d.ts.map +1 -1
  138. package/dist/op/index.js +4 -3
  139. package/dist/op/local-executor.d.ts.map +1 -1
  140. package/dist/op/local-executor.js +9 -3
  141. package/dist/op/op-waves-run.d.ts +62 -1
  142. package/dist/op/op-waves-run.d.ts.map +1 -1
  143. package/dist/op/op-waves-run.js +190 -11
  144. package/dist/op/op-waves.d.ts +55 -3
  145. package/dist/op/op-waves.d.ts.map +1 -1
  146. package/dist/op/op-waves.js +63 -8
  147. package/dist/op/schedule-overdue.d.ts +57 -0
  148. package/dist/op/schedule-overdue.d.ts.map +1 -0
  149. package/dist/op/schedule-overdue.js +138 -0
  150. package/dist/override-gate.d.ts +2 -2
  151. package/dist/override-gate.d.ts.map +1 -1
  152. package/dist/override-gate.js +1 -1
  153. package/dist/pr-forge.d.ts +36 -0
  154. package/dist/pr-forge.d.ts.map +1 -1
  155. package/dist/pr-forge.js +82 -0
  156. package/dist/pr-loop.d.ts +90 -1
  157. package/dist/pr-loop.d.ts.map +1 -1
  158. package/dist/pr-loop.js +77 -3
  159. package/dist/pr-post.d.ts +72 -0
  160. package/dist/pr-post.d.ts.map +1 -0
  161. package/dist/pr-post.js +134 -0
  162. package/dist/wave-blocked.d.ts +79 -0
  163. package/dist/wave-blocked.d.ts.map +1 -0
  164. package/dist/wave-blocked.js +245 -0
  165. package/dist/wave-engine.d.ts +6 -0
  166. package/dist/wave-engine.d.ts.map +1 -1
  167. package/dist/wave-engine.js +2 -0
  168. package/dist/wave-runtime.mjs +239 -239
  169. package/dist/workspace/__fixtures__/two-member-workspace.d.ts +1 -0
  170. package/dist/workspace/__fixtures__/two-member-workspace.d.ts.map +1 -1
  171. package/dist/workspace/__fixtures__/two-member-workspace.js +1 -1
  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-locks.d.ts +12 -0
  179. package/dist/workspace/ci-locks.d.ts.map +1 -0
  180. package/dist/workspace/ci-locks.js +6 -0
  181. package/dist/workspace/ci-run.d.ts +24 -1
  182. package/dist/workspace/ci-run.d.ts.map +1 -1
  183. package/dist/workspace/ci-run.js +85 -6
  184. package/dist/workspace/declaration.d.ts +63 -0
  185. package/dist/workspace/declaration.d.ts.map +1 -1
  186. package/dist/workspace/declaration.js +72 -1
  187. package/dist/workspace/declaration.schema.json +147 -0
  188. package/dist/workspace/identity-gate-rule.d.ts +1 -1
  189. package/dist/workspace/identity-gate-rule.d.ts.map +1 -1
  190. package/dist/workspace/identity-gate-rule.js +1 -1
  191. package/dist/workspace/identity.d.ts +1 -1
  192. package/dist/workspace/identity.d.ts.map +1 -1
  193. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  194. package/dist/workspace/lineage-upgrade.js +8 -5
  195. package/dist/workspace/member-pipeline.d.ts +6 -0
  196. package/dist/workspace/member-pipeline.d.ts.map +1 -1
  197. package/dist/workspace/member-pipeline.js +23 -0
  198. package/dist/workspace/pr-report.schema.json +73 -2
  199. package/dist/workspace/security.d.ts.map +1 -1
  200. package/dist/workspace/security.js +4 -0
  201. package/dist/workspace/status-gates.d.ts +3 -2
  202. package/dist/workspace/status-gates.d.ts.map +1 -1
  203. package/dist/workspace/status-gates.js +8 -7
  204. package/dist/workspace/status.d.ts +3 -1
  205. package/dist/workspace/status.d.ts.map +1 -1
  206. package/dist/workspace/status.js +7 -3
  207. package/dist/workspace/status.schema.json +1 -0
  208. package/dist/workspace/trust/seal.d.ts +2 -2
  209. package/dist/workspace/trust/seal.d.ts.map +1 -1
  210. package/dist/workspace/trust/seal.js +2 -2
  211. package/package.json +1 -1
  212. package/src/__fixtures__/oidc-emulator.ts +118 -0
  213. package/src/ci-handover.test.ts +209 -0
  214. package/src/ci-handover.ts +237 -0
  215. package/src/ci-identity.test.ts +201 -0
  216. package/src/ci-identity.ts +310 -0
  217. package/src/cli/component-endpoint.ts +43 -0
  218. package/src/cli/handlers/approve.ts +18 -5
  219. package/src/cli/handlers/build.ts +15 -1
  220. package/src/cli/handlers/fan-out.test.ts +10 -0
  221. package/src/cli/handlers/fan-out.ts +29 -4
  222. package/src/cli/handlers/gate-resume.ts +16 -2
  223. package/src/cli/handlers/lock.test.ts +98 -0
  224. package/src/cli/handlers/lock.ts +202 -0
  225. package/src/cli/handlers/operator.test.ts +3 -1
  226. package/src/cli/handlers/pr-endpoint.test.ts +169 -0
  227. package/src/cli/handlers/pr-env.ts +462 -0
  228. package/src/cli/handlers/pr-waves-blocked.test.ts +247 -0
  229. package/src/cli/handlers/pr.test.ts +184 -0
  230. package/src/cli/handlers/pr.ts +508 -64
  231. package/src/cli/handlers/promote.ts +25 -2
  232. package/src/cli/handlers/query.ts +78 -0
  233. package/src/cli/handlers/run.test.ts +2 -1
  234. package/src/cli/handlers/run.ts +16 -5
  235. package/src/cli/main.ts +41 -2
  236. package/src/cli/parse-args.ts +23 -2
  237. package/src/cli/registry.ts +12 -2
  238. package/src/components/auto-release-infra.test.ts +131 -0
  239. package/src/components/auto-release.test.ts +40 -1
  240. package/src/components/auto-release.ts +50 -4
  241. package/src/components/cli-support.ts +4 -0
  242. package/src/components/deploy-units.ts +5 -0
  243. package/src/components/fan-out-run.ts +39 -2
  244. package/src/components/fan-out-waves.test.ts +45 -1
  245. package/src/components/pr-environment.test.ts +249 -0
  246. package/src/components/pr-environment.ts +538 -0
  247. package/src/components/pr-hooks.test.ts +118 -0
  248. package/src/components/pr-hooks.ts +157 -0
  249. package/src/components/pr-pipeline.ts +130 -5
  250. package/src/components/pr-policy.test.ts +145 -0
  251. package/src/components/pr-policy.ts +211 -0
  252. package/src/components/pr-run.ts +5 -2
  253. package/src/components/promote.test.ts +39 -0
  254. package/src/components/promote.ts +38 -14
  255. package/src/gate-decision-bundle.test.ts +4 -3
  256. package/src/gate-decision.ts +39 -2
  257. package/src/gate-policy-bundle.test.ts +28 -0
  258. package/src/gate-policy.test.ts +162 -0
  259. package/src/gate-policy.ts +298 -0
  260. package/src/lifecycle/__fixtures__/publish-fakes.ts +6 -1
  261. package/src/lifecycle/gate-checkpoint.ts +1 -1
  262. package/src/{workspace/trust → lifecycle}/gate-seal.ts +1 -1
  263. package/src/lifecycle/publish-declaration.ts +4 -3
  264. package/src/lifecycle/publish-npm.ts +33 -8
  265. package/src/lifecycle/publish-targets.test.ts +36 -3
  266. package/src/lifecycle/query.test.ts +91 -0
  267. package/src/lifecycle/query.ts +315 -0
  268. package/src/lifecycle/release-ledger.ts +42 -1
  269. package/src/lifecycle/run-ledger.ts +18 -0
  270. package/src/lifecycle/status.test.ts +19 -0
  271. package/src/lifecycle/status.ts +14 -6
  272. package/src/lifecycle/target-lock.test.ts +195 -0
  273. package/src/lifecycle/target-lock.ts +347 -0
  274. package/src/live-endpoint.ts +31 -0
  275. package/src/op/activities/activity-contracts.ts +31 -0
  276. package/src/op/activities/index.ts +6 -0
  277. package/src/op/activities/review-agent.test.ts +240 -0
  278. package/src/op/activities/review-agent.ts +355 -0
  279. package/src/op/builders.ts +13 -0
  280. package/src/op/cron.ts +44 -0
  281. package/src/op/gate-alias.test.ts +2 -2
  282. package/src/op/gate-approval.test.ts +30 -9
  283. package/src/op/gate-approval.ts +23 -26
  284. package/src/op/gate-quorum.test.ts +139 -0
  285. package/src/op/gate-quorum.ts +248 -0
  286. package/src/op/gate-resume.ts +6 -3
  287. package/src/op/gate-review.ts +1 -1
  288. package/src/op/gate.ts +64 -23
  289. package/src/op/index.ts +13 -1
  290. package/src/op/local-executor.ts +9 -2
  291. package/src/op/op-waves-hook-targets.test.ts +126 -0
  292. package/src/op/op-waves-policy-hold.test.ts +66 -0
  293. package/src/op/op-waves-quorum.test.ts +101 -0
  294. package/src/op/op-waves-run.ts +233 -11
  295. package/src/op/op-waves.ts +83 -10
  296. package/src/op/schedule-overdue.test.ts +220 -0
  297. package/src/op/schedule-overdue.ts +161 -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 +114 -0
  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 +146 -4
  306. package/src/pr-post.test.ts +141 -0
  307. package/src/pr-post.ts +159 -0
  308. package/src/wave-blocked-bundle.test.ts +25 -0
  309. package/src/wave-blocked.test.ts +146 -0
  310. package/src/wave-blocked.ts +271 -0
  311. package/src/wave-engine.ts +8 -0
  312. package/src/workspace/__fixtures__/two-member-workspace.ts +2 -2
  313. package/src/workspace/ci-branches.test.ts +62 -0
  314. package/src/workspace/ci-branches.ts +79 -0
  315. package/src/workspace/ci-cloud-identity.test.ts +53 -0
  316. package/src/workspace/ci-cloud-identity.ts +23 -0
  317. package/src/workspace/ci-locks.ts +16 -0
  318. package/src/workspace/ci-run-index.test.ts +2 -1
  319. package/src/workspace/ci-run.test.ts +133 -8
  320. package/src/workspace/ci-run.ts +92 -7
  321. package/src/workspace/declaration.schema.json +147 -0
  322. package/src/workspace/declaration.test.ts +76 -0
  323. package/src/workspace/declaration.ts +134 -2
  324. package/src/workspace/identity-gate-rule.ts +1 -1
  325. package/src/workspace/identity.ts +1 -1
  326. package/src/workspace/lineage-upgrade.ts +11 -8
  327. package/src/workspace/member-pipeline.test.ts +29 -0
  328. package/src/workspace/member-pipeline.ts +30 -0
  329. package/src/workspace/pr-report.schema.json +73 -2
  330. package/src/workspace/security.ts +4 -0
  331. package/src/workspace/status-gates.ts +9 -6
  332. package/src/workspace/status.schema.json +1 -0
  333. package/src/workspace/status.ts +11 -4
  334. package/src/workspace/trust/gate-seal.test.ts +1 -1
  335. package/src/workspace/trust/seal.ts +2 -2
  336. package/dist/workspace/trust/gate-seal.d.ts.map +0 -1
  337. /package/dist/{workspace/trust → lifecycle}/gate-seal.js +0 -0
@@ -0,0 +1,310 @@
1
+ /**
2
+ * Plan and apply cloud identities (#4296): the pull-request jobs plan with
3
+ * one identity and apply with another, per cloud, and the plan job trades
4
+ * its OIDC token before the change's code is checked out.
5
+ *
6
+ * The workspace declaration's `ci.identity.cloud` gives each cloud a list of
7
+ * rules. A rule names environment globs and member globs, and a plan and an
8
+ * apply identity: an AWS role, a GCP service account or an Azure client id.
9
+ * The first rule whose globs match the pipeline's environment and member is
10
+ * the pipeline's ({@link resolveCloudIdentity}). A workspace generates one
11
+ * pull-request pipeline per member and environment, so each member's apply
12
+ * jobs hold that member's apply identity and no other.
13
+ *
14
+ * The plan job runs the pull request's code. Its first step, before the
15
+ * checkout, runs {@link tradeJs} with `node -e`: it asks the forge for an
16
+ * OIDC token, trades it for the plan identity's own credentials, and leaves
17
+ * those credentials for the later steps. Each later step then starts again
18
+ * without the variables a token is requested with ({@link OIDC_REQUEST_VARS},
19
+ * through `dropForgeTokens` in ./ci-handover.ts), so the change's code holds
20
+ * no OIDC token and cannot request one. The plan job's file names no apply
21
+ * identity. The record job plans the merged commit with the plan identity,
22
+ * and the apply jobs trade for the apply identity.
23
+ *
24
+ * The trade is terragucci's `oidc-trade.ts` (INTENTIUS/terragucci e6cb431,
25
+ * packages/terragucci/src/oidc-trade.ts) with these clients:
26
+ *
27
+ * AWS STS AssumeRoleWithWebIdentity, as session keys in
28
+ * AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_SESSION_TOKEN
29
+ * GCP Google's STS token exchange, then IAM generateAccessToken for the
30
+ * service account, in GOOGLE_OAUTH_ACCESS_TOKEN and
31
+ * CLOUDSDK_AUTH_ACCESS_TOKEN
32
+ * Azure Entra's client credentials grant with the token as the client
33
+ * assertion, as an ARM access token in AZURE_ACCESS_TOKEN, with
34
+ * AZURE_CLIENT_ID and AZURE_TENANT_ID (and ARM_ spellings)
35
+ *
36
+ * terragucci leaves Azure untraded because the azurerm provider takes only
37
+ * the OIDC token. chant trades it, so the change's code never holds a token
38
+ * another client could be traded for; a tool that needs the OIDC token
39
+ * itself cannot plan under this block.
40
+ */
41
+
42
+ // @ts-ignore: picomatch has no types declaration
43
+ import picomatch from "picomatch";
44
+
45
+ /** The clouds `ci.identity.cloud` names. */
46
+ export const CLOUDS = ["aws", "gcp", "azure"] as const;
47
+ export type Cloud = (typeof CLOUDS)[number];
48
+
49
+ /** One rule: the environments and members it covers, and their two identities. */
50
+ export interface CloudIdentityRule {
51
+ /** Environment-name globs. Absent: every environment. */
52
+ environments?: string[];
53
+ /** Member-name globs. Absent: every member, and a pipeline outside a workspace member. */
54
+ members?: string[];
55
+ /** The identity the plan and record jobs take. */
56
+ plan: string;
57
+ /** The identity the apply jobs take. */
58
+ apply: string;
59
+ }
60
+
61
+ export interface AwsCloudIdentity {
62
+ /** The STS region. Default `us-east-1`. */
63
+ region?: string;
64
+ /** The OIDC token's audience. Default `sts.amazonaws.com`. */
65
+ audience?: string;
66
+ /** The STS endpoint, for an emulator. Default the region's, or `AWS_ENDPOINT_URL_STS` in the job. */
67
+ endpoint?: string;
68
+ rules: CloudIdentityRule[];
69
+ }
70
+
71
+ export interface GcpCloudIdentity {
72
+ /** The workload identity provider: `projects/<number>/locations/global/workloadIdentityPools/<pool>/providers/<provider>`. */
73
+ provider: string;
74
+ /** The OIDC token's audience. Default `https://iam.googleapis.com/<provider>`. */
75
+ audience?: string;
76
+ /** Google's STS, for an emulator. Default `https://sts.googleapis.com`. */
77
+ stsEndpoint?: string;
78
+ /** IAM credentials, for an emulator. Default `https://iamcredentials.googleapis.com`. */
79
+ iamEndpoint?: string;
80
+ rules: CloudIdentityRule[];
81
+ }
82
+
83
+ export interface AzureCloudIdentity {
84
+ /** The Entra tenant. */
85
+ tenantId: string;
86
+ /** Set as AZURE_SUBSCRIPTION_ID and ARM_SUBSCRIPTION_ID when given. */
87
+ subscriptionId?: string;
88
+ /** The OIDC token's audience. Default `api://AzureADTokenExchange`. */
89
+ audience?: string;
90
+ /** The Entra authority host, for an emulator. Default `https://login.microsoftonline.com`. */
91
+ authority?: string;
92
+ rules: CloudIdentityRule[];
93
+ }
94
+
95
+ /** `ci.identity.cloud` as the declaration holds it. */
96
+ export interface CloudIdentityDeclaration {
97
+ aws?: AwsCloudIdentity;
98
+ gcp?: GcpCloudIdentity;
99
+ azure?: AzureCloudIdentity;
100
+ }
101
+
102
+ /** What one job trades for: per cloud, the one identity and where to trade it. */
103
+ export interface CloudTradeSpec {
104
+ aws?: { role: string; region: string; audience: string; endpoint?: string };
105
+ gcp?: { serviceAccount: string; provider: string; audience: string; stsEndpoint?: string; iamEndpoint?: string };
106
+ azure?: { clientId: string; tenantId: string; audience: string; subscriptionId?: string; authority?: string };
107
+ }
108
+
109
+ /** A pipeline's two trades: the plan and record jobs', and the apply jobs'. */
110
+ export interface PipelineCloudIdentity {
111
+ plan: CloudTradeSpec;
112
+ apply: CloudTradeSpec;
113
+ }
114
+
115
+ const matches = (globs: readonly string[] | undefined, name: string | undefined): boolean =>
116
+ globs === undefined || (name !== undefined && globs.some((g) => (picomatch(g) as (s: string) => boolean)(name)));
117
+
118
+ /** The first rule that covers `env` and `member`, or undefined. */
119
+ export function ruleFor(rules: readonly CloudIdentityRule[], env: string, member?: string): CloudIdentityRule | undefined {
120
+ return rules.find((r) => matches(r.environments, env) && matches(r.members, member));
121
+ }
122
+
123
+ /** The provider's resource name without the `//iam.googleapis.com/` prefix. */
124
+ const providerPath = (p: string): string => p.replace(/^(https:)?\/\/iam\.googleapis\.com\//, "");
125
+
126
+ /**
127
+ * The plan and apply trades of the pipeline for `env` and `member`, or
128
+ * undefined when no rule of any cloud covers them. A cloud no rule covers
129
+ * is left out of both.
130
+ */
131
+ export function resolveCloudIdentity(decl: CloudIdentityDeclaration | undefined, env: string, member?: string): PipelineCloudIdentity | undefined {
132
+ if (!decl) return undefined;
133
+ const plan: CloudTradeSpec = {};
134
+ const apply: CloudTradeSpec = {};
135
+ const aws = decl.aws && ruleFor(decl.aws.rules, env, member);
136
+ if (decl.aws && aws) {
137
+ const at = { region: decl.aws.region ?? "us-east-1", audience: decl.aws.audience ?? "sts.amazonaws.com", ...(decl.aws.endpoint ? { endpoint: decl.aws.endpoint } : {}) };
138
+ plan.aws = { role: aws.plan, ...at };
139
+ apply.aws = { role: aws.apply, ...at };
140
+ }
141
+ const gcp = decl.gcp && ruleFor(decl.gcp.rules, env, member);
142
+ if (decl.gcp && gcp) {
143
+ const provider = providerPath(decl.gcp.provider);
144
+ const at = {
145
+ provider,
146
+ audience: decl.gcp.audience ?? `https://iam.googleapis.com/${provider}`,
147
+ ...(decl.gcp.stsEndpoint ? { stsEndpoint: decl.gcp.stsEndpoint } : {}),
148
+ ...(decl.gcp.iamEndpoint ? { iamEndpoint: decl.gcp.iamEndpoint } : {}),
149
+ };
150
+ plan.gcp = { serviceAccount: gcp.plan, ...at };
151
+ apply.gcp = { serviceAccount: gcp.apply, ...at };
152
+ }
153
+ const azure = decl.azure && ruleFor(decl.azure.rules, env, member);
154
+ if (decl.azure && azure) {
155
+ const at = {
156
+ tenantId: decl.azure.tenantId,
157
+ audience: decl.azure.audience ?? "api://AzureADTokenExchange",
158
+ ...(decl.azure.subscriptionId ? { subscriptionId: decl.azure.subscriptionId } : {}),
159
+ ...(decl.azure.authority ? { authority: decl.azure.authority } : {}),
160
+ };
161
+ plan.azure = { clientId: azure.plan, ...at };
162
+ apply.azure = { clientId: azure.apply, ...at };
163
+ }
164
+ return Object.keys(plan).length > 0 ? { plan, apply } : undefined;
165
+ }
166
+
167
+ /** The clouds a trade covers, in {@link CLOUDS} order. */
168
+ export function tradedClouds(spec: CloudTradeSpec): Cloud[] {
169
+ return CLOUDS.filter((c) => spec[c] !== undefined);
170
+ }
171
+
172
+ /** Every identity a trade names: the role, the service account, the client id. */
173
+ export function tradeIdentities(spec: CloudTradeSpec): string[] {
174
+ return [spec.aws?.role, spec.gcp?.serviceAccount, spec.azure?.clientId].filter((v): v is string => v !== undefined);
175
+ }
176
+
177
+ /**
178
+ * The problems of a declared `ci.identity.cloud`, each with its JSON
179
+ * pointer: a rule whose plan and apply are one identity, or a plan identity
180
+ * that is another rule's apply identity. Either would let the plan job,
181
+ * which runs the change's code, act as an apply identity.
182
+ */
183
+ export function cloudIdentityProblems(decl: CloudIdentityDeclaration): Array<{ pointer: string; message: string }> {
184
+ const out: Array<{ pointer: string; message: string }> = [];
185
+ for (const cloud of CLOUDS) {
186
+ const rules = decl[cloud]?.rules ?? [];
187
+ const applies = new Set(rules.map((r) => r.apply));
188
+ rules.forEach((r, i) => {
189
+ const pointer = `/ci/identity/cloud/${cloud}/rules/${i}`;
190
+ if (r.plan === r.apply) out.push({ pointer, message: `ci.identity.cloud.${cloud}.rules[${i}] plans and applies as ${r.plan}; the plan runs the pull request's code, so give it an identity of its own` });
191
+ else if (applies.has(r.plan)) out.push({ pointer, message: `ci.identity.cloud.${cloud}.rules[${i}] plans as ${r.plan}, which another rule applies as; the plan runs the pull request's code, so give it an identity no rule applies as` });
192
+ });
193
+ }
194
+ return out;
195
+ }
196
+
197
+ /** The variable the trade step reads its {@link CloudTradeSpec} from, as JSON. */
198
+ export const TRADE_SPEC_ENV = "CHANT_CLOUD_TRADE";
199
+
200
+ /** The GitLab `id_tokens` variable a cloud's token comes in: `CHANT_ID_TOKEN_AWS`, `_GCP`, `_AZURE`. */
201
+ export function idTokenVar(cloud: Cloud): string {
202
+ return `CHANT_ID_TOKEN_${cloud.toUpperCase()}`;
203
+ }
204
+
205
+ /**
206
+ * The variables a GitHub or Forgejo step requests an OIDC token with, and
207
+ * the GitLab `id_tokens` variables the trade reads. A step that runs the
208
+ * change's code starts without them, once the trade is done.
209
+ */
210
+ export const OIDC_REQUEST_VARS = ["ACTIONS_ID_TOKEN_REQUEST_TOKEN", "ACTIONS_ID_TOKEN_REQUEST_URL", ...CLOUDS.map(idTokenVar), TRADE_SPEC_ENV] as const;
211
+
212
+ /** The variables the trade sets, per cloud. */
213
+ export const TRADED_VARS: Record<Cloud, readonly string[]> = {
214
+ aws: ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "AWS_REGION", "AWS_DEFAULT_REGION"],
215
+ gcp: ["GOOGLE_OAUTH_ACCESS_TOKEN", "CLOUDSDK_AUTH_ACCESS_TOKEN"],
216
+ azure: ["AZURE_ACCESS_TOKEN", "AZURE_CLIENT_ID", "AZURE_TENANT_ID", "ARM_CLIENT_ID", "ARM_TENANT_ID", "AZURE_SUBSCRIPTION_ID", "ARM_SUBSCRIPTION_ID"],
217
+ };
218
+
219
+ /**
220
+ * How the trade leaves what it got. `github-env`: `NAME=value` lines
221
+ * appended to the file argv 1 names (`$GITHUB_ENV`), and an `::add-mask::`
222
+ * line for each secret, so every later step of the job has them. `sh`: a
223
+ * file of `export` lines a GitLab script sources, mode 0600.
224
+ */
225
+ export type TradeFormat = "github-env" | "sh";
226
+
227
+ /**
228
+ * The trade, a script for `node -e` with no dependency, so it runs before
229
+ * the checkout and reads none of the change's files. argv 1 is the file it
230
+ * writes and argv 2 its {@link TradeFormat}; {@link TRADE_SPEC_ENV} is the
231
+ * spec. The token comes from a cloud's {@link idTokenVar} when the job has
232
+ * it (GitLab's `id_tokens`), else from the forge's request URL with the
233
+ * cloud's audience. A value that holds anything but printable ASCII with no
234
+ * space is refused, so no answer can add a line of its own to the file. The
235
+ * script never prints a token.
236
+ */
237
+ export const TRADE_JS = [
238
+ 'const fs=require("fs");',
239
+ "const [out,format]=process.argv.slice(1);",
240
+ "const Q=String.fromCharCode(39);",
241
+ 'const fail=(m)=>{console.error("chant: the OIDC trade failed: "+m);process.exit(1)};',
242
+ `let spec;try{spec=JSON.parse(process.env.${TRADE_SPEC_ENV}||"")}catch{fail("${TRADE_SPEC_ENV} is not JSON")}`,
243
+ 'const clean=(v)=>typeof v==="string"&&/^[\\x21-\\x7e]+$/.test(v);',
244
+ 'const trim=(u)=>String(u).replace(/\\/+$/,"");',
245
+ 'const post=async(url,headers,body)=>{const r=await fetch(url,{method:"POST",headers,body});const t=await r.text();if(!r.ok)throw new Error(url+" answered "+r.status+": "+t.slice(0,200).replace(/\\s+/g," "));return t};',
246
+ 'const json=(t,what)=>{try{return JSON.parse(t)}catch{throw new Error(what+" answered with no JSON")}};',
247
+ "const idToken=async(cloud,aud)=>{",
248
+ 'const named=process.env["CHANT_ID_TOKEN_"+cloud.toUpperCase()];if(named)return named;',
249
+ "const u=process.env.ACTIONS_ID_TOKEN_REQUEST_URL,k=process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;",
250
+ 'if(!u||!k)throw new Error("the job can request no OIDC token for "+cloud+": it needs id-token: write, and a pull request from a fork gets none");',
251
+ 'const r=await fetch(u+(u.includes("?")?"&":"?")+"audience="+encodeURIComponent(aud),{headers:{authorization:"bearer "+k,accept:"application/json"}});',
252
+ 'if(!r.ok)throw new Error("the OIDC token request answered "+r.status);',
253
+ 'const v=json(await r.text(),"the OIDC token request").value;if(!clean(v))throw new Error("the OIDC token request gave no token");return v};',
254
+ "const vars={},secrets=[],said=[];",
255
+ "const run=async()=>{",
256
+ "if(spec.aws){const a=spec.aws;const tok=await idToken(\"aws\",a.audience);",
257
+ 'const ep=trim(a.endpoint||process.env.AWS_ENDPOINT_URL_STS||("https://sts."+a.region+".amazonaws.com"+(a.region.startsWith("cn-")?".cn":"")));',
258
+ 'const t=await post(ep+"/",{"content-type":"application/x-www-form-urlencoded",accept:"application/xml"},new URLSearchParams({Action:"AssumeRoleWithWebIdentity",Version:"2011-06-15",RoleArn:a.role,RoleSessionName:"chant-"+(process.env.GITHUB_RUN_ID||process.env.CI_JOB_ID||"job"),WebIdentityToken:tok}).toString());',
259
+ 'const g=(k)=>{const m=new RegExp("<"+k+">([^<]+)</"+k+">").exec(t);return m?m[1].trim():undefined};',
260
+ 'const id=g("AccessKeyId"),key=g("SecretAccessKey"),st=g("SessionToken");if(!clean(id)||!clean(key)||!clean(st))throw new Error("STS gave no credentials for "+a.role);',
261
+ 'Object.assign(vars,{AWS_ACCESS_KEY_ID:id,AWS_SECRET_ACCESS_KEY:key,AWS_SESSION_TOKEN:st,AWS_REGION:a.region,AWS_DEFAULT_REGION:a.region});secrets.push(key,st);said.push(a.role)}',
262
+ "if(spec.gcp){const c=spec.gcp;const tok=await idToken(\"gcp\",c.audience);",
263
+ 'const sts=trim(c.stsEndpoint||"https://sts.googleapis.com"),iam=trim(c.iamEndpoint||"https://iamcredentials.googleapis.com");',
264
+ 'const fed=json(await post(sts+"/v1/token",{"content-type":"application/json"},JSON.stringify({grantType:"urn:ietf:params:oauth:grant-type:token-exchange",audience:"//iam.googleapis.com/"+c.provider,scope:"https://www.googleapis.com/auth/cloud-platform",requestedTokenType:"urn:ietf:params:oauth:token-type:access_token",subjectToken:tok,subjectTokenType:"urn:ietf:params:oauth:token-type:jwt"})),"Google STS").access_token;',
265
+ 'if(!clean(fed))throw new Error("Google STS gave no token");',
266
+ 'const at=json(await post(iam+"/v1/projects/-/serviceAccounts/"+encodeURIComponent(c.serviceAccount)+":generateAccessToken",{"content-type":"application/json",authorization:"Bearer "+fed},JSON.stringify({scope:["https://www.googleapis.com/auth/cloud-platform"]})),"IAM credentials").accessToken;',
267
+ 'if(!clean(at))throw new Error("IAM credentials gave no token for "+c.serviceAccount);',
268
+ "Object.assign(vars,{GOOGLE_OAUTH_ACCESS_TOKEN:at,CLOUDSDK_AUTH_ACCESS_TOKEN:at});secrets.push(at);said.push(c.serviceAccount)}",
269
+ "if(spec.azure){const z=spec.azure;const tok=await idToken(\"azure\",z.audience);",
270
+ 'const host=trim(z.authority||"https://login.microsoftonline.com");',
271
+ 'const at=json(await post(host+"/"+encodeURIComponent(z.tenantId)+"/oauth2/v2.0/token",{"content-type":"application/x-www-form-urlencoded"},new URLSearchParams({client_id:z.clientId,scope:"https://management.azure.com/.default",grant_type:"client_credentials",client_assertion_type:"urn:ietf:params:oauth:client-assertion-type:jwt-bearer",client_assertion:tok}).toString()),"Entra").access_token;',
272
+ 'if(!clean(at))throw new Error("Entra gave no token for "+z.clientId);',
273
+ "Object.assign(vars,{AZURE_ACCESS_TOKEN:at,AZURE_CLIENT_ID:z.clientId,AZURE_TENANT_ID:z.tenantId,ARM_CLIENT_ID:z.clientId,ARM_TENANT_ID:z.tenantId});",
274
+ "if(z.subscriptionId)Object.assign(vars,{AZURE_SUBSCRIPTION_ID:z.subscriptionId,ARM_SUBSCRIPTION_ID:z.subscriptionId});secrets.push(at);said.push(z.clientId)}",
275
+ 'for(const [k,v] of Object.entries(vars))if(!clean(v))throw new Error(k+" is not a value the job can hold");',
276
+ 'if(format==="github-env"){for(const s of secrets)console.log("::add-mask::"+s);fs.appendFileSync(out,Object.entries(vars).map(([k,v])=>k+"="+v+"\\n").join(""))}',
277
+ 'else fs.writeFileSync(out,Object.entries(vars).map(([k,v])=>"export "+k+"="+Q+v.split(Q).join(Q+"\\\\"+Q+Q)+Q+"\\n").join(""),{mode:0o600});',
278
+ 'for(const s of said)console.error("chant: traded the job\\u0027s OIDC token for "+s)};',
279
+ "run().catch((e)=>fail(e&&e.message?e.message:String(e)));",
280
+ ].join("");
281
+
282
+ /**
283
+ * The shell line of a trade step: `node -e` from `/`, so no file of the
284
+ * checkout (a `package.json`, an `.npmrc`) is in reach, writing `out` in
285
+ * `format`.
286
+ */
287
+ export function tradeLine(out: string, format: TradeFormat): string {
288
+ return `(cd / && node -e '${TRADE_JS}' "${out}" ${format})`;
289
+ }
290
+
291
+ /** The GitLab file the trade writes and the plan script sources, outside the project directory. */
292
+ export const GITLAB_TRADE_FILE = "${TMPDIR:-/tmp}/chant-cloud-$CI_JOB_ID.sh";
293
+
294
+ /**
295
+ * The lines a GitLab job's script starts with when it trades: the trade,
296
+ * the unset of the token variables (and, with `extraUnset`, the forge
297
+ * tokens'), and the source of what the trade wrote, which it then deletes.
298
+ */
299
+ export function gitlabTradeLines(extraUnset: readonly string[] = []): string[] {
300
+ return [
301
+ tradeLine(GITLAB_TRADE_FILE, "sh"),
302
+ `unset ${[...extraUnset, ...OIDC_REQUEST_VARS].join(" ")}`,
303
+ `. "${GITLAB_TRADE_FILE}" && rm -f "${GITLAB_TRADE_FILE}"`,
304
+ ];
305
+ }
306
+
307
+ /** GitLab's `id_tokens:` for a trade: one per cloud, with the cloud's audience. */
308
+ export function gitlabTradeIdTokens(spec: CloudTradeSpec): Record<string, { aud: string }> {
309
+ return Object.fromEntries(tradedClouds(spec).map((c) => [idTokenVar(c), { aud: spec[c]!.audience }]));
310
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The component plan and apply paths apply the environment's declared
3
+ * endpoint before any capability runs (#4281), the way `--live` reads do
4
+ * (`../live-endpoint.ts`).
5
+ */
6
+
7
+ import { resolve } from "node:path";
8
+ import { loadChantConfig, type ChantConfig } from "../config";
9
+ import { withEnvironmentEndpoint, type EndpointLexicon } from "../live-endpoint";
10
+ import { formatWarning } from "./format";
11
+ import { loadPlugins, resolveProjectLexicons } from "./plugins";
12
+ import type { CommandContext } from "./registry";
13
+
14
+ /**
15
+ * Run `run` with the declared endpoint of `environment` (default `--env`,
16
+ * else `local`) applied to every project lexicon's endpoint var. `config` is
17
+ * loaded from the working directory when not given. The project's plugins are
18
+ * loaded only when the environment declares an endpoint and the command did
19
+ * not load them; a project whose plugins can't load then stops, since running
20
+ * on would reach the real cloud.
21
+ */
22
+ export async function withComponentEndpoint<T>(
23
+ ctx: Pick<CommandContext, "args" | "plugins">,
24
+ run: () => Promise<T>,
25
+ input: { config?: ChantConfig; environment?: string } = {},
26
+ ): Promise<T> {
27
+ const projectPath = resolve(".");
28
+ const config = input.config ?? (await loadChantConfig(projectPath).catch(() => ({ config: {} as ChantConfig }))).config;
29
+ const environment = input.environment ?? ctx.args.env ?? "local";
30
+ const lexicons = async (): Promise<readonly EndpointLexicon[]> => {
31
+ if (ctx.plugins.length > 0) return ctx.plugins;
32
+ try {
33
+ return await loadPlugins(await resolveProjectLexicons(projectPath));
34
+ } catch (err) {
35
+ throw new Error(
36
+ `environment "${environment}" declares an endpoint, but the project's lexicons could not load to apply it: ${err instanceof Error ? err.message : String(err)}`,
37
+ );
38
+ }
39
+ };
40
+ return withEnvironmentEndpoint(config.environments, environment, lexicons, run, {
41
+ warn: (message) => console.error(formatWarning({ message })),
42
+ });
43
+ }
@@ -21,6 +21,7 @@ import {
21
21
  type GateApprover, type GatePolicyDecision, type GatePolicyEvaluator, type ResolvedGateApproval,
22
22
  } from "../../op/gate-approval";
23
23
  import { approverOf, humanQuorumSince, tallyGateApprovals } from "../../op/gate";
24
+ import { quorumRuleLine, quorumRules, trustQuorumJudge, UNPROVEN_JUDGE, type GateQuorumJudge } from "../../op/gate-quorum";
24
25
  import { FAN_OUT_GATE_OP, OVERRIDE_GATE_OP, RELEASE_GATE_OP, WORKSPACE_UPGRADE_GATE_OP } from "../../op/gate-name";
25
26
  import { PR_GATE_OP } from "../../pr-loop";
26
27
  import { pushLifecycleReport, requireLifecycleLedger } from "../../lifecycle/git";
@@ -680,7 +681,9 @@ export async function recordGateApproval(
680
681
  ));
681
682
  }
682
683
  if (approval) {
683
- for (const line of describeApprovalProgress(approval, record, ledger.resolutions, standingForOrigin, ledger.pending)) {
684
+ // #4290: the rules count roles from .chant/trust.json at base, for a proven approver.
685
+ const judge = trustQuorumJudge({ signers: scope.policy, roles: scope.policy.roles });
686
+ for (const line of describeApprovalProgress(approval, record, ledger.resolutions, standingForOrigin, ledger.pending, judge)) {
684
687
  console.error(formatInfo(line));
685
688
  }
686
689
  }
@@ -699,6 +702,7 @@ export function describeApprovalProgress(
699
702
  prior: GateResolutionRecord[],
700
703
  standing: PendingGateRecord | undefined,
701
704
  pending: readonly PendingGateRecord[] = standing ? [standing] : [],
705
+ judge: GateQuorumJudge = UNPROVEN_JUDGE,
702
706
  ): string[] {
703
707
  const lines: string[] = [];
704
708
  const decision = record.policyDecision;
@@ -717,15 +721,24 @@ export function describeApprovalProgress(
717
721
  const since = standing?.timestamp ?? new Date(0).toISOString();
718
722
  // #3206: count the humans the way the run will, back past facts re-recorded for this plan.
719
723
  const humansSince = standing && samePlanDigest(standing.planDigest, record.planDigest) ? humanQuorumSince(pending, standing) : since;
720
- const tally = tallyGateApprovals([...prior, record], record.gate, since, record.planDigest, approval, humansSince);
721
- const roles = approval.quorum?.roles ? ` with role ${approval.quorum.roles.join(" or ")}` : "";
724
+ const tally = tallyGateApprovals([...prior, record], record.gate, since, record.planDigest, approval, humansSince, judge);
722
725
  const who = tally.counted.map((r) => r.resolvedBy).join(", ") || "none yet";
723
- lines.push(`Quorum: ${tally.counted.length} of ${tally.need} human approval(s)${roles} for this plan (${who}).`);
726
+ if (!Array.isArray(approval.quorum)) {
727
+ const one = quorumRules(approval.quorum)[0];
728
+ const roles = one?.roles ? ` with role ${one.roles.join(" or ")}` : "";
729
+ lines.push(`Quorum: ${tally.counted.length} of ${tally.need} human approval(s)${roles} for this plan (${who}).`);
730
+ } else {
731
+ // #4290: one line per approval rule the gate takes.
732
+ for (const p of tally.rules ?? []) lines.push(`Quorum: ${quorumRuleLine(p)}.`);
733
+ }
724
734
  if (!tally.counted.includes(record)) {
735
+ const roles = quorumRules(approval.quorum).flatMap((r) => r.roles ?? []);
725
736
  lines.push(
726
737
  approverOf(record).kind === "agent"
727
738
  ? "An agent's approval does not count toward the quorum."
728
- : `This approval does not count toward the quorum: the approver claims none of its roles (${approval.quorum?.roles?.join(", ")}).`,
739
+ : judge(record).proven
740
+ ? `This approval does not count toward the quorum: .chant/trust.json at base gives ${record.resolvedBy} none of its roles (${[...new Set(roles)].join(", ")}).`
741
+ : `This approval does not count toward the quorum: its roles are read from .chant/trust.json at base, and only for an approval sealed by a signer listed at base (chant approve --sign).`,
729
742
  );
730
743
  }
731
744
  return lines;
@@ -21,6 +21,7 @@ import type { PrLoopPipelineOptions } from "../../components/pr-pipeline";
21
21
  * `review-rules` security group (#4168); off otherwise. `requireRunnerStart`
22
22
  * is `--require-runner-start`, or the `isolated-runners` group. `--apply-on-merge`
23
23
  * (#4168) writes the apply job on merge in place of the record job.
24
+ * `--pr-env` (#4298) adds the jobs of a pull request's environment.
24
25
  */
25
26
  function prLoopOption(args: CommandContext["args"], reportUrl: string | undefined, requireReview = false, requireRunnerStart = false): PrLoopPipelineOptions {
26
27
  return {
@@ -33,6 +34,7 @@ function prLoopOption(args: CommandContext["args"], reportUrl: string | undefine
33
34
  ...(args.requireReview === true || requireReview ? { requireReview: true } : {}),
34
35
  ...(args.requireRunnerStart === true || requireRunnerStart ? { requireRunnerStart: true } : {}),
35
36
  ...(args.applyOnMerge ? { applyOnMerge: true } : {}),
37
+ ...(args.prEnv ? { prEnvironment: true } : {}),
36
38
  };
37
39
  }
38
40
 
@@ -141,6 +143,17 @@ async function runGenerateComponents(ctx: CommandContext): Promise<number> {
141
143
  }
142
144
  }
143
145
 
146
+ // #4296: the plan and apply cloud identities ci.identity.cloud gives this pipeline's environment and member.
147
+ let identity: import("../../ci-identity").PipelineCloudIdentity | undefined;
148
+ if (found && args.prLoop) {
149
+ try {
150
+ identity = await (await import("../../workspace/ci-cloud-identity")).declaredCloudIdentity(resolve(args.path), args.env ?? "production", plan?.member.name);
151
+ } catch (err) {
152
+ console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
153
+ return 1;
154
+ }
155
+ }
156
+
144
157
  // Which lexicons support generate mode is a property of the loaded lexicon
145
158
  // plugins (those implementing `generateComponentPipeline`, #688), not a
146
159
  // hard-coded core list — `generateComponentsPipeline` returns a descriptive
@@ -161,7 +174,8 @@ async function runGenerateComponents(ctx: CommandContext): Promise<number> {
161
174
  ? { gatedWaves: { gate: args.waveGate, ...(args.canary?.length ? { canary: args.canary } : {}), ...(args.base ? { base: args.base } : {}) } }
162
175
  : {}),
163
176
  // #3183: a plan job per pull request and an apply job per merge.
164
- ...(args.prLoop ? { prLoop: prLoopOption(args, reportUrl, reviewRules, isolatedRunners) } : {}),
177
+ // #4292: on the branch ci.branches routes the member to, when it routes it.
178
+ ...(args.prLoop ? { prLoop: { ...prLoopOption(args, reportUrl, reviewRules, isolatedRunners), ...(plan?.branch ? { branch: plan.branch } : {}), ...(identity ? { identity } : {}) } } : {}),
165
179
  // #4002: the job image and the lines that install what it lacks, chant itself included.
166
180
  ...(args.image ? { image: args.image } : {}),
167
181
  ...(args.beforeScript?.length ? { beforeScript: args.beforeScript } : {}),
@@ -49,6 +49,16 @@ vi.mock("../../op/gate", async () => {
49
49
  });
50
50
  const sealedGates = new Set<string>();
51
51
 
52
+ /**
53
+ * The release ledger, for the same reason (#4274): a component that publishes
54
+ * nothing is recorded by its deploy-input digest, and the real ledger is this
55
+ * repository's `chant/lifecycle`, pushed to its remote.
56
+ */
57
+ vi.mock("../../components/auto-release", async () => {
58
+ const actual = await vi.importActual<typeof import("../../components/auto-release")>("../../components/auto-release");
59
+ return { ...actual, maybeRecordAutoRelease: async () => ({ recorded: false, reason: "opted-out" }) };
60
+ });
61
+
52
62
  const { CapabilityRegistry } = await import("../../components/capability");
53
63
  const { memoryGateLedgerPort } = await import("../../op/gate");
54
64
  const { runComponentsFanOut } = await import("./fan-out");
@@ -56,9 +56,10 @@ import { samePlanDigest } from "../../lifecycle/plan-digest";
56
56
  import { affectedStacks } from "../../lifecycle/affected";
57
57
  import { changedFilesBetween, lexiconChangedUnits } from "../../components/changed-files";
58
58
  import { deriveFanOut, fanOutRegistry } from "../../components/fan-out-support";
59
- import { EarlierWaveNotAppliedError, runFanOut } from "../../components/fan-out-run";
59
+ import { EarlierWaveNotAppliedError, EarlierWaveWaitingError, runFanOut, type FanOutWaveGate } from "../../components/fan-out-run";
60
60
  import { readFanOutAttempt, writeFanOutAttempt, type FanOutAttempt } from "../../components/fan-out-record";
61
61
  import { componentInputDigests, progressThatCarries } from "../../components/fan-out-inputs";
62
+ import { appliedDigestOf } from "../../components/auto-release";
62
63
  import { describeChangedWave, withWaveRecord, type WaveRecord } from "../../gated-waves";
63
64
  import { renderFanOutHuman, renderFanOutJson, renderFanOutPlan } from "../../components/fan-out-output";
64
65
  import { ndjsonProgressSink } from "../../components/run-progress";
@@ -76,6 +77,7 @@ import { FAN_OUT_GATE_OP } from "../../op/gate-name";
76
77
  import { resolveBuildRoot } from "./lifecycle";
77
78
  import { GATED_EXIT_CODE, autoReleaseRun, recordAutoReleasesForRun } from "./run";
78
79
  import type { CommandContext } from "../registry";
80
+ import { withComponentEndpoint } from "../component-endpoint";
79
81
 
80
82
  /**
81
83
  * The stack-level change signal, from whichever end the invocation supplied.
@@ -193,6 +195,12 @@ export async function runComponentsFanOut(ctx: CommandContext): Promise<number>
193
195
  if (refusesBothStreams(args)) return 1;
194
196
  const projectPath = resolve(".");
195
197
  const { config } = await loadChantConfig(projectPath).catch(() => ({ config: {} as ChantConfig }));
198
+ // #4281: every capability the fan-out plans or runs reaches the environment's declared endpoint.
199
+ return withComponentEndpoint(ctx, () => fanOut(ctx, projectPath, config), { config });
200
+ }
201
+
202
+ async function fanOut(ctx: CommandContext, projectPath: string, config: ChantConfig): Promise<number> {
203
+ const { args } = ctx;
196
204
 
197
205
  const paramsResolution = resolveCliBuildParams(config.buildParams, {
198
206
  cli: parseParamFlags(args.param),
@@ -260,7 +268,7 @@ export async function runComponentsFanOut(ctx: CommandContext): Promise<number>
260
268
  }
261
269
 
262
270
  const gate = args.gate ? { op: FAN_OUT_GATE_OP, gate: args.gate } : undefined;
263
- const waveGate = args.waveGate
271
+ const waveGate: FanOutWaveGate | undefined = args.waveGate
264
272
  ? { op: FAN_OUT_GATE_OP, gate: args.waveGate, ...(args.wave !== undefined ? { only: args.wave } : {}) }
265
273
  : undefined;
266
274
 
@@ -279,6 +287,7 @@ export async function runComponentsFanOut(ctx: CommandContext): Promise<number>
279
287
  let priorOutputs: Record<string, Record<string, unknown>> = {};
280
288
  let priorWaves: WaveRecord[] = [];
281
289
  let priorCarried: Record<string, unknown> = {};
290
+ let priorFailed: string[] = [];
282
291
  let progress: FanOutProgress | undefined;
283
292
  if (resumePath) {
284
293
  let attempt: FanOutAttempt | undefined;
@@ -322,6 +331,12 @@ export async function runComponentsFanOut(ctx: CommandContext): Promise<number>
322
331
  );
323
332
  progress = { completed: split.carried };
324
333
  priorWaves = attempt.waves ?? [];
334
+ // #4291: what failed stays in the record until it applies, so each later wave's job blocks its dependents and runs the rest.
335
+ priorFailed = attempt.failed.filter((name) => !carried.has(name));
336
+ if (waveGate?.only !== undefined) {
337
+ waveGate.failedEarlier = priorFailed;
338
+ waveGate.waitingEarlier = priorWaves.filter((w) => w.status === "gated").map((w) => ({ wave: w.wave, gate: w.gate, components: w.components }));
339
+ }
325
340
  // What steps kept per member (#3459), such as a choudoufu root's wave
326
341
  // resume file. Each step decides whether what it kept still applies.
327
342
  priorCarried = attempt.carried ?? {};
@@ -389,7 +404,7 @@ export async function runComponentsFanOut(ctx: CommandContext): Promise<number>
389
404
  // repeated command picks up from there instead of redoing them.
390
405
  const completedSoFar = new Set(priorCompleted);
391
406
  const inputsSoFar: Record<string, string> = { ...priorInputs };
392
- const failedSoFar: string[] = [];
407
+ const failedSoFar = new Set(priorFailed);
393
408
  const outputsSoFar = { ...priorOutputs };
394
409
  let wavesSoFar = priorWaves;
395
410
  const carriedSoFar: Record<string, unknown> = { ...priorCarried };
@@ -447,8 +462,9 @@ export async function runComponentsFanOut(ctx: CommandContext): Promise<number>
447
462
  completedSoFar.add(settled.component);
448
463
  if (inputsNow[settled.component] !== undefined) inputsSoFar[settled.component] = inputsNow[settled.component]!;
449
464
  if (outputs) outputsSoFar[settled.component] = outputs;
465
+ failedSoFar.delete(settled.component);
450
466
  } else if (settled.status === "fail") {
451
- failedSoFar.push(settled.component);
467
+ failedSoFar.add(settled.component);
452
468
  }
453
469
  recordAttempt();
454
470
  },
@@ -458,6 +474,11 @@ export async function runComponentsFanOut(ctx: CommandContext): Promise<number>
458
474
  } catch (err) {
459
475
  events?.settle("failed", err instanceof Error ? err.message : String(err));
460
476
  // `--wave <n>` reached before an earlier wave applied: nothing ran.
477
+ // #4291: an earlier wave waits at its gate, so this one waits too.
478
+ if (err instanceof EarlierWaveWaitingError) {
479
+ console.error(formatInfo(err.message));
480
+ return GATED_EXIT_CODE;
481
+ }
461
482
  if (err instanceof EarlierWaveNotAppliedError) {
462
483
  console.error(formatError({ message: err.message }));
463
484
  return 1;
@@ -503,6 +524,10 @@ export async function runComponentsFanOut(ctx: CommandContext): Promise<number>
503
524
  release.runId,
504
525
  resolveAutoReleaseDisabled(config, args.noReleaseRecord),
505
526
  release.runOrigin,
527
+ undefined,
528
+ undefined,
529
+ // #4274: a component that publishes nothing is recorded by its deploy-input digest.
530
+ appliedDigestOf({ components: derived.components, env: args.env ?? "local", projectRoot: projectPath }),
506
531
  );
507
532
  }
508
533
 
@@ -27,6 +27,7 @@ import {
27
27
  import { appliedGatePath, parseAppliedGateFile, type AppliedGateLine, type FinishedGateRecord } from "../../lifecycle/gate-applied";
28
28
  import { listFilesInDir, pushLifecycleStatus, readBlobFromPath, requireLifecycleLedger } from "../../lifecycle/git";
29
29
  import { parseGateSubject } from "../../op/gate-point";
30
+ import { quorumRules, ruleNeedsProof, trustQuorumJudge, type GateQuorumJudge } from "../../op/gate-quorum";
30
31
  import {
31
32
  resolutionAnswering,
32
33
  resumeGateRun,
@@ -91,6 +92,17 @@ export interface ResumeGateOptions {
91
92
  /** The lines of `_gates/<op>/applied.jsonl` (#3858). Read from `chant/lifecycle` by {@link resumeOpGates} when not given. */
92
93
  applied?: readonly AppliedGateLine[];
93
94
  finished?: readonly FinishedGateRecord[];
95
+ /** Proves approvers for approval rules that name roles (#4290). Read from the trust policy at base when a fact needs one and none is given. */
96
+ judge?: GateQuorumJudge;
97
+ }
98
+
99
+ /** The judge a fact's approval rules are met by (#4290): the trust policy at base, read only when a rule names roles or notAuthor. */
100
+ async function judgeFor(pending: readonly PendingGateRecord[], opts: ResumeGateOptions): Promise<GateQuorumJudge | undefined> {
101
+ if (opts.judge) return opts.judge;
102
+ if (!pending.some((p) => quorumRules(p.approval?.quorum).some(ruleNeedsProof))) return undefined;
103
+ const { scopeSource } = await import("../../workspace/write-scope");
104
+ const { policy } = scopeSource(process.cwd());
105
+ return trustQuorumJudge({ signers: policy, roles: policy.roles });
94
106
  }
95
107
 
96
108
  /** Every question the workspace at `cwd` lists, by id, the lifecycle ledger's included. Empty when the workspace can't be read. */
@@ -108,6 +120,7 @@ export async function resumeStanding(
108
120
  opts: ResumeGateOptions = {},
109
121
  pending: readonly PendingGateRecord[] = [standing],
110
122
  ): Promise<GateResumeReport> {
123
+ const judge = await judgeFor([standing], opts);
111
124
  const [candidate] = planGateResumes({
112
125
  op: standing.op,
113
126
  pending: [standing],
@@ -115,7 +128,7 @@ export async function resumeStanding(
115
128
  applied: opts.applied ?? [],
116
129
  now: standing.timestamp,
117
130
  ...(opts.questions ? { questions: opts.questions } : {}),
118
- answering: (r, s) => resolutionAnswering(r, s, pending),
131
+ answering: (r, s) => resolutionAnswering(r, s, pending, judge),
119
132
  });
120
133
  return resumeCandidate(candidate!, opts);
121
134
  }
@@ -199,6 +212,7 @@ export async function resumeOpGates(op: string, gate: string | undefined, opts:
199
212
  const now = opts.now ?? new Date().toISOString();
200
213
  // Read the workspace's questions once, and only when a fact waits on one (#3835).
201
214
  const questions = opts.questions ?? (ledger.pending.some((p) => p.answer) ? await workspaceQuestions(process.cwd()) : undefined);
215
+ const judge = await judgeFor(ledger.pending, opts);
202
216
  const candidates = planGateResumes({
203
217
  op,
204
218
  pending: ledger.pending,
@@ -208,7 +222,7 @@ export async function resumeOpGates(op: string, gate: string | undefined, opts:
208
222
  now,
209
223
  ...(gate !== undefined ? { gate } : {}),
210
224
  ...(questions ? { questions } : {}),
211
- answering: resolutionAnswering,
225
+ answering: (r, s, p) => resolutionAnswering(r, s, p, judge),
212
226
  });
213
227
  const reports: GateResumeReport[] = [];
214
228
  for (const c of candidates) reports.push(await resumeCandidate(c, opts));