@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,305 @@
1
+ /**
2
+ * The security group checklists, WSP161 to WSP165 (#4168 step 7).
3
+ *
4
+ * A workspace opts into a security group with `security` in the
5
+ * declaration, at the top level or on a member (../security.ts). Each group
6
+ * has one check here, which reports what the group still lacks in the tree
7
+ * read. A group the declaration does not name is never checked, so a
8
+ * workspace that opts into nothing gets no finding from this file (#4168:
9
+ * security features are opt-in).
10
+ *
11
+ * | Id | Group | Codes |
12
+ * |---|---|---|
13
+ * | WSP161 | `verified-releases` | `security-signers-missing`, `security-release-gate-missing`, `security-publish-allows-unsigned` |
14
+ * | WSP162 | `supply-chain` | `security-sbom-disabled`, `security-sbom-unsourced`, `security-publish-no-provenance`, `security-component-no-sbom`, `security-component-no-verify` |
15
+ * | WSP163 | `who-ran-it` | `security-signed-since-unset`, `security-runner-untrusted`, `security-tick-no-key` |
16
+ * | WSP164 | `review-rules` | `security-review-not-required`, `security-attribution-any`, `security-gate-unsigned` |
17
+ * | WSP165 | `isolated-runners` | `security-runner-start-no-key`, `security-runner-start-no-role`, `security-trust-unreadable` |
18
+ *
19
+ * The checks read files, never run them: a member's `chant.config.ts` and
20
+ * `*.component.ts` are scanned as text, and the pipeline files are the
21
+ * forge files at the workspace root and in each member (`.github/workflows`,
22
+ * `.forgejo/workflows`, `.gitlab-ci.yml`). A command line is one line of a
23
+ * pipeline file that runs `chant release publish`, `chant ci tick` or
24
+ * `chant components pr-apply`.
25
+ */
26
+
27
+ import type { WorkspaceCheck, WorkspaceCheckContext, WorkspaceDiagnostic } from "../checks";
28
+ import type { Declaration, Member } from "../declaration";
29
+ import type { ReasonCode } from "../reason-codes";
30
+ import { securityGroupsOf, type SecurityGroup } from "../security";
31
+ import { joinPath, type WorkspaceTree } from "../tree";
32
+ import { runnerStartReadiness } from "../ci-runner-start";
33
+ import type { RecordSource } from "../record-source";
34
+ import { readTrustPolicy, type TrustPolicy } from "../trust/policy";
35
+ import { DEFAULT_SIGNERS_PATH, TRUST_CONFIG_PATH } from "../trust/trust-files";
36
+
37
+ /** The read contract's codes for the security findings, carried as `code` on each. */
38
+ export const SECURITY_FINDING_CODES = [
39
+ "security-signers-missing",
40
+ "security-release-gate-missing",
41
+ "security-publish-allows-unsigned",
42
+ "security-sbom-disabled",
43
+ "security-sbom-unsourced",
44
+ "security-publish-no-provenance",
45
+ "security-component-no-sbom",
46
+ "security-component-no-verify",
47
+ "security-signed-since-unset",
48
+ "security-runner-untrusted",
49
+ "security-tick-no-key",
50
+ "security-review-not-required",
51
+ "security-attribution-any",
52
+ "security-gate-unsigned",
53
+ "security-runner-start-no-key",
54
+ "security-runner-start-no-role",
55
+ "security-trust-unreadable",
56
+ ] as const satisfies readonly ReasonCode[];
57
+
58
+ type SecurityCode = (typeof SECURITY_FINDING_CODES)[number];
59
+
60
+ export const WSP_SECURITY_VERIFIED_RELEASES = "WSP161";
61
+ export const WSP_SECURITY_SUPPLY_CHAIN = "WSP162";
62
+ export const WSP_SECURITY_WHO_RAN_IT = "WSP163";
63
+ export const WSP_SECURITY_REVIEW_RULES = "WSP164";
64
+ export const WSP_SECURITY_ISOLATED_RUNNERS = "WSP165";
65
+
66
+ const memberDir = (dir: string) => (dir === "." ? "" : dir);
67
+
68
+ /** The members under `group`: every member when the top level names it, else those that name it themselves. */
69
+ function membersUnder(declaration: Declaration, group: SecurityGroup): Member[] {
70
+ return declaration.members.filter((m) => securityGroupsOf(declaration, m.name).has(group));
71
+ }
72
+
73
+ /** Whether the declaration names `group` anywhere. */
74
+ function declares(declaration: Declaration, group: SecurityGroup): boolean {
75
+ return declaration.security.includes(group) || declaration.members.some((m) => m.security.includes(group));
76
+ }
77
+
78
+ /** Where the group is named, for the finding's pointer: the top level, else the first member naming it. */
79
+ function groupPointer(declaration: Declaration, group: SecurityGroup): string {
80
+ const top = declaration.security.indexOf(group);
81
+ if (top >= 0) return `/security/${top}`;
82
+ const m = declaration.members.find((e) => e.security.includes(group));
83
+ return m ? `${m.pointer}/security/${m.security.indexOf(group)}` : "/security";
84
+ }
85
+
86
+ export function read(tree: WorkspaceTree, path: string): string | null {
87
+ if (tree.stat(path) !== "file") return null;
88
+ try {
89
+ return tree.read(path);
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ /** The forge pipeline files under `dir`, each as its path and lines. */
96
+ function pipelineFiles(tree: WorkspaceTree, dir: string): { path: string; lines: string[] }[] {
97
+ const out: { path: string; lines: string[] }[] = [];
98
+ for (const folder of [".github/workflows", ".forgejo/workflows", ".gitea/workflows"]) {
99
+ for (const e of tree.list(joinPath(dir, folder)) ?? []) {
100
+ if (e.type !== "file" || !/\.ya?ml$/.test(e.name)) continue;
101
+ const path = joinPath(dir, folder, e.name);
102
+ const text = read(tree, path);
103
+ if (text !== null) out.push({ path, lines: text.split("\n") });
104
+ }
105
+ }
106
+ const gitlab = read(tree, joinPath(dir, ".gitlab-ci.yml"));
107
+ if (gitlab !== null) out.push({ path: joinPath(dir, ".gitlab-ci.yml"), lines: gitlab.split("\n") });
108
+ return out;
109
+ }
110
+
111
+ /** Every pipeline file of the workspace: its root's, and each member's. */
112
+ export function allPipelineFiles(ctx: WorkspaceCheckContext): { path: string; lines: string[] }[] {
113
+ const dirs = new Set(["", ...ctx.declaration.members.map((m) => memberDir(m.dir))]);
114
+ return [...dirs].flatMap((d) => pipelineFiles(ctx.tree, d));
115
+ }
116
+
117
+ /** The lines of the pipeline files that run a chant command, `command` naming it, such as `release publish`. */
118
+ export function commandLines(ctx: WorkspaceCheckContext, command: RegExp): { path: string; line: number; text: string }[] {
119
+ return allPipelineFiles(ctx).flatMap((f) => f.lines.flatMap((text, i) => (command.test(text) ? [{ path: f.path, line: i + 1, text }] : [])));
120
+ }
121
+
122
+ /** The member's `*.component.ts` files, walked a few levels down, past node_modules and build output. */
123
+ export function componentFiles(tree: WorkspaceTree, dir: string, depth = 4): string[] {
124
+ const out: string[] = [];
125
+ for (const e of tree.list(dir) ?? []) {
126
+ if (e.type === "dir") {
127
+ if (depth > 0 && !["node_modules", "dist", ".git", ".chant", "target"].includes(e.name)) out.push(...componentFiles(tree, joinPath(dir, e.name), depth - 1));
128
+ } else if (e.name.endsWith(".component.ts")) out.push(joinPath(dir, e.name));
129
+ }
130
+ return out;
131
+ }
132
+
133
+ /** Whether a lockfile chant writes an SBOM from is in `dir` or a directory above it in the tree. */
134
+ function lockfileAbove(tree: WorkspaceTree, dir: string): boolean {
135
+ let at = dir;
136
+ for (;;) {
137
+ if (["package-lock.json", "pom.xml", "Cargo.lock"].some((f) => tree.stat(joinPath(at, f)) === "file")) return true;
138
+ if (at === "") return false;
139
+ at = at.includes("/") ? at.slice(0, at.lastIndexOf("/")) : "";
140
+ }
141
+ }
142
+
143
+ /** The trust policy in the tree read: `.chant/trust.json` and the signers file it names. */
144
+ function treePolicy(tree: WorkspaceTree): TrustPolicy {
145
+ const source: RecordSource = {
146
+ label: tree.label,
147
+ list: (dir) => tree.list(dir)?.filter((e) => e.type === "file").map((e) => e.name),
148
+ read: (path) => tree.read(path),
149
+ bytes: (path) => (tree.bytes ? tree.bytes(path) : new Uint8Array(Buffer.from(tree.read(path), "utf-8"))),
150
+ };
151
+ return readTrustPolicy(source, null);
152
+ }
153
+
154
+ function finding(check: WorkspaceCheck, code: SecurityCode, message: string, at: { pointer: string; entity?: string; treeFile?: string; line?: number }): WorkspaceDiagnostic {
155
+ return {
156
+ checkId: check.id,
157
+ severity: check.severity,
158
+ code,
159
+ message: `${code}: ${message}`,
160
+ pointer: at.pointer,
161
+ ...(at.entity !== undefined ? { entity: at.entity } : {}),
162
+ ...(at.treeFile !== undefined ? { treeFile: at.treeFile, line: at.line ?? 1, column: 1 } : {}),
163
+ };
164
+ }
165
+
166
+ const RELEASE_PUBLISH = /\bchant\b.*\brelease\s+publish\b/;
167
+ const CI_TICK = /\bchant\b.*\bci\s+tick\b|main\.ts\s+ci\s+tick\b/;
168
+ const PR_APPLY = /\bcomponents\s+pr-apply\b|\bci\s+run\s+apply\b/;
169
+
170
+ export const SECURITY_CHECKS: readonly WorkspaceCheck[] = [
171
+ {
172
+ id: WSP_SECURITY_VERIFIED_RELEASES,
173
+ name: "security-verified-releases",
174
+ description: "Under the verified-releases group: a signers file, a release gate naming a class, and no publish job that passes --allow-unsigned.",
175
+ severity: "warning",
176
+ configurable: true,
177
+ check(ctx) {
178
+ const d = ctx.declaration;
179
+ if (!declares(d, "verified-releases")) return [];
180
+ const pointer = groupPointer(d, "verified-releases");
181
+ const out: WorkspaceDiagnostic[] = [];
182
+ if (ctx.tree.stat(DEFAULT_SIGNERS_PATH) !== "file") {
183
+ out.push(finding(this, "security-signers-missing", `the workspace opts into verified-releases, and there is no ${DEFAULT_SIGNERS_PATH}${ctx.tree.label} to verify a release's signature against; add the keys of the people who cut releases to it, in ssh-keygen's allowed_signers format`, { pointer }));
184
+ }
185
+ const gate = d.identity?.gates.release;
186
+ if (!gate?.class) {
187
+ out.push(finding(this, "security-release-gate-missing", gate ? `identity.gates.release names no class, so any signer in ${DEFAULT_SIGNERS_PATH} may cut a release; name the class that may` : "the workspace opts into verified-releases, and identity.gates has no release entry, so a cut is not held to a class of signers", { pointer: gate?.pointer ?? pointer }));
188
+ }
189
+ for (const l of commandLines(ctx, RELEASE_PUBLISH)) {
190
+ if (!/--allow-unsigned\b/.test(l.text)) continue;
191
+ out.push(finding(this, "security-publish-allows-unsigned", `${l.path} runs release publish with --allow-unsigned, which publishes a release whose signature does not verify`, { pointer, treeFile: l.path, line: l.line }));
192
+ }
193
+ return out;
194
+ },
195
+ },
196
+ {
197
+ id: WSP_SECURITY_SUPPLY_CHAIN,
198
+ name: "security-supply-chain",
199
+ description: "Under the supply-chain group: every publish target attaches an SBOM it has a source for, publish jobs require OIDC provenance, and components generate an SBOM and verify what they deploy.",
200
+ severity: "warning",
201
+ configurable: true,
202
+ check(ctx) {
203
+ const d = ctx.declaration;
204
+ if (!declares(d, "supply-chain")) return [];
205
+ const pointer = groupPointer(d, "supply-chain");
206
+ const out: WorkspaceDiagnostic[] = [];
207
+ for (const m of membersUnder(d, "supply-chain")) {
208
+ const configPath = joinPath(memberDir(m.dir), "chant.config.ts");
209
+ const config = read(ctx.tree, configPath);
210
+ if (config !== null && /\bpublish\s*:/.test(config)) {
211
+ const lines = config.split("\n");
212
+ const off = lines.findIndex((l) => /\bsbom\s*:\s*false\b/.test(l));
213
+ if (off >= 0) out.push(finding(this, "security-sbom-disabled", `member ${m.name}'s chant.config.ts sets sbom: false on a publish target, so what it publishes carries no SBOM`, { pointer, entity: m.name, treeFile: configPath, line: off + 1 }));
214
+ else if (!/\bsbom\s*:\s*["'`]/.test(config) && !lockfileAbove(ctx.tree, memberDir(m.dir))) {
215
+ out.push(finding(this, "security-sbom-unsourced", `member ${m.name} publishes, names no sbom file and has no package-lock.json, pom.xml or Cargo.lock in its directory or above it, so publish would refuse it as publish-sbom-missing`, { pointer, entity: m.name, treeFile: configPath }));
216
+ }
217
+ }
218
+ const components = componentFiles(ctx.tree, memberDir(m.dir));
219
+ if (components.length === 0) continue;
220
+ const text = components.map((f) => read(ctx.tree, f) ?? "").join("\n");
221
+ if (!/\bgenerateSbom\s*\(|["']generate-sbom["']/.test(text)) {
222
+ out.push(finding(this, "security-component-no-sbom", `member ${m.name}'s components have no generate-sbom step, so what they build carries no SBOM`, { pointer, entity: m.name, treeFile: components[0] }));
223
+ }
224
+ if (!/\bverify\s*\(|["']verify["']/.test(text)) {
225
+ out.push(finding(this, "security-component-no-verify", `member ${m.name}'s components have no verify step, so a signature or attestation is never checked before a deploy`, { pointer, entity: m.name, treeFile: components[0] }));
226
+ }
227
+ }
228
+ for (const l of commandLines(ctx, RELEASE_PUBLISH)) {
229
+ if (/--require-provenance\b/.test(l.text)) continue;
230
+ out.push(finding(this, "security-publish-no-provenance", `${l.path} runs release publish without --require-provenance, so a run with no OIDC token publishes with only a warning`, { pointer, treeFile: l.path, line: l.line }));
231
+ }
232
+ return out;
233
+ },
234
+ },
235
+ {
236
+ id: WSP_SECURITY_WHO_RAN_IT,
237
+ name: "security-who-ran-it",
238
+ description: "Under the who-ran-it group: ci.green.signedSince is set, .chant/trust.json lists a runner key, and the tick signs its tags.",
239
+ severity: "warning",
240
+ configurable: true,
241
+ check(ctx) {
242
+ const d = ctx.declaration;
243
+ if (!declares(d, "who-ran-it")) return [];
244
+ const pointer = groupPointer(d, "who-ran-it");
245
+ const out: WorkspaceDiagnostic[] = [];
246
+ if (!d.ci?.green?.signedSince) {
247
+ out.push(finding(this, "security-signed-since-unset", d.ci?.green ? "ci.green sets no signedSince, so an unsigned green tag still counts" : "the workspace opts into who-ran-it, and has no ci.green block, so no commit's green tag is signed", { pointer: d.ci?.green?.pointer ?? pointer }));
248
+ }
249
+ const runners = treePolicy(ctx.tree).runners.filter((r) => r.class === "runner").length;
250
+ if (runners === 0) {
251
+ out.push(finding(this, "security-runner-untrusted", `${TRUST_CONFIG_PATH}${ctx.tree.label} lists no runner key, so no run or tag can be traced to a runner; add one with chant ci serve --setup-key --trust`, { pointer }));
252
+ }
253
+ for (const l of commandLines(ctx, CI_TICK)) {
254
+ const file = allPipelineFiles(ctx).find((f) => f.path === l.path);
255
+ if (/--key\b/.test(l.text) || file?.lines.some((t) => /CHANT_RUNNER_KEY/.test(t))) continue;
256
+ out.push(finding(this, "security-tick-no-key", `${l.path} runs ci tick with no runner key, so its tags are unsigned; regenerate it with chant ci workflow --runner-key-secret <NAME>`, { pointer, treeFile: l.path, line: l.line }));
257
+ }
258
+ return out;
259
+ },
260
+ },
261
+ {
262
+ id: WSP_SECURITY_REVIEW_RULES,
263
+ name: "security-review-rules",
264
+ description: "Under the review-rules group: no apply job turns forge review off, attribution is identified, and every gate an apply job names needs a signed approval.",
265
+ severity: "warning",
266
+ configurable: true,
267
+ check(ctx) {
268
+ const d = ctx.declaration;
269
+ if (!declares(d, "review-rules")) return [];
270
+ const pointer = groupPointer(d, "review-rules");
271
+ const out: WorkspaceDiagnostic[] = [];
272
+ const applies = commandLines(ctx, PR_APPLY);
273
+ for (const l of applies) {
274
+ if (!/--no-require-review\b/.test(l.text)) continue;
275
+ out.push(finding(this, "security-review-not-required", `${l.path} passes --no-require-review, which the review-rules group overrides; drop it so the file says what runs`, { pointer, treeFile: l.path, line: l.line }));
276
+ }
277
+ if (d.identity?.attribution !== "identified") {
278
+ out.push(finding(this, "security-attribution-any", "identity.attribution is not identified, so a person-attributed write may name anyone", { pointer: d.identity ? "/identity" : pointer }));
279
+ }
280
+ const signed = new Set(Object.keys(d.identity?.gates ?? {}));
281
+ const named = new Set(applies.flatMap((l) => [...l.text.matchAll(/--(?:wave-)?gate\s+["']?([A-Za-z0-9_.-]+)/g)].map((m) => m[1]!)));
282
+ const unsigned = [...named].filter((g) => !signed.has(g));
283
+ if (unsigned.length > 0) {
284
+ out.push(finding(this, "security-gate-unsigned", `the apply jobs name the gate${unsigned.length > 1 ? "s" : ""} ${unsigned.join(", ")}, which identity.gates does not list, so an unsigned approval passes ${unsigned.length > 1 ? "them" : "it"}`, { pointer }));
285
+ } else if (named.size === 0 && signed.size === 0) {
286
+ out.push(finding(this, "security-gate-unsigned", "identity.gates lists no gate, so every approval may be unsigned", { pointer }));
287
+ }
288
+ return out;
289
+ },
290
+ },
291
+ {
292
+ id: WSP_SECURITY_ISOLATED_RUNNERS,
293
+ name: "security-isolated-runners",
294
+ description: "Under the isolated-runners group: .chant/trust.json lists a runner key whose principal holds the runner role, so an apply job can require a runner-start statement (#4147).",
295
+ severity: "warning",
296
+ configurable: true,
297
+ check(ctx) {
298
+ const d = ctx.declaration;
299
+ if (!declares(d, "isolated-runners")) return [];
300
+ const pointer = groupPointer(d, "isolated-runners");
301
+ const codes = { "no-runner-key": "security-runner-start-no-key", "no-runner-role": "security-runner-start-no-role", "trust-unreadable": "security-trust-unreadable" } as const;
302
+ return runnerStartReadiness(treePolicy(ctx.tree)).gaps.map((g) => finding(this, codes[g.gap], `the workspace opts into isolated-runners, so every apply job requires a runner-start statement, and ${g.reason}`, { pointer }));
303
+ },
304
+ },
305
+ ];
@@ -37,6 +37,8 @@
37
37
  * | WSP126, WSP127 | box intent: the decision record a box names exists, and constrains a member or path of this workspace (#2850, #2857) |
38
38
  * | WSP141, WSP142 | member links resolved against a live read, with `--live` (#2549) |
39
39
  * | WSP131 to WSP133 | diagram artifacts: source and render exist, a recorded source hash still matches (#2764) |
40
+ * | WSP151 | what a member under `ci.workflow` still lacks (#4168 step 6) |
41
+ * | WSP161 to WSP165 | the security groups the declaration opts into: what each still lacks (#4168 step 7) |
40
42
  */
41
43
 
42
44
  import { realpathSync } from "node:fs";
@@ -58,6 +60,8 @@ import { gatherLiveLinkFacts, LIVE_CHECKS, type LiveLinkFacts } from "./checks/l
58
60
  import { RECORD_CHECKS, type RecordFacts } from "./checks/records";
59
61
  import { BOX_CHECKS } from "./checks/boxes";
60
62
  import { DIAGRAM_CHECKS } from "./checks/diagrams";
63
+ import { SECURITY_CHECKS } from "./checks/security";
64
+ import { CI_WORKFLOW_CHECKS } from "./checks/ci-workflow";
61
65
  import { loadDeclaredKinds, type DeclaredKind } from "./declared-kinds";
62
66
  import { resolveBoxIntents, type ResolvedBoxIntent } from "./box-intent";
63
67
  import { gatherLexiconOutputs, type MemberLexiconOutputs } from "./lexicon-outputs";
@@ -371,6 +375,10 @@ export const WORKSPACE_CHECKS: readonly WorkspaceCheck[] = [
371
375
  ...DIAGRAM_CHECKS,
372
376
  // Links resolved against a live read, with --live (#2549), WSP141 and WSP142.
373
377
  ...LIVE_CHECKS,
378
+ // What a member under ci.workflow still lacks (#4168 step 6), WSP151.
379
+ ...CI_WORKFLOW_CHECKS,
380
+ // The security groups the declaration opts into (#4168 step 7), WSP161 to WSP165.
381
+ ...SECURITY_CHECKS,
374
382
  ];
375
383
 
376
384
  const BY_ID = new Map(WORKSPACE_CHECKS.map((c) => [c.id, c]));
@@ -0,0 +1,208 @@
1
+ /**
2
+ * #4169 end to end: `chant ci tick` and `chant ci run --at`, as a process,
3
+ * against a bare git remote and a local server that answers GitHub's check
4
+ * run, re-run and workflow dispatch calls the way Actions would for a
5
+ * `suites` phase declared `build: "newest"`.
6
+ *
7
+ * Seven commits go to main at once: a green base, five commits the suites
8
+ * never ran on (GitHub dropped their waiting runs), and the newest, whose
9
+ * suites run fails. The fourth of the five broke the suites. The tick runs
10
+ * each failed job again once, then bisects: it halves the five, steps back
11
+ * from the red, and names the culprit, leaving the two commits below the
12
+ * green middle one unbuilt.
13
+ */
14
+
15
+ import { spawn } from "node:child_process";
16
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
17
+ import type { AddressInfo } from "node:net";
18
+ import { join } from "node:path";
19
+ import { pathToFileURL } from "node:url";
20
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
21
+ import { cleanScratch, declaration, git, REPO, scratchDir, writeFiles } from "./__fixtures__/contract-repo";
22
+
23
+ afterAll(cleanScratch);
24
+
25
+ const GREEN = {
26
+ branch: "main",
27
+ phases: { check: { runs: ["check"], build: "every" }, suites: { runs: ["suites"], build: "newest" } },
28
+ require: ["check", "suites"],
29
+ };
30
+ const SUITES_WORKFLOW = "name: release-suites\non:\n push:\n branches: [main]\n workflow_dispatch: {}\njobs:\n suites:\n runs-on: ubuntu-latest\n steps:\n - run: true\n";
31
+
32
+ interface Run {
33
+ id: number;
34
+ name: string;
35
+ status: string;
36
+ conclusion: string | null;
37
+ }
38
+
39
+ /** The fake GitHub: runs per commit, and what dispatching the suites at a commit concludes. */
40
+ class FakeGithub {
41
+ runs = new Map<string, Run[]>();
42
+ dispatched: string[] = [];
43
+ reruns: number[] = [];
44
+ private id = 1;
45
+ server!: Server;
46
+ constructor(
47
+ readonly origin: string,
48
+ readonly suitesPass: (sha: string) => boolean,
49
+ ) {}
50
+
51
+ add(sha: string, name: string, conclusion: string): void {
52
+ this.runs.set(sha, [...(this.runs.get(sha) ?? []), { id: this.id++, name, status: "completed", conclusion }]);
53
+ }
54
+
55
+ async listen(): Promise<string> {
56
+ this.server = createServer((req, res) => void this.handle(req, res));
57
+ await new Promise<void>((done) => this.server.listen(0, "127.0.0.1", done));
58
+ return `http://127.0.0.1:${(this.server.address() as AddressInfo).port}`;
59
+ }
60
+
61
+ private async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
62
+ const body = await new Promise<string>((done) => {
63
+ let text = "";
64
+ req.on("data", (c) => (text += c));
65
+ req.on("end", () => done(text));
66
+ });
67
+ const url = new URL(req.url ?? "/", "http://x");
68
+ const send = (status: number, json?: unknown) => {
69
+ res.writeHead(status, { "Content-Type": "application/json" });
70
+ res.end(json === undefined ? "" : JSON.stringify(json));
71
+ };
72
+ let m = /^\/repos\/acme\/app\/commits\/([0-9a-f]+)\/check-runs$/.exec(url.pathname);
73
+ if (req.method === "GET" && m) {
74
+ const runs = this.runs.get(m[1]) ?? [];
75
+ return send(200, { total_count: runs.length, check_runs: runs.map((r) => ({ ...r, completed_at: null, html_url: null })) });
76
+ }
77
+ m = /^\/repos\/acme\/app\/actions\/jobs\/(\d+)\/rerun$/.exec(url.pathname);
78
+ if (req.method === "POST" && m) {
79
+ const id = Number(m[1]);
80
+ this.reruns.push(id);
81
+ // The job runs again and fails the same way.
82
+ for (const [sha, runs] of this.runs) {
83
+ const r = runs.find((x) => x.id === id);
84
+ if (r) this.add(sha, r.name, r.conclusion ?? "failure");
85
+ }
86
+ return send(201, {});
87
+ }
88
+ m = /^\/repos\/acme\/app\/actions\/workflows\/([^/]+)\/dispatches$/.exec(url.pathname);
89
+ if (req.method === "POST" && m) {
90
+ const ref = (JSON.parse(body) as { ref: string }).ref;
91
+ const sha = git(this.origin, "rev-parse", `refs/tags/${ref}^{commit}`);
92
+ this.dispatched.push(sha);
93
+ this.add(sha, "suites", this.suitesPass(sha) ? "success" : "failure");
94
+ return send(204);
95
+ }
96
+ send(404, { message: "not found" });
97
+ }
98
+ }
99
+
100
+ function chant(cwd: string, api: string, ...args: string[]): Promise<{ code: number; out: string; err: string }> {
101
+ return new Promise((done) => {
102
+ const child = spawn(process.execPath, ["--import", pathToFileURL(join(REPO, "node_modules/tsx/dist/loader.mjs")).href, join(REPO, "packages/core/src/cli/main.ts"), ...args], {
103
+ cwd,
104
+ env: { ...process.env, GITHUB_API_URL: api, GITHUB_REPOSITORY: "acme/app", GITHUB_TOKEN: "t", GIT_TERMINAL_PROMPT: "0", NO_COLOR: "1" },
105
+ stdio: ["ignore", "pipe", "pipe"],
106
+ });
107
+ let out = "";
108
+ let err = "";
109
+ child.stdout.on("data", (c) => (out += c));
110
+ child.stderr.on("data", (c) => (err += c));
111
+ child.on("close", (code) => done({ code: code ?? 1, out, err }));
112
+ });
113
+ }
114
+
115
+ describe("a newest phase, bisected end to end (#4169)", () => {
116
+ let root: string;
117
+ let origin: string;
118
+ let forge: FakeGithub;
119
+ let api: string;
120
+ const shas: Record<string, string> = {};
121
+
122
+ beforeAll(async () => {
123
+ origin = scratchDir("chant-builds-e2e-origin-");
124
+ git(origin, "init", "-q", "--bare");
125
+ root = scratchDir("chant-builds-e2e-work-");
126
+ git(root, "init", "-q");
127
+ git(root, "symbolic-ref", "HEAD", "refs/heads/main");
128
+ git(root, "config", "user.name", "t");
129
+ git(root, "config", "user.email", "t@example.com");
130
+ git(root, "remote", "add", "origin", origin);
131
+ writeFiles(root, {
132
+ "chant.workspace.json": declaration([{ name: "app", dir: "app", kind: "other", because: "the app" }], { ci: { green: GREEN } }),
133
+ ".github/workflows/release-suites.yml": SUITES_WORKFLOW,
134
+ });
135
+ for (const name of ["base", "u1", "u2", "u3", "u4", "u5", "top"]) {
136
+ writeFiles(root, { [`app/${name}.txt`]: `${name}\n` });
137
+ git(root, "add", "-A");
138
+ git(root, "commit", "-q", "-m", name);
139
+ shas[name] = git(root, "rev-parse", "HEAD");
140
+ }
141
+ git(root, "push", "-q", "origin", "main");
142
+ const order = ["base", "u1", "u2", "u3", "u4", "u5", "top"].map((n) => shas[n]);
143
+ // u4 broke the suites: they pass below it.
144
+ forge = new FakeGithub(origin, (sha) => order.indexOf(sha) < order.indexOf(shas.u4));
145
+ for (const sha of order) forge.add(sha, "check", "success");
146
+ forge.add(shas.base, "suites", "success");
147
+ forge.add(shas.top, "suites", "failure");
148
+ api = await forge.listen();
149
+ });
150
+
151
+ afterAll(() => forge?.server.close());
152
+
153
+ const tags = () => git(origin, "for-each-ref", "--format=%(refname:lstrip=2)", "refs/tags/").split("\n").filter(Boolean).sort();
154
+ const record = (name: string) => JSON.parse(git(origin, "show", `refs/heads/chant/lifecycle:_ci/${shas[name]}.json`));
155
+ const tick = async () => {
156
+ const r = await chant(root, api, "ci", "tick");
157
+ expect(r.code, r.err).toBe(0);
158
+ return r.out;
159
+ };
160
+
161
+ test("retries once, bisects to the culprit, and leaves the commits below the green one unbuilt", async () => {
162
+ // The failed suites run again once, and fail again.
163
+ expect(await tick()).toContain(`ran suites at ${shas.top.slice(0, 12)} again`);
164
+ expect(forge.reruns).toHaveLength(1);
165
+ expect(record("top")).toMatchObject({ state: "pending", phases: { suites: { reason: "retrying" } } });
166
+
167
+ // Red now: five unbuilt below it, so the middle one, u3.
168
+ await tick();
169
+ expect(forge.dispatched).toEqual([shas.u3]);
170
+ // u3 passes and goes green; then the step back from the red, u5, then u4,
171
+ // each red run of them run again once before the next step.
172
+ await tick();
173
+ expect(tags()).toContain(`ci/green/${shas.u3}`);
174
+ expect(forge.dispatched).toEqual([shas.u3, shas.u5]);
175
+ expect(await tick()).toContain(`ran suites at ${shas.u5.slice(0, 12)} again`);
176
+ await tick();
177
+ expect(forge.dispatched).toEqual([shas.u3, shas.u5, shas.u4]);
178
+ expect(await tick()).toContain(`ran suites at ${shas.u4.slice(0, 12)} again`);
179
+ await tick();
180
+ expect(forge.dispatched).toHaveLength(3);
181
+ expect(forge.reruns).toHaveLength(3);
182
+
183
+ for (const name of ["u4", "u5", "top"]) expect(record(name), name).toMatchObject({ state: "red", phases: { suites: { culprit: shas.u4 } } });
184
+ for (const name of ["u1", "u2"]) expect(record(name), name).toMatchObject({ state: "unbuilt", phases: { suites: { verdict: "unbuilt", reason: "superseded" } } });
185
+ expect(record("u3")).toMatchObject({ state: "green" });
186
+ expect(tags().filter((t) => t.startsWith("ci/build/"))).toEqual([]);
187
+ expect(tags()).toEqual([`ci/green/${shas.base}`, `ci/green/${shas.u3}`].sort());
188
+
189
+ const last = await chant(root, api, "ci", "last-green", "--json");
190
+ const doc = JSON.parse(last.out);
191
+ expect(doc.commit.sha).toBe(shas.u3);
192
+ expect(doc.newer.map((n: { sha: string; record: { state: string; culprit?: string } }) => [n.sha, n.record.state, n.record.culprit])).toEqual([
193
+ [shas.top, "red", shas.u4],
194
+ [shas.u5, "red", shas.u4],
195
+ [shas.u4, "red", shas.u4],
196
+ ]);
197
+ }, 180_000);
198
+
199
+ test("ci run --at asks for a chosen commit and starts the suites there", async () => {
200
+ const r = await chant(root, api, "ci", "run", "--at", shas.u1);
201
+ expect(r.code, r.err).toBe(0);
202
+ expect(r.out).toContain(`Asked for check, suites at ${shas.u1.slice(0, 12)}: ci/build/${shas.u1}. Started release-suites.yml.`);
203
+ expect(tags()).toContain(`ci/build/${shas.u1}`);
204
+ await tick();
205
+ expect(tags()).toContain(`ci/green/${shas.u1}`);
206
+ expect(tags()).not.toContain(`ci/build/${shas.u1}`);
207
+ }, 60_000);
208
+ });