@intentius/chant 0.124.27 → 0.124.28

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 (450) hide show
  1. package/README.md +1 -1
  2. package/dist/__golden__/wave-loop/fan-out-gated.json +8 -0
  3. package/dist/__golden__/wave-loop/fan-out-no-members.json +4 -0
  4. package/dist/__golden__/wave-loop/fan-out-only.json +6 -0
  5. package/dist/__golden__/wave-loop/op-by-apply-hold.json +2 -0
  6. package/dist/__golden__/wave-loop/op-cost-member.json +3 -0
  7. package/dist/__golden__/wave-loop/op-held-on-before-apply.json +1 -0
  8. package/dist/__golden__/wave-loop/op-held-on.json +2 -0
  9. package/dist/__golden__/wave-loop/op-hooks-held.json +2 -0
  10. package/dist/__golden__/wave-loop/op-hooks-hold-before-apply.json +2 -0
  11. package/dist/__golden__/wave-loop/op-hooks-refused-at-base.json +3 -0
  12. package/dist/__golden__/wave-loop/op-hooks-share.json +3 -0
  13. package/dist/__golden__/wave-loop/op-per-run.json +7 -0
  14. package/dist/__golden__/wave-loop/op-pr-review.json +7 -0
  15. package/dist/__golden__/wave-loop/op-sealed.json +3 -0
  16. package/dist/__golden__/wave-loop/op-spend.json +3 -0
  17. package/dist/__golden__/wave-loop/op-wave-commands.json +7 -0
  18. package/dist/__golden__/wave-loop/op-wide-waiting.json +4 -0
  19. package/dist/change-set-graph.d.ts +56 -0
  20. package/dist/change-set-graph.d.ts.map +1 -0
  21. package/dist/change-set-graph.js +128 -0
  22. package/dist/change-set.d.ts +52 -3
  23. package/dist/change-set.d.ts.map +1 -1
  24. package/dist/change-set.js +35 -4
  25. package/dist/chant-version.d.ts +9 -0
  26. package/dist/chant-version.d.ts.map +1 -0
  27. package/dist/chant-version.js +16 -0
  28. package/dist/cli/commands/build.d.ts +8 -0
  29. package/dist/cli/commands/build.d.ts.map +1 -1
  30. package/dist/cli/commands/build.js +9 -8
  31. package/dist/cli/commands/check-lexicon-docs.d.ts.map +1 -1
  32. package/dist/cli/commands/check-lexicon-docs.js +30 -2
  33. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  34. package/dist/cli/commands/check-lexicon.js +13 -2
  35. package/dist/cli/commands/init-lexicon/templates/docs.d.ts +4 -1
  36. package/dist/cli/commands/init-lexicon/templates/docs.d.ts.map +1 -1
  37. package/dist/cli/commands/init-lexicon/templates/docs.js +6 -6
  38. package/dist/cli/commands/init-lexicon.d.ts.map +1 -1
  39. package/dist/cli/commands/init-lexicon.js +17 -8
  40. package/dist/cli/handlers/approve.d.ts.map +1 -1
  41. package/dist/cli/handlers/approve.js +19 -4
  42. package/dist/cli/handlers/build.d.ts.map +1 -1
  43. package/dist/cli/handlers/build.js +28 -0
  44. package/dist/cli/handlers/change-set.d.ts +10 -0
  45. package/dist/cli/handlers/change-set.d.ts.map +1 -1
  46. package/dist/cli/handlers/change-set.js +51 -1
  47. package/dist/cli/handlers/fan-out.d.ts +1 -1
  48. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  49. package/dist/cli/handlers/fan-out.js +14 -3
  50. package/dist/cli/handlers/pr.d.ts +8 -2
  51. package/dist/cli/handlers/pr.d.ts.map +1 -1
  52. package/dist/cli/handlers/pr.js +148 -24
  53. package/dist/cli/handlers/promote.d.ts.map +1 -1
  54. package/dist/cli/handlers/promote.js +256 -21
  55. package/dist/cli/handlers/release.d.ts +34 -0
  56. package/dist/cli/handlers/release.d.ts.map +1 -0
  57. package/dist/cli/handlers/release.js +203 -0
  58. package/dist/cli/handlers/run.d.ts +28 -2
  59. package/dist/cli/handlers/run.d.ts.map +1 -1
  60. package/dist/cli/handlers/run.js +170 -13
  61. package/dist/cli/main.d.ts.map +1 -1
  62. package/dist/cli/main.js +88 -13
  63. package/dist/cli/mcp/workspace-tools.js +1 -1
  64. package/dist/cli/parse-args.d.ts.map +1 -1
  65. package/dist/cli/parse-args.js +49 -3
  66. package/dist/cli/registry.d.ts +29 -3
  67. package/dist/cli/registry.d.ts.map +1 -1
  68. package/dist/cli/run-events.d.ts +29 -0
  69. package/dist/cli/run-events.d.ts.map +1 -0
  70. package/dist/cli/run-events.js +47 -0
  71. package/dist/cli/version.d.ts +3 -5
  72. package/dist/cli/version.d.ts.map +1 -1
  73. package/dist/cli/version.js +3 -13
  74. package/dist/codegen/docs-in-site.d.ts +71 -0
  75. package/dist/codegen/docs-in-site.d.ts.map +1 -0
  76. package/dist/codegen/docs-in-site.js +177 -0
  77. package/dist/codegen/docs-types.d.ts +14 -1
  78. package/dist/codegen/docs-types.d.ts.map +1 -1
  79. package/dist/codegen/docs.d.ts +18 -1
  80. package/dist/codegen/docs.d.ts.map +1 -1
  81. package/dist/codegen/docs.js +40 -7
  82. package/dist/codegen/rehype-base-url.mjs +15 -9
  83. package/dist/components/auto-release.d.ts +4 -1
  84. package/dist/components/auto-release.d.ts.map +1 -1
  85. package/dist/components/auto-release.js +2 -0
  86. package/dist/components/cli-support.d.ts +6 -0
  87. package/dist/components/cli-support.d.ts.map +1 -1
  88. package/dist/components/cli-support.js +10 -9
  89. package/dist/components/driver.d.ts +42 -9
  90. package/dist/components/driver.d.ts.map +1 -1
  91. package/dist/components/driver.js +81 -54
  92. package/dist/components/fan-out-run.d.ts +7 -0
  93. package/dist/components/fan-out-run.d.ts.map +1 -1
  94. package/dist/components/fan-out-run.js +35 -26
  95. package/dist/components/pr-run.d.ts +11 -0
  96. package/dist/components/pr-run.d.ts.map +1 -1
  97. package/dist/components/pr-run.js +20 -1
  98. package/dist/components/promote-order.d.ts +38 -0
  99. package/dist/components/promote-order.d.ts.map +1 -0
  100. package/dist/components/promote-order.js +62 -0
  101. package/dist/components/promote.d.ts +15 -2
  102. package/dist/components/promote.d.ts.map +1 -1
  103. package/dist/components/promote.js +17 -0
  104. package/dist/components/release-compile.d.ts +133 -0
  105. package/dist/components/release-compile.d.ts.map +1 -0
  106. package/dist/components/release-compile.js +376 -0
  107. package/dist/components/release-gate.d.ts +125 -0
  108. package/dist/components/release-gate.d.ts.map +1 -0
  109. package/dist/components/release-gate.js +282 -0
  110. package/dist/components/release-promote.d.ts +96 -0
  111. package/dist/components/release-promote.d.ts.map +1 -0
  112. package/dist/components/release-promote.js +204 -0
  113. package/dist/components/run-progress.d.ts +7 -0
  114. package/dist/components/run-progress.d.ts.map +1 -1
  115. package/dist/components/wave-plan.d.ts +3 -1
  116. package/dist/components/wave-plan.d.ts.map +1 -1
  117. package/dist/components/wave-plan.js +1 -1
  118. package/dist/config.d.ts +7 -0
  119. package/dist/config.d.ts.map +1 -1
  120. package/dist/config.js +1 -0
  121. package/dist/index.d.ts +1 -0
  122. package/dist/index.d.ts.map +1 -1
  123. package/dist/index.js +2 -0
  124. package/dist/lifecycle/change-set-store.d.ts +67 -0
  125. package/dist/lifecycle/change-set-store.d.ts.map +1 -0
  126. package/dist/lifecycle/change-set-store.js +96 -0
  127. package/dist/lifecycle/commit-sha.d.ts +13 -0
  128. package/dist/lifecycle/commit-sha.d.ts.map +1 -0
  129. package/dist/lifecycle/commit-sha.js +21 -0
  130. package/dist/lifecycle/gate-change-set.d.ts +69 -0
  131. package/dist/lifecycle/gate-change-set.d.ts.map +1 -0
  132. package/dist/lifecycle/gate-change-set.js +106 -0
  133. package/dist/lifecycle/gate-ledger.d.ts +49 -1
  134. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  135. package/dist/lifecycle/gate-ledger.js +18 -0
  136. package/dist/lifecycle/release-cut.d.ts +107 -0
  137. package/dist/lifecycle/release-cut.d.ts.map +1 -0
  138. package/dist/lifecycle/release-cut.js +273 -0
  139. package/dist/lifecycle/release-ledger.d.ts +54 -0
  140. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  141. package/dist/lifecycle/release-ledger.js +53 -20
  142. package/dist/lifecycle/release-store.d.ts +190 -0
  143. package/dist/lifecycle/release-store.d.ts.map +1 -0
  144. package/dist/lifecycle/release-store.js +318 -0
  145. package/dist/lifecycle/run-ledger.d.ts +4 -0
  146. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  147. package/dist/lifecycle/run-ledger.js +2 -0
  148. package/dist/op/activities/change-coverage.d.ts.map +1 -1
  149. package/dist/op/activities/change-coverage.js +4 -2
  150. package/dist/op/activities/source-release.d.ts.map +1 -1
  151. package/dist/op/activities/source-release.js +7 -3
  152. package/dist/op/activities/source-rollback.d.ts.map +1 -1
  153. package/dist/op/activities/source-rollback.js +7 -3
  154. package/dist/op/gate-name.d.ts +11 -2
  155. package/dist/op/gate-name.d.ts.map +1 -1
  156. package/dist/op/gate-name.js +12 -3
  157. package/dist/op/gate-run-locator.d.ts +22 -0
  158. package/dist/op/gate-run-locator.d.ts.map +1 -1
  159. package/dist/op/gate-run-locator.js +31 -0
  160. package/dist/op/gate.d.ts +27 -3
  161. package/dist/op/gate.d.ts.map +1 -1
  162. package/dist/op/gate.js +57 -3
  163. package/dist/op/local-executor.d.ts +36 -1
  164. package/dist/op/local-executor.d.ts.map +1 -1
  165. package/dist/op/local-executor.js +158 -52
  166. package/dist/op/op-waves-run.d.ts.map +1 -1
  167. package/dist/op/op-waves-run.js +4 -0
  168. package/dist/op/run-live.d.ts +2 -0
  169. package/dist/op/run-live.d.ts.map +1 -1
  170. package/dist/op/run-live.js +1 -1
  171. package/dist/op/runtime.d.ts +46 -0
  172. package/dist/op/runtime.d.ts.map +1 -1
  173. package/dist/op/runtimes/local.d.ts.map +1 -1
  174. package/dist/op/runtimes/local.js +8 -0
  175. package/dist/point-decision.d.ts +2 -0
  176. package/dist/point-decision.d.ts.map +1 -1
  177. package/dist/point-decision.js +9 -2
  178. package/dist/run-event-store.d.ts +77 -0
  179. package/dist/run-event-store.d.ts.map +1 -0
  180. package/dist/run-event-store.js +204 -0
  181. package/dist/run-event-stream.d.ts +132 -0
  182. package/dist/run-event-stream.d.ts.map +1 -0
  183. package/dist/run-event-stream.js +238 -0
  184. package/dist/run-event.d.ts +267 -0
  185. package/dist/run-event.d.ts.map +1 -0
  186. package/dist/run-event.js +207 -0
  187. package/dist/wave-runtime.mjs +204 -199
  188. package/dist/workspace/__fixtures__/run-event-check.d.ts +19 -0
  189. package/dist/workspace/__fixtures__/run-event-check.d.ts.map +1 -0
  190. package/dist/workspace/__fixtures__/run-event-check.js +59 -0
  191. package/dist/workspace/change-set-show.d.ts +56 -0
  192. package/dist/workspace/change-set-show.d.ts.map +1 -0
  193. package/dist/workspace/change-set-show.js +54 -0
  194. package/dist/workspace/change-set-show.schema.json +102 -0
  195. package/dist/workspace/change-set.schema.json +28 -1
  196. package/dist/workspace/changes-cli.d.ts.map +1 -1
  197. package/dist/workspace/changes-cli.js +10 -1
  198. package/dist/workspace/changes.d.ts +37 -0
  199. package/dist/workspace/changes.d.ts.map +1 -1
  200. package/dist/workspace/changes.js +49 -2
  201. package/dist/workspace/changes.schema.json +135 -0
  202. package/dist/workspace/ci-green-cli.d.ts +19 -4
  203. package/dist/workspace/ci-green-cli.d.ts.map +1 -1
  204. package/dist/workspace/ci-green-cli.js +75 -12
  205. package/dist/workspace/ci-green-forge.d.ts +20 -3
  206. package/dist/workspace/ci-green-forge.d.ts.map +1 -1
  207. package/dist/workspace/ci-green-forge.js +6 -4
  208. package/dist/workspace/ci-green-workflow.d.ts +11 -0
  209. package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
  210. package/dist/workspace/ci-green-workflow.js +6 -0
  211. package/dist/workspace/ci-green.d.ts +273 -13
  212. package/dist/workspace/ci-green.d.ts.map +1 -1
  213. package/dist/workspace/ci-green.js +449 -38
  214. package/dist/workspace/ci-last-green.schema.json +32 -2
  215. package/dist/workspace/ci-list.schema.json +525 -0
  216. package/dist/workspace/commit-links.d.ts +85 -0
  217. package/dist/workspace/commit-links.d.ts.map +1 -0
  218. package/dist/workspace/commit-links.js +163 -0
  219. package/dist/workspace/declaration.d.ts +19 -0
  220. package/dist/workspace/declaration.d.ts.map +1 -1
  221. package/dist/workspace/declaration.js +1 -1
  222. package/dist/workspace/declaration.schema.json +12 -0
  223. package/dist/workspace/identity-gate-rule.d.ts +37 -0
  224. package/dist/workspace/identity-gate-rule.d.ts.map +1 -1
  225. package/dist/workspace/identity-gate-rule.js +72 -7
  226. package/dist/workspace/identity.d.ts +12 -0
  227. package/dist/workspace/identity.d.ts.map +1 -1
  228. package/dist/workspace/identity.js +13 -1
  229. package/dist/workspace/intent.d.ts +6 -1
  230. package/dist/workspace/intent.d.ts.map +1 -1
  231. package/dist/workspace/intent.js +51 -1
  232. package/dist/workspace/ls.d.ts +4 -0
  233. package/dist/workspace/ls.d.ts.map +1 -1
  234. package/dist/workspace/ls.js +2 -0
  235. package/dist/workspace/ls.schema.json +3 -1
  236. package/dist/workspace/member-pipeline.d.ts +6 -0
  237. package/dist/workspace/member-pipeline.d.ts.map +1 -1
  238. package/dist/workspace/member-pipeline.js +6 -0
  239. package/dist/workspace/promotion-order.d.ts +41 -0
  240. package/dist/workspace/promotion-order.d.ts.map +1 -0
  241. package/dist/workspace/promotion-order.js +95 -0
  242. package/dist/workspace/reason-codes.d.ts +2 -0
  243. package/dist/workspace/reason-codes.d.ts.map +1 -1
  244. package/dist/workspace/reason-codes.js +4 -1
  245. package/dist/workspace/releases.d.ts +72 -0
  246. package/dist/workspace/releases.d.ts.map +1 -0
  247. package/dist/workspace/releases.js +94 -0
  248. package/dist/workspace/releases.schema.json +391 -0
  249. package/dist/workspace/run-event.schema.json +288 -0
  250. package/dist/workspace/status-gates.d.ts +26 -1
  251. package/dist/workspace/status-gates.d.ts.map +1 -1
  252. package/dist/workspace/status-gates.js +31 -2
  253. package/dist/workspace/status-stewards.d.ts +4 -0
  254. package/dist/workspace/status-stewards.d.ts.map +1 -1
  255. package/dist/workspace/status-stewards.js +2 -0
  256. package/dist/workspace/status.d.ts +39 -0
  257. package/dist/workspace/status.d.ts.map +1 -1
  258. package/dist/workspace/status.js +48 -1
  259. package/dist/workspace/status.schema.json +70 -1
  260. package/dist/workspace/trust/ci-tag-statement.d.ts +102 -0
  261. package/dist/workspace/trust/ci-tag-statement.d.ts.map +1 -0
  262. package/dist/workspace/trust/ci-tag-statement.js +123 -0
  263. package/dist/workspace/trust/op-run-statement.d.ts +91 -0
  264. package/dist/workspace/trust/op-run-statement.d.ts.map +1 -0
  265. package/dist/workspace/trust/op-run-statement.js +143 -0
  266. package/dist/workspace/trust/rotation.d.ts +18 -2
  267. package/dist/workspace/trust/rotation.d.ts.map +1 -1
  268. package/dist/workspace/trust/rotation.js +65 -16
  269. package/dist/workspace/trust/seal.d.ts +11 -0
  270. package/dist/workspace/trust/seal.d.ts.map +1 -1
  271. package/dist/workspace/trust/seal.js +9 -0
  272. package/dist/workspace/trust/ssh-commit.d.ts.map +1 -1
  273. package/dist/workspace/trust/ssh-commit.js +10 -10
  274. package/package.json +1 -1
  275. package/src/__golden__/wave-loop/fan-out-gated.json +8 -0
  276. package/src/__golden__/wave-loop/fan-out-no-members.json +4 -0
  277. package/src/__golden__/wave-loop/fan-out-only.json +6 -0
  278. package/src/__golden__/wave-loop/op-by-apply-hold.json +2 -0
  279. package/src/__golden__/wave-loop/op-cost-member.json +3 -0
  280. package/src/__golden__/wave-loop/op-held-on-before-apply.json +1 -0
  281. package/src/__golden__/wave-loop/op-held-on.json +2 -0
  282. package/src/__golden__/wave-loop/op-hooks-held.json +2 -0
  283. package/src/__golden__/wave-loop/op-hooks-hold-before-apply.json +2 -0
  284. package/src/__golden__/wave-loop/op-hooks-refused-at-base.json +3 -0
  285. package/src/__golden__/wave-loop/op-hooks-share.json +3 -0
  286. package/src/__golden__/wave-loop/op-per-run.json +7 -0
  287. package/src/__golden__/wave-loop/op-pr-review.json +7 -0
  288. package/src/__golden__/wave-loop/op-sealed.json +3 -0
  289. package/src/__golden__/wave-loop/op-spend.json +3 -0
  290. package/src/__golden__/wave-loop/op-wave-commands.json +7 -0
  291. package/src/__golden__/wave-loop/op-wide-waiting.json +4 -0
  292. package/src/change-set-graph.test.ts +72 -0
  293. package/src/change-set-graph.ts +133 -0
  294. package/src/change-set.ts +76 -6
  295. package/src/chant-version.ts +16 -0
  296. package/src/cli/commands/build.ts +19 -8
  297. package/src/cli/commands/check-lexicon-docs.test.ts +51 -0
  298. package/src/cli/commands/check-lexicon-docs.ts +39 -2
  299. package/src/cli/commands/check-lexicon.ts +14 -2
  300. package/src/cli/commands/init-lexicon/templates/docs.ts +7 -6
  301. package/src/cli/commands/init-lexicon.test.ts +25 -0
  302. package/src/cli/commands/init-lexicon.ts +18 -8
  303. package/src/cli/handlers/approve-release.test.ts +80 -0
  304. package/src/cli/handlers/approve.ts +18 -2
  305. package/src/cli/handlers/build.test.ts +52 -0
  306. package/src/cli/handlers/build.ts +29 -0
  307. package/src/cli/handlers/change-set.ts +49 -1
  308. package/src/cli/handlers/fan-out.test.ts +48 -0
  309. package/src/cli/handlers/fan-out.ts +15 -3
  310. package/src/cli/handlers/operator.test.ts +37 -0
  311. package/src/cli/handlers/pr.test.ts +194 -0
  312. package/src/cli/handlers/pr.ts +168 -34
  313. package/src/cli/handlers/promote.test.ts +389 -5
  314. package/src/cli/handlers/promote.ts +279 -20
  315. package/src/cli/handlers/release-implicit.e2e.test.ts +153 -0
  316. package/src/cli/handlers/release.e2e.test.ts +85 -0
  317. package/src/cli/handlers/release.ts +206 -0
  318. package/src/cli/handlers/run-auto-release.test.ts +71 -0
  319. package/src/cli/handlers/run-events-cmd.test.ts +92 -0
  320. package/src/cli/handlers/run.test.ts +30 -0
  321. package/src/cli/handlers/run.ts +176 -15
  322. package/src/cli/main.test.ts +7 -0
  323. package/src/cli/main.ts +88 -13
  324. package/src/cli/mcp/never-over-mcp.test.ts +2 -0
  325. package/src/cli/mcp/workspace-tools.ts +1 -1
  326. package/src/cli/parse-args.ts +37 -3
  327. package/src/cli/registry.ts +29 -3
  328. package/src/cli/run-events.ts +54 -0
  329. package/src/cli/version.ts +3 -13
  330. package/src/codegen/docs-in-site.test.ts +166 -0
  331. package/src/codegen/docs-in-site.ts +182 -0
  332. package/src/codegen/docs-site/AgentPrompt.astro +26 -1
  333. package/src/codegen/docs-types.ts +14 -1
  334. package/src/codegen/docs.ts +60 -7
  335. package/src/codegen/rehype-base-url.d.mts +3 -1
  336. package/src/codegen/rehype-base-url.mjs +15 -9
  337. package/src/codegen/rehype-base-url.test.ts +34 -0
  338. package/src/components/auto-release.test.ts +21 -0
  339. package/src/components/auto-release.ts +6 -1
  340. package/src/components/cli-support.test.ts +82 -0
  341. package/src/components/cli-support.ts +16 -9
  342. package/src/components/driver.ts +129 -56
  343. package/src/components/fan-out-run.ts +56 -35
  344. package/src/components/pr-run.ts +30 -1
  345. package/src/components/promote-order.test.ts +90 -0
  346. package/src/components/promote-order.ts +75 -0
  347. package/src/components/promote.test.ts +10 -1
  348. package/src/components/promote.ts +26 -1
  349. package/src/components/release-compile.test.ts +147 -0
  350. package/src/components/release-compile.ts +445 -0
  351. package/src/components/release-gate.test.ts +215 -0
  352. package/src/components/release-gate.ts +341 -0
  353. package/src/components/release-promote.test.ts +159 -0
  354. package/src/components/release-promote.ts +232 -0
  355. package/src/components/run-progress.ts +7 -0
  356. package/src/components/wave-plan.ts +1 -1
  357. package/src/config.ts +7 -1
  358. package/src/index.ts +2 -0
  359. package/src/lifecycle/change-set-store.test.ts +217 -0
  360. package/src/lifecycle/change-set-store.ts +112 -0
  361. package/src/lifecycle/commit-sha.ts +23 -0
  362. package/src/lifecycle/gate-change-set.e2e.test.ts +122 -0
  363. package/src/lifecycle/gate-change-set.ts +133 -0
  364. package/src/lifecycle/gate-ledger.test.ts +33 -0
  365. package/src/lifecycle/gate-ledger.ts +66 -1
  366. package/src/lifecycle/release-cut-signed.test.ts +180 -0
  367. package/src/lifecycle/release-cut.test.ts +411 -0
  368. package/src/lifecycle/release-cut.ts +367 -0
  369. package/src/lifecycle/release-ledger.test.ts +14 -0
  370. package/src/lifecycle/release-ledger.ts +85 -17
  371. package/src/lifecycle/release-store.ts +423 -0
  372. package/src/lifecycle/run-ledger.test.ts +10 -0
  373. package/src/lifecycle/run-ledger.ts +6 -0
  374. package/src/op/activities/change-coverage.ts +4 -1
  375. package/src/op/activities/source-release.ts +8 -3
  376. package/src/op/activities/source-rollback.ts +8 -4
  377. package/src/op/commit-run-gate-deploy-join.test.ts +117 -0
  378. package/src/op/gate-name.ts +13 -3
  379. package/src/op/gate-release.test.ts +126 -0
  380. package/src/op/gate-run-locator.test.ts +110 -0
  381. package/src/op/gate-run-locator.ts +35 -0
  382. package/src/op/gate.ts +88 -6
  383. package/src/op/local-executor.ts +207 -52
  384. package/src/op/op-waves-run.ts +4 -0
  385. package/src/op/run-events.test.ts +166 -0
  386. package/src/op/run-live.ts +1 -1
  387. package/src/op/runtime.ts +39 -0
  388. package/src/op/runtimes/local.test.ts +16 -0
  389. package/src/op/runtimes/local.ts +9 -0
  390. package/src/point-decision.ts +12 -2
  391. package/src/run-event-store.test.ts +144 -0
  392. package/src/run-event-store.ts +213 -0
  393. package/src/run-event-stream.test.ts +175 -0
  394. package/src/run-event-stream.ts +306 -0
  395. package/src/run-event.test.ts +277 -0
  396. package/src/run-event.ts +451 -0
  397. package/src/wave-loop-golden.test.ts +9 -0
  398. package/src/workspace/__fixtures__/run-event-check.ts +58 -0
  399. package/src/workspace/ancestry-index.test.ts +93 -0
  400. package/src/workspace/change-set-show.schema.json +102 -0
  401. package/src/workspace/change-set-show.ts +72 -0
  402. package/src/workspace/change-set.schema.json +28 -1
  403. package/src/workspace/changes-cli.ts +10 -1
  404. package/src/workspace/changes.schema.json +135 -0
  405. package/src/workspace/changes.test.ts +1 -1
  406. package/src/workspace/changes.ts +84 -2
  407. package/src/workspace/ci-green-cli.ts +75 -13
  408. package/src/workspace/ci-green-forge.ts +24 -4
  409. package/src/workspace/ci-green-runs.test.ts +242 -0
  410. package/src/workspace/ci-green-signed.test.ts +302 -0
  411. package/src/workspace/ci-green-workflow.test.ts +16 -0
  412. package/src/workspace/ci-green-workflow.ts +12 -0
  413. package/src/workspace/ci-green.test.ts +131 -2
  414. package/src/workspace/ci-green.ts +624 -42
  415. package/src/workspace/ci-last-green.schema.json +32 -2
  416. package/src/workspace/ci-list.schema.json +525 -0
  417. package/src/workspace/commit-links.test.ts +279 -0
  418. package/src/workspace/commit-links.ts +183 -0
  419. package/src/workspace/declaration.schema.json +12 -0
  420. package/src/workspace/declaration.ts +22 -2
  421. package/src/workspace/identity-gate-rule.ts +91 -7
  422. package/src/workspace/identity.ts +14 -1
  423. package/src/workspace/intent.ts +48 -1
  424. package/src/workspace/ls.schema.json +3 -1
  425. package/src/workspace/ls.ts +6 -0
  426. package/src/workspace/member-pipeline.test.ts +17 -0
  427. package/src/workspace/member-pipeline.ts +9 -0
  428. package/src/workspace/promotion-order.ts +97 -0
  429. package/src/workspace/read-contract.e2e.test.ts +4 -1
  430. package/src/workspace/reason-codes.test.ts +2 -1
  431. package/src/workspace/reason-codes.ts +4 -1
  432. package/src/workspace/releases.schema.json +391 -0
  433. package/src/workspace/releases.test.ts +108 -0
  434. package/src/workspace/releases.ts +122 -0
  435. package/src/workspace/run-event.schema.json +288 -0
  436. package/src/workspace/status-contract.test.ts +52 -1
  437. package/src/workspace/status-gates.ts +56 -1
  438. package/src/workspace/status-stewards.ts +6 -0
  439. package/src/workspace/status.schema.json +70 -1
  440. package/src/workspace/status.ts +80 -1
  441. package/src/workspace/trust/ci-tag-statement.ts +151 -0
  442. package/src/workspace/trust/op-run-statement.ts +148 -0
  443. package/src/workspace/trust/rotation.ts +61 -14
  444. package/src/workspace/trust/seal.ts +10 -0
  445. package/src/workspace/trust/ssh-commit.ts +10 -9
  446. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/astro.config.mjs +0 -14
  447. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/package.json +0 -16
  448. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content/docs/index.mdx +0 -8
  449. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content.config.ts +0 -15
  450. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/tsconfig.json +0 -10
