@intentius/chant 0.124.32 → 0.124.34

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 (320) hide show
  1. package/dist/ci-jobs.d.ts +4 -0
  2. package/dist/ci-jobs.d.ts.map +1 -1
  3. package/dist/ci-jobs.js +9 -2
  4. package/dist/cli/handlers/build.d.ts.map +1 -1
  5. package/dist/cli/handlers/build.js +18 -3
  6. package/dist/cli/handlers/lifecycle-health.d.ts +16 -0
  7. package/dist/cli/handlers/lifecycle-health.d.ts.map +1 -0
  8. package/dist/cli/handlers/lifecycle-health.js +150 -0
  9. package/dist/cli/handlers/lifecycle.d.ts +26 -0
  10. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  11. package/dist/cli/handlers/lifecycle.js +1 -1
  12. package/dist/cli/handlers/pr.d.ts +2 -0
  13. package/dist/cli/handlers/pr.d.ts.map +1 -1
  14. package/dist/cli/handlers/pr.js +124 -2
  15. package/dist/cli/handlers/promote.d.ts.map +1 -1
  16. package/dist/cli/handlers/promote.js +277 -74
  17. package/dist/cli/handlers/release.d.ts +16 -1
  18. package/dist/cli/handlers/release.d.ts.map +1 -1
  19. package/dist/cli/handlers/release.js +83 -11
  20. package/dist/cli/main.d.ts.map +1 -1
  21. package/dist/cli/main.js +99 -22
  22. package/dist/cli/parse-args.d.ts.map +1 -1
  23. package/dist/cli/parse-args.js +30 -1
  24. package/dist/cli/registry.d.ts +21 -5
  25. package/dist/cli/registry.d.ts.map +1 -1
  26. package/dist/components/deploy-inputs.d.ts +91 -0
  27. package/dist/components/deploy-inputs.d.ts.map +1 -0
  28. package/dist/components/deploy-inputs.js +184 -0
  29. package/dist/components/driver.d.ts.map +1 -1
  30. package/dist/components/driver.js +9 -0
  31. package/dist/components/gate-plan.d.ts +8 -0
  32. package/dist/components/gate-plan.d.ts.map +1 -1
  33. package/dist/components/gate-plan.js +6 -0
  34. package/dist/components/pr-pipeline.d.ts +33 -10
  35. package/dist/components/pr-pipeline.d.ts.map +1 -1
  36. package/dist/components/pr-pipeline.js +42 -11
  37. package/dist/components/pr-run.d.ts +1 -1
  38. package/dist/components/pr-run.d.ts.map +1 -1
  39. package/dist/components/promote-rounds.d.ts +102 -0
  40. package/dist/components/promote-rounds.d.ts.map +1 -0
  41. package/dist/components/promote-rounds.js +174 -0
  42. package/dist/components/release-compile.d.ts +37 -2
  43. package/dist/components/release-compile.d.ts.map +1 -1
  44. package/dist/components/release-compile.js +64 -8
  45. package/dist/components/release-gate.d.ts +30 -0
  46. package/dist/components/release-gate.d.ts.map +1 -1
  47. package/dist/components/release-gate.js +92 -42
  48. package/dist/components/release-promote.d.ts +17 -3
  49. package/dist/components/release-promote.d.ts.map +1 -1
  50. package/dist/components/release-promote.js +79 -18
  51. package/dist/components/verbs/bom-writer.d.ts +1 -1
  52. package/dist/components/verbs/bom-writer.d.ts.map +1 -1
  53. package/dist/components/verbs/lockfile-sbom-generator.d.ts +20 -0
  54. package/dist/components/verbs/lockfile-sbom-generator.d.ts.map +1 -1
  55. package/dist/components/verbs/lockfile-sbom-generator.js +41 -4
  56. package/dist/components/wave-plan.d.ts +10 -0
  57. package/dist/components/wave-plan.d.ts.map +1 -1
  58. package/dist/components/wave-plan.js +32 -4
  59. package/dist/graph-declared.d.ts +4 -0
  60. package/dist/graph-declared.d.ts.map +1 -1
  61. package/dist/graph-declared.js +11 -1
  62. package/dist/graph-detail.d.ts.map +1 -1
  63. package/dist/graph-detail.js +32 -7
  64. package/dist/health.d.ts +110 -0
  65. package/dist/health.d.ts.map +1 -0
  66. package/dist/health.js +105 -0
  67. package/dist/health.schema.json +191 -0
  68. package/dist/lexicon.d.ts +27 -0
  69. package/dist/lexicon.d.ts.map +1 -1
  70. package/dist/lifecycle/merge-record.d.ts +104 -0
  71. package/dist/lifecycle/merge-record.d.ts.map +1 -0
  72. package/dist/lifecycle/merge-record.js +102 -0
  73. package/dist/lifecycle/publish-attachments.d.ts +8 -5
  74. package/dist/lifecycle/publish-attachments.d.ts.map +1 -1
  75. package/dist/lifecycle/publish-attachments.js +27 -13
  76. package/dist/lifecycle/publish-npm.d.ts +3 -2
  77. package/dist/lifecycle/publish-npm.d.ts.map +1 -1
  78. package/dist/lifecycle/publish-npm.js +4 -3
  79. package/dist/lifecycle/release-at.d.ts +40 -0
  80. package/dist/lifecycle/release-at.d.ts.map +1 -0
  81. package/dist/lifecycle/release-at.js +83 -0
  82. package/dist/lifecycle/release-cut.d.ts.map +1 -1
  83. package/dist/lifecycle/release-cut.js +23 -12
  84. package/dist/lifecycle/release-publish.d.ts +28 -12
  85. package/dist/lifecycle/release-publish.d.ts.map +1 -1
  86. package/dist/lifecycle/release-publish.js +29 -17
  87. package/dist/lifecycle/release-store.d.ts +25 -2
  88. package/dist/lifecycle/release-store.d.ts.map +1 -1
  89. package/dist/lifecycle/release-store.js +40 -13
  90. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  91. package/dist/lifecycle/run-ledger.js +1 -0
  92. package/dist/lint/rules/op/index.d.ts +2 -1
  93. package/dist/lint/rules/op/index.d.ts.map +1 -1
  94. package/dist/lint/rules/op/index.js +4 -2
  95. package/dist/lint/rules/op/ops018-shell-image-digest.d.ts +10 -0
  96. package/dist/lint/rules/op/ops018-shell-image-digest.d.ts.map +1 -0
  97. package/dist/lint/rules/op/ops018-shell-image-digest.js +38 -0
  98. package/dist/op/activities/activity-contracts.d.ts +5 -0
  99. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  100. package/dist/op/activities/activity-contracts.js +3 -0
  101. package/dist/op/activities/shell.d.ts +38 -0
  102. package/dist/op/activities/shell.d.ts.map +1 -1
  103. package/dist/op/activities/shell.js +89 -3
  104. package/dist/op/builders.d.ts +7 -0
  105. package/dist/op/builders.d.ts.map +1 -1
  106. package/dist/op/builders.js +7 -0
  107. package/dist/op/runtime.d.ts +2 -0
  108. package/dist/op/runtime.d.ts.map +1 -1
  109. package/dist/pr-loop.d.ts +14 -3
  110. package/dist/pr-loop.d.ts.map +1 -1
  111. package/dist/pr-loop.js +15 -5
  112. package/dist/pr-report-url.d.ts +2 -2
  113. package/dist/pr-report-url.d.ts.map +1 -1
  114. package/dist/runner-start.d.ts +179 -0
  115. package/dist/runner-start.d.ts.map +1 -0
  116. package/dist/runner-start.js +277 -0
  117. package/dist/wave-runtime.mjs +236 -229
  118. package/dist/workspace/__fixtures__/contract-repo.d.ts.map +1 -1
  119. package/dist/workspace/__fixtures__/contract-repo.js +13 -2
  120. package/dist/workspace/check.schema.json +88 -2
  121. package/dist/workspace/checks/ci-workflow.d.ts +17 -0
  122. package/dist/workspace/checks/ci-workflow.d.ts.map +1 -0
  123. package/dist/workspace/checks/ci-workflow.js +123 -0
  124. package/dist/workspace/checks/security.d.ts +50 -0
  125. package/dist/workspace/checks/security.d.ts.map +1 -0
  126. package/dist/workspace/checks/security.js +305 -0
  127. package/dist/workspace/checks.d.ts +2 -0
  128. package/dist/workspace/checks.d.ts.map +1 -1
  129. package/dist/workspace/checks.js +8 -0
  130. package/dist/workspace/ci-builds.d.ts +163 -0
  131. package/dist/workspace/ci-builds.d.ts.map +1 -0
  132. package/dist/workspace/ci-builds.js +315 -0
  133. package/dist/workspace/ci-green-cli.d.ts +2 -1
  134. package/dist/workspace/ci-green-cli.d.ts.map +1 -1
  135. package/dist/workspace/ci-green-cli.js +25 -5
  136. package/dist/workspace/ci-green-forge.d.ts +12 -0
  137. package/dist/workspace/ci-green-forge.d.ts.map +1 -1
  138. package/dist/workspace/ci-green-forge.js +12 -0
  139. package/dist/workspace/ci-green-workflow.d.ts +55 -1
  140. package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
  141. package/dist/workspace/ci-green-workflow.js +128 -1
  142. package/dist/workspace/ci-green.d.ts +89 -4
  143. package/dist/workspace/ci-green.d.ts.map +1 -1
  144. package/dist/workspace/ci-green.js +310 -23
  145. package/dist/workspace/ci-init.d.ts +100 -0
  146. package/dist/workspace/ci-init.d.ts.map +1 -0
  147. package/dist/workspace/ci-init.js +434 -0
  148. package/dist/workspace/ci-last-green.schema.json +49 -0
  149. package/dist/workspace/ci-list.schema.json +35 -1
  150. package/dist/workspace/ci-run-index.d.ts +1 -1
  151. package/dist/workspace/ci-run-index.d.ts.map +1 -1
  152. package/dist/workspace/ci-run-index.schema.json +1 -1
  153. package/dist/workspace/ci-run.d.ts +12 -6
  154. package/dist/workspace/ci-run.d.ts.map +1 -1
  155. package/dist/workspace/ci-run.js +72 -16
  156. package/dist/workspace/ci-runner-start.d.ts +77 -0
  157. package/dist/workspace/ci-runner-start.d.ts.map +1 -0
  158. package/dist/workspace/ci-runner-start.js +185 -0
  159. package/dist/workspace/ci-serve.d.ts +76 -7
  160. package/dist/workspace/ci-serve.d.ts.map +1 -1
  161. package/dist/workspace/ci-serve.js +153 -19
  162. package/dist/workspace/ci-workflow.d.ts +91 -0
  163. package/dist/workspace/ci-workflow.d.ts.map +1 -0
  164. package/dist/workspace/ci-workflow.js +33 -0
  165. package/dist/workspace/declaration.d.ts +35 -0
  166. package/dist/workspace/declaration.d.ts.map +1 -1
  167. package/dist/workspace/declaration.js +12 -2
  168. package/dist/workspace/declaration.schema.json +51 -0
  169. package/dist/workspace/init.js +2 -2
  170. package/dist/workspace/ls.d.ts +2 -0
  171. package/dist/workspace/ls.d.ts.map +1 -1
  172. package/dist/workspace/ls.js +1 -1
  173. package/dist/workspace/ls.schema.json +2 -1
  174. package/dist/workspace/member-pipeline.d.ts +1 -0
  175. package/dist/workspace/member-pipeline.d.ts.map +1 -1
  176. package/dist/workspace/member-pipeline.js +2 -0
  177. package/dist/workspace/merge-record.schema.json +49 -0
  178. package/dist/workspace/pr-report.schema.json +3 -3
  179. package/dist/workspace/reason-codes.d.ts +26 -0
  180. package/dist/workspace/reason-codes.d.ts.map +1 -1
  181. package/dist/workspace/reason-codes.js +28 -0
  182. package/dist/workspace/release-candidates.d.ts +129 -0
  183. package/dist/workspace/release-candidates.d.ts.map +1 -0
  184. package/dist/workspace/release-candidates.js +233 -0
  185. package/dist/workspace/release-candidates.schema.json +353 -0
  186. package/dist/workspace/release-workflow.d.ts +31 -0
  187. package/dist/workspace/release-workflow.d.ts.map +1 -0
  188. package/dist/workspace/release-workflow.js +150 -0
  189. package/dist/workspace/security.d.ts +40 -0
  190. package/dist/workspace/security.d.ts.map +1 -0
  191. package/dist/workspace/security.js +99 -0
  192. package/package.json +8 -1
  193. package/src/__fixtures__/pr-loop/pr-plan.note.golden.md +1 -1
  194. package/src/ci-jobs.ts +11 -2
  195. package/src/cli/handlers/build.test.ts +36 -0
  196. package/src/cli/handlers/build.ts +18 -3
  197. package/src/cli/handlers/lifecycle-health.test.ts +123 -0
  198. package/src/cli/handlers/lifecycle-health.ts +157 -0
  199. package/src/cli/handlers/lifecycle.ts +1 -1
  200. package/src/cli/handlers/pr-deploy-inputs.test.ts +209 -0
  201. package/src/cli/handlers/pr-record.test.ts +201 -0
  202. package/src/cli/handlers/pr.test.ts +22 -0
  203. package/src/cli/handlers/pr.ts +127 -4
  204. package/src/cli/handlers/promote.test.ts +17 -0
  205. package/src/cli/handlers/promote.ts +296 -84
  206. package/src/cli/handlers/release-cut-at.e2e.test.ts +115 -0
  207. package/src/cli/handlers/release-publish-targets.e2e.test.ts +2 -2
  208. package/src/cli/handlers/release-verify.e2e.test.ts +12 -10
  209. package/src/cli/handlers/release.e2e.test.ts +19 -28
  210. package/src/cli/handlers/release.ts +81 -11
  211. package/src/cli/main.ts +99 -22
  212. package/src/cli/parse-args.ts +22 -1
  213. package/src/cli/registry.ts +21 -5
  214. package/src/components/deploy-inputs.test.ts +108 -0
  215. package/src/components/deploy-inputs.ts +225 -0
  216. package/src/components/driver.ts +11 -0
  217. package/src/components/gate-plan.ts +9 -0
  218. package/src/components/pr-pipeline.ts +56 -15
  219. package/src/components/pr-run.ts +1 -1
  220. package/src/components/promote-rounds.test.ts +220 -0
  221. package/src/components/promote-rounds.ts +232 -0
  222. package/src/components/release-compile.test.ts +34 -0
  223. package/src/components/release-compile.ts +89 -8
  224. package/src/components/release-gate.test.ts +47 -0
  225. package/src/components/release-gate.ts +111 -46
  226. package/src/components/release-promote.test.ts +55 -2
  227. package/src/components/release-promote.ts +93 -23
  228. package/src/components/verbs/bom-writer.ts +1 -1
  229. package/src/components/verbs/lockfile-sbom-generator.test.ts +36 -0
  230. package/src/components/verbs/lockfile-sbom-generator.ts +43 -5
  231. package/src/components/wave-plan.ts +32 -4
  232. package/src/graph-declared-exports.test.ts +40 -0
  233. package/src/graph-declared.ts +9 -1
  234. package/src/graph-detail.test.ts +57 -0
  235. package/src/graph-detail.ts +33 -7
  236. package/src/health.schema.json +191 -0
  237. package/src/health.test.ts +62 -0
  238. package/src/health.ts +178 -0
  239. package/src/lexicon.ts +41 -1
  240. package/src/lifecycle/merge-record.test.ts +111 -0
  241. package/src/lifecycle/merge-record.ts +154 -0
  242. package/src/lifecycle/publish-attachments.ts +25 -13
  243. package/src/lifecycle/publish-npm.ts +4 -3
  244. package/src/lifecycle/publish-targets.test.ts +34 -4
  245. package/src/lifecycle/release-at.test.ts +48 -0
  246. package/src/lifecycle/release-at.ts +100 -0
  247. package/src/lifecycle/release-cut.test.ts +41 -0
  248. package/src/lifecycle/release-cut.ts +22 -11
  249. package/src/lifecycle/release-publish.test.ts +43 -13
  250. package/src/lifecycle/release-publish.ts +38 -18
  251. package/src/lifecycle/release-store.ts +57 -13
  252. package/src/lifecycle/run-ledger.test.ts +13 -0
  253. package/src/lifecycle/run-ledger.ts +1 -0
  254. package/src/lint/rules/op/index.ts +4 -2
  255. package/src/lint/rules/op/ops018-shell-image-digest.test.ts +60 -0
  256. package/src/lint/rules/op/ops018-shell-image-digest.ts +48 -0
  257. package/src/op/activities/activity-contracts.ts +3 -0
  258. package/src/op/activities/shell-container.e2e.test.ts +67 -0
  259. package/src/op/activities/shell.test.ts +44 -1
  260. package/src/op/activities/shell.ts +110 -3
  261. package/src/op/builders.ts +7 -0
  262. package/src/op/runtime.ts +2 -0
  263. package/src/op/step-console.test.ts +4 -2
  264. package/src/pr-loop.test.ts +29 -0
  265. package/src/pr-loop.ts +26 -7
  266. package/src/pr-report-url.ts +2 -2
  267. package/src/runner-start-bundle.test.ts +29 -0
  268. package/src/runner-start.test.ts +179 -0
  269. package/src/runner-start.ts +329 -0
  270. package/src/workspace/__fixtures__/contract-repo.ts +11 -2
  271. package/src/workspace/check-contract.test.ts +4 -2
  272. package/src/workspace/check.schema.json +88 -2
  273. package/src/workspace/checks/ci-workflow.test.ts +150 -0
  274. package/src/workspace/checks/ci-workflow.ts +129 -0
  275. package/src/workspace/checks/security.test.ts +138 -0
  276. package/src/workspace/checks/security.ts +305 -0
  277. package/src/workspace/checks.ts +8 -0
  278. package/src/workspace/ci-builds.e2e.test.ts +208 -0
  279. package/src/workspace/ci-builds.test.ts +174 -0
  280. package/src/workspace/ci-builds.ts +385 -0
  281. package/src/workspace/ci-green-builds.test.ts +180 -0
  282. package/src/workspace/ci-green-cli.test.ts +54 -0
  283. package/src/workspace/ci-green-cli.ts +26 -5
  284. package/src/workspace/ci-green-forge.ts +24 -0
  285. package/src/workspace/ci-green-workflow.test.ts +84 -1
  286. package/src/workspace/ci-green-workflow.ts +139 -2
  287. package/src/workspace/ci-green.test.ts +5 -2
  288. package/src/workspace/ci-green.ts +359 -27
  289. package/src/workspace/ci-init.test.ts +159 -0
  290. package/src/workspace/ci-init.ts +445 -0
  291. package/src/workspace/ci-last-green.schema.json +49 -0
  292. package/src/workspace/ci-list.schema.json +35 -1
  293. package/src/workspace/ci-report-url.test.ts +6 -1
  294. package/src/workspace/ci-run-index.schema.json +1 -1
  295. package/src/workspace/ci-run-index.ts +1 -1
  296. package/src/workspace/ci-run.test.ts +24 -7
  297. package/src/workspace/ci-run.ts +78 -16
  298. package/src/workspace/ci-runner-start.test.ts +151 -0
  299. package/src/workspace/ci-runner-start.ts +193 -0
  300. package/src/workspace/ci-serve-builds.test.ts +86 -0
  301. package/src/workspace/ci-serve.test.ts +59 -0
  302. package/src/workspace/ci-serve.ts +177 -22
  303. package/src/workspace/ci-workflow.ts +71 -0
  304. package/src/workspace/declaration.schema.json +51 -0
  305. package/src/workspace/declaration.test.ts +17 -0
  306. package/src/workspace/declaration.ts +53 -3
  307. package/src/workspace/init.ts +2 -2
  308. package/src/workspace/ls.schema.json +2 -1
  309. package/src/workspace/ls.ts +3 -2
  310. package/src/workspace/member-pipeline.ts +2 -1
  311. package/src/workspace/merge-record.schema.json +49 -0
  312. package/src/workspace/pr-report.schema.json +3 -3
  313. package/src/workspace/reason-codes.test.ts +4 -0
  314. package/src/workspace/reason-codes.ts +28 -0
  315. package/src/workspace/release-candidates.schema.json +353 -0
  316. package/src/workspace/release-candidates.test.ts +106 -0
  317. package/src/workspace/release-candidates.ts +315 -0
  318. package/src/workspace/release-workflow.ts +167 -0
  319. package/src/workspace/security.test.ts +70 -0
  320. package/src/workspace/security.ts +118 -0
