@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
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Cutting a release from a workstation as from CI (#4027).
3
+ *
4
+ * `chant release cut` runs the same steps wherever it runs. What differs
5
+ * between a laptop and a CI job is read here, from git and the process
6
+ * environment, so the CLI and its tests resolve it the same way:
7
+ *
8
+ * - trunk: the branch `ci.green.branch` names in the declaration at base,
9
+ * else the branch `origin/HEAD` points at, else `main`. The commit must be
10
+ * reachable from `origin/<branch>` as this clone last fetched it, so a
11
+ * commit that is on the local branch only is refused as not pushed. Nothing
12
+ * is fetched: a stale `origin/<branch>` can only refuse a commit that was
13
+ * pushed since, never admit one that wasn't.
14
+ * - a clean tree: no tracked change and no untracked file that git does not
15
+ * ignore, since the compile reads the directory and a new
16
+ * `*.component.ts` would be compiled into the release.
17
+ * - the actor: `--actor`, else the principal the signers file at base lists
18
+ * for the signing key, else the CI's actor, else git's `user.email`, else
19
+ * `$USER`.
20
+ * - the tag: `release/<label>`, or `release/<member>/<label>` in a workspace
21
+ * member, an annotated tag on the commit that is created locally and never
22
+ * pushed by chant.
23
+ */
24
+
25
+ import { execFileSync, spawnSync } from "node:child_process";
26
+ import { existsSync, readFileSync } from "node:fs";
27
+ import type { Signer } from "../workspace/trust/trust-files";
28
+
29
+ export type WorkstationCheckCode = "release-commit-not-on-trunk" | "release-commit-not-pushed" | "release-worktree-dirty" | "release-tag-taken" | "release-tag-invalid";
30
+
31
+ /** A refusal before anything is compiled or written. */
32
+ export class WorkstationCheckError extends Error {
33
+ constructor(
34
+ readonly code: WorkstationCheckCode,
35
+ message: string,
36
+ readonly fix?: string,
37
+ ) {
38
+ super(message);
39
+ this.name = "WorkstationCheckError";
40
+ }
41
+ }
42
+
43
+ function tryGit(cwd: string, args: string[]): string | null {
44
+ try {
45
+ return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ const isAncestor = (cwd: string, a: string, b: string): boolean =>
52
+ spawnSync("git", ["merge-base", "--is-ancestor", a, b], { cwd, stdio: "ignore" }).status === 0;
53
+
54
+ /** The remote a release's commit must be pushed to. */
55
+ export const RELEASE_REMOTE = "origin";
56
+
57
+ /** The trunk branch, and where its name came from. */
58
+ export interface Trunk {
59
+ remote: string;
60
+ branch: string;
61
+ from: "ci.green.branch" | "origin/HEAD" | "default";
62
+ }
63
+
64
+ /** The trunk of the repository at `cwd`: `ci.green.branch` when declared, else what `origin/HEAD` names, else `main`. */
65
+ export function resolveTrunk(cwd: string, greenBranch: string | null): Trunk {
66
+ if (greenBranch) return { remote: RELEASE_REMOTE, branch: greenBranch, from: "ci.green.branch" };
67
+ const head = tryGit(cwd, ["symbolic-ref", "--quiet", `refs/remotes/${RELEASE_REMOTE}/HEAD`]);
68
+ const prefix = `refs/remotes/${RELEASE_REMOTE}/`;
69
+ if (head?.startsWith(prefix)) return { remote: RELEASE_REMOTE, branch: head.slice(prefix.length), from: "origin/HEAD" };
70
+ return { remote: RELEASE_REMOTE, branch: "main", from: "default" };
71
+ }
72
+
73
+ /**
74
+ * Refuse `commit` unless it is reachable from `<remote>/<branch>` as this
75
+ * clone knows it. A commit on the local branch only is `release-commit-not-pushed`;
76
+ * any other is `release-commit-not-on-trunk`.
77
+ */
78
+ export function checkOnTrunk(cwd: string, commit: string, trunk: Trunk): void {
79
+ const remoteRef = `refs/remotes/${trunk.remote}/${trunk.branch}`;
80
+ const short = commit.slice(0, 12);
81
+ const named = `${trunk.remote}/${trunk.branch}`;
82
+ const why = trunk.from === "ci.green.branch" ? " (the branch ci.green names)" : trunk.from === "origin/HEAD" ? ` (the branch ${trunk.remote}/HEAD names)` : "";
83
+ const remoteTip = tryGit(cwd, ["rev-parse", "--verify", "--quiet", `${remoteRef}^{commit}`]);
84
+ if (remoteTip && isAncestor(cwd, commit, remoteTip)) return;
85
+ const localTip = tryGit(cwd, ["rev-parse", "--verify", "--quiet", `refs/heads/${trunk.branch}^{commit}`]);
86
+ if (localTip && isAncestor(cwd, commit, localTip)) {
87
+ throw new WorkstationCheckError(
88
+ "release-commit-not-pushed",
89
+ remoteTip
90
+ ? `commit ${short} is on ${trunk.branch}${why} but not on ${named}, so it has not been pushed`
91
+ : `commit ${short} is on ${trunk.branch}${why}, and this clone knows no ${named}, so it can't be shown pushed`,
92
+ `Push it, or fetch if it was pushed elsewhere: git push ${trunk.remote} ${trunk.branch}, or git fetch ${trunk.remote} ${trunk.branch}`,
93
+ );
94
+ }
95
+ throw new WorkstationCheckError(
96
+ "release-commit-not-on-trunk",
97
+ remoteTip
98
+ ? `commit ${short} is not on ${named}${why}, so a release cut from it would ship what trunk never had`
99
+ : `commit ${short} is not on ${trunk.branch}${why}, and this clone knows no ${named}`,
100
+ `Check out a commit of ${named} after fetching it: git fetch ${trunk.remote} ${trunk.branch}`,
101
+ );
102
+ }
103
+
104
+ /** Refuse a tree with a tracked change or an untracked file git does not ignore. */
105
+ export function checkCleanTree(cwd: string, commit: string): void {
106
+ const status = tryGit(cwd, ["status", "--porcelain", "--untracked-files=normal"]);
107
+ if (status === null || status === "") return;
108
+ const lines = status.split("\n");
109
+ const untracked = lines.filter((l) => l.startsWith("??")).map((l) => l.slice(3));
110
+ const tracked = lines.length - untracked.length;
111
+ const parts = [
112
+ ...(tracked > 0 ? [`${tracked} tracked file(s) with uncommitted changes`] : []),
113
+ ...(untracked.length > 0 ? [`${untracked.length} untracked file(s) such as ${untracked[0]}`] : []),
114
+ ];
115
+ throw new WorkstationCheckError(
116
+ "release-worktree-dirty",
117
+ `the working tree has ${parts.join(" and ")}, so a compile here would not be commit ${commit.slice(0, 12)}'s`,
118
+ "Commit, stash or remove them (or add them to .gitignore), or cut from a clean checkout of the commit.",
119
+ );
120
+ }
121
+
122
+ /** Where the actor came from. */
123
+ export type ActorSource = "flag" | "signer" | "ci" | "git" | "user";
124
+
125
+ /** The public key `keyFile` holds or names, as `<type> <base64>`, or null when it can't be read without a passphrase. */
126
+ export function publicKeyOf(keyFile: string): string | null {
127
+ const parse = (text: string): string | null => {
128
+ const [type, blob] = text.trim().split(/\s+/);
129
+ return type && blob && /^(ssh-|ecdsa-|sk-)/.test(type) ? `${type} ${blob}` : null;
130
+ };
131
+ try {
132
+ if (existsSync(keyFile)) {
133
+ const own = parse(readFileSync(keyFile, "utf-8"));
134
+ if (own) return own;
135
+ }
136
+ if (existsSync(`${keyFile}.pub`)) {
137
+ const pub = parse(readFileSync(`${keyFile}.pub`, "utf-8"));
138
+ if (pub) return pub;
139
+ }
140
+ } catch {
141
+ return null;
142
+ }
143
+ const derived = spawnSync("ssh-keygen", ["-y", "-P", "", "-f", keyFile], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
144
+ return derived.status === 0 ? parse(derived.stdout) : null;
145
+ }
146
+
147
+ /** The one principal `signers` lists for `publicKey`, or null for none or several. */
148
+ export function signerPrincipal(publicKey: string, signers: readonly Signer[]): string | null {
149
+ const principals = [...new Set(signers.filter((s) => s.key === publicKey).map((s) => s.principal))];
150
+ return principals.length === 1 ? principals[0] : null;
151
+ }
152
+
153
+ /**
154
+ * Who cut the release, and where the name came from: `--actor`, else the
155
+ * signer the signers file at base lists for the signing key, else the CI's
156
+ * actor (`GITHUB_ACTOR`, `GITLAB_USER_LOGIN`), else git's `user.email`, else
157
+ * `$USER`. Undefined when none is set.
158
+ */
159
+ export function resolveCutActor(req: {
160
+ cwd: string;
161
+ explicit?: string;
162
+ /** The signing key's public half, when the cut is signed and it could be read. */
163
+ publicKey?: string | null;
164
+ signers?: readonly Signer[];
165
+ env?: NodeJS.ProcessEnv;
166
+ }): { actor: string; source: ActorSource } | undefined {
167
+ const env = req.env ?? process.env;
168
+ if (req.explicit) return { actor: req.explicit, source: "flag" };
169
+ if (req.publicKey && req.signers) {
170
+ const principal = signerPrincipal(req.publicKey, req.signers);
171
+ if (principal) return { actor: principal, source: "signer" };
172
+ }
173
+ const ci = env.GITHUB_ACTOR || env.GITLAB_USER_LOGIN;
174
+ if (ci) return { actor: ci, source: "ci" };
175
+ const email = tryGit(req.cwd, ["config", "--get", "user.email"]);
176
+ if (email) return { actor: email, source: "git" };
177
+ if (env.USER) return { actor: env.USER, source: "user" };
178
+ return undefined;
179
+ }
180
+
181
+ /** The tag a labelled release gets: `release/<label>`, or `release/<member>/<label>` for a workspace member. */
182
+ export function releaseTagName(member: string | null, label: string): string {
183
+ return member ? `release/${member}/${label}` : `release/${label}`;
184
+ }
185
+
186
+ /**
187
+ * Whether `tag` can name `commit`: `absent` (the cut creates it), `present`
188
+ * (it already names the commit), or a {@link WorkstationCheckError} for a
189
+ * tag that names another commit or is not a valid tag name.
190
+ */
191
+ export function checkReleaseTag(cwd: string, tag: string, commit: string): "absent" | "present" {
192
+ if (spawnSync("git", ["check-ref-format", `refs/tags/${tag}`], { cwd, stdio: "ignore" }).status !== 0) {
193
+ throw new WorkstationCheckError("release-tag-invalid", `"${tag}" is not a valid git tag name, so the release can't be tagged`, "Pick a label git accepts in a ref name.");
194
+ }
195
+ const at = tryGit(cwd, ["rev-parse", "--verify", "--quiet", `refs/tags/${tag}^{commit}`]);
196
+ if (at === null) return "absent";
197
+ if (at === commit) return "present";
198
+ throw new WorkstationCheckError(
199
+ "release-tag-taken",
200
+ `the tag ${tag} already names commit ${at.slice(0, 12)}, not ${commit.slice(0, 12)}`,
201
+ "Pick another label, or delete the tag if it was made by mistake: git tag -d " + tag,
202
+ );
203
+ }
204
+
205
+ /**
206
+ * Create `tag` on `commit` as an annotated tag whose message names the
207
+ * release. Local only. A clone with no git identity tags as chant, as the ci
208
+ * tags are made.
209
+ */
210
+ export function createReleaseTag(cwd: string, tag: string, commit: string, release: { releaseDigest: string; label: string; member: string | null }): void {
211
+ const ident: string[] = [];
212
+ if (tryGit(cwd, ["config", "user.name"]) === null) ident.push("-c", "user.name=chant");
213
+ if (tryGit(cwd, ["config", "user.email"]) === null) ident.push("-c", "user.email=chant@localhost");
214
+ const message = [`release ${release.label}`, "", `Release-Digest: ${release.releaseDigest}`, ...(release.member ? [`Member: ${release.member}`] : [])].join("\n");
215
+ execFileSync("git", [...ident, "tag", "-a", "-m", message, tag, commit], { cwd, stdio: ["ignore", "ignore", "pipe"] });
216
+ }
@@ -0,0 +1,279 @@
1
+ /**
2
+ * How `chant/lifecycle` grows in a month at chant's own rate, and what
3
+ * retention's garbage collection takes back (#4036, requirement 3).
4
+ *
5
+ * The rates are chant's own, counted from its repository for the thirty days
6
+ * from 2026-09-17 to 2026-10-16:
7
+ *
8
+ * 855 first-parent commits on main 28.5 a day: one recorded run each
9
+ * 751 squash-merged pull requests 25 a day: a pull-request gate each,
10
+ * with a stored change set, a pending
11
+ * fact and an approval
12
+ * 96 chant-v and 44 reference- 4.7 a day: a release cut each, of
13
+ * workspace-v tags two members in the same proportion
14
+ * 0 ci/revoked tags
15
+ *
16
+ * A release changes one or two of its member's three components, so the
17
+ * other templates keep their bytes and are stored once. Each release deploys
18
+ * to staging under a release gate that passes `empty`, and every third one is
19
+ * promoted to prod after an approval. A month of that is written to a temp repository, one commit per
20
+ * write as chant writes them (through `git fast-import`, to be quick), with
21
+ * one local run event file per run. Then the garbage collection runs with
22
+ * the default policy at the end of the month, and the test prints the tree
23
+ * at the tip and the packed history before and after.
24
+ *
25
+ * End to end: it writes a few thousand commits and repacks twice.
26
+ */
27
+
28
+ import { describe, expect, test } from "vitest";
29
+ import { withTestDir } from "@intentius/chant-test-utils";
30
+ import { execFileSync } from "node:child_process";
31
+ import { mkdirSync, readdirSync, statSync, utimesSync, writeFileSync } from "node:fs";
32
+ import { join } from "node:path";
33
+ import { contentDigest } from "../content-digest";
34
+ import { makeRelease } from "./release-store";
35
+ import { runLifecycleGc } from "./retention";
36
+ import { DEFAULT_RETENTION } from "./retention-policy";
37
+ import { RUN_EVENTS_DIR_ENV } from "../run-event-store";
38
+
39
+ const DAYS = 30;
40
+ const RATES = { runs: 855 / 30, pullRequests: 751 / 30, releases: 140 / 30 };
41
+ const MEMBERS = [
42
+ { name: "chant", share: 96 / 140, components: ["api", "worker", "web"] },
43
+ { name: "reference-workspace", share: 44 / 140, components: ["svc", "db", "edge"] },
44
+ ];
45
+ const START = Date.UTC(2026, 8, 17);
46
+ const DAY = 86_400_000;
47
+
48
+ /** A small deterministic generator, so every run writes the same month. */
49
+ function rng(seed: number): () => number {
50
+ let a = seed;
51
+ return () => {
52
+ a = (a + 0x6d2b79f5) | 0;
53
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
54
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
55
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
56
+ };
57
+ }
58
+ const rand = rng(4036);
59
+ const hex = (n: number) => Array.from({ length: n }, () => Math.floor(rand() * 16).toString(16)).join("");
60
+ const iso = (ms: number) => new Date(ms).toISOString();
61
+
62
+ /** A compiled template of a component: a deployment manifest of about 4 KB. */
63
+ function template(component: string, env: string, version: number): string {
64
+ const lines = [`apiVersion: apps/v1`, `kind: Deployment`, `metadata:`, ` name: ${component}`, ` namespace: ${env}`, ` labels:`, ` app.kubernetes.io/managed-by: chant`, ` chant.intentius.io/env: ${env}`, `spec:`, ` replicas: ${env === "prod" ? 3 : 1}`, ` template:`, ` spec:`, ` containers:`, ` - name: ${component}`, ` image: registry.example.com/${component}@${contentDigest(`${component}:${version}`)}`, ` env:`];
65
+ for (let i = 0; i < 40; i++) lines.push(` - name: SETTING_${i}`, ` value: "${component}-${env}-${i % 7 === 0 ? version : i}"`);
66
+ lines.push(` resources:`, ` limits: { cpu: "${env === "prod" ? 2 : 1}", memory: 512Mi }`);
67
+ return `${lines.join("\n")}\n`;
68
+ }
69
+
70
+ /** A stored change-set document of a few entries, about 2 to 4 KB. */
71
+ function changeSet(digest: string, member: string, entries: number): string {
72
+ const doc = {
73
+ $schema: "https://intentius.io/chant/schemas/change-set/v1/change-set.schema.json",
74
+ contract: 1,
75
+ digest,
76
+ members: [member],
77
+ entries: Array.from({ length: entries }, (_, i) => ({
78
+ member,
79
+ component: `c${i % 3}`,
80
+ address: `aws_lambda_function.handler_${hex(6)}`,
81
+ action: ["update", "create", "delete"][i % 3],
82
+ before: { memory_size: 256, timeout: 30, environment: { LOG_LEVEL: "info", REVISION: hex(12) } },
83
+ after: { memory_size: 512, timeout: 30, environment: { LOG_LEVEL: "info", REVISION: hex(12) } },
84
+ node: `${member}:${hex(16)}`,
85
+ })),
86
+ graph: { digest: `sha256:${hex(64)}`, nodes: 40 },
87
+ };
88
+ return `${JSON.stringify(doc)}\n`;
89
+ }
90
+
91
+ interface Write {
92
+ at: number;
93
+ message: string;
94
+ files: Array<{ path: string; content?: string; append?: string }>;
95
+ }
96
+
97
+ /** The month's writes, oldest first, and the run event files it leaves. */
98
+ function month(): { writes: Write[]; runs: Array<{ at: number; id: string; text: string }> } {
99
+ const writes: Write[] = [];
100
+ const runs: Array<{ at: number; id: string; text: string }> = [];
101
+ const spread = (perDay: number, fn: (at: number, i: number) => void) => {
102
+ const total = Math.round(perDay * DAYS);
103
+ for (let i = 0; i < total; i++) fn(START + ((i + rand()) / total) * DAYS * DAY, i);
104
+ };
105
+ const line = (o: object) => `${JSON.stringify(o)}\n`;
106
+
107
+ // Runs: a run record on the run ledger and a local event file.
108
+ spread(RATES.runs, (at, i) => {
109
+ const op = `op-${i % 12}`;
110
+ const id = `run-${i}-${hex(8)}`;
111
+ writes.push({ at, message: `Op run record: ${op}`, files: [{ path: `_members/chant/ci/runs__${op}.jsonl`, append: line({ version: 1, op, runId: id, status: "succeeded", startedAt: iso(at - 60_000), finishedAt: iso(at), gitSha: hex(40), actor: "ci", runOrigin: { forge: "github", repo: "INTENTIUS/chant", url: `https://github.com/INTENTIUS/chant/actions/runs/${i}` }, waves: [{ name: "build", status: "succeeded" }, { name: "check", status: "succeeded" }] }) }] });
112
+ const events = Array.from({ length: 40 }, (_, k) => line({ runId: id, name: op, index: k, type: k === 0 ? "run-start" : k === 39 ? "run-settle" : "step-done", at: iso(at - 60_000 + k * 1000), step: `step-${k}`, status: "ok", detail: hex(40) }));
113
+ runs.push({ at, id, text: events.join("") });
114
+ });
115
+
116
+ // Pull requests: a change set, a pending fact, and an approval two hours on.
117
+ spread(RATES.pullRequests, (at, i) => {
118
+ const member = i % 5 === 4 ? "reference-workspace" : "chant";
119
+ const prefix = `_members/${member}/`;
120
+ const digest = `jcs1-sha256:${hex(64)}`;
121
+ const op = `pr-${3300 + i}`;
122
+ const gates = `${prefix}_gates/${op}.jsonl`;
123
+ writes.push({ at, message: `Change set ${digest}`, files: [{ path: `${prefix}_change-sets/${digest.replace(/:/g, "_")}.json`, content: changeSet(digest, member, 4 + (i % 9)) }] });
124
+ writes.push({ at: at + 1000, message: "Pending gate record", files: [{ path: gates, append: line({ version: 1, kind: "pending", op, gate: "review", timestamp: iso(at + 1000), expiresAt: iso(at + 1000 + 2 * DAY), planDigest: digest, gitSha: hex(40), runId: `${i}`, url: `https://github.com/INTENTIUS/chant/pull/${3300 + i}` }) }] });
125
+ writes.push({ at: at + 2 * 3600_000, message: "Gate resolution record", files: [{ path: gates, append: line({ version: 1, kind: "resolution", op, gate: "review", resolvedBy: "github:lex00", timestamp: iso(at + 2 * 3600_000), planDigest: digest, gitSha: hex(40) }) }] });
126
+ });
127
+
128
+ // Releases: cut, deploy to staging under an empty release gate, every third promoted to prod after an approval.
129
+ const version = new Map<string, number>();
130
+ /** The release version each component's bytes last changed in. */
131
+ const built = new Map<string, number>();
132
+ spread(RATES.releases, (at, i) => {
133
+ const m = rand() < MEMBERS[0].share ? MEMBERS[0] : MEMBERS[1];
134
+ const prefix = `_members/${m.name}/`;
135
+ const v = (version.get(m.name) ?? 0) + 1;
136
+ version.set(m.name, v);
137
+ // One or two components change in a release; the rest keep their template bytes.
138
+ const changed = new Set(m.components.filter((_, k) => k === v % 3 || (v % 2 === 0 && k === (v + 1) % 3)));
139
+ const templates: Array<{ env: string; component: string; content: string }> = [];
140
+ for (const c of m.components) if (changed.has(c) || !built.has(c)) built.set(c, v);
141
+ for (const env of ["staging", "prod"]) for (const c of m.components) templates.push({ env, component: c, content: template(c, env, built.get(c)!) });
142
+ const release = makeRelease({
143
+ member: m.name,
144
+ commit: hex(40),
145
+ artifactDigests: m.components.map((c) => ({ component: c, kind: "image" as const, path: `${c}.tar`, digest: contentDigest(`${c}:${built.get(c)}`) })),
146
+ targets: ["staging", "prod"].map((env) => ({
147
+ env,
148
+ templates: templates.filter((t) => t.env === env).map((t) => ({ component: t.component, path: `dist/${t.component}.yaml`, digest: contentDigest(t.content) })),
149
+ params: [{ name: "replicas", value: env === "prod" ? 3 : 1, source: "default" }],
150
+ })),
151
+ });
152
+ const stem = (d: string) => d.replace(/:/g, "_");
153
+ writes.push({
154
+ at,
155
+ message: `Release ${release.releaseDigest}`,
156
+ files: [{ path: `${prefix}_releases/${stem(release.releaseDigest)}.json`, content: `${JSON.stringify(release)}\n` }, ...templates.map((t) => ({ path: `${prefix}_releases/templates/${stem(contentDigest(t.content))}.txt`, content: t.content }))],
157
+ });
158
+ writes.push({ at: at + 1000, message: "Release cut", files: [{ path: `${prefix}_releases/cuts.jsonl`, append: line({ version: 1, releaseDigest: release.releaseDigest, label: `v0.${100 + i}.0`, actor: "lex00", timestamp: iso(at + 1000), runOrigin: null, signature: null }) }] });
159
+ const promote = (env: string, t: number, approved: boolean) => {
160
+ const cs = `jcs1-sha256:${hex(64)}`;
161
+ writes.push({ at: t, message: `Change set ${cs}`, files: [{ path: `${prefix}_change-sets/${stem(cs)}.json`, content: changeSet(cs, m.name, 3) }] });
162
+ const gate = { op: "release", gate: "ship", planDigest: cs, releaseDigest: release.releaseDigest, environment: env };
163
+ if (approved) {
164
+ writes.push({ at: t + 1000, message: "Pending gate record", files: [{ path: `${prefix}_gates/release.jsonl`, append: line({ version: 1, kind: "pending", ...gate, timestamp: iso(t + 1000), expiresAt: iso(t + 1000 + 2 * DAY) }) }] });
165
+ writes.push({ at: t + 3600_000, message: "Gate resolution record", files: [{ path: `${prefix}_gates/release.jsonl`, append: line({ version: 1, kind: "resolution", ...gate, resolvedBy: "github:lex00", timestamp: iso(t + 3600_000) }) }] });
166
+ } else {
167
+ writes.push({ at: t + 1000, message: "Gate resolution record", files: [{ path: `${prefix}_gates/release.jsonl`, append: line({ version: 1, kind: "resolution", ...gate, resolvedBy: "chant", empty: true, timestamp: iso(t + 1000) }) }] });
168
+ }
169
+ const deployedAt = t + (approved ? 3600_000 : 0) + 5000;
170
+ for (const c of m.components) {
171
+ writes.push({ at: deployedAt, message: "Release record", files: [{ path: `${prefix}${env}/releases.jsonl`, append: line({ version: 1, component: c, env, digest: release.artifactDigests.find((a) => a.component === c)!.digest, gitSha: release.commit, runId: hex(10), timestamp: iso(deployedAt), actor: "ci", releaseDigest: release.releaseDigest, runOrigin: { forge: "github", repo: "INTENTIUS/chant" } }) }] });
172
+ }
173
+ };
174
+ promote("staging", at + 60_000, false);
175
+ if (i % 3 === 0) promote("prod", at + 6 * 3600_000, true);
176
+ });
177
+
178
+ writes.sort((a, b) => a.at - b.at);
179
+ return { writes, runs };
180
+ }
181
+
182
+ /** Write every commit with `git fast-import`, as chant/lifecycle's history would hold them. */
183
+ function importMonth(dir: string, writes: readonly Write[]): void {
184
+ const state = new Map<string, string>();
185
+ const chunks: string[] = [];
186
+ writes.forEach((w, i) => {
187
+ const seconds = Math.floor(w.at / 1000);
188
+ chunks.push(`commit refs/heads/chant/lifecycle\nmark :${i + 1}\ncommitter chant <chant@localhost> ${seconds} +0000\ndata ${Buffer.byteLength(w.message)}\n${w.message}\n`);
189
+ if (i > 0) chunks.push(`from :${i}\n`);
190
+ for (const f of w.files) {
191
+ const content = f.content ?? `${state.get(f.path) ?? ""}${f.append}`;
192
+ state.set(f.path, content);
193
+ chunks.push(`M 100644 inline ${f.path}\ndata ${Buffer.byteLength(content)}\n${content}\n`);
194
+ }
195
+ });
196
+ execFileSync("git", ["fast-import", "--quiet"], { cwd: dir, input: chunks.join(""), maxBuffer: 1024 * 1024 * 1024 });
197
+ }
198
+
199
+ /** The packed size of the repository, which holds chant/lifecycle alone. */
200
+ function packed(dir: string): { commits: number; objects: number; packKiB: number } {
201
+ execFileSync("git", ["repack", "-a", "-d", "-q"], { cwd: dir });
202
+ const v = Object.fromEntries(
203
+ execFileSync("git", ["count-objects", "-v"], { cwd: dir, encoding: "utf-8" })
204
+ .trim()
205
+ .split("\n")
206
+ .map((l) => l.split(": ")),
207
+ ) as Record<string, string>;
208
+ const commits = Number(execFileSync("git", ["rev-list", "--count", "chant/lifecycle"], { cwd: dir, encoding: "utf-8" }).trim());
209
+ return { commits, objects: Number(v["in-pack"]), packKiB: Number(v["size-pack"]) };
210
+ }
211
+
212
+ const kib = (bytes: number) => Math.round(bytes / 1024);
213
+
214
+ describe("retention at chant's own rate (#4036)", () => {
215
+ test("a month of records, then gc with the default policy", async () => {
216
+ await withTestDir(async (dir) => {
217
+ execFileSync("git", ["init", "-q", "-b", "main"], { cwd: dir });
218
+ const events = join(dir, ".run-events");
219
+ mkdirSync(events);
220
+ const prior = process.env[RUN_EVENTS_DIR_ENV];
221
+ process.env[RUN_EVENTS_DIR_ENV] = events;
222
+ try {
223
+ const { writes, runs } = month();
224
+ importMonth(dir, writes);
225
+ for (const r of runs) {
226
+ const file = join(events, `${r.id}.jsonl`);
227
+ writeFileSync(file, r.text);
228
+ utimesSync(file, r.at / 1000, r.at / 1000);
229
+ }
230
+ const eventBytes = () => readdirSync(events).reduce((n, f) => n + statSync(join(events, f)).size, 0);
231
+ const eventsBefore = { files: readdirSync(events).length, kib: kib(eventBytes()) };
232
+ const historyBefore = packed(dir);
233
+
234
+ const now = new Date(START + DAYS * DAY);
235
+ const t0 = Date.now();
236
+ const report = await runLifecycleGc({ cwd: dir, policy: DEFAULT_RETENTION, now, dryRun: false });
237
+ const gcMs = Date.now() - t0;
238
+ const historyAfter = packed(dir);
239
+ const eventsAfter = { files: readdirSync(events).length, kib: kib(eventBytes()) };
240
+
241
+ const lc = report.lifecycle;
242
+ const tree = (s: typeof lc.before) => ({
243
+ files: s.files,
244
+ kib: kib(s.bytes),
245
+ changeSets: `${s.changeSets.files} files, ${kib(s.changeSets.bytes)} KiB`,
246
+ releases: `${s.releases.files} files, ${kib(s.releases.bytes)} KiB`,
247
+ templates: `${s.templates.files} files, ${kib(s.templates.bytes)} KiB`,
248
+ ledgers: `${s.ledgers.files} files, ${kib(s.ledgers.bytes)} KiB`,
249
+ });
250
+ const summary = {
251
+ rates: { runsPerDay: RATES.runs, pullRequestGatesPerDay: RATES.pullRequests, releasesPerDay: RATES.releases, days: DAYS, writes: writes.length },
252
+ policy: DEFAULT_RETENTION,
253
+ removed: { changeSets: lc.remove.filter((f) => f.kind === "change-set").length, releases: lc.remove.filter((f) => f.kind === "release").length, templates: lc.remove.filter((f) => f.kind === "template").length },
254
+ treeBefore: tree(lc.before),
255
+ treeAfter: tree(lc.after),
256
+ historyBefore,
257
+ historyAfter,
258
+ runEventsBefore: eventsBefore,
259
+ runEventsAfter: eventsAfter,
260
+ gcMs,
261
+ };
262
+ console.log(`retention fixture (#4036):\n${JSON.stringify(summary, null, 2)}`);
263
+
264
+ expect(lc.commit).not.toBeNull();
265
+ expect(lc.after.bytes).toBeLessThan(lc.before.bytes);
266
+ expect(lc.after.ledgers).toEqual(lc.before.ledgers);
267
+ // The prune adds a commit and its trees; it never takes history back.
268
+ expect(historyAfter.commits).toBe(historyBefore.commits + 1);
269
+ expect(historyAfter.packKiB).toBeGreaterThanOrEqual(historyBefore.packKiB);
270
+ expect(eventsAfter.files).toBeLessThan(eventsBefore.files);
271
+ // What prod and staging run, and the newest releases, stay.
272
+ expect(lc.keep.some((f) => f.reasons.some((r) => r.code === "deployed"))).toBe(true);
273
+ } finally {
274
+ if (prior === undefined) delete process.env[RUN_EVENTS_DIR_ENV];
275
+ else process.env[RUN_EVENTS_DIR_ENV] = prior;
276
+ }
277
+ });
278
+ }, 300_000);
279
+ });
@@ -0,0 +1,63 @@
1
+ /**
2
+ * How long chant keeps each kind of stored record (#4036): the retention
3
+ * policy `chant lifecycle gc` prunes by. It reads no git, so the workspace
4
+ * declaration's parser imports it for the defaults and the shape.
5
+ *
6
+ * Three kinds are pruned:
7
+ *
8
+ * changeSets `_change-sets/<digest>.json` on `chant/lifecycle` (#4022)
9
+ * releases `_releases/<digest>.json` on `chant/lifecycle` (#4026), with
10
+ * the template bytes under `_releases/templates/` that only
11
+ * pruned releases name
12
+ * runEvents `<git-common-dir>/chant/run-events/<run>.jsonl`, local only (#4042)
13
+ *
14
+ * The ledgers (`cuts.jsonl`, `<env>/releases.jsonl`, `_gates/<op>.jsonl` and
15
+ * the run ledgers) are append-only and never pruned. See ./retention.ts.
16
+ */
17
+
18
+ /** The retention of one kind: the age in days past which a record can go, and, where it applies, how many of the newest are kept whatever their age. */
19
+ export interface KindRetention {
20
+ days: number;
21
+ keep: number;
22
+ }
23
+
24
+ /** The retention of every kind, with the defaults filled in. */
25
+ export interface RetentionPolicy {
26
+ /** A stored change set is kept for `days` after it was stored or last named by a gate fact, whichever is later. `keep` does not apply and is 0. */
27
+ changeSets: KindRetention;
28
+ /** A release is promotable for `days` after its newest cut, and the newest `keep` releases of each member stay promotable at any age. */
29
+ releases: KindRetention;
30
+ /** A run's event file is kept for `days` after its last write, and the newest `keep` files are kept at any age. */
31
+ runEvents: KindRetention;
32
+ }
33
+
34
+ /** The record kinds a policy names, in the order a report lists them. */
35
+ export const RETENTION_KINDS = ["changeSets", "releases", "runEvents"] as const;
36
+ export type RetentionKind = (typeof RETENTION_KINDS)[number];
37
+
38
+ /**
39
+ * The defaults. At chant's own rate (about 25 pull-request gates, 28 main
40
+ * commits and 5 release cuts a day in September and October 2026) two weeks
41
+ * keeps every change set an open pull request or a promotion can still need,
42
+ * and the newest ten releases of a member cover a member that cuts rarely.
43
+ */
44
+ export const DEFAULT_RETENTION: RetentionPolicy = {
45
+ changeSets: { days: 14, keep: 0 },
46
+ releases: { days: 14, keep: 10 },
47
+ runEvents: { days: 14, keep: 100 },
48
+ };
49
+
50
+ /** The declaration's `retention` block as written: any kind or field may be left out. */
51
+ export type RetentionOverride = Partial<Record<RetentionKind, Partial<KindRetention>>>;
52
+
53
+ /** The policy an override gives: each field it sets replaces the default. */
54
+ export function resolveRetention(override?: RetentionOverride | null): RetentionPolicy {
55
+ const out = structuredClone(DEFAULT_RETENTION);
56
+ for (const kind of RETENTION_KINDS) {
57
+ const o = override?.[kind];
58
+ if (!o) continue;
59
+ if (typeof o.days === "number") out[kind].days = o.days;
60
+ if (typeof o.keep === "number" && kind !== "changeSets") out[kind].keep = o.keep;
61
+ }
62
+ return out;
63
+ }