@@ -0,0 +1,423 @@
1
+ /**
2
+ * The release (#4026, ws-104): one green commit of one member, compiled for
3
+ * every environment the member declares, stored on `chant/lifecycle`.
4
+ *
5
+ * A release is a different object from a deploy record. A deploy record is
6
+ * one line of `<env>/releases.jsonl` (./release-ledger.ts) saying a component
7
+ * was deployed. A release is what is cut once from a green commit and then
8
+ * moves through the environments: the commit, the artifacts built once from
9
+ * it, and one compile target per environment, each with the template digests
10
+ * and the build parameters that environment was compiled with.
11
+ *
12
+ * Storage, per ws-104 question 3:
13
+ *
14
+ * _releases/<releaseDigest>.json the release, written once and never rewritten
15
+ * _releases/cuts.jsonl one line per cut, with the release's label
16
+ * _releases/templates/<digest>.txt each template's bytes, by content digest
17
+ *
18
+ * `:` is written as `_` in file names, as `_plans/` and `_builds/` do. A
19
+ * workspace member's copy sits under `_members/<member>/` through the same
20
+ * `ledgerDir` prefix every other store uses.
21
+ *
22
+ * The template bytes are kept so that a promote under a release deploys the
23
+ * templates the cut recorded, not whatever the promoting checkout holds
24
+ * (ws-104 question 5). They are named by content digest, so a template that
25
+ * two environments or two releases share is stored once.
26
+ *
27
+ * `releaseDigest` is `computePlanDigest("release", subject)` over the member,
28
+ * the commit, the artifact digests and each target's env, templates and the
29
+ * name and value of each param. A param's `source`, the label, the actor, the
30
+ * time and any signature stay outside it, so two cuts of one commit give the
31
+ * same digest.
32
+ */
33
+
34
+ import { sortedJsonReplacer } from "../utils";
35
+ import { contentDigest } from "../content-digest";
36
+ import { computePlanDigest } from "./plan-digest";
37
+ import {
38
+ ledgerDir,
39
+ listFilesInDir,
40
+ readBlobBySha,
41
+ readBlobFromPath,
42
+ readPathSha,
43
+ RefCASConflictError,
44
+ writeLedgerFiles,
45
+ type LedgerFileWrite,
46
+ type LedgerLocation,
47
+ } from "./git";
48
+ import type { RunOrigin } from "./release-ledger";
49
+
50
+ /** The directory on `chant/lifecycle` that holds releases, cuts and template bytes. */
51
+ export const RELEASES_DIR = "_releases";
52
+ /** The cut log inside {@link RELEASES_DIR}. */
53
+ export const CUTS_FILE = "cuts.jsonl";
54
+ /** The directory inside {@link RELEASES_DIR} that holds template bytes by content digest. */
55
+ export const TEMPLATES_SUBDIR = "templates";
56
+
57
+ /** The `kind` a release digest is computed under, so it can never equal a plan digest of another kind. */
58
+ export const RELEASE_DIGEST_KIND = "release";
59
+
60
+ /** An artifact built once from the release's commit: an `image` or `asset` archive entry. */
61
+ export interface ReleaseArtifact {
62
+ component: string;
63
+ kind: "image" | "asset";
64
+ /** The archive path the build step wrote. */
65
+ path: string;
66
+ digest: string;
67
+ }
68
+
69
+ /** One template of a target: a file a component's deploy reads, compiled for the target's environment. */
70
+ export interface ReleaseTemplate {
71
+ /** The component whose deploy reads it, or null for a project with no components. */
72
+ component: string | null;
73
+ /** The path the deploy step names, relative to the member's directory. */
74
+ path: string;
75
+ /** `sha256:<hex>` of the file's bytes. */
76
+ digest: string;
77
+ }
78
+
79
+ /** One resolved build parameter of a target. */
80
+ export interface ReleaseParam {
81
+ name: string;
82
+ value: string | number | boolean;
83
+ /** Where the value came from: `cli`, `params-file`, `env` or `default`. Outside the digest. */
84
+ source: string;
85
+ }
86
+
87
+ /** What one environment compiles to. */
88
+ export interface ReleaseTarget {
89
+ env: string;
90
+ templates: ReleaseTemplate[];
91
+ params: ReleaseParam[];
92
+ }
93
+
94
+ /** The release file, `_releases/<releaseDigest>.json`. */
95
+ export interface Release {
96
+ version: 1;
97
+ releaseDigest: string;
98
+ /** The workspace member, its path of names joined with `/` when nested, or null outside a workspace. */
99
+ member: string | null;
100
+ /** The full sha of the green commit. */
101
+ commit: string;
102
+ artifactDigests: ReleaseArtifact[];
103
+ targets: ReleaseTarget[];
104
+ }
105
+
106
+ /** One line of `_releases/cuts.jsonl`. */
107
+ export interface ReleaseCut {
108
+ version: 1;
109
+ releaseDigest: string;
110
+ /** The human name of the release, such as `v1.4.0`, or null for a cut with none. */
111
+ label: string | null;
112
+ actor: string;
113
+ timestamp: string;
114
+ /** The CI run the cut ran in, or null outside CI. */
115
+ runOrigin: RunOrigin | null;
116
+ /**
117
+ * The cut's signature (#4031): an armored ssh signature by the actor's key
118
+ * in the `chant-release` namespace over the release digest, the label, the
119
+ * actor and the time, one per line (`releaseSealPayload`). Null for an
120
+ * unsigned cut, and on every cut written before 0.124.28.
121
+ */
122
+ signature: string | null;
123
+ }
124
+
125
+ /** The parts of a release a digest is computed from. */
126
+ export type ReleaseSubject = Pick<Release, "member" | "commit" | "artifactDigests" | "targets">;
127
+
128
+ const byString = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
129
+
130
+ /** The same release with every list in the order ws-104 sets: artifacts by component then path, targets by env, templates by component then path, params by name. */
131
+ export function normalizeRelease<T extends ReleaseSubject>(release: T): T {
132
+ return {
133
+ ...release,
134
+ artifactDigests: [...release.artifactDigests]
135
+ .map(({ component, kind, path, digest }) => ({ component, kind, path, digest }))
136
+ .sort((a, b) => byString(a.component, b.component) || byString(a.path, b.path)),
137
+ targets: [...release.targets]
138
+ .map((t) => ({
139
+ env: t.env,
140
+ templates: [...t.templates]
141
+ .map(({ component, path, digest }) => ({ component, path, digest }))
142
+ .sort((a, b) => byString(a.component ?? "", b.component ?? "") || byString(a.path, b.path)),
143
+ params: [...t.params].map(({ name, value, source }) => ({ name, value, source })).sort((a, b) => byString(a.name, b.name)),
144
+ }))
145
+ .sort((a, b) => byString(a.env, b.env)),
146
+ };
147
+ }
148
+
149
+ /** The hash input of ws-104 question 3: a param's `source` is left out. */
150
+ export function releaseHashInput(release: ReleaseSubject): unknown {
151
+ const n = normalizeRelease(release);
152
+ return {
153
+ member: n.member,
154
+ commit: n.commit,
155
+ artifactDigests: n.artifactDigests,
156
+ targets: n.targets.map((t) => ({ env: t.env, templates: t.templates, params: t.params.map(({ name, value }) => ({ name, value })) })),
157
+ };
158
+ }
159
+
160
+ /** `computePlanDigest("release", input)` over {@link releaseHashInput}. */
161
+ export function computeReleaseDigest(release: ReleaseSubject): string {
162
+ return computePlanDigest(RELEASE_DIGEST_KIND, releaseHashInput(release));
163
+ }
164
+
165
+ /** A release with its digest computed and its lists in order. */
166
+ export function makeRelease(subject: ReleaseSubject): Release {
167
+ const normalized = normalizeRelease(subject);
168
+ return { version: 1, releaseDigest: computeReleaseDigest(normalized), ...normalized };
169
+ }
170
+
171
+ /** Whether a release's own `releaseDigest` is the digest of what it holds. */
172
+ export function verifyReleaseDigest(release: Release): boolean {
173
+ return release.releaseDigest === computeReleaseDigest(release);
174
+ }
175
+
176
+ const stem = (digest: string): string => digest.replace(/:/g, "_");
177
+ const releaseFile = (digest: string): string => `${stem(digest)}.json`;
178
+ const templateFile = (digest: string): string => `${stem(digest)}.txt`;
179
+
180
+ /** A label names a release in commands such as `chant release cut <label>`, so it is one plain word. */
181
+ const LABEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$/;
182
+
183
+ /** Why a label can't be used, or undefined when it can. */
184
+ export function invalidLabel(label: string): string | undefined {
185
+ if (LABEL_PATTERN.test(label)) return undefined;
186
+ return `"${label}" is not a release label: use letters, digits and . _ + -, starting with a letter or digit, at most 128 characters`;
187
+ }
188
+
189
+ /** Thrown when a cut would give a label two digests, or a digest two labels. */
190
+ export class ReleaseLabelError extends Error {
191
+ constructor(
192
+ readonly code: "release-label-taken" | "release-already-labelled" | "release-label-invalid",
193
+ message: string,
194
+ ) {
195
+ super(message);
196
+ this.name = "ReleaseLabelError";
197
+ }
198
+ }
199
+
200
+ /** A release's template bytes, as the cut read them. */
201
+ export interface TemplateContent {
202
+ digest: string;
203
+ content: string;
204
+ }
205
+
206
+ /**
207
+ * Store a release and its template bytes in one commit. A release already
208
+ * stored under its digest is never rewritten, and a template already stored
209
+ * is not written again. Does not push.
210
+ */
211
+ export async function persistRelease(
212
+ release: Release,
213
+ templates: readonly TemplateContent[],
214
+ opts?: { cwd?: string },
215
+ ): Promise<{ commit: string | null; written: boolean }> {
216
+ if (!verifyReleaseDigest(release)) {
217
+ throw new Error(`release ${release.releaseDigest} does not hash to its own digest; refusing to store it`);
218
+ }
219
+ for (const t of templates) {
220
+ if (contentDigest(t.content) !== t.digest) throw new Error(`template bytes do not hash to ${t.digest}; refusing to store them`);
221
+ }
222
+ const dir = await ledgerDir(RELEASES_DIR, opts);
223
+ const files: LedgerFileWrite[] = [];
224
+ const releasePath = `${dir}/${releaseFile(release.releaseDigest)}`;
225
+ const exists = (await readPathSha(RELEASES_DIR, releaseFile(release.releaseDigest), opts)) !== null;
226
+ if (!exists) files.push({ path: releasePath, content: `${JSON.stringify(release, sortedJsonReplacer)}\n`, expectPriorSha: null });
227
+ const seen = new Set<string>();
228
+ for (const t of templates) {
229
+ if (seen.has(t.digest)) continue;
230
+ seen.add(t.digest);
231
+ if ((await readPathSha(`${RELEASES_DIR}/${TEMPLATES_SUBDIR}`, templateFile(t.digest), opts)) !== null) continue;
232
+ files.push({ path: `${dir}/${TEMPLATES_SUBDIR}/${templateFile(t.digest)}`, content: t.content, expectPriorSha: null });
233
+ }
234
+ if (files.length === 0) return { commit: null, written: false };
235
+ try {
236
+ const commit = await writeLedgerFiles(files, `Release ${release.releaseDigest}`, opts);
237
+ return { commit, written: !exists };
238
+ } catch (err) {
239
+ // Another writer stored the same content-addressed files first.
240
+ if (err instanceof RefCASConflictError) return { commit: null, written: false };
241
+ throw err;
242
+ }
243
+ }
244
+
245
+ /** Parse a stored release file, or null when it is not a release. */
246
+ export function parseRelease(text: string): Release | null {
247
+ let parsed: unknown;
248
+ try {
249
+ parsed = JSON.parse(text);
250
+ } catch {
251
+ return null;
252
+ }
253
+ if (!parsed || typeof parsed !== "object") return null;
254
+ const r = parsed as Partial<Release>;
255
+ const ok =
256
+ r.version === 1 &&
257
+ typeof r.releaseDigest === "string" &&
258
+ (r.member === null || typeof r.member === "string") &&
259
+ typeof r.commit === "string" &&
260
+ Array.isArray(r.artifactDigests) &&
261
+ r.artifactDigests.every((a) => a && typeof a.component === "string" && (a.kind === "image" || a.kind === "asset") && typeof a.path === "string" && typeof a.digest === "string") &&
262
+ Array.isArray(r.targets) &&
263
+ r.targets.every(
264
+ (t) =>
265
+ t &&
266
+ typeof t.env === "string" &&
267
+ Array.isArray(t.templates) &&
268
+ t.templates.every((x) => x && (x.component === null || typeof x.component === "string") && typeof x.path === "string" && typeof x.digest === "string") &&
269
+ Array.isArray(t.params) &&
270
+ t.params.every((p) => p && typeof p.name === "string" && ["string", "number", "boolean"].includes(typeof p.value) && typeof p.source === "string"),
271
+ );
272
+ return ok ? (r as Release) : null;
273
+ }
274
+
275
+ /** Read one release by digest. Null when none is stored, or when the file is not a release whose content hashes to its digest. */
276
+ export async function readRelease(digest: string, opts?: LedgerLocation): Promise<Release | null> {
277
+ const text = await readBlobFromPath(RELEASES_DIR, releaseFile(digest), opts);
278
+ if (text === null) return null;
279
+ const release = parseRelease(text);
280
+ if (!release || release.releaseDigest !== digest || !verifyReleaseDigest(release)) return null;
281
+ return release;
282
+ }
283
+
284
+ /** Every stored release, and how many release files could not be read as one. */
285
+ export async function readAllReleases(opts?: LedgerLocation): Promise<{ releases: Release[]; malformed: number }> {
286
+ const releases: Release[] = [];
287
+ let malformed = 0;
288
+ for (const name of await listFilesInDir(RELEASES_DIR, opts)) {
289
+ if (!name.endsWith(".json")) continue;
290
+ const digest = name.slice(0, -".json".length).replace(/_/g, ":");
291
+ const release = await readRelease(digest, opts);
292
+ if (release) releases.push(release);
293
+ else malformed++;
294
+ }
295
+ return { releases, malformed };
296
+ }
297
+
298
+ /** Parse the lines of `cuts.jsonl`. A line that is not a cut is counted and skipped. */
299
+ export function parseCuts(text: string | null): { cuts: ReleaseCut[]; malformed: number } {
300
+ const cuts: ReleaseCut[] = [];
301
+ let malformed = 0;
302
+ for (const line of (text ?? "").split("\n").map((l) => l.trim()).filter(Boolean)) {
303
+ try {
304
+ const c = JSON.parse(line) as Partial<ReleaseCut>;
305
+ const ok =
306
+ c &&
307
+ c.version === 1 &&
308
+ typeof c.releaseDigest === "string" &&
309
+ (c.label === null || typeof c.label === "string") &&
310
+ typeof c.actor === "string" &&
311
+ typeof c.timestamp === "string" &&
312
+ (c.runOrigin === null || (typeof c.runOrigin === "object" && typeof c.runOrigin?.forge === "string")) &&
313
+ (c.signature === null || c.signature === undefined || typeof c.signature === "string");
314
+ if (!ok) {
315
+ malformed++;
316
+ continue;
317
+ }
318
+ cuts.push({ ...(c as ReleaseCut), signature: c.signature ?? null });
319
+ } catch {
320
+ malformed++;
321
+ }
322
+ }
323
+ return { cuts, malformed };
324
+ }
325
+
326
+ /** Every cut, oldest first. */
327
+ export async function readCuts(opts?: LedgerLocation): Promise<{ cuts: ReleaseCut[]; malformed: number }> {
328
+ return parseCuts(await readBlobFromPath(RELEASES_DIR, CUTS_FILE, opts));
329
+ }
330
+
331
+ /** The label each digest carries, from its labelled cuts. */
332
+ export function labelsByDigest(cuts: readonly ReleaseCut[]): Map<string, string> {
333
+ const out = new Map<string, string>();
334
+ for (const c of cuts) if (c.label !== null && !out.has(c.releaseDigest)) out.set(c.releaseDigest, c.label);
335
+ return out;
336
+ }
337
+
338
+ /** What {@link appendCut} did. */
339
+ export type CutOutcome =
340
+ | { appended: true; cut: ReleaseCut }
341
+ /** The digest was already cut; nothing was written. `label` is the label it carries, or null. */
342
+ | { appended: false; label: string | null };
343
+
344
+ const CUT_RETRY_ATTEMPTS = 5;
345
+
346
+ /**
347
+ * Append a cut for `releaseDigest`. A label names one digest and a digest
348
+ * takes at most one label (ws-104 question 3): cutting a digest again is a
349
+ * no-op that returns its label, giving an unlabelled digest a label appends a
350
+ * cut, and cutting a digest under a label other than its own, or under a
351
+ * label another digest has, throws {@link ReleaseLabelError}. Does not push.
352
+ */
353
+ export async function appendCut(
354
+ input: Omit<ReleaseCut, "version" | "signature"> & { signature?: string | null },
355
+ opts?: { cwd?: string },
356
+ ): Promise<CutOutcome> {
357
+ if (input.label !== null) {
358
+ const why = invalidLabel(input.label);
359
+ if (why) throw new ReleaseLabelError("release-label-invalid", why);
360
+ }
361
+ let lastErr: unknown;
362
+ for (let attempt = 1; attempt <= CUT_RETRY_ATTEMPTS; attempt++) {
363
+ const priorSha = await readPathSha(RELEASES_DIR, CUTS_FILE, opts);
364
+ const existing = priorSha ? await readBlobBySha(priorSha, opts) : null;
365
+ const { cuts } = parseCuts(existing);
366
+ const labels = labelsByDigest(cuts);
367
+ const own = labels.get(input.releaseDigest);
368
+ const alreadyCut = cuts.some((c) => c.releaseDigest === input.releaseDigest);
369
+ if (own !== undefined) {
370
+ if (input.label === null || input.label === own) return { appended: false, label: own };
371
+ throw new ReleaseLabelError(
372
+ "release-already-labelled",
373
+ `release ${input.releaseDigest} is already labelled "${own}", and a release takes one label`,
374
+ );
375
+ }
376
+ if (input.label === null && alreadyCut) return { appended: false, label: null };
377
+ if (input.label !== null) {
378
+ for (const [digest, label] of labels) {
379
+ if (label === input.label) {
380
+ throw new ReleaseLabelError("release-label-taken", `the label "${input.label}" already names release ${digest}`);
381
+ }
382
+ }
383
+ }
384
+ const cut: ReleaseCut = {
385
+ version: 1,
386
+ releaseDigest: input.releaseDigest,
387
+ label: input.label,
388
+ actor: input.actor,
389
+ timestamp: input.timestamp,
390
+ runOrigin: input.runOrigin,
391
+ signature: input.signature ?? null,
392
+ };
393
+ const content = `${existing ? `${existing.replace(/\n$/, "")}\n` : ""}${JSON.stringify(cut, sortedJsonReplacer)}\n`;
394
+ try {
395
+ const dir = await ledgerDir(RELEASES_DIR, opts);
396
+ await writeLedgerFiles([{ path: `${dir}/${CUTS_FILE}`, content, expectPriorSha: priorSha }], "Release cut", opts);
397
+ return { appended: true, cut };
398
+ } catch (err) {
399
+ if (!(err instanceof RefCASConflictError)) throw err;
400
+ lastErr = err;
401
+ }
402
+ }
403
+ throw lastErr;
404
+ }
405
+
406
+ /** A template's bytes by digest, or null when none is stored. */
407
+ export async function readReleaseTemplate(digest: string, opts?: LedgerLocation): Promise<string | null> {
408
+ return readBlobFromPath(`${RELEASES_DIR}/${TEMPLATES_SUBDIR}`, templateFile(digest), opts);
409
+ }
410
+
411
+ /**
412
+ * Find a release by its digest or its label. Null when neither names one.
413
+ * A string that names a digest is read as a digest first.
414
+ */
415
+ export async function resolveRelease(ref: string, opts?: LedgerLocation): Promise<{ release: Release; label: string | null } | null> {
416
+ const { cuts } = await readCuts(opts);
417
+ const labels = labelsByDigest(cuts);
418
+ let digest: string | undefined = ref.includes(":") ? ref : undefined;
419
+ if (!digest) for (const [d, label] of labels) if (label === ref) digest = d;
420
+ if (!digest) return null;
421
+ const release = await readRelease(digest, opts);
422
+ return release ? { release, label: labels.get(digest) ?? null } : null;
423
+ }
@@ -92,6 +92,16 @@ describe("buildRunRecord", () => {
92
92
  expect(input.phases[1].steps.map((s) => s.status)).toEqual(["ok", "skipped"]);
93
93
  });