@@ -0,0 +1,315 @@
1
+ /**
2
+ * The `release-candidates` read (#4168 4.2): which green commits of trunk a
3
+ * person can cut a release from, and what each one would ship.
4
+ *
5
+ * `chant release candidates [--env <env>] [--json]` prints this document.
6
+ * It walks the first-parent line of the trunk (`ci.green.branch`, else what
7
+ * `origin/HEAD` names, as `release cut` resolves it) from its tip back to the
8
+ * oldest commit an environment of the member runs, or `limit` commits when an
9
+ * environment runs no release yet. An environment runs the releases its
10
+ * latest deploy records name in `releaseDigest`, as the `releases` read
11
+ * (./releases.ts) says.
12
+ *
13
+ * Every green commit that is not revoked is a candidate. Its `changes` are
14
+ * the first-parent commits after the previous candidate up to and including
15
+ * it, so the candidates partition the walk. A revoked commit is listed under
16
+ * `skipped`, and its changes carry on into the next candidate. Commits after
17
+ * the newest candidate are listed under `pending`: they are not green yet, or
18
+ * went red.
19
+ *
20
+ * For each change it names the work items and other records its
21
+ * `Chant-Record` trailers name, the pull request a forge's squash subject
22
+ * names (`... (#123)`), and the merge record `_merges/<sha>.json` on
23
+ * `chant/lifecycle` when one exists (#4168 4.1). Each candidate lists the
24
+ * flags whose record files changed in its range, from the declared flag kinds
25
+ * (#4024).
26
+ *
27
+ * It reads local refs and tags only and never fetches. Git is read through a
28
+ * local `git` subprocess.
29
+ */
30
+
31
+ import { execFileSync } from "node:child_process";
32
+ import { relative, resolve, sep } from "node:path";
33
+ import { latestPerComponent, readReleaseLedger } from "../lifecycle/release-ledger";
34
+ import { readAllReleases, readCuts } from "../lifecycle/release-store";
35
+ import { resolveMemberLedger } from "../lifecycle/member-ledger";
36
+ import { readBlobFromPath } from "../lifecycle/git";
37
+ import { readerVersion } from "./declaration";
38
+
39
+ export const RELEASE_CANDIDATES_CONTRACT_VERSION = 1;
40
+ export const RELEASE_CANDIDATES_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/release-candidates/v1/release-candidates.schema.json";
41
+
42
+ /** Why the read stopped. Closed; each code is in `reason-codes.ts`. */
43
+ export const RELEASE_CANDIDATES_ERROR_CODES = ["not-a-git-repository", "ledger-unreadable"] as const;
44
+
45
+ /** How many first-parent commits the walk reads when no environment bounds it. */
46
+ export const DEFAULT_CANDIDATE_LIMIT = 50;
47
+
48
+ /** A release an environment runs, by the components whose latest deploy names it. */
49
+ export interface RunningRelease {
50
+ releaseDigest: string;
51
+ label: string | null;
52
+ /** The commit it was cut from, or null when its release file is not on this clone's branch. */
53
+ commit: string | null;
54
+ components: string[];
55
+ }
56
+
57
+ export interface CandidateEnvironment {
58
+ env: string;
59
+ /** Newest cut first. Empty when no deploy there names a release. */
60
+ running: RunningRelease[];
61
+ }
62
+
63
+ /** One first-parent commit in a candidate's range. */
64
+ export interface CandidateChange {
65
+ commit: string;
66
+ subject: string;
67
+ /** Author date, ISO 8601. */
68
+ date: string;
69
+ /** The ids its `Chant-Record: work:<id>` trailers name. */
70
+ work: string[];
71
+ /** Every `Chant-Record` trailer, as `<kind>:<id>`. */
72
+ records: string[];
73
+ /** The pull request the subject names (`(#123)` at its end), the merge record's, or null. */
74
+ pr: number | null;
75
+ /** The merge record `_merges/<sha>.json` (#4168 4.1), as stored, or null when there is none. */
76
+ merge: Record<string, unknown> | null;
77
+ }
78
+
79
+ export interface CandidateFlag {
80
+ id: string;
81
+ member: string;
82
+ param: string;
83
+ /** The record file from the workspace root. */
84
+ path: string;
85
+ /** The value per environment, as the working tree's record writes it. */
86
+ values: Record<string, string | number | boolean>;
87
+ }
88
+
89
+ export interface Candidate {
90
+ commit: string;
91
+ subject: string;
92
+ date: string;
93
+ /** The environments that don't run this commit or a later one yet. */
94
+ newFor: string[];
95
+ /** The labels of releases already cut from it. */
96
+ cut: string[];
97
+ changes: CandidateChange[];
98
+ flags: CandidateFlag[];
99
+ }
100
+
101
+ export type ReleaseCandidatesDocument =
102
+ | {
103
+ $schema: string;
104
+ contract: number;
105
+ chant: string;
106
+ member: string | null;
107
+ branch: string;
108
+ /** The tip of the branch as this clone knows it. */
109
+ tip: string | null;
110
+ environments: CandidateEnvironment[];
111
+ /** The oldest commit an environment runs, where the walk stopped, or null when it read `limit` commits. */
112
+ since: string | null;
113
+ /** Newest first. */
114
+ candidates: Candidate[];
115
+ /** Revoked green commits, newest first. */
116
+ skipped: Array<{ commit: string; subject: string; reason: "revoked" }>;
117
+ /** Commits after the newest candidate, newest first. */
118
+ pending: CandidateChange[];
119
+ }
120
+ | { $schema: string; contract: number; chant: string; error: { code: (typeof RELEASE_CANDIDATES_ERROR_CODES)[number]; message: string } };
121
+
122
+ export interface ReleaseCandidatesQuery {
123
+ cwd: string;
124
+ /** Only this environment's view: the walk stops at what it runs, and `newFor` names only it. */
125
+ env?: string;
126
+ limit?: number;
127
+ }
128
+
129
+ function git(top: string, args: string[]): string {
130
+ return execFileSync("git", args, { cwd: top, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 64 * 1024 * 1024 }).trim();
131
+ }
132
+
133
+ function tryGit(top: string, args: string[]): string | null {
134
+ try {
135
+ return git(top, args);
136
+ } catch {
137
+ return null;
138
+ }
139
+ }
140
+
141
+ const PR_IN_SUBJECT = /\(#(\d+)\)\s*$/;
142
+
143
+ /** The work ids and `<kind>:<id>` records a commit's `Chant-Record` trailers name. */
144
+ export function recordTrailers(trailers: Record<string, string[]>): { work: string[]; records: string[] } {
145
+ const values = Object.entries(trailers)
146
+ .filter(([k]) => k.toLowerCase() === "chant-record")
147
+ .flatMap(([, v]) => v);
148
+ const records = values.map((v) => v.trim()).filter((v) => /^[^:\s]+:\S+$/.test(v));
149
+ const work = records.filter((r) => r.startsWith("work:")).map((r) => r.slice("work:".length));
150
+ return { work, records };
151
+ }
152
+
153
+ /** The pull request number at the end of a squash subject, or null. */
154
+ export function prFromSubject(subject: string): number | null {
155
+ const m = PR_IN_SUBJECT.exec(subject);
156
+ return m ? Number(m[1]) : null;
157
+ }
158
+
159
+ /** Read the release candidates of the member at `query.cwd`. */
160
+ export async function releaseCandidatesDocument(query: ReleaseCandidatesQuery): Promise<ReleaseCandidatesDocument> {
161
+ const head = { $schema: RELEASE_CANDIDATES_SCHEMA_ID, contract: RELEASE_CANDIDATES_CONTRACT_VERSION, chant: readerVersion() };
162
+ const cwd = resolve(query.cwd);
163
+ const top = tryGit(cwd, ["rev-parse", "--show-toplevel"]);
164
+ if (!top) return { ...head, error: { code: "not-a-git-repository", message: "candidates are read from trunk's commits and tags, and this directory is not in a git repository" } };
165
+ try {
166
+ const { scopeSource } = await import("./write-scope");
167
+ const { resolveTrunk } = await import("../lifecycle/release-workstation");
168
+ const { judgedCiTags } = await import("../lifecycle/release-revocation");
169
+ const { commitDetails } = await import("./intent");
170
+ const source = scopeSource(cwd);
171
+ const green = source.declaration?.ci?.green ?? null;
172
+ const trunk = resolveTrunk(top, green?.branch ?? null);
173
+ const tip =
174
+ tryGit(top, ["rev-parse", "--verify", "--quiet", `refs/remotes/${trunk.remote}/${trunk.branch}^{commit}`]) ??
175
+ tryGit(top, ["rev-parse", "--verify", "--quiet", `refs/heads/${trunk.branch}^{commit}`]);
176
+ const ledger = await resolveMemberLedger(cwd);
177
+ const loc = { cwd };
178
+
179
+ // What each environment runs.
180
+ const { readMemberPromotionOrder } = await import("./promotion-order");
181
+ const order = ((await readMemberPromotionOrder(cwd)) ?? []).map((s) => s.name);
182
+ const envNames = query.env !== undefined ? [query.env] : order;
183
+ const { releases } = await readAllReleases(loc);
184
+ const { cuts } = await readCuts(loc);
185
+ const commitOf = new Map(releases.map((r) => [r.releaseDigest, r.commit]));
186
+ const labelOf = new Map<string, string>();
187
+ const cutAt = new Map<string, string>();
188
+ for (const c of cuts) {
189
+ if (c.label !== null && !labelOf.has(c.releaseDigest)) labelOf.set(c.releaseDigest, c.label);
190
+ if (!cutAt.has(c.releaseDigest) || c.timestamp > cutAt.get(c.releaseDigest)!) cutAt.set(c.releaseDigest, c.timestamp);
191
+ }
192
+ const environments: CandidateEnvironment[] = [];
193
+ for (const env of envNames) {
194
+ const { records } = await readReleaseLedger(env, loc);
195
+ const byDigest = new Map<string, string[]>();
196
+ for (const record of latestPerComponent(records).values()) {
197
+ const digest = (record as { releaseDigest?: unknown }).releaseDigest;
198
+ if (typeof digest !== "string") continue;
199
+ byDigest.set(digest, [...(byDigest.get(digest) ?? []), record.component].sort());
200
+ }
201
+ const running = [...byDigest]
202
+ .map(([releaseDigest, components]) => ({ releaseDigest, label: labelOf.get(releaseDigest) ?? null, commit: commitOf.get(releaseDigest) ?? null, components }))
203
+ .sort((a, b) => ((cutAt.get(b.releaseDigest) ?? "") < (cutAt.get(a.releaseDigest) ?? "") ? -1 : 1));
204
+ environments.push({ env, running });
205
+ }
206
+
207
+ const empty = { ...head, member: ledger.members.length > 0 ? ledger.members.join("/") : null, branch: trunk.branch, tip, environments, since: null, candidates: [], skipped: [], pending: [] };
208
+ if (!tip) return empty;
209
+
210
+ // The oldest commit any environment runs bounds the walk, when every environment runs one.
211
+ const limit = query.limit ?? DEFAULT_CANDIDATE_LIMIT;
212
+ const line = git(top, ["rev-list", "--first-parent", `--max-count=${Math.max(limit, 1) * 20}`, tip]).split("\n").filter(Boolean);
213
+ const position = new Map(line.map((sha, i) => [sha, i]));
214
+ const runningCommits = (e: CandidateEnvironment): string[] => e.running.map((r) => r.commit).filter((c): c is string => c !== null && position.has(c));
215
+ const bounded = environments.length > 0 && environments.every((e) => runningCommits(e).length > 0);
216
+ let since: string | null = null;
217
+ let walk = line.slice(0, limit);
218
+ if (bounded) {
219
+ const oldest = Math.max(...environments.flatMap((e) => runningCommits(e).map((c) => position.get(c)!)));
220
+ since = line[oldest];
221
+ walk = line.slice(0, oldest);
222
+ }
223
+ // An environment's newest running commit on the line, by position (smaller is newer).
224
+ const newestRun = new Map(environments.map((e) => [e.env, Math.min(...runningCommits(e).map((c) => position.get(c)!), Number.POSITIVE_INFINITY)]));
225
+
226
+ const tags = await judgedCiTags(top, green);
227
+ const details = commitDetails(top, walk);
228
+ const merges = new Map<string, Record<string, unknown>>();
229
+ for (const sha of walk) {
230
+ const text = (await readBlobFromPath("_merges", `${sha}.json`, loc)) ?? (ledger.prefix !== "" ? await readBlobFromPath("_merges", `${sha}.json`, { cwd, ledgerPrefix: "" }) : null);
231
+ if (text === null) continue;
232
+ try {
233
+ const parsed = JSON.parse(text) as unknown;
234
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) merges.set(sha, parsed as Record<string, unknown>);
235
+ } catch {
236
+ // A merge record that is not JSON is left out.
237
+ }
238
+ }
239
+ const change = (sha: string): CandidateChange => {
240
+ const d = details.get(sha);
241
+ const { work, records } = recordTrailers(d?.trailers ?? {});
242
+ const merge = merges.get(sha) ?? null;
243
+ const mergePr = merge && typeof merge.pr === "number" ? merge.pr : null;
244
+ const subject = d?.subject ?? "";
245
+ return { commit: sha, subject, date: d?.date ?? "", work, records, pr: mergePr ?? prFromSubject(subject), merge };
246
+ };
247
+
248
+ const flagReader = await flagsReader(top, source.rootOnDisk);
249
+ const cutLabels = new Map<string, string[]>();
250
+ for (const r of releases) {
251
+ const label = labelOf.get(r.releaseDigest);
252
+ if (label !== undefined) cutLabels.set(r.commit, [...(cutLabels.get(r.commit) ?? []), label].sort());
253
+ }
254
+
255
+ // Oldest first, so each candidate closes the commits since the previous one.
256
+ const candidates: Candidate[] = [];
257
+ const skipped: Array<{ commit: string; subject: string; reason: "revoked" }> = [];
258
+ let open: string[] = [];
259
+ for (let i = walk.length - 1; i >= 0; i--) {
260
+ const sha = walk[i];
261
+ open.push(sha);
262
+ if (tags.revoked.has(sha)) {
263
+ skipped.push({ commit: sha, subject: details.get(sha)?.subject ?? "", reason: "revoked" });
264
+ continue;
265
+ }
266
+ if (!tags.green.has(sha)) continue;
267
+ const own = change(sha);
268
+ candidates.push({
269
+ commit: sha,
270
+ subject: own.subject,
271
+ date: own.date,
272
+ newFor: environments.filter((e) => i < newestRun.get(e.env)!).map((e) => e.env),
273
+ cut: cutLabels.get(sha) ?? [],
274
+ changes: open.map(change).reverse(),
275
+ flags: flagReader(open),
276
+ });
277
+ open = [];
278
+ }
279
+ return {
280
+ ...empty,
281
+ since,
282
+ candidates: candidates.reverse(),
283
+ skipped: skipped.reverse(),
284
+ pending: open.map(change).reverse(),
285
+ };
286
+ } catch (err) {
287
+ return { ...head, error: { code: "ledger-unreadable", message: `reading the candidates failed: ${err instanceof Error ? err.message : String(err)}` } };
288
+ }
289
+ }
290
+
291
+ /** The flags whose record files the given commits change, or a reader that finds none when no flag kind can be read. */
292
+ async function flagsReader(top: string, root: string | null): Promise<(shas: string[]) => CandidateFlag[]> {
293
+ if (!root) return () => [];
294
+ let flags: Awaited<ReturnType<typeof import("./flags-read").readWorkspaceFlags>>["flags"];
295
+ try {
296
+ const { readWorkspaceFlags } = await import("./flags-read");
297
+ flags = (await readWorkspaceFlags(root)).flags;
298
+ } catch {
299
+ return () => [];
300
+ }
301
+ if (flags.length === 0) return () => [];
302
+ const fromTop = (p: string): string => relative(top, resolve(root, p)).split(sep).join("/");
303
+ const byPath = new Map(flags.map((f) => [fromTop(f.path), f]));
304
+ return (shas) => {
305
+ const touched = new Set<string>();
306
+ for (const sha of shas) {
307
+ const names = tryGit(top, ["diff-tree", "--no-commit-id", "--name-only", "-r", "--root", sha]) ?? "";
308
+ for (const n of names.split("\n")) if (byPath.has(n)) touched.add(n);
309
+ }
310
+ return [...touched]
311
+ .sort()
312
+ .map((p) => byPath.get(p)!)
313
+ .map((f) => ({ id: f.id, member: f.member, param: f.param, path: f.path, values: f.values }));
314
+ };
315
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * `chant release workflow` (#4168 step 6): the publish workflow, generated
3
+ * the way `chant ci workflow` writes the tick, where the guides used to have
4
+ * one pasted (guide/release-from-workstation.mdx).
5
+ *
6
+ * The job runs on a `release/**` tag, checks the tag out, fetches the ci tags
7
+ * and `chant/lifecycle`, builds what the targets upload, and runs `chant
8
+ * release publish` for the label the tag names. The security groups decide
9
+ * the rest, and nothing is on without one (#4168 section 8):
10
+ *
11
+ * - `supply-chain` adds `--require-provenance`, so a run with no OIDC token
12
+ * fails rather than publishing without provenance.
13
+ * - `verified-releases` adds a `chant release verify` step before the publish.
14
+ *
15
+ * The OIDC permission (`id-token: write`, or GitLab's `id_tokens`) is always
16
+ * there: it costs nothing, and with it what the job uploads carries
17
+ * provenance whether or not the group requires it.
18
+ */
19
+ import { GENERATED_MARKER } from "../discovery/files";
20
+ import type { SecurityGroup } from "./security";
21
+
22
+ export const RELEASE_WORKFLOW_FORGES = ["github", "forgejo", "gitlab"] as const;
23
+ export type ReleaseWorkflowForge = (typeof RELEASE_WORKFLOW_FORGES)[number];
24
+
25
+ /** Where each forge's publish workflow is written, relative to the repository root. */
26
+ export const RELEASE_WORKFLOW_FILES: Record<ReleaseWorkflowForge, string> = {
27
+ github: ".github/workflows/chant-release.yml",
28
+ forgejo: ".forgejo/workflows/chant-release.yml",
29
+ // GitLab reads one .gitlab-ci.yml, which includes this file: `include: { local: .gitlab/chant-release.yml }`.
30
+ gitlab: ".gitlab/chant-release.yml",
31
+ };
32
+
33
+ export interface ReleaseWorkflowInput {
34
+ forge: ReleaseWorkflowForge;
35
+ /** The security groups the workspace opts into at its root. */
36
+ groups: ReadonlySet<SecurityGroup>;
37
+ /** The command that installs what the build needs. Default `npm ci`. */
38
+ install?: string;
39
+ /** The command that builds what the targets upload, or none. Default `npm run build`. */
40
+ build?: string | null;
41
+ /** What runs chant. Default `npx chant`. */
42
+ chant?: string;
43
+ /** The workspace root relative to the repository root, `.` at the top. */
44
+ workspaceRoot?: string;
45
+ }
46
+
47
+ /** The flags `chant release publish` gets under the groups. */
48
+ export function publishFlags(groups: ReadonlySet<SecurityGroup>): string[] {
49
+ return [...(groups.has("supply-chain") ? ["--require-provenance"] : []), "--yes"];
50
+ }
51
+
52
+ /** The workflow file's text, without the generated-by header. */
53
+ export function renderReleaseWorkflow(input: ReleaseWorkflowInput): string {
54
+ const chant = input.chant ?? "npx chant";
55
+ const install = input.install ?? "npm ci";
56
+ const build = input.build === undefined ? "npm run build" : input.build;
57
+ const root = input.workspaceRoot && input.workspaceRoot !== "." ? input.workspaceRoot : null;
58
+ const verify = input.groups.has("verified-releases");
59
+ const flags = publishFlags(input.groups).join(" ");
60
+ if (input.forge === "gitlab") {
61
+ const label = '"${CI_COMMIT_TAG#release/}"';
62
+ const lines = [
63
+ "chant-release-publish:",
64
+ " rules:",
65
+ " - if: $CI_COMMIT_TAG =~ /^release\\//",
66
+ " id_tokens:",
67
+ " SIGSTORE_ID_TOKEN:",
68
+ " aud: sigstore",
69
+ " script:",
70
+ ...(root ? [` - cd ${root}`] : []),
71
+ ` - ${install}`,
72
+ ...(build ? [` - ${build}`] : []),
73
+ " - git fetch origin 'refs/tags/ci/*:refs/tags/ci/*'",
74
+ " - git fetch origin chant/lifecycle:chant/lifecycle",
75
+ ...(verify ? [` - ${chant} release verify ${label}`] : []),
76
+ ` - ${chant} release publish ${label} ${flags}`,
77
+ ];
78
+ return `${lines.join("\n")}\n`;
79
+ }
80
+ const label = '"${GITHUB_REF_NAME#release/}"';
81
+ const lines = [
82
+ "name: chant release publish",
83
+ "on:",
84
+ " push:",
85
+ ' tags: ["release/**"]',
86
+ "permissions:",
87
+ " contents: write",
88
+ " id-token: write",
89
+ "jobs:",
90
+ " publish:",
91
+ " runs-on: ubuntu-latest",
92
+ ...(root ? [" defaults:", " run:", ` working-directory: ${root}`] : []),
93
+ " steps:",
94
+ " - uses: actions/checkout@v4",
95
+ " with:",
96
+ " fetch-depth: 0",
97
+ " - uses: actions/setup-node@v4",
98
+ " with:",
99
+ " node-version: 24",
100
+ " registry-url: https://registry.npmjs.org",
101
+ ` - run: ${install}`,
102
+ ...(build ? [` - run: ${build}`] : []),
103
+ " - run: git fetch origin 'refs/tags/ci/*:refs/tags/ci/*'",
104
+ " - run: git fetch origin chant/lifecycle:chant/lifecycle",
105
+ ...(verify ? [` - run: ${chant} release verify ${label}`] : []),
106
+ ` - run: ${chant} release publish ${label} ${flags}`,
107
+ ];
108
+ return `${lines.join("\n")}\n`;
109
+ }
110
+
111
+ /** The header that marks the file generated, naming the command that regenerates it. */
112
+ export function releaseWorkflowHeader(command: string): string {
113
+ return `# ${GENERATED_MARKER}. Regenerate with: ${command} (in the workspace root)\n`;
114
+ }
115
+
116
+ /**
117
+ * `chant release workflow [--forge github|gitlab|forgejo] [--install <command>]
118
+ * [--build <command>|--no-build] [--chant <command>] [--output <file>]`: write
119
+ * the publish workflow for the workspace's forge, under its security groups.
120
+ */
121
+ export async function runReleaseWorkflow(ctx: import("../cli/registry").CommandContext): Promise<number> {
122
+ const { existsSync, mkdirSync, realpathSync, writeFileSync } = await import("node:fs");
123
+ const { dirname, join, relative, resolve, sep } = await import("node:path");
124
+ const { formatError, formatSuccess } = await import("../cli/format");
125
+ const { findWorkspaceRoot } = await import("../project-root");
126
+ const { gitTop } = await import("./tree");
127
+ const { securityGroupsAt } = await import("./security");
128
+ const { shellArg } = await import("./member-pipeline");
129
+ const usage = "chant release workflow [--forge github|gitlab|forgejo] [--install <command>] [--build <command>] [--chant <command>] [--output <file>]";
130
+ const { args } = ctx;
131
+ const found = findWorkspaceRoot(process.cwd());
132
+ if (!found) {
133
+ console.error(formatError({ message: "no chant.workspace.json between this directory and the git root", hint: usage }));
134
+ return 1;
135
+ }
136
+ const root = realpathSync(found.dir);
137
+ const repoRoot = gitTop(root) ?? root;
138
+ const forge = (args.forge ??
139
+ (existsSync(join(repoRoot, ".github", "workflows")) ? "github" : existsSync(join(repoRoot, ".forgejo", "workflows")) ? "forgejo" : existsSync(join(repoRoot, ".gitlab-ci.yml")) ? "gitlab" : "github")) as ReleaseWorkflowForge;
140
+ if (!(RELEASE_WORKFLOW_FORGES as readonly string[]).includes(forge)) {
141
+ console.error(formatError({ message: `--forge ${JSON.stringify(forge)} is not one of ${RELEASE_WORKFLOW_FORGES.join(", ")}`, hint: usage }));
142
+ return 1;
143
+ }
144
+ const groups = securityGroupsAt(root);
145
+ const workspaceRoot = relative(repoRoot, root).split(sep).join("/") || ".";
146
+ const text = renderReleaseWorkflow({
147
+ forge,
148
+ groups,
149
+ workspaceRoot,
150
+ ...(args.ciInstall ? { install: args.ciInstall } : {}),
151
+ ...(args.buildCommand ? { build: args.buildCommand } : {}),
152
+ ...(args.ciChant ? { chant: args.ciChant } : {}),
153
+ });
154
+ const target = args.output ? resolve(process.cwd(), args.output) : join(repoRoot, RELEASE_WORKFLOW_FILES[forge]);
155
+ const parts = ["chant", "release", "workflow", "--forge", forge];
156
+ if (args.ciInstall) parts.push("--install", args.ciInstall);
157
+ if (args.buildCommand) parts.push("--build", args.buildCommand);
158
+ if (args.ciChant) parts.push("--chant", args.ciChant);
159
+ if (args.output) parts.push("--output", relative(root, target).split(sep).join("/"));
160
+ mkdirSync(dirname(target), { recursive: true });
161
+ writeFileSync(target, releaseWorkflowHeader(parts.map(shellArg).join(" ")) + text);
162
+ const shown = relative(process.cwd(), target).split(sep).join("/") || target;
163
+ const extras = [groups.has("supply-chain") ? "requires OIDC provenance (supply-chain)" : null, groups.has("verified-releases") ? "verifies the release first (verified-releases)" : null].filter(Boolean);
164
+ console.log(formatSuccess(`Wrote ${shown}: publishes the release a release/** tag names${extras.length > 0 ? `, and ${extras.join(" and ")}` : ""}.`));
165
+ if (forge === "gitlab") console.log(`Include it from .gitlab-ci.yml: include: [{ local: ${RELEASE_WORKFLOW_FILES.gitlab} }]`);
166
+ return 0;
167
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The security groups (#4168): which groups a member runs under, and the
3
+ * reference pages printed from the registries.
4
+ */
5
+
6
+ import { execFileSync } from "node:child_process";
7
+ import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { afterAll, describe, expect, test } from "vitest";
12
+ import { WORKSPACE_CHECKS } from "./checks";
13
+ import { memberAtCwd, securityFeatureTable, securityGroupsAt, securityGroupsOf } from "./security";
14
+
15
+ const DOCS = fileURLToPath(new URL("../../../../docs/src/content/docs/", import.meta.url));
16
+
17
+ const scratch: string[] = [];
18
+ afterAll(() => {
19
+ for (const d of scratch) rmSync(d, { recursive: true, force: true });
20
+ });
21
+
22
+ describe("securityGroupsOf", () => {
23
+ const declaration = {
24
+ security: ["verified-releases" as const],
25
+ members: [
26
+ { name: "api", dir: "api", security: ["supply-chain" as const] },
27
+ { name: "web", dir: "web", security: [] },
28
+ ],
29
+ };
30
+
31
+ test("a member runs under the top-level groups and its own", () => {
32
+ expect([...securityGroupsOf(declaration as never, "api")]).toEqual(["verified-releases", "supply-chain"]);
33
+ expect([...securityGroupsOf(declaration as never, "web")]).toEqual(["verified-releases"]);
34
+ expect([...securityGroupsOf(declaration as never)]).toEqual(["verified-releases"]);
35
+ });
36
+
37
+ test("no declaration or no security key turns nothing on", () => {
38
+ expect(securityGroupsOf(null).size).toBe(0);
39
+ expect(securityGroupsOf({ security: [], members: [] } as never, "api").size).toBe(0);
40
+ });
41
+
42
+ test("securityGroupsAt reads the member holding the directory", () => {
43
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "chant-security-groups-")));
44
+ scratch.push(root);
45
+ execFileSync("git", ["init", "-q"], { cwd: root });
46
+ mkdirSync(join(root, "api", "src"), { recursive: true });
47
+ writeFileSync(
48
+ join(root, "chant.workspace.json"),
49
+ JSON.stringify({ name: "acme", schema: 1, security: ["review-rules"], members: [{ name: "api", dir: "api", kind: "other", because: "x", security: ["who-ran-it"] }] }),
50
+ );
51
+ expect(memberAtCwd({ declaration: { members: [{ name: "api", dir: "api" }] } as never, rootOnDisk: root }, join(root, "api", "src"))).toBe("api");
52
+ expect([...securityGroupsAt(join(root, "api", "src"))]).toEqual(["review-rules", "who-ran-it"]);
53
+ expect([...securityGroupsAt(root)]).toEqual(["review-rules"]);
54
+ });
55
+ });
56
+
57
+ describe("reference pages", () => {
58
+ test("the security features page carries the table SECURITY_FEATURES prints", () => {
59
+ const page = readFileSync(join(DOCS, "reference", "security-features.mdx"), "utf-8");
60
+ const table = /^\| Feature \| Default \|[^\n]*\n(?:\|[^\n]*\n)*/m.exec(page)?.[0].trimEnd();
61
+ expect(table, "regenerate the table under Features from securityFeatureTable()").toBe(securityFeatureTable());
62
+ });
63
+
64
+ test("the workspace check page lists every check with its name, default severity and whether it can be set", () => {
65
+ const page = readFileSync(join(DOCS, "cli", "workspace-check.mdx"), "utf-8");
66
+ for (const c of WORKSPACE_CHECKS) {
67
+ expect(page, c.id).toContain(`| \`${c.id}\` | ${c.name} | ${c.severity} | ${c.configurable ? "yes" : "no"} |`);
68
+ }
69
+ });
70
+ });