@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,295 @@
1
+ /**
2
+ * `chant release publish` with targets, end to end (#4047): a member that
3
+ * declares an npm target of two packages and an OCI target is cut, then
4
+ * published to stand-ins only, a fake `npm` first on PATH and a registry fake
5
+ * on 127.0.0.1 that this test starts and closes. The publish is a dry run
6
+ * without --yes; with --yes and a CI OIDC token it stages each package under
7
+ * next with --provenance and the image by digest, then moves latest and the
8
+ * tag (#4048); no credential appears in its output or on `chant/lifecycle`;
9
+ * and a checkout that moved past the release's commit is refused. A publish
10
+ * that fails part way is resumed from a fresh clone, as a CI job would, from
11
+ * the record it pushed, and publishes nothing again.
12
+ *
13
+ * #4049: the first release is cut signed with a throwaway key the signers
14
+ * file lists, and published as CI publishes it: each package carries its
15
+ * SBOM, the image gets its SBOM and the release's provenance statement as
16
+ * referrers, and the fake npm records `npm audit signatures` run over what
17
+ * was published. The second is published unsigned under --allow-unsigned.
18
+ */
19
+
20
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
21
+ import { execFileSync, spawn, spawnSync } from "node:child_process";
22
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
23
+ import { tmpdir } from "node:os";
24
+ import { join } from "node:path";
25
+ import { pathToFileURL } from "node:url";
26
+ import { REPO } from "../../workspace/__fixtures__/contract-repo";
27
+ import { readFakeNpmRegistry, startFakeRegistry, writeFakeNpm, writeLockfile, writeOciLayout, writePackage, type FakeRegistry } from "../../lifecycle/__fixtures__/publish-fakes";
28
+ import { hasSshKeygen } from "../../workspace/trust/test-repo";
29
+
30
+ const CHANT = [process.execPath, "--import", pathToFileURL(join(REPO, "node_modules", "tsx", "dist", "loader.mjs")).href, join(REPO, "packages", "core", "src", "cli", "main.ts")];
31
+ const CI_VARS = [
32
+ "CI",
33
+ "GITHUB_ACTIONS",
34
+ "GITHUB_RUN_ID",
35
+ "GITHUB_SHA",
36
+ "GITHUB_ACTOR",
37
+ "GITHUB_REPOSITORY",
38
+ "GITHUB_SERVER_URL",
39
+ "GITHUB_API_URL",
40
+ "GITHUB_TOKEN",
41
+ "ACTIONS_ID_TOKEN_REQUEST_URL",
42
+ "ACTIONS_ID_TOKEN_REQUEST_TOKEN",
43
+ "GITLAB_CI",
44
+ "CI_PIPELINE_ID",
45
+ "GITLAB_USER_LOGIN",
46
+ "CHANT_ENV",
47
+ "NODE_AUTH_TOKEN",
48
+ "OCI_USERNAME",
49
+ "OCI_PASSWORD",
50
+ ];
51
+
52
+ /** Run chant without blocking this process, so the registry fake in it can answer. */
53
+ function chant(cwd: string, args: string[], extra: Record<string, string> = {}): Promise<{ code: number; stdout: string; stderr: string }> {
54
+ const env: NodeJS.ProcessEnv = { ...process.env, USER: "tester" };
55
+ for (const k of CI_VARS) delete env[k];
56
+ Object.assign(env, extra);
57
+ return new Promise((resolve) => {
58
+ const child = spawn(CHANT[0]!, [...CHANT.slice(1), ...args], { cwd, env });
59
+ let stdout = "";
60
+ let stderr = "";
61
+ child.stdout.on("data", (c: Buffer) => (stdout += c.toString()));
62
+ child.stderr.on("data", (c: Buffer) => (stderr += c.toString()));
63
+ child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
64
+ });
65
+ }
66
+
67
+ function git(cwd: string, ...args: string[]): string {
68
+ const r = spawnSync("git", args, { cwd, encoding: "utf-8" });
69
+ if (r.status !== 0) throw new Error(r.stderr);
70
+ return r.stdout.trim();
71
+ }
72
+
73
+ const BUILD = `mkdir -p dist && printf '{"env":"%s"}' "$CHANT_ENV" > dist/api.template.json`;
74
+ const NPM_TOKEN = "npm_e2eSecretToken0042";
75
+ const OCI_PASSWORD = "oci-e2e-password-0042";
76
+ const REGISTRY_TOKEN = "registry-e2e-token-0042";
77
+
78
+ let reg: FakeRegistry;
79
+ let root: string;
80
+ beforeAll(async () => {
81
+ reg = await startFakeRegistry({ username: "robot", password: OCI_PASSWORD, token: REGISTRY_TOKEN });
82
+ root = realpathSync(mkdtempSync(join(tmpdir(), "chant-release-publish-targets-")));
83
+ });
84
+ afterAll(async () => {
85
+ await reg.close();
86
+ rmSync(root, { recursive: true, force: true });
87
+ });
88
+
89
+ describe("chant release publish with targets", () => {
90
+ test.skipIf(!hasSshKeygen)(
91
+ "a dry run by default; with --yes publishes a signed release's two npm packages in order with --provenance, audits their signatures, and an image by digest with its SBOM and provenance; prints no credential, and refuses a moved checkout",
92
+ async () => {
93
+ const dir = join(root, "work");
94
+ const remote = join(root, "origin.git");
95
+ git(root, "init", "-q", "--bare", "-b", "main", remote);
96
+ git(root, "init", "-q", "-b", "main", dir);
97
+ git(dir, "config", "user.email", "t@chant.dev");
98
+ git(dir, "config", "user.name", "T");
99
+ const publish = [
100
+ { type: "npm", packages: ["packages/core", "packages/cli"], access: "public" },
101
+ { type: "oci", image: "image", repository: `${reg.host}/acme/api`, usernameEnv: "OCI_USERNAME", passwordEnv: "OCI_PASSWORD" },
102
+ ];
103
+ writeFileSync(join(dir, "chant.config.ts"), `export default { environments: ["dev"], release: { publish: ${JSON.stringify(publish)} } };\n`);
104
+ writeFileSync(
105
+ join(dir, "api.component.ts"),
106
+ `export const api = { name: "api", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "cfn-deploy", template: "dist/api.template.json" }] }] };\n`,
107
+ );
108
+ writeFileSync(join(dir, ".gitignore"), "dist/\n");
109
+ writePackage(join(dir, "packages", "core"), "@acme/core", "1.0.0");
110
+ writePackage(join(dir, "packages", "cli"), "@acme/cli", "1.0.0");
111
+ writeLockfile(dir);
112
+ const { digest } = writeOciLayout(join(dir, "image"));
113
+ // #4049: alice's throwaway key, which the signers file lists.
114
+ const key = join(root, "alice-key");
115
+ execFileSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-C", "alice", "-f", key]);
116
+ const alicePub = readFileSync(`${key}.pub`, "utf-8").trim().split(" ").slice(0, 2).join(" ");
117
+ mkdirSync(join(dir, ".chant"), { recursive: true });
118
+ writeFileSync(join(dir, ".chant", "allowed_signers"), `alice ${alicePub}\n`);
119
+ git(dir, "add", ".");
120
+ git(dir, "commit", "-q", "-m", "init");
121
+ git(dir, "remote", "add", "origin", remote);
122
+ git(dir, "push", "-q", "origin", "main");
123
+ git(dir, "fetch", "-q", "origin");
124
+ const sha = git(dir, "rev-parse", "HEAD");
125
+ git(dir, "tag", `ci/green/${sha}`);
126
+
127
+ const log = join(root, "npm.log");
128
+ const bin = join(root, "bin");
129
+ writeFakeNpm(bin, log);
130
+ const path = { PATH: `${bin}:${process.env.PATH}` };
131
+ const calls = () => (existsSync(log) ? readFileSync(log, "utf-8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l) as { args: string[]; tokenSet: boolean }) : []);
132
+
133
+ const dryCut = await chant(dir, ["release", "cut", "v1", "--build", BUILD], path);
134
+ expect(dryCut.code, dryCut.stderr).toBe(0);
135
+ expect(dryCut.stdout).toContain("chant release publish v1: npm (2 packages) to npm the registry npm's config names");
136
+ expect(dryCut.stdout).toContain(`chant release publish v1: oci ${reg.host}/acme/api to ${reg.host}/acme/api`);
137
+ const cut = await chant(dir, ["release", "cut", "v1", "--build", BUILD, "--sign", key, "--yes"], path);
138
+ expect(cut.code, cut.stderr).toBe(0);
139
+ expect(cut.stderr).toContain("signed by alice");
140
+
141
+ const secrets = { NODE_AUTH_TOKEN: NPM_TOKEN, OCI_USERNAME: "robot", OCI_PASSWORD };
142
+ const oidc = { GITHUB_ACTIONS: "true", ACTIONS_ID_TOKEN_REQUEST_URL: "https://token.example", ACTIONS_ID_TOKEN_REQUEST_TOKEN: "oidc-request-token-0042" };
143
+
144
+ const dry = await chant(dir, ["release", "publish", "v1"], { ...path, ...secrets, ...oidc });
145
+ expect(dry.code, dry.stderr).toBe(0);
146
+ expect(dry.stdout).toContain("What would be published where:");
147
+ expect(dry.stdout).toContain(" npm (2 packages), with --provenance:\n @acme/core@1.0.0 to the registry npm's config names, dist-tag next, then latest, carrying sbom.spdx.json\n @acme/cli@1.0.0 to");
148
+ expect(dry.stdout).toContain(` image (tag v1) to ${reg.host}/acme/api@${digest}`);
149
+ expect(dry.stderr).toContain("Dry run: nothing was published. Run again with --yes to publish: chant release publish v1 --yes");
150
+ expect(calls()).toEqual([]);
151
+ expect(reg.requests).toEqual([]);
152
+
153
+ const pub = await chant(dir, ["release", "publish", "v1", "--yes"], { ...path, ...secrets, ...oidc });
154
+ expect(pub.code, pub.stderr).toBe(0);
155
+ expect(pub.stderr).toContain("signed by alice");
156
+ expect(calls().map((c) => c.args)).toEqual([
157
+ ["view", "@acme/core@1.0.0", "version"],
158
+ ["publish", join(dir, "packages/core"), "--tag", "next", "--access", "public", "--provenance"],
159
+ ["view", "@acme/cli@1.0.0", "version"],
160
+ ["publish", join(dir, "packages/cli"), "--tag", "next", "--access", "public", "--provenance"],
161
+ ["dist-tag", "add", "@acme/core@1.0.0", "latest"],
162
+ ["dist-tag", "add", "@acme/cli@1.0.0", "latest"],
163
+ // #4049: in CI, what was published is installed in an empty directory and its signatures and attestations audited.
164
+ ["install", "--ignore-scripts", "--no-audit", "--no-fund"],
165
+ ["audit", "signatures"],
166
+ ]);
167
+ expect(pub.stderr).toContain("2 packages have verified attestations");
168
+ expect(calls().every((c) => c.tokenSet)).toBe(true);
169
+ expect(reg.manifests.has(digest)).toBe(true);
170
+ expect(reg.manifests.has("v1")).toBe(true);
171
+ expect(pub.stderr).toContain(`published: image (tag v1) to ${reg.host}/acme/api@${digest}`);
172
+ expect(pub.stderr).toContain("published: @acme/core@1.0.0 to the registry npm's config names, dist-tag latest, carrying sbom.spdx.json");
173
+ expect(pub.stderr).toMatch(/published: sbom\.spdx\.json \(SBOM\) to .*@sha256:[0-9a-f]{64}, a referrer of the image/);
174
+ expect(pub.stderr).toMatch(/published: release\.intoto\.json \(provenance\) to .*@sha256:[0-9a-f]{64}, a referrer of the image/);
175
+ expect(pub.stderr).toContain("Published v1 to 2 target(s)");
176
+ // The image's referrers: each names the image as its subject, and the provenance statement carries the cut's signature.
177
+ const referrers = [...reg.manifests.entries()]
178
+ .filter(([k]) => k.startsWith("sha256:") && k !== digest)
179
+ .map(([, m]) => JSON.parse(m.bytes.toString("utf-8")) as { artifactType: string; subject: { digest: string }; layers: Array<{ digest: string }> });
180
+ expect(referrers.map((m) => m.artifactType).sort()).toEqual(["application/spdx+json", "application/vnd.in-toto+json"]);
181
+ expect(referrers.every((m) => m.subject.digest === digest)).toBe(true);
182
+ const statement = JSON.parse(reg.blobs.get(referrers.find((m) => m.artifactType === "application/vnd.in-toto+json")!.layers[0]!.digest)!.toString("utf-8"));
183
+ expect(statement).toMatchObject({
184
+ _type: "https://in-toto.io/Statement/v1",
185
+ subject: [{ digest: { sha256: digest.slice(7) } }],
186
+ predicateType: "https://intentius.io/chant/release/v1",
187
+ predicate: { label: "v1", cut: { actor: "alice", namespace: "chant-release", signature: expect.stringMatching(/^-----BEGIN SSH SIGNATURE-----/) }, provenance: { kind: "oidc", ci: "github" } },
188
+ });
189
+
190
+ // The release verifies offline against the signers file at its commit.
191
+ const verified = await chant(dir, ["release", "verify", "v1", "--json"]);
192
+ expect(verified.code, verified.stderr).toBe(0);
193
+ expect(JSON.parse(verified.stdout)).toMatchObject({ label: "v1", status: "verified", verifiedBy: { actor: "alice" } });
194
+
195
+ // No credential in the output, or anywhere on chant/lifecycle.
196
+ const lifecycle = git(dir, "log", "-p", "--all");
197
+ for (const secret of [NPM_TOKEN, OCI_PASSWORD, REGISTRY_TOKEN, "oidc-request-token-0042"]) {
198
+ for (const text of [dry.stdout, dry.stderr, pub.stdout, pub.stderr, lifecycle]) expect(text).not.toContain(secret);
199
+ }
200
+ expect(pub.stderr).toContain("npm notice token [redacted]");
201
+
202
+ // From a workstation the publish warns that it carries no provenance, and the plan offers none.
203
+ const asJson = await chant(dir, ["release", "publish", "v1", "--json"], { ...path, ...secrets });
204
+ expect(asJson.code, asJson.stderr).toBe(0);
205
+ expect(asJson.stderr).toContain("this publish runs without OIDC");
206
+ const doc = JSON.parse(asJson.stdout);
207
+ expect(doc).toMatchObject({ dryRun: true, label: "v1", provenance: { oidc: false } });
208
+ expect(doc.targets.map((t: { kind: string }) => t.kind)).toEqual(["npm", "oci"]);
209
+
210
+ // A checkout past the release's commit is refused before anything runs.
211
+ git(dir, "commit", "-q", "--allow-empty", "-m", "later");
212
+ const moved = await chant(dir, ["release", "publish", "v1", "--yes"], { ...path, ...secrets, ...oidc });
213
+ expect(moved.code).toBe(1);
214
+ expect(moved.stderr).toMatch(/\[release-head-mismatch\]/);
215
+ expect(calls()).toHaveLength(8);
216
+ },
217
+ 240_000,
218
+ );
219
+ test(
220
+ "a publish that failed at the image is resumed from a fresh clone with the record it pushed, and stages no package again",
221
+ async () => {
222
+ const work = join(root, "resume");
223
+ const remote = join(root, "resume-origin.git");
224
+ git(root, "init", "-q", "--bare", "-b", "main", remote);
225
+ git(root, "init", "-q", "-b", "main", work);
226
+ git(work, "config", "user.email", "t@chant.dev");
227
+ git(work, "config", "user.name", "T");
228
+ const publish = [
229
+ { type: "npm", packages: ["packages/core", "packages/cli"] },
230
+ { type: "oci", image: "image", repository: `${reg.host}/acme/resume` },
231
+ ];
232
+ writeFileSync(join(work, "chant.config.ts"), `export default { environments: ["dev"], release: { publish: ${JSON.stringify(publish)} } };\n`);
233
+ writeFileSync(
234
+ join(work, "api.component.ts"),
235
+ `export const api = { name: "api", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "cfn-deploy", template: "dist/api.template.json" }] }] };\n`,
236
+ );
237
+ writeFileSync(join(work, ".gitignore"), "dist/\n");
238
+ writePackage(join(work, "packages", "core"), "@acme/rcore", "3.0.0");
239
+ writePackage(join(work, "packages", "cli"), "@acme/rcli", "3.0.0");
240
+ writeLockfile(work);
241
+ const { digest } = writeOciLayout(join(work, "image"), "resume layer");
242
+ git(work, "add", ".");
243
+ git(work, "commit", "-q", "-m", "init");
244
+ git(work, "remote", "add", "origin", remote);
245
+ git(work, "push", "-q", "origin", "main");
246
+ git(work, "fetch", "-q", "origin");
247
+ git(work, "tag", `ci/green/${git(work, "rev-parse", "HEAD")}`);
248
+
249
+ const log = join(root, "resume-npm.log");
250
+ const bin = join(root, "resume-bin");
251
+ writeFakeNpm(bin, log);
252
+ const path = { PATH: `${bin}:${process.env.PATH}` };
253
+ const calls = () => (existsSync(log) ? readFileSync(log, "utf-8").trim().split("\n").filter(Boolean).map((l) => (JSON.parse(l) as { args: string[] }).args) : []);
254
+ const cut = await chant(work, ["release", "cut", "r3", "--build", BUILD, "--yes"], path);
255
+ expect(cut.code, cut.stderr).toBe(0);
256
+
257
+ // The registry refuses the password: npm staged, the image did not, and no tag moved. These packages
258
+ // are new, and npm makes a new package's first version latest whatever tag it was published under.
259
+ const failed = await chant(work, ["release", "publish", "r3", "--allow-unsigned", "--yes"], { ...path, OCI_USERNAME: "robot", OCI_PASSWORD: "wrong-password-0042" });
260
+ expect(failed.code).toBe(1);
261
+ expect(failed.stderr).toMatch(/\[publish-oci-failed\]/);
262
+ expect(failed.stderr).toContain("nothing is visible yet; staged: npm (2 packages)");
263
+ expect(failed.stderr).toContain("Run the same command again to resume; what went out is not published again: chant release publish r3 --allow-unsigned --yes");
264
+ expect(failed.stderr).not.toContain("wrong-password-0042");
265
+ expect(readFakeNpmRegistry(log).packages["@acme/rcore"]!.distTags).toEqual({ next: "3.0.0", latest: "3.0.0" });
266
+ expect(calls().filter((a) => a[0] === "dist-tag")).toEqual([]);
267
+ expect([...reg.manifests.keys()].filter((k) => k === "r3")).toEqual([]);
268
+
269
+ // A CI job resumes on a fresh clone of the release's commit: the record came with chant/lifecycle.
270
+ const fresh = join(root, "resume-fresh");
271
+ git(root, "clone", "-q", remote, fresh);
272
+ git(fresh, "config", "user.email", "ci@chant.dev");
273
+ git(fresh, "config", "user.name", "CI");
274
+ const before = calls().length;
275
+ const dry = await chant(fresh, ["release", "publish", "r3", "--allow-unsigned"], path);
276
+ expect(dry.code, dry.stderr).toBe(0);
277
+ expect(dry.stdout).toContain("npm (2 packages) (staged by an earlier run; only revealed):");
278
+ const resumed = await chant(fresh, ["release", "publish", "r3", "--allow-unsigned", "--yes", "--json"], { ...path, OCI_USERNAME: "robot", OCI_PASSWORD });
279
+ expect(resumed.code, resumed.stderr).toBe(0);
280
+ expect(calls().slice(before)).toEqual([
281
+ ["dist-tag", "add", "@acme/rcore@3.0.0", "latest"],
282
+ ["dist-tag", "add", "@acme/rcli@3.0.0", "latest"],
283
+ ]);
284
+ expect(reg.manifests.has(digest)).toBe(true);
285
+ expect(reg.manifests.has("r3")).toBe(true);
286
+ const doc = JSON.parse(resumed.stdout);
287
+ expect(doc.resumed).toEqual([{ target: "npm (2 packages)", step: "staged" }]);
288
+ expect(doc.targets.map((t: { progress: string }) => t.progress)).toEqual(["staged", "none"]);
289
+ const record = git(fresh, "show", "chant/lifecycle:_releases/publishes.jsonl").split("\n").map((l) => JSON.parse(l) as { kind: string; step: string });
290
+ expect(record.map((l) => `${l.kind} ${l.step}`)).toEqual(["npm staged", "oci staged", "npm revealed", "oci revealed"]);
291
+ expect(git(fresh, "log", "-p", "--all")).not.toContain(OCI_PASSWORD);
292
+ },
293
+ 240_000,
294
+ );
295
+ });
@@ -0,0 +1,117 @@
1
+ /**
2
+ * `chant release verify` and the publish's signature check, end to end
3
+ * (#4049), in a temp repo with a temp bare remote and throwaway ssh keys. A
4
+ * release cut signed with a key `.chant/allowed_signers` lists verifies, and a
5
+ * publish takes it; an unsigned one and one signed by a key the file does not
6
+ * list fail `release verify`, and a publish refuses them unless
7
+ * `--allow-unsigned`. Nothing is published: the member declares no target.
8
+ */
9
+
10
+ import { describe, expect, test } from "vitest";
11
+ import { execFileSync, spawnSync } from "node:child_process";
12
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { pathToFileURL } from "node:url";
16
+ import { REPO } from "../../workspace/__fixtures__/contract-repo";
17
+ import { hasSshKeygen } from "../../workspace/trust/test-repo";
18
+
19
+ const CHANT = [process.execPath, "--import", pathToFileURL(join(REPO, "node_modules", "tsx", "dist", "loader.mjs")).href, join(REPO, "packages", "core", "src", "cli", "main.ts")];
20
+ const CI_VARS = ["CI", "GITHUB_ACTIONS", "GITHUB_RUN_ID", "GITHUB_SHA", "GITHUB_ACTOR", "ACTIONS_ID_TOKEN_REQUEST_URL", "ACTIONS_ID_TOKEN_REQUEST_TOKEN", "GITLAB_CI", "CI_PIPELINE_ID", "GITLAB_USER_LOGIN", "CHANT_ENV"];
21
+
22
+ function chant(cwd: string, args: string[]): { code: number; stdout: string; stderr: string } {
23
+ const env: NodeJS.ProcessEnv = { ...process.env, USER: "tester" };
24
+ for (const k of CI_VARS) delete env[k];
25
+ const r = spawnSync(CHANT[0]!, [...CHANT.slice(1), ...args], { cwd, encoding: "utf-8", env, timeout: 120_000 });
26
+ return { code: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
27
+ }
28
+
29
+ function git(cwd: string, ...args: string[]): string {
30
+ const r = spawnSync("git", args, { cwd, encoding: "utf-8" });
31
+ if (r.status !== 0) throw new Error(r.stderr);
32
+ return r.stdout.trim();
33
+ }
34
+
35
+ function key(root: string, name: string): { file: string; pub: string } {
36
+ const file = join(root, `key-${name}`);
37
+ execFileSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-C", name, "-f", file]);
38
+ return { file, pub: readFileSync(`${file}.pub`, "utf-8").trim().split(" ").slice(0, 2).join(" ") };
39
+ }
40
+
41
+ const BUILD = `mkdir -p dist && printf '{"env":"%s"}' "$CHANT_ENV" > dist/api.template.json`;
42
+
43
+ describe("chant release verify", () => {
44
+ test.skipIf(!hasSshKeygen)(
45
+ "passes for a signed release, fails for an unsigned one and for one signed by an unlisted key; a publish refuses both unless --allow-unsigned",
46
+ () => {
47
+ const root = mkdtempSync(join(tmpdir(), "chant-release-verify-"));
48
+ try {
49
+ const alice = key(root, "alice");
50
+ const mallory = key(root, "mallory");
51
+ const dir = join(root, "work");
52
+ const remote = join(root, "origin.git");
53
+ git(root, "init", "-q", "--bare", "-b", "main", remote);
54
+ git(root, "init", "-q", "-b", "main", dir);
55
+ git(dir, "config", "user.email", "t@chant.dev");
56
+ git(dir, "config", "user.name", "T");
57
+ writeFileSync(join(dir, "chant.config.ts"), `export default { environments: ["dev"] };\n`);
58
+ writeFileSync(join(dir, "api.component.ts"), `export const api = { name: "api", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "cfn-deploy", template: "dist/api.template.json" }] }] };\n`);
59
+ writeFileSync(join(dir, ".gitignore"), "dist/\n");
60
+ mkdirSync(join(dir, ".chant"));
61
+ writeFileSync(join(dir, ".chant", "allowed_signers"), `alice ${alice.pub}\n`);
62
+ git(dir, "add", ".");
63
+ git(dir, "commit", "-q", "-m", "init");
64
+ git(dir, "remote", "add", "origin", remote);
65
+ // A green commit pushed to trunk, ready to cut.
66
+ const next = (message?: string) => {
67
+ if (message) git(dir, "commit", "-q", "--allow-empty", "-m", message);
68
+ git(dir, "push", "-q", "origin", "main");
69
+ git(dir, "fetch", "-q", "origin");
70
+ git(dir, "tag", `ci/green/${git(dir, "rev-parse", "HEAD")}`);
71
+ };
72
+ next();
73
+
74
+ // v1: signed with alice's key, which the signers file lists.
75
+ const signed = chant(dir, ["release", "cut", "v1", "--build", BUILD, "--sign", alice.file, "--yes"]);
76
+ expect(signed.code, signed.stderr).toBe(0);
77
+ const ok = chant(dir, ["release", "verify", "v1"]);
78
+ expect(ok.code, ok.stderr).toBe(0);
79
+ expect(ok.stderr).toMatch(/Verified .*v1.*: signed by alice, checked against \.chant\/allowed_signers at [0-9a-f]{12}/);
80
+ expect(JSON.parse(chant(dir, ["release", "verify", "v1", "--json"]).stdout)).toMatchObject({ label: "v1", status: "verified", digestMatches: true, verifiedBy: { actor: "alice" } });
81
+ const pubSigned = chant(dir, ["release", "publish", "v1"]);
82
+ expect(pubSigned.code, pubSigned.stderr).toBe(0);
83
+ expect(pubSigned.stderr).toContain("signed by alice");
84
+
85
+ // v2: not signed.
86
+ next("v2");
87
+ expect(chant(dir, ["release", "cut", "v2", "--build", BUILD, "--yes"]).code).toBe(0);
88
+ const unsigned = chant(dir, ["release", "verify", "v2"]);
89
+ expect(unsigned.code).toBe(1);
90
+ expect(unsigned.stderr).toMatch(/v2.* does not verify: no cut of the release carries a signature \[release-unsigned\]/);
91
+ expect(JSON.parse(chant(dir, ["release", "verify", "v2", "--json"]).stdout)).toMatchObject({ status: "unsigned", error: { code: "release-unsigned" } });
92
+ const pubUnsigned = chant(dir, ["release", "publish", "v2"]);
93
+ expect(pubUnsigned.code).toBe(1);
94
+ expect(pubUnsigned.stderr).toMatch(/\[release-unsigned\]/);
95
+ expect(chant(dir, ["release", "publish", "v2", "--allow-unsigned"]).code).toBe(0);
96
+
97
+ // v3: signed by mallory, whom the signers file does not list.
98
+ next("v3");
99
+ const cut3 = chant(dir, ["release", "cut", "v3", "--build", BUILD, "--sign", mallory.file, "--actor", "mallory", "--yes"]);
100
+ expect(cut3.code, cut3.stderr).toBe(0);
101
+ const unlisted = chant(dir, ["release", "verify", "v3"]);
102
+ expect(unlisted.code).toBe(1);
103
+ expect(unlisted.stderr).toMatch(/mallory has no key in \.chant\/allowed_signers at [0-9a-f]{12}.*\[release-signature-unverified\]/);
104
+ const pubUnlisted = chant(dir, ["release", "publish", "v3"]);
105
+ expect(pubUnlisted.code).toBe(1);
106
+ expect(pubUnlisted.stderr).toMatch(/\[release-signature-unverified\]/);
107
+
108
+ const missing = chant(dir, ["release", "verify", "v9"]);
109
+ expect(missing.code).toBe(1);
110
+ expect(missing.stderr).toMatch(/\[release-not-found\]/);
111
+ } finally {
112
+ rmSync(root, { recursive: true, force: true });
113
+ }
114
+ },
115
+ 240_000,
116
+ );
117
+ });
@@ -0,0 +1,135 @@
1
+ /**
2
+ * #4027 requirement 3: the same command cuts the same release on a
3
+ * workstation and in CI. Two clones of one commit, each with its own temp
4
+ * bare remote, run `chant release cut v1 --yes`, one with a workstation's
5
+ * environment and one with GitHub Actions'. The records are equivalent:
6
+ * the release files are byte-identical, and the cut lines differ only in
7
+ * the run id and its origin, the actor and the timestamp.
8
+ */
9
+
10
+ import { describe, test, expect } from "vitest";
11
+ import { spawnSync } from "node:child_process";
12
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { pathToFileURL } from "node:url";
16
+ import { REPO } from "../../workspace/__fixtures__/contract-repo";
17
+
18
+ const CHANT = [process.execPath, "--import", pathToFileURL(join(REPO, "node_modules", "tsx", "dist", "loader.mjs")).href, join(REPO, "packages", "core", "src", "cli", "main.ts")];
19
+
20
+ const CI_VARS = ["CI", "GITHUB_ACTIONS", "GITHUB_RUN_ID", "GITHUB_SHA", "GITHUB_ACTOR", "GITHUB_REPOSITORY", "GITHUB_SERVER_URL", "GITLAB_CI", "CI_PIPELINE_ID", "GITLAB_USER_LOGIN", "CHANT_ENV"];
21
+
22
+ function chant(cwd: string, args: string[], extra: Record<string, string>): { code: number; stdout: string; stderr: string } {
23
+ const env: NodeJS.ProcessEnv = { ...process.env };
24
+ for (const k of CI_VARS) delete env[k];
25
+ Object.assign(env, extra);
26
+ const r = spawnSync(CHANT[0], [...CHANT.slice(1), ...args], { cwd, encoding: "utf-8", env, timeout: 120_000 });
27
+ return { code: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
28
+ }
29
+
30
+ function git(cwd: string, ...args: string[]): string {
31
+ const r = spawnSync("git", args, { cwd, encoding: "utf-8" });
32
+ if (r.status !== 0) throw new Error(`git ${args.join(" ")}: ${r.stderr}`);
33
+ return r.stdout.trim();
34
+ }
35
+
36
+ const BUILD = `mkdir -p dist && printf '{"env":"%s"}' "$CHANT_ENV" > dist/api.template.json`;
37
+
38
+ /** Every file under `_releases/` on the clone's chant/lifecycle, by path, as bytes. */
39
+ function releaseFiles(dir: string): Map<string, string> {
40
+ const out = new Map<string, string>();
41
+ for (const path of git(dir, "ls-tree", "-r", "--name-only", "chant/lifecycle", "_releases").split("\n").filter(Boolean)) {
42
+ if (path.endsWith("cuts.jsonl")) continue;
43
+ out.set(path, git(dir, "show", `chant/lifecycle:${path}`));
44
+ }
45
+ return out;
46
+ }
47
+
48
+ describe("a release cut on a workstation and one cut in CI (#4027)", () => {
49
+ test(
50
+ "from the same commit produce the same release files, and cut lines that differ only in run id, run origin, actor and timestamp",
51
+ () => {
52
+ const root = mkdtempSync(join(tmpdir(), "chant-release-equiv-"));
53
+ try {
54
+ const seed = join(root, "seed");
55
+ git(root, "init", "-q", "-b", "main", seed);
56
+ git(seed, "config", "user.email", "seed@chant.dev");
57
+ git(seed, "config", "user.name", "Seed");
58
+ writeFileSync(join(seed, "chant.config.ts"), `export default { environments: ["dev", "prod"] };\n`);
59
+ writeFileSync(
60
+ join(seed, "api.component.ts"),
61
+ `export const api = { name: "api", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "cfn-deploy", template: "dist/api.template.json" }] }] };\n`,
62
+ );
63
+ writeFileSync(join(seed, ".gitignore"), "dist/\n");
64
+ git(seed, "add", ".");
65
+ git(seed, "commit", "-q", "-m", "init");
66
+ const sha = git(seed, "rev-parse", "HEAD");
67
+
68
+ // Each clone pushes to a bare remote of its own, which holds main; the green tag is made locally in each.
69
+ const clone = (name: string, email: string | null): string => {
70
+ const dir = join(root, name);
71
+ git(root, "init", "-q", "--bare", "-b", "main", `${name}.git`);
72
+ git(root, "clone", "-q", seed, dir);
73
+ git(dir, "remote", "set-url", "origin", join(root, `${name}.git`));
74
+ git(dir, "push", "-q", "origin", "main");
75
+ git(dir, "fetch", "-q", "origin");
76
+ if (email) git(dir, "config", "user.email", email);
77
+ git(dir, "tag", `ci/green/${sha}`);
78
+ return dir;
79
+ };
80
+ const laptop = clone("laptop", "alice@example.com");
81
+ const runner = clone("runner", null);
82
+
83
+ const workstation = { USER: "alice" };
84
+ const ci = {
85
+ CI: "true",
86
+ GITHUB_ACTIONS: "true",
87
+ GITHUB_RUN_ID: "4242",
88
+ GITHUB_ACTOR: "release-bot",
89
+ GITHUB_REPOSITORY: "acme/app",
90
+ GITHUB_SERVER_URL: "https://github.com",
91
+ GITHUB_SHA: sha,
92
+ USER: "runner",
93
+ };
94
+ const cut = (dir: string, env: Record<string, string>) => {
95
+ const r = chant(dir, ["release", "cut", "v1", "--build", BUILD, "--yes", "--json"], env);
96
+ expect(r.code, r.stderr).toBe(0);
97
+ return JSON.parse(r.stdout);
98
+ };
99
+ const a = cut(laptop, workstation);
100
+ const b = cut(runner, ci);
101
+
102
+ // The release is the same object, stored as the same bytes.
103
+ expect(b.release).toEqual(a.release);
104
+ const [filesA, filesB] = [releaseFiles(laptop), releaseFiles(runner)];
105
+ expect([...filesA.keys()].sort()).toEqual([...filesB.keys()].sort());
106
+ expect(filesA.size).toBeGreaterThan(0);
107
+ for (const [path, bytes] of filesA) expect(filesB.get(path), path).toBe(bytes);
108
+
109
+ // The cut lines, as stored, are equal apart from the run, the actor and the time.
110
+ const line = (dir: string) => JSON.parse(git(dir, "show", "chant/lifecycle:_releases/cuts.jsonl"));
111
+ const [la, lb] = [line(laptop), line(runner)];
112
+ const rest = ({ runId: _r, runOrigin: _o, actor: _a, timestamp: _t, ...others }: Record<string, unknown>) => others;
113
+ expect(rest(lb)).toEqual(rest(la));
114
+ expect(Object.keys(la).sort()).toEqual(Object.keys(lb).sort());
115
+
116
+ expect(la).toMatchObject({ actor: "alice@example.com", runId: expect.stringMatching(/^local-\d+$/), runOrigin: { forge: "local" }, signature: null });
117
+ expect(lb).toMatchObject({
118
+ actor: "release-bot",
119
+ runId: "4242",
120
+ runOrigin: { forge: "github", repo: "acme/app", url: "https://github.com/acme/app/actions/runs/4242" },
121
+ signature: null,
122
+ });
123
+
124
+ // Both tag the commit the same way; the next command differs by where it ran.
125
+ expect(a.tag).toEqual({ name: "release/v1", created: true });
126
+ expect(b.tag).toEqual({ name: "release/v1", created: true });
127
+ expect(a.next[0]).toBe("git push origin release/v1");
128
+ expect(b.next[0]).toBe("chant release publish v1");
129
+ } finally {
130
+ rmSync(root, { recursive: true, force: true });
131
+ }
132
+ },
133
+ 240_000,
134
+ );
135
+ });