94
94
 
95
+ test("carries the commit and the CI run origin when given (#4020), and neither when not", () => {
96
+ const times = { started: "2026-01-01T00:00:00.000Z", ended: "2026-01-01T00:00:01.000Z", status: "ok" as const };
97
+ const withBoth = buildRunRecord({ name: "ship" }, [], { ...times, gitSha: "a".repeat(40), runOrigin: { forge: "github", repo: "acme/app" } });
98
+ expect(withBoth.gitSha).toBe("a".repeat(40));
99
+ expect(withBoth.runOrigin).toEqual({ forge: "github", repo: "acme/app" });
100
+ const without = buildRunRecord({ name: "ship" }, [], times);
101
+ expect(without).not.toHaveProperty("gitSha");
102
+ expect(without).not.toHaveProperty("runOrigin");
103
+ });
104
+
95
105
  test("a phase with a failed step is fail; a phase whose steps all skipped is skipped", () => {
96
106
  const input = buildRunRecord(
97
107
  { name: "deploy" },
@@ -89,6 +89,10 @@ export function buildRunRecord(
89
89
  steward?: string;
90
90
  /** The gate the run resumed at, for a run that resumed (#3947). */
91
91
  resumed?: OpRunRecord["resumed"];
92
+ /** The commit the run built from (#4020). */
93
+ gitSha?: string;
94
+ /** The CI run the Op ran in (#4020), when it ran in CI. */
95
+ runOrigin?: OpRunRecord["runOrigin"];
92
96
  /** Each phase's wall-clock time, by name, as the executor measured it. */
93
97
  phaseDurations?: Record<string, number>;
94
98
  },
@@ -138,6 +142,8 @@ export function buildRunRecord(
138
142
  ...(times.point ? { point: times.point } : {}),
139
143
  ...(times.steward ? { steward: times.steward } : {}),
140
144
  ...(times.resumed ? { resumed: times.resumed } : {}),
145
+ ...(times.gitSha ? { gitSha: times.gitSha } : {}),
146
+ ...(times.runOrigin ? { runOrigin: times.runOrigin } : {}),
141
147
  };
142
148
  }
143
149
 
@@ -55,6 +55,9 @@ export async function changeCoverage(args: ChangeCoverageArgs = {}): Promise<Cha
55
55
  findings: doc.findings.map(({ code, path, message, severity, records, triage }) => ({ code, path, message, severity, records, triage })),
56
56
  summary: doc.summary,
57
57
  };
58
- if (!doc.ok) throw new Error(`changeCoverage: ${doc.findings.length} change ${doc.findings.length === 1 ? "finding" : "findings"} at severity fail: ${doc.findings.map((f) => `${f.code} ${f.path}`).join(", ")}`);
58
+ if (!doc.ok) {
59
+ const all = [...doc.findings.map((f) => `${f.code} ${f.path}`), ...(doc.links?.findings ?? []).map((f) => `${f.code} ${f.commit.slice(0, 8)}`)];
60
+ throw new Error(`changeCoverage: ${all.length} change ${all.length === 1 ? "finding" : "findings"} at severity fail: ${all.join(", ")}`);
61
+ }
59
62
  return result;
60
63
  }
