@intentius/chant 0.124.28 → 0.124.29

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 (395) hide show
  1. package/dist/build-params.d.ts +34 -1
  2. package/dist/build-params.d.ts.map +1 -1
  3. package/dist/build-params.js +31 -1
  4. package/dist/change-set-graph.d.ts +8 -1
  5. package/dist/change-set-graph.d.ts.map +1 -1
  6. package/dist/change-set-graph.js +4 -2
  7. package/dist/change-set.d.ts +10 -0
  8. package/dist/change-set.d.ts.map +1 -1
  9. package/dist/change-set.js +4 -3
  10. package/dist/cli/build-params-cli.d.ts +7 -1
  11. package/dist/cli/build-params-cli.d.ts.map +1 -1
  12. package/dist/cli/build-params-cli.js +17 -8
  13. package/dist/cli/commands/build.d.ts.map +1 -1
  14. package/dist/cli/commands/build.js +6 -3
  15. package/dist/cli/handlers/approve.d.ts.map +1 -1
  16. package/dist/cli/handlers/approve.js +45 -0
  17. package/dist/cli/handlers/build.d.ts.map +1 -1
  18. package/dist/cli/handlers/build.js +15 -4
  19. package/dist/cli/handlers/fan-out.d.ts +9 -0
  20. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  21. package/dist/cli/handlers/fan-out.js +40 -3
  22. package/dist/cli/handlers/lifecycle-gc.d.ts +19 -0
  23. package/dist/cli/handlers/lifecycle-gc.d.ts.map +1 -0
  24. package/dist/cli/handlers/lifecycle-gc.js +143 -0
  25. package/dist/cli/handlers/pr.d.ts +11 -3
  26. package/dist/cli/handlers/pr.d.ts.map +1 -1
  27. package/dist/cli/handlers/pr.js +134 -26
  28. package/dist/cli/handlers/promote.d.ts +7 -0
  29. package/dist/cli/handlers/promote.d.ts.map +1 -1
  30. package/dist/cli/handlers/promote.js +210 -33
  31. package/dist/cli/handlers/release.d.ts +62 -5
  32. package/dist/cli/handlers/release.d.ts.map +1 -1
  33. package/dist/cli/handlers/release.js +414 -28
  34. package/dist/cli/handlers/run.d.ts +11 -1
  35. package/dist/cli/handlers/run.d.ts.map +1 -1
  36. package/dist/cli/handlers/run.js +67 -6
  37. package/dist/cli/main.d.ts.map +1 -1
  38. package/dist/cli/main.js +61 -10
  39. package/dist/cli/parse-args.d.ts.map +1 -1
  40. package/dist/cli/parse-args.js +24 -2
  41. package/dist/cli/registry.d.ts +15 -3
  42. package/dist/cli/registry.d.ts.map +1 -1
  43. package/dist/cli/run-events.d.ts +3 -2
  44. package/dist/cli/run-events.d.ts.map +1 -1
  45. package/dist/cli/run-events.js +5 -3
  46. package/dist/components/auto-release.d.ts +2 -0
  47. package/dist/components/auto-release.d.ts.map +1 -1
  48. package/dist/components/auto-release.js +1 -0
  49. package/dist/components/capability.d.ts +9 -0
  50. package/dist/components/capability.d.ts.map +1 -1
  51. package/dist/components/driver.d.ts +8 -0
  52. package/dist/components/driver.d.ts.map +1 -1
  53. package/dist/components/driver.js +35 -0
  54. package/dist/components/fan-out-inputs.d.ts +69 -0
  55. package/dist/components/fan-out-inputs.d.ts.map +1 -0
  56. package/dist/components/fan-out-inputs.js +169 -0
  57. package/dist/components/fan-out-record.d.ts +13 -0
  58. package/dist/components/fan-out-record.d.ts.map +1 -1
  59. package/dist/components/fan-out-record.js +16 -0
  60. package/dist/components/fan-out-run.d.ts.map +1 -1
  61. package/dist/components/fan-out-run.js +50 -0
  62. package/dist/components/fan-out.d.ts +4 -0
  63. package/dist/components/fan-out.d.ts.map +1 -1
  64. package/dist/components/pr-pipeline.d.ts +25 -0
  65. package/dist/components/pr-pipeline.d.ts.map +1 -1
  66. package/dist/components/pr-pipeline.js +22 -4
  67. package/dist/components/promote.d.ts +23 -0
  68. package/dist/components/promote.d.ts.map +1 -1
  69. package/dist/components/promote.js +60 -2
  70. package/dist/components/release-compile.d.ts +9 -0
  71. package/dist/components/release-compile.d.ts.map +1 -1
  72. package/dist/components/release-compile.js +9 -3
  73. package/dist/components/release-gate.d.ts +11 -1
  74. package/dist/components/release-gate.d.ts.map +1 -1
  75. package/dist/components/release-gate.js +10 -4
  76. package/dist/components/wave-plan.d.ts +5 -0
  77. package/dist/components/wave-plan.d.ts.map +1 -1
  78. package/dist/components/wave-plan.js +10 -2
  79. package/dist/config.d.ts +38 -0
  80. package/dist/config.d.ts.map +1 -1
  81. package/dist/config.js +2 -0
  82. package/dist/flag-params.d.ts +54 -0
  83. package/dist/flag-params.d.ts.map +1 -0
  84. package/dist/flag-params.js +130 -0
  85. package/dist/lifecycle/__fixtures__/publish-fakes.d.ts +120 -0
  86. package/dist/lifecycle/__fixtures__/publish-fakes.d.ts.map +1 -0
  87. package/dist/lifecycle/__fixtures__/publish-fakes.js +304 -0
  88. package/dist/lifecycle/change-set-store.d.ts +2 -1
  89. package/dist/lifecycle/change-set-store.d.ts.map +1 -1
  90. package/dist/lifecycle/change-set-store.js +2 -1
  91. package/dist/lifecycle/delivery-metrics.d.ts +139 -0
  92. package/dist/lifecycle/delivery-metrics.d.ts.map +1 -0
  93. package/dist/lifecycle/delivery-metrics.js +145 -0
  94. package/dist/lifecycle/deploy-outcome.d.ts +81 -0
  95. package/dist/lifecycle/deploy-outcome.d.ts.map +1 -0
  96. package/dist/lifecycle/deploy-outcome.js +101 -0
  97. package/dist/lifecycle/gate-change-set.d.ts +10 -1
  98. package/dist/lifecycle/gate-change-set.d.ts.map +1 -1
  99. package/dist/lifecycle/gate-change-set.js +35 -3
  100. package/dist/lifecycle/gate-ledger.d.ts +2 -3
  101. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  102. package/dist/lifecycle/git.d.ts +30 -1
  103. package/dist/lifecycle/git.d.ts.map +1 -1
  104. package/dist/lifecycle/git.js +31 -5
  105. package/dist/lifecycle/index.d.ts +4 -0
  106. package/dist/lifecycle/index.d.ts.map +1 -1
  107. package/dist/lifecycle/index.js +4 -0
  108. package/dist/lifecycle/mixed-environment.d.ts +95 -0
  109. package/dist/lifecycle/mixed-environment.d.ts.map +1 -0
  110. package/dist/lifecycle/mixed-environment.js +154 -0
  111. package/dist/lifecycle/publish-attachments.d.ts +79 -0
  112. package/dist/lifecycle/publish-attachments.d.ts.map +1 -0
  113. package/dist/lifecycle/publish-attachments.js +179 -0
  114. package/dist/lifecycle/publish-credentials.d.ts +37 -0
  115. package/dist/lifecycle/publish-credentials.d.ts.map +1 -0
  116. package/dist/lifecycle/publish-credentials.js +69 -0
  117. package/dist/lifecycle/publish-declaration.d.ts +97 -0
  118. package/dist/lifecycle/publish-declaration.d.ts.map +1 -0
  119. package/dist/lifecycle/publish-declaration.js +83 -0
  120. package/dist/lifecycle/publish-forge.d.ts +53 -0
  121. package/dist/lifecycle/publish-forge.d.ts.map +1 -0
  122. package/dist/lifecycle/publish-forge.js +298 -0
  123. package/dist/lifecycle/publish-npm.d.ts +61 -0
  124. package/dist/lifecycle/publish-npm.d.ts.map +1 -0
  125. package/dist/lifecycle/publish-npm.js +242 -0
  126. package/dist/lifecycle/publish-oci.d.ts +64 -0
  127. package/dist/lifecycle/publish-oci.d.ts.map +1 -0
  128. package/dist/lifecycle/publish-oci.js +383 -0
  129. package/dist/lifecycle/publish-record.d.ts +66 -0
  130. package/dist/lifecycle/publish-record.d.ts.map +1 -0
  131. package/dist/lifecycle/publish-record.js +84 -0
  132. package/dist/lifecycle/publish-targets.d.ts +45 -0
  133. package/dist/lifecycle/publish-targets.d.ts.map +1 -0
  134. package/dist/lifecycle/publish-targets.js +85 -0
  135. package/dist/lifecycle/release-cut.d.ts +48 -1
  136. package/dist/lifecycle/release-cut.d.ts.map +1 -1
  137. package/dist/lifecycle/release-cut.js +116 -23
  138. package/dist/lifecycle/release-join.d.ts +95 -0
  139. package/dist/lifecycle/release-join.d.ts.map +1 -0
  140. package/dist/lifecycle/release-join.js +139 -0
  141. package/dist/lifecycle/release-publish.d.ts +312 -0
  142. package/dist/lifecycle/release-publish.d.ts.map +1 -0
  143. package/dist/lifecycle/release-publish.js +335 -0
  144. package/dist/lifecycle/release-revocation.d.ts +80 -0
  145. package/dist/lifecycle/release-revocation.d.ts.map +1 -0
  146. package/dist/lifecycle/release-revocation.js +123 -0
  147. package/dist/lifecycle/release-store.d.ts +12 -2
  148. package/dist/lifecycle/release-store.d.ts.map +1 -1
  149. package/dist/lifecycle/release-store.js +4 -2
  150. package/dist/lifecycle/release-verify.d.ts +61 -0
  151. package/dist/lifecycle/release-verify.d.ts.map +1 -0
  152. package/dist/lifecycle/release-verify.js +92 -0
  153. package/dist/lifecycle/release-workstation.d.ts +91 -0
  154. package/dist/lifecycle/release-workstation.d.ts.map +1 -0
  155. package/dist/lifecycle/release-workstation.js +180 -0
  156. package/dist/lifecycle/retention-policy.d.ts +45 -0
  157. package/dist/lifecycle/retention-policy.d.ts.map +1 -0
  158. package/dist/lifecycle/retention-policy.js +43 -0
  159. package/dist/lifecycle/retention.d.ts +185 -0
  160. package/dist/lifecycle/retention.d.ts.map +1 -0
  161. package/dist/lifecycle/retention.js +400 -0
  162. package/dist/plan-summary.d.ts.map +1 -1
  163. package/dist/plan-summary.js +8 -2
  164. package/dist/pr-loop.d.ts +7 -0
  165. package/dist/pr-loop.d.ts.map +1 -1
  166. package/dist/pr-loop.js +23 -4
  167. package/dist/pr-report-url.d.ts +82 -0
  168. package/dist/pr-report-url.d.ts.map +1 -0
  169. package/dist/pr-report-url.js +174 -0
  170. package/dist/provenance.d.ts +2 -2
  171. package/dist/provenance.d.ts.map +1 -1
  172. package/dist/run-event-store.d.ts +29 -2
  173. package/dist/run-event-store.d.ts.map +1 -1
  174. package/dist/run-event-store.js +50 -3
  175. package/dist/run-event-stream.d.ts +2 -1
  176. package/dist/run-event-stream.d.ts.map +1 -1
  177. package/dist/run-event-stream.js +5 -1
  178. package/dist/run-event.d.ts +19 -1
  179. package/dist/run-event.d.ts.map +1 -1
  180. package/dist/run-event.js +2 -0
  181. package/dist/wave-runtime.mjs +260 -229
  182. package/dist/workspace/__fixtures__/flag-workspace.d.ts +31 -0
  183. package/dist/workspace/__fixtures__/flag-workspace.d.ts.map +1 -0
  184. package/dist/workspace/__fixtures__/flag-workspace.js +114 -0
  185. package/dist/workspace/change-set.schema.json +5 -1
  186. package/dist/workspace/check.schema.json +4 -2
  187. package/dist/workspace/checks/records.d.ts +9 -1
  188. package/dist/workspace/checks/records.d.ts.map +1 -1
  189. package/dist/workspace/checks/records.js +35 -1
  190. package/dist/workspace/checks.d.ts +1 -0
  191. package/dist/workspace/checks.d.ts.map +1 -1
  192. package/dist/workspace/checks.js +2 -1
  193. package/dist/workspace/ci-green.d.ts +8 -0
  194. package/dist/workspace/ci-green.d.ts.map +1 -1
  195. package/dist/workspace/ci-green.js +10 -3
  196. package/dist/workspace/declaration.d.ts +16 -0
  197. package/dist/workspace/declaration.d.ts.map +1 -1
  198. package/dist/workspace/declaration.js +26 -0
  199. package/dist/workspace/declaration.schema.json +64 -0
  200. package/dist/workspace/declared-kinds.d.ts +16 -0
  201. package/dist/workspace/declared-kinds.d.ts.map +1 -1
  202. package/dist/workspace/declared-kinds.js +25 -0
  203. package/dist/workspace/delivery-metrics.d.ts +91 -0
  204. package/dist/workspace/delivery-metrics.d.ts.map +1 -0
  205. package/dist/workspace/delivery-metrics.js +217 -0
  206. package/dist/workspace/delivery-metrics.schema.json +519 -0
  207. package/dist/workspace/flags-read.d.ts +37 -0
  208. package/dist/workspace/flags-read.d.ts.map +1 -0
  209. package/dist/workspace/flags-read.js +103 -0
  210. package/dist/workspace/flags.d.ts +100 -0
  211. package/dist/workspace/flags.d.ts.map +1 -0
  212. package/dist/workspace/flags.js +98 -0
  213. package/dist/workspace/graph-cli.js +1 -1
  214. package/dist/workspace/intent-cli.d.ts +11 -2
  215. package/dist/workspace/intent-cli.d.ts.map +1 -1
  216. package/dist/workspace/intent-cli.js +54 -5
  217. package/dist/workspace/intent-trace.d.ts +219 -0
  218. package/dist/workspace/intent-trace.d.ts.map +1 -0
  219. package/dist/workspace/intent-trace.js +314 -0
  220. package/dist/workspace/intent.d.ts +30 -6
  221. package/dist/workspace/intent.d.ts.map +1 -1
  222. package/dist/workspace/intent.js +141 -21
  223. package/dist/workspace/intent.schema.json +702 -6
  224. package/dist/workspace/overlap.d.ts +146 -0
  225. package/dist/workspace/overlap.d.ts.map +1 -0
  226. package/dist/workspace/overlap.js +276 -0
  227. package/dist/workspace/reason-codes.d.ts +2 -0
  228. package/dist/workspace/reason-codes.d.ts.map +1 -1
  229. package/dist/workspace/reason-codes.js +3 -0
  230. package/dist/workspace/records.d.ts +15 -2
  231. package/dist/workspace/records.d.ts.map +1 -1
  232. package/dist/workspace/records.js +32 -0
  233. package/dist/workspace/records.schema.json +21 -2
  234. package/dist/workspace/reference-kinds/flag/flag.kind.mjs +32 -0
  235. package/dist/workspace/reference-kinds/flag/flag.schema.json +53 -0
  236. package/dist/workspace/releases.d.ts +9 -0
  237. package/dist/workspace/releases.d.ts.map +1 -1
  238. package/dist/workspace/releases.js +9 -0
  239. package/dist/workspace/releases.schema.json +123 -1
  240. package/dist/workspace/run-event.schema.json +10 -3
  241. package/dist/workspace/status-gates.d.ts +8 -0
  242. package/dist/workspace/status-gates.d.ts.map +1 -1
  243. package/dist/workspace/status.d.ts +78 -0
  244. package/dist/workspace/status.d.ts.map +1 -1
  245. package/dist/workspace/status.js +170 -3
  246. package/dist/workspace/status.schema.json +198 -1
  247. package/dist/workspace/wip.d.ts.map +1 -1
  248. package/dist/workspace/wip.js +11 -3
  249. package/package.json +1 -1
  250. package/src/build-params.test.ts +46 -0
  251. package/src/build-params.ts +55 -1
  252. package/src/change-set-graph.ts +10 -2
  253. package/src/change-set.ts +14 -3
  254. package/src/cli/build-params-cli.ts +22 -9
  255. package/src/cli/commands/build.ts +6 -3
  256. package/src/cli/handlers/approve-release.test.ts +5 -1
  257. package/src/cli/handlers/approve-revoked.test.ts +155 -0
  258. package/src/cli/handlers/approve.ts +47 -0
  259. package/src/cli/handlers/build.test.ts +15 -0
  260. package/src/cli/handlers/build.ts +17 -4
  261. package/src/cli/handlers/fan-out.test.ts +180 -1
  262. package/src/cli/handlers/fan-out.ts +38 -3
  263. package/src/cli/handlers/intent-trace.e2e.test.ts +201 -0
  264. package/src/cli/handlers/lifecycle-gc.test.ts +126 -0
  265. package/src/cli/handlers/lifecycle-gc.ts +146 -0
  266. package/src/cli/handlers/pr.test.ts +148 -1
  267. package/src/cli/handlers/pr.ts +139 -28
  268. package/src/cli/handlers/promote.test.ts +280 -2
  269. package/src/cli/handlers/promote.ts +210 -34
  270. package/src/cli/handlers/release-implicit.e2e.test.ts +12 -4
  271. package/src/cli/handlers/release-join.e2e.test.ts +185 -0
  272. package/src/cli/handlers/release-publish-targets.e2e.test.ts +295 -0
  273. package/src/cli/handlers/release-verify.e2e.test.ts +117 -0
  274. package/src/cli/handlers/release-workstation-ci.e2e.test.ts +135 -0
  275. package/src/cli/handlers/release.e2e.test.ts +146 -32
  276. package/src/cli/handlers/release.ts +415 -28
  277. package/src/cli/handlers/run-auto-release.test.ts +9 -0
  278. package/src/cli/handlers/run-release.test.ts +55 -0
  279. package/src/cli/handlers/run.ts +69 -5
  280. package/src/cli/main.test.ts +1 -0
  281. package/src/cli/main.ts +61 -10
  282. package/src/cli/parse-args.ts +19 -2
  283. package/src/cli/registry.ts +15 -3
  284. package/src/cli/run-events.ts +5 -2
  285. package/src/components/auto-release.test.ts +9 -0
  286. package/src/components/auto-release.ts +3 -0
  287. package/src/components/capability.ts +9 -0
  288. package/src/components/driver.test.ts +24 -0
  289. package/src/components/driver.ts +36 -0
  290. package/src/components/fan-out-inputs.test.ts +85 -0
  291. package/src/components/fan-out-inputs.ts +192 -0
  292. package/src/components/fan-out-record.test.ts +10 -0
  293. package/src/components/fan-out-record.ts +19 -0
  294. package/src/components/fan-out-run.ts +48 -0
  295. package/src/components/fan-out.ts +4 -0
  296. package/src/components/pr-pipeline.ts +35 -4
  297. package/src/components/promote.ts +68 -2
  298. package/src/components/release-compile.ts +17 -3
  299. package/src/components/release-gate.ts +18 -4
  300. package/src/components/wave-plan.ts +15 -2
  301. package/src/config.ts +8 -0
  302. package/src/flag-params.test.ts +144 -0
  303. package/src/flag-params.ts +134 -0
  304. package/src/lifecycle/__fixtures__/publish-fakes.ts +351 -0
  305. package/src/lifecycle/change-set-store.ts +2 -1
  306. package/src/lifecycle/delivery-metrics.ts +263 -0
  307. package/src/lifecycle/deploy-outcome.ts +147 -0
  308. package/src/lifecycle/gate-change-set.ts +37 -6
  309. package/src/lifecycle/gate-ledger.ts +2 -2
  310. package/src/lifecycle/git.ts +61 -8
  311. package/src/lifecycle/index.ts +4 -0
  312. package/src/lifecycle/mixed-environment.test.ts +97 -0
  313. package/src/lifecycle/mixed-environment.ts +204 -0
  314. package/src/lifecycle/publish-attachments.ts +196 -0
  315. package/src/lifecycle/publish-credentials.ts +84 -0
  316. package/src/lifecycle/publish-declaration.ts +95 -0
  317. package/src/lifecycle/publish-forge.ts +325 -0
  318. package/src/lifecycle/publish-npm.ts +259 -0
  319. package/src/lifecycle/publish-oci.ts +396 -0
  320. package/src/lifecycle/publish-record.ts +113 -0
  321. package/src/lifecycle/publish-resume.test.ts +168 -0
  322. package/src/lifecycle/publish-targets.test.ts +494 -0
  323. package/src/lifecycle/publish-targets.ts +100 -0
  324. package/src/lifecycle/release-cut.test.ts +74 -2
  325. package/src/lifecycle/release-cut.ts +158 -22
  326. package/src/lifecycle/release-flag.test.ts +112 -0
  327. package/src/lifecycle/release-join.test.ts +68 -0
  328. package/src/lifecycle/release-join.ts +182 -0
  329. package/src/lifecycle/release-publish.test.ts +225 -0
  330. package/src/lifecycle/release-publish.ts +568 -0
  331. package/src/lifecycle/release-revocation.ts +147 -0
  332. package/src/lifecycle/release-store.ts +15 -4
  333. package/src/lifecycle/release-verify.test.ts +110 -0
  334. package/src/lifecycle/release-verify.ts +120 -0
  335. package/src/lifecycle/release-workstation.test.ts +204 -0
  336. package/src/lifecycle/release-workstation.ts +216 -0
  337. package/src/lifecycle/retention-growth.e2e.test.ts +279 -0
  338. package/src/lifecycle/retention-policy.ts +63 -0
  339. package/src/lifecycle/retention.test.ts +363 -0
  340. package/src/lifecycle/retention.ts +508 -0
  341. package/src/plan-summary.test.ts +20 -0
  342. package/src/plan-summary.ts +8 -1
  343. package/src/pr-loop.ts +25 -4
  344. package/src/pr-report-url.test.ts +131 -0
  345. package/src/pr-report-url.ts +209 -0
  346. package/src/provenance.ts +2 -2
  347. package/src/run-event-store.ts +63 -3
  348. package/src/run-event-stream.ts +4 -1
  349. package/src/run-event.ts +21 -1
  350. package/src/workspace/__fixtures__/flag-workspace.ts +132 -0
  351. package/src/workspace/__fixtures__/older-records/change-set-984534ea9.json +100 -0
  352. package/src/workspace/__fixtures__/older-records/older-records.json +326 -0
  353. package/src/workspace/change-set.schema.json +5 -1
  354. package/src/workspace/check.schema.json +4 -2
  355. package/src/workspace/checks/records.ts +35 -1
  356. package/src/workspace/checks.ts +2 -1
  357. package/src/workspace/ci-green.ts +14 -3
  358. package/src/workspace/declaration.schema.json +64 -0
  359. package/src/workspace/declaration.test.ts +28 -0
  360. package/src/workspace/declaration.ts +38 -0
  361. package/src/workspace/declared-kinds.ts +40 -1
  362. package/src/workspace/delivery-metrics.schema.json +519 -0
  363. package/src/workspace/delivery-metrics.test.ts +236 -0
  364. package/src/workspace/delivery-metrics.ts +286 -0
  365. package/src/workspace/flags-read.ts +121 -0
  366. package/src/workspace/flags.test.ts +141 -0
  367. package/src/workspace/flags.ts +157 -0
  368. package/src/workspace/graph-cli.ts +1 -1
  369. package/src/workspace/intent-cli.ts +46 -5
  370. package/src/workspace/intent-trace.test.ts +173 -0
  371. package/src/workspace/intent-trace.ts +484 -0
  372. package/src/workspace/intent.schema.json +702 -6
  373. package/src/workspace/intent.ts +148 -25
  374. package/src/workspace/older-records.test.ts +412 -0
  375. package/src/workspace/overlap.test.ts +165 -0
  376. package/src/workspace/overlap.ts +340 -0
  377. package/src/workspace/read-contract.e2e.test.ts +2 -1
  378. package/src/workspace/reason-codes.test.ts +5 -0
  379. package/src/workspace/reason-codes.ts +3 -0
  380. package/src/workspace/record-assets.test.ts +2 -1
  381. package/src/workspace/records-contract.e2e.test.ts +2 -1
  382. package/src/workspace/records.schema.json +21 -2
  383. package/src/workspace/records.ts +36 -2
  384. package/src/workspace/reference-kinds/flag/flag.kind.mjs +32 -0
  385. package/src/workspace/reference-kinds/flag/flag.schema.json +53 -0
  386. package/src/workspace/releases.schema.json +123 -1
  387. package/src/workspace/releases.ts +11 -0
  388. package/src/workspace/run-event.schema.json +10 -3
  389. package/src/workspace/status-contract.test.ts +29 -0
  390. package/src/workspace/status-gates.ts +8 -0
  391. package/src/workspace/status-mixed.test.ts +152 -0
  392. package/src/workspace/status-revoked.test.ts +153 -0
  393. package/src/workspace/status.schema.json +198 -1
  394. package/src/workspace/status.ts +254 -3
  395. package/src/workspace/wip.ts +11 -2