@@ -20,6 +20,7 @@
20
20
 
21
21
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
22
22
  import { join, resolve } from "node:path";
23
+ import type { ReleaseAdmission } from "../../lifecycle/release-ledger";
23
24
  import { sortedJsonReplacer } from "../../utils";
24
25
  import { archiveSourceTree, sha256Digest, type SourceArchive } from "../source-archive";
25
26
 
@@ -126,16 +127,19 @@ export async function releaseRecord(args: ReleaseRecordArgs): Promise<ReleaseRec
126
127
  const env = plan.env as string;
127
128
  const gitSha = plan.gitSha as string;
128
129
 
129
- const { readReleaseLedger, latestPerComponent, appendReleaseRecord, resolveRunId } = await import("../../lifecycle/release-ledger");
130
+ const { readReleaseLedger, latestPerComponent, appendReleaseRecord, resolveRunId, admissionOf } = await import("../../lifecycle/release-ledger");
130
131
  const { persistReleasePlan } = await import("../../lifecycle/plan-ledger");
131
132
  const { pushLifecycle } = await import("../../lifecycle/git");
132
133
 
133
134
  let approver: string | null = null;
135
+ let admission: ReleaseAdmission | undefined;
134
136
  if (args.approval) {
135
137
  const { readGateLedger } = await import("../../lifecycle/gate-ledger");
136
138
  const { resolutions } = await readGateLedger(args.approval.op, opts);
137
- const bound = resolutions.filter((r) => r.gate === args.approval!.gate && r.planDigest === plan.digest);
138
- approver = bound.length > 0 ? [...new Set(bound.map((r) => r.resolvedBy))].join(", ") : null;
139
+ // #4020: the approver and the gate resolution that admitted the release.
140
+ const admitted = admissionOf(resolutions, { ...args.approval, planDigest: plan.digest });
141
+ approver = admitted?.approver ?? null;
142
+ admission = admitted?.admission;
139
143
  }
140
144
 
141
145
  const latest = latestPerComponent((await readReleaseLedger(env, opts)).records).get(component);
@@ -160,6 +164,7 @@ export async function releaseRecord(args: ReleaseRecordArgs): Promise<ReleaseRec
160
164
  timestamp: new Date().toISOString(),
161
165
  actor,
162
166
  ...(approver ? { approver } : {}),
167
+ ...(admission ? { admission } : {}),
163
168
  },
164
169
  opts,
165
170
  );
@@ -24,7 +24,7 @@
24
24
  import { execFileSync } from "node:child_process";
25
25
  import { realpathSync } from "node:fs";
26
26
  import { join, resolve } from "node:path";
27
- import type { ReleaseRecord, ReleaseRef } from "../../lifecycle/release-ledger";
27
+ import type { ReleaseAdmission, ReleaseRecord, ReleaseRef } from "../../lifecycle/release-ledger";
28
28
  import { archiveSourceTree } from "../source-archive";
29
29
  import { readReleasePlan, releasePlan, releasePlanDigest } from "./source-release";
30
30
 
@@ -200,15 +200,18 @@ export async function releaseRollbackRecord(args: ReleaseRollbackRecordArgs): Pr
200
200
  const env = plan.env as string;
201
201
  const gitSha = plan.gitSha as string;