@@ -8,7 +8,7 @@ import { withTestDir } from "@intentius/chant-test-utils";
8
8
  import { spawnSync } from "node:child_process";
9
9
  import { mkdirSync, writeFileSync } from "node:fs";
10
10
  import { join } from "node:path";
11
- import { cutRelease, differingBuildSteps, ReleaseCutError } from "./release-cut";
11
+ import { cutRelease, differingBuildSteps, planRelease, ReleaseCutError } from "./release-cut";
12
12
  import {
13
13
  appendCut,
14
14
  computeReleaseDigest,
@@ -149,7 +149,16 @@ describe("cutRelease", () => {
149
149
  expect(await readReleaseTemplate(prod.templates[0].digest, { cwd: dir })).toBe(`{"env":"prod"}\n`);
150
150
  const { cuts } = await readCuts({ cwd: dir });
151
151
  expect(cuts).toEqual([
152
- { version: 1, releaseDigest: release.releaseDigest, label: "v1.0.0", actor: "alice", timestamp: "2026-10-16T10:00:00.000Z", runOrigin: null, signature: null },
152
+ {
153
+ version: 1,
154
+ releaseDigest: release.releaseDigest,
155
+ label: "v1.0.0",
156
+ actor: "alice",
157
+ timestamp: "2026-10-16T10:00:00.000Z",
158
+ runId: `local-${Date.parse("2026-10-16T10:00:00.000Z")}`,
159
+ runOrigin: { forge: "local" },
160
+ signature: null,
161
+ },
153
162
  ]);
154
163
  expect(git(dir, "ls-tree", "-r", "--name-only", "chant/lifecycle").split("\n").filter((p) => p.endsWith("releases.jsonl"))).toEqual([]);
155
164
  expect(await resolveRelease("v1.0.0", { cwd: dir })).toEqual({ release, label: "v1.0.0" });
@@ -409,3 +418,66 @@ describe("differingBuildSteps", () => {
409
418
  expect(differingBuildSteps([t("dev", { a: [{ x: 1 }], b: [{ y: 1 }] }), t("prod", { a: [{ x: 2 }], b: [{ y: 1 }] }), t("qa", { c: [] })])).toEqual([{ component: "a", envs: ["dev", "prod"] }]);
410
419
  });
411
420
  });
421
+
422
+ describe("the dry run of a cut (#4027)", () => {
423
+ const plan = (dir: string, compiler: TargetCompiler, label: string | null) =>
424
+ planRelease({ cwd: dir, compiler, label, actor: "alice", env: {}, now: () => new Date("2026-10-16T10:00:00.000Z") });
425
+
426
+ test("compiles every environment and says what it would build, without building, storing or cutting", () =>
427
+ withTestDir(async (dir) => {
428
+ green(dir, initRepo(dir));
429
+ const compiler = fixtureCompiler();
430
+ const p = await plan(dir, compiler, "v1.0.0");
431
+ expect(compiler.compiled).toEqual(["dev", "staging", "prod"]);
432
+ expect(compiler.built).toEqual([]);
433
+ expect(p).toMatchObject({ label: "v1.0.0", releaseDigest: null, alreadyCut: null, build: [{ component: "api", env: "dev" }], reused: [], signatureRequired: false });
434
+ expect(p.targets.map((t) => [t.env, t.templates])).toEqual([
435
+ ["dev", [{ component: "api", path: "dist/api.template.json" }]],
436
+ ["staging", [{ component: "api", path: "dist/api.template.json" }]],
437
+ ["prod", [{ component: "api", path: "dist/api.template.json" }]],
438
+ ]);
439
+ expect(spawnSync("git", ["rev-parse", "--verify", "--quiet", "refs/heads/chant/lifecycle"], { cwd: dir }).status).not.toBe(0);
440
+ }));
441
+
442
+ test("knows the digest when every artifact is carried, and that it is already cut", () =>
443
+ withTestDir(async (dir) => {
444
+ green(dir, initRepo(dir));
445
+ const first = await cut(dir, fixtureCompiler(), "v1.0.0");
446
+ const sha = commitFile(dir, "README.md", "docs only\n");
447
+ green(dir, sha);
448
+ const p = await plan(dir, fixtureCompiler(), "v1.1.0");
449
+ expect(p.build).toEqual([]);
450
+ expect(p.reused).toEqual([{ component: "api", from: first.release.releaseDigest }]);
451
+ const second = await cut(dir, fixtureCompiler(), "v1.1.0");
452
+ expect(p.releaseDigest).toBe(second.release.releaseDigest);
453
+ expect((await plan(dir, fixtureCompiler(), "v1.1.0")).alreadyCut).toBe(second.release.releaseDigest);
454
+ expect((await plan(dir, fixtureCompiler(), null)).alreadyCut).toBe(second.release.releaseDigest);
455
+ }));
456
+
457
+ test("refuses a label another commit's release holds, and one that is not a label", () =>
458
+ withTestDir(async (dir) => {
459
+ green(dir, initRepo(dir));
460
+ await cut(dir, fixtureCompiler(), "v1.0.0");
461
+ green(dir, commitFile(dir, "api/Dockerfile", "FROM busybox\n"));
462
+ const taken = await plan(dir, fixtureCompiler(), "v1.0.0").catch((e: unknown) => e);
463
+ expect(taken).toBeInstanceOf(ReleaseLabelError);
464
+ expect((taken as ReleaseLabelError).code).toBe("release-label-taken");
465
+ expect((taken as ReleaseLabelError).message).toMatch(/cut from commit [0-9a-f]{12}/);
466
+ expect(((await plan(dir, fixtureCompiler(), "-bad").catch((e: unknown) => e)) as ReleaseLabelError).code).toBe("release-label-invalid");
467
+ }));
468
+
469
+ test("lists each component's publish steps with the environments whose deploy runs them", () =>
470
+ withTestDir(async (dir) => {
471
+ green(dir, initRepo(dir));
472
+ const base = fixtureCompiler();
473
+ const compiler: TargetCompiler = {
474
+ ...base,
475
+ async compile(env) {
476
+ const t = await base.compile(env);
477
+ return { ...t, publishSteps: { api: [{ kind: "publish-image", from: "archive", to: "$env.registry" }] } };
478
+ },
479
+ };
480
+ const p = await plan(dir, compiler, null);
481
+ expect(p.publishSteps).toEqual([{ component: "api", kind: "publish-image", to: "$env.registry", envs: ["dev", "staging", "prod"] }]);
482
+ }));
483
+ });
@@ -43,10 +43,14 @@ import { resolveMemberLedger } from "./member-ledger";
43
43
  import { resolveCiRun } from "./release-ledger";
44
44
  import {
45
45
  appendCut,
46
+ invalidLabel,
47
+ labelsByDigest,
46
48
  makeRelease,
47
49
  persistRelease,
48
50
  readAllReleases,
49
51
  readCuts,
52
+ readRelease,
53
+ ReleaseLabelError,
50
54
  type Release,
51
55
  type ReleaseArtifact,
52
56
  type ReleaseCut,
@@ -136,15 +140,10 @@ export async function greenState(
136
140
  sha: string,
137
141
  green: import("../workspace/declaration").CiGreen | null = null,
138
142
  ): Promise<{ state: "green" | "revoked" | "none"; ignored: Array<{ tag: string; status: string; reason: string }> }> {
139
- const { readCiTags } = await import("../workspace/ci-green");
140
- const base = green
141
- ? (tryGit(top, ["rev-parse", "--verify", "--quiet", `refs/remotes/origin/${green.branch}^{commit}`]) ??
142
- tryGit(top, ["rev-parse", "--verify", "--quiet", `refs/heads/${green.branch}^{commit}`]) ??
143
- tryGit(top, ["rev-parse", "HEAD"]))
144
- : null;
145
- let tags: ReturnType<typeof readCiTags>;
143
+ const { judgedCiTags } = await import("./release-revocation");
144
+ let tags: Awaited<ReturnType<typeof judgedCiTags>>;
146
145
  try {
147
- tags = readCiTags(top, green, base);
146
+ tags = await judgedCiTags(top, green);
148
147
  } catch (err) {
149
148
  const { WorkspaceReadError } = await import("../workspace/declaration");
150
149
  if (err instanceof WorkspaceReadError) throw new ReleaseCutError("declaration-invalid", err.message);
@@ -186,15 +185,28 @@ async function previousRelease(top: string, commit: string, cwd: string): Promis
186
185
  return candidates[0]?.r;
187
186
  }
188
187
 
188
+ /** What every cut and every dry run reads and compiles before it decides. */
189
+ interface Prepared {
190
+ cwd: string;
191
+ commit: string;
192
+ envs: readonly string[];
193
+ compiled: CompiledTarget[];
194
+ releaseRule: ReturnType<typeof import("../workspace/identity").releaseRuleFrom>;
195
+ /** The components to build, by the environment they are built in. */
196
+ toBuild: Map<string, string[]>;
197
+ /** The artifacts carried from an earlier release. */
198
+ carried: ReleaseArtifact[];
199
+ reused: CutResult["reused"];
200
+ member: string | null;
201
+ }
202
+
189
203
  /**
190
- * Cut a release of the member at `req.cwd` from its checkout's `HEAD`. Writes
191
- * the release and the cut to the local `chant/lifecycle` branch and does not
192
- * push. Throws {@link ReleaseCutError} before anything is written when the
193
- * commit isn't green, the tree is dirty, or an environment can't be compiled
194
- * to the same artifacts, and `ReleaseLabelError` (./release-store.ts) when the
195
- * label is taken or the release already carries another one.
204
+ * The checks and the compile a cut makes before it builds or writes: the
205
+ * commit is green and not revoked, the cut is signed when the release gate
206
+ * asks, the tree is clean, every environment compiles with the same build
207
+ * steps, and which artifacts are carried from an earlier release.
196
208
  */
197
- export async function cutRelease(req: CutRequest): Promise<CutResult> {
209
+ async function prepare(req: CutRequest): Promise<Prepared> {
198
210
  const cwd = resolve(req.cwd);
199
211
  const top = tryGit(cwd, ["rev-parse", "--show-toplevel"]);
200
212
  if (!top) throw new ReleaseCutError("not-a-git-repository", "a release is cut from a commit, and this directory is not in a git repository");
@@ -296,11 +308,22 @@ export async function cutRelease(req: CutRequest): Promise<CutResult> {
296
308
  }
297
309
  toBuild.set(target.env, [...(toBuild.get(target.env) ?? []), component]);
298
310
  }
299
- for (const env of envs) {
300
- const names = toBuild.get(env);
301
- if (names) artifacts.push(...(await req.compiler.buildArtifacts(env, names)));
302
- }
311
+ const ledger = await resolveMemberLedger(cwd);
312
+ return {
313
+ cwd,
314
+ commit,
315
+ envs,
316
+ compiled,
317
+ releaseRule,
318
+ toBuild,
319
+ carried: artifacts,
320
+ reused,
321
+ member: ledger.members.length > 0 ? ledger.members.join("/") : null,
322
+ };
323
+ }
303
324
 
325
+ /** The targets and template bytes of a compile, each template by its content digest. */
326
+ function targetsOf(compiled: readonly CompiledTarget[]): { targets: ReleaseTarget[]; templates: TemplateContent[] } {
304
327
  const templates: TemplateContent[] = [];
305
328
  const targets: ReleaseTarget[] = compiled.map((t) => ({
306
329
  env: t.env,
@@ -311,9 +334,121 @@ export async function cutRelease(req: CutRequest): Promise<CutResult> {
311
334
  return { component: tpl.component, path: tpl.path, digest };
312
335
  }),
313
336
  }));
337
+ return { targets, templates };
338
+ }
314
339
 
315
- const ledger = await resolveMemberLedger(cwd);
316
- const release = makeRelease({ member: ledger.members.length > 0 ? ledger.members.join("/") : null, commit, artifactDigests: artifacts, targets });
340
+ /** What a dry run of the cut found (#4027): what the cut would do, with nothing built, signed or written. */
341
+ export interface CutPlan {
342
+ commit: string;
343
+ member: string | null;
344
+ label: string | null;
345
+ /** The release's digest when no artifact needs building, so it is known now; null when a build decides it. */
346
+ releaseDigest: string | null;
347
+ /** The digest already cut under `label`, or this digest when it is already cut: the cut would then write no new release. */
348
+ alreadyCut: string | null;
349
+ /** Each environment's target: its params, and its templates by component and path. */
350
+ targets: Array<{ env: string; params: ReleaseTarget["params"]; templates: Array<{ component: string | null; path: string }> }>;
351
+ /** The components whose artifacts would be built, and the environment each is built in. */
352
+ build: Array<{ component: string; env: string }>;
353
+ /** The components whose artifacts would be carried from an earlier release. */
354
+ reused: CutResult["reused"];
355
+ /** Each component's publish steps, with the environments whose deploy runs them. */
356
+ publishSteps: Array<{ component: string; kind: string; to: string | null; envs: string[] }>;
357
+ /** Whether the release gate in identity.gates requires a signed cut. */
358
+ signatureRequired: boolean;
359
+ }
360
+
361
+ /**
362
+ * The dry run of {@link cutRelease} (#4027): the same checks and compile,
363
+ * then what the cut would build, carry and record, without building an
364
+ * artifact, signing, or writing anything. Throws what the cut throws before
365
+ * it builds, and `ReleaseLabelError` when the label is invalid or names a
366
+ * release of another commit.
367
+ */
368
+ export async function planRelease(req: CutRequest): Promise<CutPlan> {
369
+ const p = await prepare(req);
370
+ const { targets } = targetsOf(p.compiled);
371
+ const build: CutPlan["build"] = [];
372
+ for (const env of p.envs) for (const component of p.toBuild.get(env) ?? []) build.push({ component, env });
373
+ const releaseDigest = build.length === 0 ? makeRelease({ member: p.member, commit: p.commit, artifactDigests: p.carried, targets }).releaseDigest : null;
374
+
375
+ const { cuts } = await readCuts({ cwd: p.cwd });
376
+ const labels = labelsByDigest(cuts);
377
+ let alreadyCut: string | null = null;
378
+ if (req.label !== null) {
379
+ const why = invalidLabel(req.label);
380
+ if (why) throw new ReleaseLabelError("release-label-invalid", why);
381
+ const named = [...labels].find(([, l]) => l === req.label)?.[0];
382
+ if (named !== undefined) {
383
+ const other = await readRelease(named, { cwd: p.cwd });
384
+ const sameCommit = other?.commit === p.commit;
385
+ if (!sameCommit || (releaseDigest !== null && named !== releaseDigest)) {
386
+ throw new ReleaseLabelError(
387
+ "release-label-taken",
388
+ `the label "${req.label}" already names release ${named}${other && !sameCommit ? `, cut from commit ${other.commit.slice(0, 12)}` : ""}`,
389
+ );
390
+ }
391
+ alreadyCut = named;
392
+ }
393
+ }
394
+ if (releaseDigest !== null && alreadyCut === null && cuts.some((c) => c.releaseDigest === releaseDigest)) {
395
+ const own = labels.get(releaseDigest);
396
+ if (own !== undefined && req.label !== null && own !== req.label) {
397
+ throw new ReleaseLabelError("release-already-labelled", `release ${releaseDigest} is already labelled "${own}", and a release takes one label`);
398
+ }
399
+ alreadyCut = releaseDigest;
400
+ }
401
+
402
+ const steps = new Map<string, CutPlan["publishSteps"][number]>();
403
+ for (const t of p.compiled) {
404
+ for (const [component, list] of Object.entries(t.publishSteps ?? {})) {
405
+ for (const step of list as Array<{ kind?: unknown; to?: unknown }>) {
406
+ const kind = typeof step.kind === "string" ? step.kind : "publish";
407
+ const to = typeof step.to === "string" ? step.to : null;
408
+ const key = JSON.stringify([component, kind, to]);
409
+ const entry = steps.get(key) ?? { component, kind, to, envs: [] };
410
+ if (!entry.envs.includes(t.env)) entry.envs.push(t.env);
411
+ steps.set(key, entry);
412
+ }
413
+ }
414
+ }
415
+
416
+ return {
417
+ commit: p.commit,
418
+ member: p.member,
419
+ label: req.label,
420
+ releaseDigest,
421
+ alreadyCut,
422
+ targets: targets.map((t) => ({ env: t.env, params: t.params, templates: t.templates.map(({ component, path }) => ({ component, path })) })),
423
+ build,
424
+ reused: p.reused,
425
+ publishSteps: [...steps.values()],
426
+ signatureRequired: p.releaseRule.requirement !== null,
427
+ };
428
+ }
429
+
430
+ /**
431
+ * Cut a release of the member at `req.cwd` from its checkout's `HEAD`. Writes
432
+ * the release and the cut to the local `chant/lifecycle` branch and does not
433
+ * push. Throws {@link ReleaseCutError} before anything is written when the
434
+ * commit isn't green, the tree is dirty, or an environment can't be compiled
435
+ * to the same artifacts, and `ReleaseLabelError` (./release-store.ts) when the
436
+ * label is taken or the release already carries another one.
437
+ *
438
+ * The cut line names its run (#4027): the CI run's id and origin in CI, and
439
+ * outside CI an id minted from the cut's time, `local-<epoch ms>`, with the
440
+ * origin `{ forge: "local" }`.
441
+ */
442
+ export async function cutRelease(req: CutRequest): Promise<CutResult> {
443
+ const p = await prepare(req);
444
+ const { cwd, commit, compiled, releaseRule, reused } = p;
445
+ const artifacts = [...p.carried];
446
+ for (const env of p.envs) {
447
+ const names = p.toBuild.get(env);
448
+ if (names) artifacts.push(...(await req.compiler.buildArtifacts(env, names)));
449
+ }
450
+ const { targets, templates } = targetsOf(compiled);
451
+ const release = makeRelease({ member: p.member, commit, artifactDigests: artifacts, targets });
317
452
  const timestamp = (req.now ?? (() => new Date()))().toISOString();
318
453
  const signed = { releaseDigest: release.releaseDigest, label: req.label, actor: req.actor, timestamp };
319
454
  let signature: string | null = null;
@@ -352,7 +487,8 @@ export async function cutRelease(req: CutRequest): Promise<CutResult> {
352
487
  label: req.label,
353
488
  actor: req.actor,
354
489
  timestamp,
355
- runOrigin: ci?.runOrigin ?? null,
490
+ runId: ci?.runId ?? `local-${Date.parse(timestamp)}`,
491
+ runOrigin: ci?.runOrigin ?? { forge: "local" },
356
492
  signature,
357
493
  },
358
494
  { cwd },
@@ -0,0 +1,112 @@
1
+ /**
2
+ * A flag change is a commit, so it is gated like any change (#4024, ws-104):
3
+ * the release cut from it carries the flag's new value as a param whose
4
+ * source names the flag record, keeps the previous release's artifact
5
+ * digests when no artifact source changed, and its change set for the
6
+ * environment whose value moved shows the difference while the others are
7
+ * empty. On the fixture in `../workspace/__fixtures__/flag-workspace.ts`,
8
+ * with a compiler that resolves each environment's parameters as a build
9
+ * does and compiles them into the template.
10
+ */
11
+
12
+ import { readFileSync, writeFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { afterAll, describe, expect, test } from "vitest";
15
+ import { cutRelease } from "./release-cut";
16
+ import type { ReleaseArtifact, Release } from "./release-store";
17
+ import type { ReleaseRecord } from "./release-ledger";
18
+ import type { CompiledTarget, TargetCompiler } from "../components/release-compile";
19
+ import { chantComponentPart } from "../components/release-gate";
20
+ import type { DriverComponent } from "../components/driver";
21
+ import { composeChangeSet, isEmptyChangeSet } from "../change-set";
22
+ import type { BuildParamsConfig } from "../build-params";
23
+ import { resolveFlaggedBuildParams } from "../flag-params";
24
+ import { cleanScratch, commitAll, git } from "../workspace/__fixtures__/contract-repo";
25
+ import { flagRecord, flagWorkspace } from "../workspace/__fixtures__/flag-workspace";
26
+
27
+ afterAll(cleanScratch);
28
+
29
+ const SHOP: BuildParamsConfig = { newCheckout: { type: "boolean", default: false } };
30
+ const IMAGE = `sha256:${"a".repeat(64)}`;
31
+
32
+ /** Compiles each environment with the parameters a build resolves there, flags included, into the shop's template. */
33
+ function flagCompiler(memberDir: string): TargetCompiler & { built: string[][] } {
34
+ const built: string[][] = [];
35
+ return {
36
+ built,
37
+ async compile(env): Promise<CompiledTarget> {
38
+ const resolved = await resolveFlaggedBuildParams(memberDir, SHOP, { echo: false }, env);
39
+ if (!resolved.success) throw new Error(resolved.errors.join("\n"));
40
+ const params = resolved.provenance.map(({ name, value, source }) => ({ name, value, source }));
41
+ return {
42
+ env,
43
+ params,
44
+ components: ["shop"],
45
+ buildSteps: { shop: [{ kind: "docker-build", context: "app", into: "shop.tar" }] },
46
+ buildInputs: { shop: ["app", "app/Dockerfile"] },
47
+ componentFiles: { shop: "shop.component.ts" },
48
+ templates: [{ component: "shop", path: "dist/shop.json", content: JSON.stringify({ env, params: Object.fromEntries(params.map((p) => [p.name, p.value])) }) + "\n" }],
49
+ };
50
+ },
51
+ async buildArtifacts(env, components): Promise<ReleaseArtifact[]> {
52
+ built.push([env, ...components]);
53
+ return components.map((c) => ({ component: c, kind: "image" as const, path: `${c}.tar`, digest: IMAGE }));
54
+ },
55
+ };
56
+ }
57
+
58
+ const shop: DriverComponent = { name: "shop", deploy: [{ phase: "Apply", steps: [{ kind: "gate", gate: "prod-release" }, { kind: "apply", template: "dist/shop.json" }] }] };
59
+
60
+ const deployed = (release: Release, env: string): ReleaseRecord => ({
61
+ version: 1,
62
+ component: "shop",
63
+ env,
64
+ digest: IMAGE,
65
+ gitSha: release.commit,
66
+ runId: "run-1",
67
+ timestamp: "2026-10-16T00:00:00.000Z",
68
+ actor: "ci",
69
+ releaseDigest: release.releaseDigest,
70
+ });
71
+
72
+ describe("a flag change is a release of its own (#4024)", () => {
73
+ test("it carries the new value with source flag:<id>, the previous artifact digests, and a change set only where the value moved", async () => {
74
+ const root = flagWorkspace({ "shop/app/Dockerfile": "FROM scratch\n" });
75
+ const member = join(root, "shop");
76
+ const compiler = flagCompiler(member);
77
+ const cut = (label: string) => cutRelease({ cwd: member, compiler, label, actor: "alice", env: {}, now: () => new Date("2026-10-17T10:00:00.000Z") });
78
+
79
+ const first = git(root, "rev-parse", "HEAD");
80
+ git(root, "tag", `ci/green/${first}`, first);
81
+ const v1 = (await cut("v1")).release;
82
+ expect(compiler.built).toEqual([["dev", "shop"]]);
83
+ expect(v1.targets.find((t) => t.env === "prod")!.params).toEqual([{ name: "newCheckout", value: false, source: "flag:new-checkout" }]);
84
+
85
+ // Turning the flag on in prod is a commit to the flag record, and nothing else.
86
+ const flagFile = join(root, "flags", "new-checkout.flag.md");
87
+ writeFileSync(flagFile, flagRecord("new-checkout", { work: "W-002", member: "shop", param: "newCheckout", values: { dev: true, prod: true } }));
88
+ expect(readFileSync(flagFile, "utf-8")).toMatch(/"prod": true/);
89
+ const second = commitAll(root, "turn new-checkout on in prod");
90
+ git(root, "tag", `ci/green/${second}`, second);
91
+ const result = await cut("v2");
92
+ const v2 = result.release;
93
+
94
+ // A config-only release: no artifact was built again, and the digests are the previous release's.
95
+ expect(compiler.built).toEqual([["dev", "shop"]]);
96
+ expect(result.reused).toEqual([{ component: "shop", from: v1.releaseDigest }]);
97
+ expect(v2.artifactDigests).toEqual(v1.artifactDigests);
98
+ expect(v2.commit).toBe(second);
99
+ expect(v2.releaseDigest).not.toBe(v1.releaseDigest);
100
+ expect(v2.targets.find((t) => t.env === "prod")!.params).toEqual([{ name: "newCheckout", value: true, source: "flag:new-checkout" }]);
101
+
102
+ // The flag change alters prod's change set, and leaves dev's and staging's empty.
103
+ const part = (env: string) => chantComponentPart({ component: shop, release: v2, env, digest: IMAGE, current: { record: deployed(v1, env), release: v1 } });
104
+ const prod = part("prod");
105
+ expect(prod.entries).toHaveLength(1);
106
+ expect(prod.entries[0]).toMatchObject({ address: "shop", action: "update", scope: "prod" });
107
+ expect(prod.entries[0].attributes.map((a) => a.path)).toEqual(["template:dist/shop.json", "param:newCheckout"]);
108
+ expect(prod.entries[0].attributes.find((a) => a.path === "param:newCheckout")).toEqual({ path: "param:newCheckout", before: "false", after: "true" });
109
+ expect(isEmptyChangeSet(composeChangeSet([prod]))).toBe(false);
110
+ for (const env of ["dev", "staging"]) expect(isEmptyChangeSet(composeChangeSet([part(env)]))).toBe(true);
111
+ });
112
+ });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The join through the release digest (#4043), against a temp repo: two
3
+ * releases of one commit, and deploys of one release in two environments.
4
+ * The end-to-end chain, with a real cut, promote and approval, is in
5
+ * cli/handlers/release-join.e2e.test.ts.
6
+ */
7
+ import { describe, test, expect } from "vitest";
8
+ import { withTestDir } from "@intentius/chant-test-utils";
9
+ import { spawnSync } from "node:child_process";
10
+ import { writeFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { joinRelease } from "./release-join";
13
+ import { appendCut, makeRelease, persistRelease } from "./release-store";
14
+ import { appendReleaseRecord } from "./release-ledger";
15
+
16
+ function git(dir: string, ...args: string[]): string {
17
+ const r = spawnSync("git", args, { cwd: dir, encoding: "utf-8" });
18
+ if (r.status !== 0) throw new Error(`git ${args.join(" ")}: ${r.stderr}`);
19
+ return r.stdout.trim();
20
+ }
21
+
22
+ const subject = (commit: string, digest: string) => ({
23
+ member: null,
24
+ commit,
25
+ artifactDigests: [{ component: "api", kind: "image" as const, path: "api.tar", digest }],
26
+ targets: [{ env: "dev", templates: [], params: [] }],
27
+ });
28
+
29
+ describe("joinRelease", () => {
30
+ test("a commit joins to every release cut from it, each with its own deploys across environments", async () => {
31
+ await withTestDir(async (dir) => {
32
+ git(dir, "init", "-q", "-b", "main");
33
+ git(dir, "config", "user.email", "t@chant.dev");
34
+ git(dir, "config", "user.name", "T");
35
+ writeFileSync(join(dir, "README.md"), "x\n");
36
+ git(dir, "add", ".");
37
+ git(dir, "commit", "-q", "-m", "init");
38
+ const sha = git(dir, "rev-parse", "HEAD");
39
+ const one = makeRelease(subject(sha, "sha256:1"));
40
+ const two = makeRelease(subject(sha, "sha256:2"));
41
+ for (const [r, label] of [[one, "v1"], [two, null]] as const) {
42
+ await persistRelease(r, [], { cwd: dir });
43
+ await appendCut({ releaseDigest: r.releaseDigest, label, actor: "alice", timestamp: "2026-10-16T10:00:00.000Z", runOrigin: null, signature: null }, { cwd: dir });
44
+ }
45
+ const rec = (env: string, runId: string, timestamp: string, releaseDigest?: string) =>
46
+ appendReleaseRecord({ component: "api", env, digest: "sha256:1", gitSha: sha, runId, timestamp, actor: "ci", ...(releaseDigest ? { releaseDigest } : {}) }, { cwd: dir });
47
+ await rec("prod", "r3", "2026-10-16T13:00:00.000Z", one.releaseDigest);
48
+ await rec("dev", "r1", "2026-10-16T11:00:00.000Z", one.releaseDigest);
49
+ await rec("dev", "r2", "2026-10-16T12:00:00.000Z"); // a deploy of the commit that named no release
50
+ await rec("dev", "r4", "2026-10-16T14:00:00.000Z", two.releaseDigest);
51
+
52
+ const { releases } = await joinRelease({ commit: sha }, { cwd: dir });
53
+ expect(releases.map((r) => [r.label, r.deploys.map((d) => `${d.env}:${d.runId}`)])).toEqual(
54
+ expect.arrayContaining([["v1", ["dev:r1", "prod:r3"]], [null, ["dev:r4"]]]),
55
+ );
56
+ expect(releases).toHaveLength(2);
57
+ expect(releases.every((r) => r.commit === sha)).toBe(true);
58
+ expect(releases.find((r) => r.label === "v1")!.gates).toEqual({ pending: [], resolutions: [] });
59
+
60
+ // The unlabelled release is reached by its digest, and the record that named no release joins nowhere.
61
+ expect((await joinRelease({ release: two.releaseDigest }, { cwd: dir })).releases[0].deploys.map((d) => d.runId)).toEqual(["r4"]);
62
+ expect((await joinRelease({ deploy: { releaseDigest: undefined } }, { cwd: dir })).releases).toEqual([]);
63
+ expect((await joinRelease({ release: "nope" }, { cwd: dir })).reason).toMatch(/no release is labelled or digested "nope"/);
64
+ const ghost = `jcs1-sha256:${"9".repeat(64)}`;
65
+ expect((await joinRelease({ gate: { releaseDigest: ghost } }, { cwd: dir })).reason).toMatch(/file is not on this checkout/);
66
+ });
67
+ });
68
+ });