202
202
 
203
- const { readReleaseLedger, latestPerComponent, appendReleaseRecord, resolveRunId } = await import("../../lifecycle/release-ledger");
203
+ const { readReleaseLedger, latestPerComponent, appendReleaseRecord, resolveRunId, admissionOf } = await import("../../lifecycle/release-ledger");
204
204
  const { pushLifecycle } = await import("../../lifecycle/git");
205
205
 
206
206
  let approver: string | null = null;
207
+ let admission: ReleaseAdmission | undefined;
207
208
  if (args.approval) {
208
209
  const { readGateLedger } = await import("../../lifecycle/gate-ledger");
209
210
  const { resolutions } = await readGateLedger(args.approval.op, opts);
210
- const bound = resolutions.filter((r) => r.gate === args.approval!.gate && r.planDigest === plan.digest);
211
- approver = bound.length > 0 ? [...new Set(bound.map((r) => r.resolvedBy))].join(", ") : null;
211
+ // #4020: the approver and the gate resolution that admitted the release.
212
+ const admitted = admissionOf(resolutions, { ...args.approval, planDigest: plan.digest });
213
+ approver = admitted?.approver ?? null;
214
+ admission = admitted?.admission;
212
215
  }
213
216
 
214
217
  const latest = latestPerComponent((await readReleaseLedger(env, opts)).records.filter((r) => r.component === component)).get(component);
@@ -230,6 +233,7 @@ export async function releaseRollbackRecord(args: ReleaseRollbackRecordArgs): Pr
230
233
  timestamp: new Date().toISOString(),
231
234
  actor,
232
235
  ...(approver ? { approver } : {}),
236
+ ...(admission ? { admission } : {}),
233
237
  restores: rollback.restores,
234
238
  },
235
239
  opts,
@@ -0,0 +1,117 @@
1
+ /**
2
+ * A gated deploy leaves four records that name each other (#4020): the run
3
+ * record, the pending gate fact, the resolution and the release record. From
4
+ * any one of them, the other three are one lookup away, by the commit sha, the
5
+ * run id or the gate.
6
+ */
7
+ import { describe, test, expect, afterEach, vi } from "vitest";
8
+ import type { ActivityFn, ActivityProfile } from "./activity-registry";
9
+ import type { OpConfig } from "./types";
10
+ import { runOpLocally } from "./local-executor";
11
+ import { memoryGateLedgerPort } from "./gate";
12
+ import { resolveGateRunLocator, runOriginOfLocator } from "./gate-run-locator";
13
+ import { admissionOf, resolveRunId, type ReleaseRecord } from "../lifecycle/release-ledger";
14
+ import type { GateResolutionRecord } from "../lifecycle/gate-ledger";
15
+
16
+ const SHA = "c0ffee".repeat(6) + "c0ff";
17
+ const PROFILES: Record<string, ActivityProfile> = { single: { timeout: "5m", retry: { maximumAttempts: 1 } } };
18
+ const NOW = "2026-10-16T10:00:00.000Z";
19
+ const CI = {
20
+ GITHUB_ACTIONS: "true",
21
+ GITHUB_REPOSITORY: "acme/app",
22
+ GITHUB_RUN_ID: "9001",
23
+ GITHUB_SERVER_URL: "https://github.example",
24
+ GITHUB_API_URL: "https://github.example/api/v3",
25
+ };
26
+
27
+ afterEach(() => vi.unstubAllEnvs());
28
+
29
+ const config: OpConfig = {
30
+ name: "ship",
31
+ overview: "",
32
+ labels: { Env: "prod" },
33
+ phases: [
34
+ { name: "Build", steps: [{ kind: "activity", fn: "build" }] },
35
+ { name: "Release", steps: [{ kind: "gate", gate: "approve-release" }, { kind: "activity", fn: "release" }] },
36
+ ],
37
+ };
38
+ const activities = new Map<string, ActivityFn>([
39
+ ["build", async () => undefined],
40
+ ["release", async () => undefined],
41
+ ]);
42
+
43
+ describe("a gated deploy: commit, run, gate and deploy record join (#4020)", () => {
44
+ test("each record names the commit, and the run, gate and deploy name one another", async () => {
45
+ for (const [k, v] of Object.entries(CI)) vi.stubEnv(k, v);
46
+ const port = memoryGateLedgerPort();
47
+
48
+ // 1. The run reaches the gate and stops. Its record and the pending fact carry the commit.
49
+ const first = await runOpLocally(config, activities, PROFILES, undefined, { gates: port, now: NOW, commit: SHA });
50
+ expect(first.status).toBe("gated");
51
+ const run = first.record;
52
+ const pending = port.appended[0]!;
53
+ expect(run.gitSha).toBe(SHA);
54
+ expect(run.runOrigin).toEqual({ forge: "github", repo: "acme/app", url: "https://github.example/acme/app/actions/runs/9001" });
55
+ expect(pending.gitSha).toBe(SHA);
56
+ expect(pending.runId).toBe(run.id);
57
+
58
+ // 2. Someone approves. `chant approve` copies the commit and run off the pending fact it answers.
59
+ const resolution: GateResolutionRecord = {
60
+ version: 1, op: pending.op, gate: pending.gate, resolvedBy: "alice", timestamp: "2026-10-16T10:05:00.000Z",
61
+ gitSha: pending.gitSha, runId: pending.runId,
62
+ };
63
+ const second = await runOpLocally(config, activities, PROFILES, undefined, {
64
+ gates: memoryGateLedgerPort({ pending: [pending], resolutions: [resolution] }),
65
+ now: "2026-10-16T10:06:00.000Z",
66
+ commit: SHA,
67
+ });
68
+ expect(second.status).toBe("ok");
69
+
70
+ // 3. The release activity records the deploy, naming the gate that admitted it.
71
+ const admitted = admissionOf([resolution], { op: "ship", gate: "approve-release" })!;
72
+ const ci = resolveRunId();
73
+ const release: ReleaseRecord = {
74
+ version: 1, component: "site", env: "prod", digest: `sha256:${"d".repeat(64)}`, gitSha: SHA,
75
+ runId: ci.runId, runOrigin: ci.runOrigin, timestamp: "2026-10-16T10:07:00.000Z", actor: "ci",
76
+ approver: admitted.approver, admission: admitted.admission,
77
+ };
78
+
79
+ // From the commit: the run, the gate and the deploy.
80
+ const byCommit = {
81
+ runs: [run, second.record].filter((r) => r.gitSha === SHA),
82
+ pending: port.appended.filter((p) => p.gitSha === SHA),
83
+ resolutions: [resolution].filter((r) => r.gitSha === SHA),
84
+ releases: [release].filter((r) => r.gitSha === SHA),
85
+ };
86
+ expect(byCommit.runs.map((r) => r.id)).toContain(run.id);
87
+ expect(byCommit.pending).toEqual([pending]);
88
+ expect(byCommit.resolutions).toEqual([resolution]);
89
+ expect(byCommit.releases).toEqual([release]);
90
+
91
+ // From the run: its gate, by run id, and the CI run it ran in.
92
+ expect(run.gate).toMatchObject({ name: pending.gate });
93
+ expect(resolution.runId).toBe(run.id);
94
+ expect(pending.runId).toBe(run.id);
95
+
96
+ // From the deploy: the gate resolution that admitted it, the approver, and the CI run the run was in.
97
+ expect(release.admission).toEqual({ op: pending.op, gate: pending.gate, resolvedAt: resolution.timestamp });
98
+ expect(release.approver).toBe(resolution.resolvedBy);
99
+ expect(release.runOrigin).toEqual(run.runOrigin);
100
+ expect(release.runId).toBe(runOriginOfLocator(resolveGateRunLocator()!).runId);
101
+ expect(release.gitSha).toBe(run.gitSha);
102
+ });
103
+
104
+ test("a run outside CI records its commit and no run origin", async () => {
105
+ for (const k of ["GITHUB_RUN_ID", "CI_PIPELINE_ID"]) vi.stubEnv(k, "");
106
+ const result = await runOpLocally(config, activities, PROFILES, undefined, { gates: memoryGateLedgerPort(), now: NOW, commit: SHA });
107
+ expect(result.record.gitSha).toBe(SHA);
108
+ expect(result.record).not.toHaveProperty("runOrigin");
109
+ });
110
+
111
+ test("admissionOf names the newest of several approvals and joins their approvers", () => {
112
+ const at = (resolvedBy: string, timestamp: string): GateResolutionRecord => ({ version: 1, op: "ship", gate: "g", resolvedBy, timestamp, planDigest: "p" });
113
+ expect(admissionOf([at("a", "2026-01-01T00:00:00Z"), at("b", "2026-01-02T00:00:00Z"), at("a", "2026-01-03T00:00:00Z")], { op: "ship", gate: "g", planDigest: "p" }))
114
+ .toEqual({ approver: "a, b", admission: { op: "ship", gate: "g", resolvedAt: "2026-01-03T00:00:00Z" } });
115
+ expect(admissionOf([at("a", "2026-01-01T00:00:00Z")], { op: "ship", gate: "g", planDigest: "other" })).toBeUndefined();
116
+ });
117
+ });