@intentius/chant 0.124.31 → 0.124.32

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 (321) hide show
  1. package/bin/chant +7 -2
  2. package/dist/cli/commands/check-lexicon-plugin.d.ts +7 -4
  3. package/dist/cli/commands/check-lexicon-plugin.d.ts.map +1 -1
  4. package/dist/cli/commands/check-lexicon-plugin.js +10 -7
  5. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  6. package/dist/cli/commands/check-lexicon.js +18 -12
  7. package/dist/cli/commands/import.d.ts +10 -0
  8. package/dist/cli/commands/import.d.ts.map +1 -1
  9. package/dist/cli/commands/import.js +37 -0
  10. package/dist/cli/commands/init-lexicon/templates/project.d.ts.map +1 -1
  11. package/dist/cli/commands/init-lexicon/templates/project.js +4 -3
  12. package/dist/cli/commands/onboard.d.ts.map +1 -1
  13. package/dist/cli/commands/onboard.js +8 -4
  14. package/dist/cli/commands/update.d.ts.map +1 -1
  15. package/dist/cli/commands/update.js +2 -1
  16. package/dist/cli/dependencies.d.ts +18 -0
  17. package/dist/cli/dependencies.d.ts.map +1 -0
  18. package/dist/cli/dependencies.js +120 -0
  19. package/dist/cli/handlers/build.d.ts.map +1 -1
  20. package/dist/cli/handlers/build.js +23 -4
  21. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  22. package/dist/cli/handlers/fan-out.js +12 -4
  23. package/dist/cli/handlers/graph.d.ts.map +1 -1
  24. package/dist/cli/handlers/graph.js +16 -6
  25. package/dist/cli/handlers/lint.d.ts.map +1 -1
  26. package/dist/cli/handlers/lint.js +6 -0
  27. package/dist/cli/handlers/operator.d.ts +2 -2
  28. package/dist/cli/handlers/operator.js +3 -3
  29. package/dist/cli/handlers/pr.d.ts +14 -0
  30. package/dist/cli/handlers/pr.d.ts.map +1 -1
  31. package/dist/cli/handlers/pr.js +488 -32
  32. package/dist/cli/handlers/promote.d.ts.map +1 -1
  33. package/dist/cli/handlers/promote.js +3 -1
  34. package/dist/cli/handlers/run.d.ts +13 -1
  35. package/dist/cli/handlers/run.d.ts.map +1 -1
  36. package/dist/cli/handlers/run.js +38 -15
  37. package/dist/cli/main.d.ts.map +1 -1
  38. package/dist/cli/main.js +44 -16
  39. package/dist/cli/parse-args.d.ts.map +1 -1
  40. package/dist/cli/parse-args.js +15 -0
  41. package/dist/cli/registry.d.ts +13 -4
  42. package/dist/cli/registry.d.ts.map +1 -1
  43. package/dist/cli/registry.js +6 -2
  44. package/dist/codegen/generate-post-synth-barrel.js +2 -2
  45. package/dist/codegen/lexicon-regen.d.ts +4 -0
  46. package/dist/codegen/lexicon-regen.d.ts.map +1 -1
  47. package/dist/codegen/lexicon-regen.js +1 -1
  48. package/dist/codegen/pinned-upgrade.js +2 -2
  49. package/dist/components/capability.d.ts +44 -3
  50. package/dist/components/capability.d.ts.map +1 -1
  51. package/dist/components/capability.js +21 -0
  52. package/dist/components/changed-files.d.ts +7 -1
  53. package/dist/components/changed-files.d.ts.map +1 -1
  54. package/dist/components/changed-files.js +9 -1
  55. package/dist/components/driver.d.ts +38 -0
  56. package/dist/components/driver.d.ts.map +1 -1
  57. package/dist/components/driver.js +47 -6
  58. package/dist/components/fan-out-record.d.ts +6 -1
  59. package/dist/components/fan-out-record.d.ts.map +1 -1
  60. package/dist/components/fan-out-record.js +24 -0
  61. package/dist/components/fan-out-run.d.ts +20 -0
  62. package/dist/components/fan-out-run.d.ts.map +1 -1
  63. package/dist/components/fan-out-run.js +21 -1
  64. package/dist/components/pr-pipeline.d.ts +65 -2
  65. package/dist/components/pr-pipeline.d.ts.map +1 -1
  66. package/dist/components/pr-pipeline.js +107 -15
  67. package/dist/components/pr-run.d.ts +127 -29
  68. package/dist/components/pr-run.d.ts.map +1 -1
  69. package/dist/components/pr-run.js +290 -70
  70. package/dist/components/wave-plan.d.ts +5 -2
  71. package/dist/components/wave-plan.d.ts.map +1 -1
  72. package/dist/components/wave-plan.js +12 -1
  73. package/dist/discovery/fold-import.d.ts.map +1 -1
  74. package/dist/discovery/fold-import.js +16 -6
  75. package/dist/discovery/sandbox/bundle.d.ts.map +1 -1
  76. package/dist/discovery/sandbox/bundle.js +6 -0
  77. package/dist/discovery/specifier-resolve.d.ts.map +1 -1
  78. package/dist/discovery/specifier-resolve.js +8 -2
  79. package/dist/fold/fold.d.ts.map +1 -1
  80. package/dist/fold/fold.js +68 -1
  81. package/dist/fold/subset.d.ts +37 -0
  82. package/dist/fold/subset.d.ts.map +1 -1
  83. package/dist/fold/subset.js +88 -0
  84. package/dist/lexicon-module.d.ts +10 -0
  85. package/dist/lexicon-module.d.ts.map +1 -1
  86. package/dist/lexicon-module.js +53 -8
  87. package/dist/lexicon-plugin-helpers.d.ts.map +1 -1
  88. package/dist/lexicon-plugin-helpers.js +6 -4
  89. package/dist/lexicon.d.ts +2 -0
  90. package/dist/lexicon.d.ts.map +1 -1
  91. package/dist/lifecycle/affected.d.ts.map +1 -1
  92. package/dist/lifecycle/affected.js +7 -1
  93. package/dist/lifecycle/release-ledger.d.ts +21 -0
  94. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  95. package/dist/lifecycle/release-ledger.js +24 -0
  96. package/dist/lifecycle/work-dir.d.ts +45 -0
  97. package/dist/lifecycle/work-dir.d.ts.map +1 -0
  98. package/dist/lifecycle/work-dir.js +78 -0
  99. package/dist/lint/discover.d.ts +17 -9
  100. package/dist/lint/discover.d.ts.map +1 -1
  101. package/dist/lint/discover.js +62 -54
  102. package/dist/module-source.d.ts +57 -0
  103. package/dist/module-source.d.ts.map +1 -0
  104. package/dist/module-source.js +104 -0
  105. package/dist/op/activities/factory.js +1 -1
  106. package/dist/op/discover.d.ts.map +1 -1
  107. package/dist/op/discover.js +13 -3
  108. package/dist/op/gate.d.ts.map +1 -1
  109. package/dist/op/gate.js +3 -0
  110. package/dist/op/op-waves-run.d.ts.map +1 -1
  111. package/dist/op/op-waves-run.js +20 -5
  112. package/dist/op/operator.d.ts +2 -2
  113. package/dist/op/operator.js +2 -2
  114. package/dist/op/steward.d.ts +6 -4
  115. package/dist/op/steward.d.ts.map +1 -1
  116. package/dist/op/steward.js +5 -3
  117. package/dist/op/work-lease-run.d.ts +20 -7
  118. package/dist/op/work-lease-run.d.ts.map +1 -1
  119. package/dist/op/work-lease-run.js +56 -12
  120. package/dist/pr-forge.d.ts +10 -0
  121. package/dist/pr-forge.d.ts.map +1 -1
  122. package/dist/pr-forge.js +39 -0
  123. package/dist/pr-loop.d.ts +141 -4
  124. package/dist/pr-loop.d.ts.map +1 -1
  125. package/dist/pr-loop.js +122 -6
  126. package/dist/pr-report-url.d.ts +2 -2
  127. package/dist/pr-report-url.d.ts.map +1 -1
  128. package/dist/pr-report-url.js +5 -5
  129. package/dist/ts-loader.d.ts +38 -4
  130. package/dist/ts-loader.d.ts.map +1 -1
  131. package/dist/ts-loader.js +190 -16
  132. package/dist/wave-runtime.mjs +208 -208
  133. package/dist/workspace/chant-migrations/fountain-retired-kinds.d.ts +34 -0
  134. package/dist/workspace/chant-migrations/fountain-retired-kinds.d.ts.map +1 -0
  135. package/dist/workspace/chant-migrations/fountain-retired-kinds.js +176 -0
  136. package/dist/workspace/chant-migrations.d.ts.map +1 -1
  137. package/dist/workspace/chant-migrations.js +2 -0
  138. package/dist/workspace/checks/boxes.d.ts +6 -6
  139. package/dist/workspace/checks/boxes.js +9 -9
  140. package/dist/workspace/ci-green-forge.d.ts +6 -4
  141. package/dist/workspace/ci-green-forge.d.ts.map +1 -1
  142. package/dist/workspace/ci-green.d.ts +28 -12
  143. package/dist/workspace/ci-green.d.ts.map +1 -1
  144. package/dist/workspace/ci-green.js +49 -21
  145. package/dist/workspace/ci-list.schema.json +12 -6
  146. package/dist/workspace/ci-report-url.d.ts +18 -0
  147. package/dist/workspace/ci-report-url.d.ts.map +1 -0
  148. package/dist/workspace/ci-report-url.js +33 -0
  149. package/dist/workspace/ci-run-index.d.ts +12 -0
  150. package/dist/workspace/ci-run-index.d.ts.map +1 -1
  151. package/dist/workspace/ci-run-index.js +6 -4
  152. package/dist/workspace/ci-run-index.schema.json +5 -3
  153. package/dist/workspace/ci-run.d.ts +75 -2
  154. package/dist/workspace/ci-run.d.ts.map +1 -1
  155. package/dist/workspace/ci-run.js +195 -53
  156. package/dist/workspace/ci-runner-key.d.ts +16 -3
  157. package/dist/workspace/ci-runner-key.d.ts.map +1 -1
  158. package/dist/workspace/ci-runner-key.js +30 -10
  159. package/dist/workspace/ci-serve.d.ts +54 -4
  160. package/dist/workspace/ci-serve.d.ts.map +1 -1
  161. package/dist/workspace/ci-serve.js +175 -26
  162. package/dist/workspace/declaration.d.ts +8 -0
  163. package/dist/workspace/declaration.d.ts.map +1 -1
  164. package/dist/workspace/declaration.js +11 -2
  165. package/dist/workspace/declaration.schema.json +7 -2
  166. package/dist/workspace/export.d.ts +1 -1
  167. package/dist/workspace/export.d.ts.map +1 -1
  168. package/dist/workspace/export.js +7 -3
  169. package/dist/workspace/ls.d.ts +6 -1
  170. package/dist/workspace/ls.d.ts.map +1 -1
  171. package/dist/workspace/ls.js +1 -0
  172. package/dist/workspace/ls.schema.json +7 -1
  173. package/dist/workspace/pin-integrity.d.ts.map +1 -1
  174. package/dist/workspace/pin-integrity.js +3 -2
  175. package/dist/workspace/pr-report.schema.json +74 -7
  176. package/dist/workspace/reason-codes.d.ts +1 -1
  177. package/dist/workspace/reason-codes.d.ts.map +1 -1
  178. package/dist/workspace/reason-codes.js +2 -2
  179. package/dist/workspace/run-event.schema.json +3 -1
  180. package/dist/workspace/status.schema.json +3 -1
  181. package/dist/workspace/write-scope.d.ts +9 -0
  182. package/dist/workspace/write-scope.d.ts.map +1 -1
  183. package/dist/workspace/write-scope.js +11 -1
  184. package/package.json +1 -1
  185. package/src/__fixtures__/pr-loop/pr-apply-wave-waiting.note.golden.md +48 -0
  186. package/src/cli/commands/__fixtures__/init-lexicon-output/package.json +3 -3
  187. package/src/cli/commands/audit.test.ts +2 -2
  188. package/src/cli/commands/check-lexicon-plugin.test.ts +9 -0
  189. package/src/cli/commands/check-lexicon-plugin.ts +11 -8
  190. package/src/cli/commands/check-lexicon.test.ts +1 -1
  191. package/src/cli/commands/check-lexicon.ts +16 -11
  192. package/src/cli/commands/import.ts +40 -0
  193. package/src/cli/commands/init-lexicon/templates/project.ts +4 -3
  194. package/src/cli/commands/init-lexicon.test.ts +8 -5
  195. package/src/cli/commands/onboard.test.ts +45 -0
  196. package/src/cli/commands/onboard.ts +8 -4
  197. package/src/cli/commands/update.test.ts +6 -0
  198. package/src/cli/commands/update.ts +4 -1
  199. package/src/cli/compiled-cli.e2e.test.ts +76 -11
  200. package/src/cli/dependencies.e2e.test.ts +101 -0
  201. package/src/cli/dependencies.test.ts +93 -0
  202. package/src/cli/dependencies.ts +122 -0
  203. package/src/cli/handlers/build.test.ts +69 -0
  204. package/src/cli/handlers/build.ts +20 -4
  205. package/src/cli/handlers/fan-out.test.ts +73 -0
  206. package/src/cli/handlers/fan-out.ts +11 -4
  207. package/src/cli/handlers/graph.test.ts +76 -0
  208. package/src/cli/handlers/graph.ts +19 -12
  209. package/src/cli/handlers/lint.test.ts +42 -0
  210. package/src/cli/handlers/lint.ts +6 -0
  211. package/src/cli/handlers/operator.ts +3 -3
  212. package/src/cli/handlers/pr-waves.test.ts +449 -0
  213. package/src/cli/handlers/pr.test.ts +134 -0
  214. package/src/cli/handlers/pr.ts +523 -31
  215. package/src/cli/handlers/promote.test.ts +26 -1
  216. package/src/cli/handlers/promote.ts +3 -2
  217. package/src/cli/handlers/run-components-exit.test.ts +99 -0
  218. package/src/cli/handlers/run-signer.test.ts +79 -0
  219. package/src/cli/handlers/run.ts +34 -16
  220. package/src/cli/main.test.ts +18 -0
  221. package/src/cli/main.ts +43 -16
  222. package/src/cli/parse-args.ts +12 -0
  223. package/src/cli/registry.ts +19 -6
  224. package/src/codegen/generate-post-synth-barrel.ts +2 -2
  225. package/src/codegen/lexicon-regen.ts +1 -1
  226. package/src/codegen/pinned-upgrade.ts +2 -2
  227. package/src/codegen/post-synth-barrel-guards.test.ts +6 -11
  228. package/src/components/capability.ts +51 -3
  229. package/src/components/changed-files.test.ts +16 -1
  230. package/src/components/changed-files.ts +9 -1
  231. package/src/components/driver.test.ts +36 -1
  232. package/src/components/driver.ts +76 -4
  233. package/src/components/fan-out-record.ts +29 -1
  234. package/src/components/fan-out-run.test.ts +35 -0
  235. package/src/components/fan-out-run.ts +40 -1
  236. package/src/components/pr-pipeline.test.ts +100 -0
  237. package/src/components/pr-pipeline.ts +132 -17
  238. package/src/components/pr-run.test.ts +323 -18
  239. package/src/components/pr-run.ts +354 -75
  240. package/src/components/wave-plan.ts +16 -4
  241. package/src/discovery/fold-host-method.test.ts +87 -0
  242. package/src/discovery/fold-import.ts +15 -5
  243. package/src/discovery/fold-package-composite.test.ts +27 -0
  244. package/src/discovery/fold-specifiers.test.ts +8 -0
  245. package/src/discovery/sandbox/bundle.ts +6 -0
  246. package/src/discovery/specifier-resolve.ts +8 -2
  247. package/src/fold/fold.test.ts +72 -0
  248. package/src/fold/fold.ts +71 -0
  249. package/src/fold/subset.test.ts +31 -0
  250. package/src/fold/subset.ts +99 -0
  251. package/src/lexicon-dist.e2e.test.ts +254 -0
  252. package/src/lexicon-module.test.ts +47 -0
  253. package/src/lexicon-module.ts +49 -7
  254. package/src/lexicon-plugin-helpers.ts +6 -4
  255. package/src/lexicon.ts +2 -0
  256. package/src/lifecycle/affected.test.ts +7 -0
  257. package/src/lifecycle/affected.ts +6 -1
  258. package/src/lifecycle/release-ledger.ts +34 -0
  259. package/src/lifecycle/work-dir.test.ts +77 -0
  260. package/src/lifecycle/work-dir.ts +82 -0
  261. package/src/lint/discover.test.ts +46 -2
  262. package/src/lint/discover.ts +55 -49
  263. package/src/module-source.test.ts +85 -0
  264. package/src/module-source.ts +112 -0
  265. package/src/op/activities/factory.ts +1 -1
  266. package/src/op/discover-root.test.ts +31 -2
  267. package/src/op/discover.ts +14 -4
  268. package/src/op/gate.test.ts +30 -0
  269. package/src/op/gate.ts +3 -0
  270. package/src/op/op-waves-run.ts +20 -5
  271. package/src/op/op-waves.test.ts +49 -0
  272. package/src/op/operator.ts +2 -2
  273. package/src/op/steward.ts +6 -4
  274. package/src/op/work-lease-run.test.ts +88 -2
  275. package/src/op/work-lease-run.ts +52 -12
  276. package/src/pr-forge-bundle.test.ts +2 -2
  277. package/src/pr-forge.test.ts +42 -0
  278. package/src/pr-forge.ts +40 -0
  279. package/src/pr-loop-bundle.test.ts +2 -1
  280. package/src/pr-loop.test.ts +117 -1
  281. package/src/pr-loop.ts +252 -10
  282. package/src/pr-report-url.ts +5 -5
  283. package/src/terraform/__fixtures__/advise.test.ts +21 -2
  284. package/src/ts-loader.test.ts +89 -1
  285. package/src/ts-loader.ts +180 -13
  286. package/src/workspace/changes.test.ts +1 -1
  287. package/src/workspace/chant-migrations/fountain-retired-kinds.test.ts +33 -0
  288. package/src/workspace/chant-migrations/fountain-retired-kinds.ts +190 -0
  289. package/src/workspace/chant-migrations.e2e.test.ts +5 -0
  290. package/src/workspace/chant-migrations.ts +2 -0
  291. package/src/workspace/checks/boxes.ts +9 -9
  292. package/src/workspace/ci-green-forge.ts +6 -4
  293. package/src/workspace/ci-green-runs.test.ts +53 -11
  294. package/src/workspace/ci-green.test.ts +1 -1
  295. package/src/workspace/ci-green.ts +50 -22
  296. package/src/workspace/ci-list.schema.json +12 -6
  297. package/src/workspace/ci-report-url.test.ts +220 -0
  298. package/src/workspace/ci-report-url.ts +33 -0
  299. package/src/workspace/ci-run-index.schema.json +5 -3
  300. package/src/workspace/ci-run-index.test.ts +42 -3
  301. package/src/workspace/ci-run-index.ts +18 -4
  302. package/src/workspace/ci-run.test.ts +185 -7
  303. package/src/workspace/ci-run.ts +224 -54
  304. package/src/workspace/ci-runner-key.test.ts +55 -17
  305. package/src/workspace/ci-runner-key.ts +36 -11
  306. package/src/workspace/ci-serve.test.ts +150 -6
  307. package/src/workspace/ci-serve.ts +169 -25
  308. package/src/workspace/declaration.schema.json +7 -2
  309. package/src/workspace/declaration.test.ts +23 -0
  310. package/src/workspace/declaration.ts +19 -3
  311. package/src/workspace/export.ts +6 -3
  312. package/src/workspace/ls.schema.json +7 -1
  313. package/src/workspace/ls.ts +7 -2
  314. package/src/workspace/pin-integrity.test.ts +5 -0
  315. package/src/workspace/pin-integrity.ts +3 -2
  316. package/src/workspace/pr-report.schema.json +74 -7
  317. package/src/workspace/reason-codes.ts +2 -2
  318. package/src/workspace/run-event.schema.json +3 -1
  319. package/src/workspace/status.schema.json +3 -1
  320. package/src/workspace/write-scope.test.ts +29 -1
  321. package/src/workspace/write-scope.ts +12 -1
@@ -32,8 +32,8 @@ export interface CheckRun {
32
32
  url: string | null;
33
33
  /**
34
34
  * Set when the "check run" is a chant Op run (#4044, `ci.green.source:
35
- * "runs"`): the run's id and env, the runner whose statement it carries,
36
- * and that statement. `name` is then the Op's name and `id` the run's end
35
+ * "runs"`): the run's id and env, and the runner whose statement it
36
+ * carries with that statement, or a null signer for an unsigned run. `name` is then the Op's name and `id` the run's end
37
37
  * time in milliseconds, which orders a re-run after the run it replaced.
38
38
  */
39
39
  opRun?: OpRunRef;
@@ -45,8 +45,10 @@ export interface CheckRun {
45
45
  export interface OpRunRef {
46
46
  id: string;
47
47
  env: string;
48
- signer: string;
49
- statement: NonNullable<OpRunRecord["statement"]>;
48
+ /** The runner whose statement the run carries, or null for an unsigned run, which counts unless `ci.green.signedSince` asks for one (#4156). */
49
+ signer: string | null;
50
+ /** The run's statement, when it has one. */
51
+ statement?: NonNullable<OpRunRecord["statement"]>;
50
52
  }
51
53
 
52
54
  export interface CiForge {
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * #4044: chant run results as a status source for `ci tick`. With
3
3
  * `ci.green.source: "runs"` (or `--source runs`) the tick reads Op runs from
4
- * the run ledger on `chant/lifecycle` instead of a forge's check runs. A run
5
- * counts only when its record carries a statement signed by a runner key
6
- * `.chant/trust.json` lists for a principal holding the `runner` role; the
7
- * tick ignores and reports any other. Every tag it makes is signed, and names
8
- * the runs behind it.
4
+ * the run ledger on `chant/lifecycle` instead of a forge's check runs.
5
+ * Signing is opt-in (#4156): an unsigned run counts with no signer, and a run
6
+ * whose statement a runner key `.chant/trust.json` lists for a principal
7
+ * holding the `runner` role signed counts with that signer. A statement that
8
+ * does not verify is ignored and reported. With `ci.green.signedSince` an
9
+ * unsigned run on that commit or after it is ignored too. A tick given a
10
+ * runner key signs its tags, and every tag names the runs behind it.
9
11
  *
10
12
  * Each test works in its own temp repository with a bare origin, and every
11
13
  * key is a throwaway one. Nothing is written in process.cwd().
@@ -149,19 +151,61 @@ describe("chant runs as the ci tick's status source (#4044)", () => {
149
151
  expect((await ciTick({ cwd: root, now: NOW, runnerKey: BOT.pem })).made).toEqual([]);
150
152
  });
151
153
 
152
- test("an unsigned run is refused: it neither greens nor revokes a commit, and is reported", async () => {
154
+ test("an unsigned run counts with no signer: with no key anywhere the tick tags the commit and ci list shows it (#4156)", async () => {
153
155
  const { root } = workspace();
154
156
  const a = commitAt(root, 3, "app/a.txt");
157
+ const lint = await runOp(root, "lint");
158
+ await runOp(root, "test");
159
+ expect(lint.statement).toBeUndefined();
160
+ push(root);
161
+
162
+ const list = (await ciList({ cwd: root, now: NOW })) as Extract<Awaited<ReturnType<typeof ciList>>, { commits: unknown }>;
163
+ readList.expectValid(list);
164
+ const listed = list.commits.find((c) => c.sha === a)!;
165
+ expect(listed.phases.map((p) => [p.phase, p.result])).toEqual([
166
+ ["lint", "pass"],
167
+ ["test", "pass"],
168
+ ]);
169
+ expect(listed.phases[0]!.runs[0]).toMatchObject({ name: "lint", id: null, opRun: { id: lint.id, env: "ci", signer: null } });
170
+ expect(listed.ignoredRuns).toEqual([]);
171
+
172
+ // No runner key: the tick still tags, unsigned, and the tag names the unsigned runs.
173
+ const tick = await ciTick({ cwd: root, now: NOW });
174
+ expect(tick.made).toEqual([`ci/green/${a}`]);
175
+ expect(tick.signer).toBeNull();
176
+ const record = recordOf(root, `ci/green/${a}`) as { statement?: unknown; phases: Record<string, { opRun: Record<string, unknown> }[]> };
177
+ expect(record.statement).toBeUndefined();
178
+ expect(record.phases.lint![0]!.opRun).toEqual({ id: lint.id, env: "ci", signer: null });
179
+
180
+ // A signed run beside them shows its signer.
181
+ const signed = await runOp(root, "lint", { pem: BOT.pem });
182
+ const source = await readOpRunSource(root, root, git(root, "rev-parse", "HEAD"));
183
+ expect(source.checkRuns(a).map((r) => [r.opRun!.id, r.opRun!.signer])).toEqual([
184
+ [lint.id, null],
185
+ [signed.id, "ci-bot"],
186
+ [expect.any(String), null],
187
+ ]);
188
+ expect(source.ignored.size).toBe(0);
189
+ });
190
+
191
+ test("with ci.green.signedSince an unsigned run neither greens nor revokes a commit from that one on, and is reported", async () => {
192
+ const { root } = workspace();
193
+ const a = commitAt(root, 3, "app/a.txt");
194
+ git(root, "push", "-q", "origin", "main");
195
+ // The opt-in: every run on a, and after it, must be signed.
196
+ writeFiles(root, { "chant.workspace.json": declaration([{ name: "app", dir: "app", kind: "other", because: "the app" }], { ci: { green: { ...GREEN, signedSince: a } } }) });
155
197
  await runOp(root, "lint", { pem: BOT.pem });
156
198
  const unsigned = await runOp(root, "test");
157
199
  expect(unsigned.statement).toBeUndefined();
158
200
  push(root);
159
201
 
202
+ // The opt-in also asks for signed tags, so the tick needs the key.
203
+ await expect(ciTick({ cwd: root, now: NOW })).rejects.toThrow(/needs a statement signed by a runner key, and none was given/);
160
204
  const tick = await ciTick({ cwd: root, now: NOW, runnerKey: BOT.pem });
161
205
  expect(tick.made).toEqual([]);
162
206
  const at = tick.commits.find((c) => c.sha === a)!;
163
207
  expect(at.verdict?.verdict).toBe("pending");
164
- expect(at.ignoredRuns).toEqual([{ run: unsigned.id, op: "test", env: "ci", status: "unsigned", reason: expect.stringContaining("carries no statement signed by a runner key") }]);
208
+ expect(at.ignoredRuns).toEqual([{ run: unsigned.id, op: "test", env: "ci", status: "unsigned", reason: expect.stringContaining("ci.green.signedSince is") }]);
165
209
  expect(formatTick(tick)).toContain(`ignored run ${unsigned.id} of test in ci (unsigned)`);
166
210
  expect(git(root, "tag", "-l")).toBe("");
167
211
 
@@ -173,7 +217,6 @@ describe("chant runs as the ci tick's status source (#4044)", () => {
173
217
  ["test", "pending"],
174
218
  ]);
175
219
  expect(listed.ignoredRuns?.map((r) => r.status)).toEqual(["unsigned"]);
176
- expect(formatList(list)).toContain(`ignored run ${unsigned.id} of test in ci (unsigned)`);
177
220
 
178
221
  // Signed now, the commit goes green; a later unsigned failure does not revoke it.
179
222
  await runOp(root, "test", { pem: BOT.pem });
@@ -186,7 +229,7 @@ describe("chant runs as the ci tick's status source (#4044)", () => {
186
229
  expect(after.commits.find((c) => c.sha === a)!.ignoredRuns?.map((r) => r.status)).toEqual(["unsigned", "unsigned"]);
187
230
  });
188
231
 
189
- test("a run signed by an unlisted key, or whose record changed after signing, does not count", async () => {
232
+ test("a run signed by an unlisted key, or whose record changed after signing, does not count, with or without the opt-in", async () => {
190
233
  const { root } = workspace();
191
234
  const a = commitAt(root, 3, "app/a.txt");
192
235
  // A key trust.json does not list can't sign a run here at all.
@@ -219,13 +262,12 @@ describe("chant runs as the ci tick's status source (#4044)", () => {
219
262
  expect(record).toMatchObject({ source: "runs", phase: "test", run: { name: "test", id: null, conclusion: "failure", opRun: { id: red.id, signer: "ci-bot" } } });
220
263
  });
221
264
 
222
- test("a tick from runs needs a runner key unless it is a dry run, --source overrides the declaration, and ci list refuses --forge", async () => {
265
+ test("a tick from runs without a runner key makes unsigned tags, --source overrides the declaration, and ci list refuses --forge", async () => {
223
266
  const { root } = workspace({ ...GREEN, source: "checks" });
224
267
  const a = commitAt(root, 3, "app/a.txt");
225
268
  await runOp(root, "lint", { pem: BOT.pem });
226
269
  await runOp(root, "test", { pem: BOT.pem });
227
270
  push(root);
228
- await expect(ciTick({ cwd: root, now: NOW, source: "runs" })).rejects.toThrow(/every tag it makes from them is signed by a runner key, and none was given/);
229
271
  expect((await ciTick({ cwd: root, now: NOW, source: "runs", dryRun: true })).made).toEqual([`ci/green/${a}`]);
230
272
  expect(git(root, "tag", "-l")).toBe("");
231
273
  // The declaration says checks, so without --source the tick wants a forge.
@@ -51,7 +51,7 @@ describe("the ci.green block (#3573)", () => {
51
51
  });
52
52
  expect(parseDeclaration(decl({ green: { ...GREEN, signedSince: "a".repeat(40) } }), "chant.workspace.json").ci!.green!.signedSince).toBe("a".repeat(40));
53
53
  expect(parseDeclaration(decl({ green: { ...GREEN, window: "90m" } }), "chant.workspace.json").ci!.green!.window).toBe("90m");
54
- expect(parseDeclaration(decl({}), "chant.workspace.json").ci).toEqual({ green: null });
54
+ expect(parseDeclaration(decl({}), "chant.workspace.json").ci).toEqual({ green: null, reportUrl: null });
55
55
  expect(parseDeclaration(JSON.stringify({ name: "w", schema: 1, members: [] }), "chant.workspace.json").ci).toBeNull();
56
56
  });
57
57
 
@@ -41,12 +41,16 @@
41
41
  * pipelines run as chant Ops, such as one on GitLab or Forgejo, gets green
42
42
  * and revoked tags with no forge check runs. A phase's patterns then match
43
43
  * Op names, a run counts for the commit its record names as `gitSha`, and
44
- * the latest run of each Op decides, by its end time. A run counts only when
45
- * its record carries a statement signed by a runner key whose principal
46
- * holds the `runner` role at base (`./trust/op-run-statement.ts`); the tick
47
- * ignores and reports any other, so it neither greens nor revokes a commit.
48
- * A tick from runs signs every tag it makes, and each run in the tag's
49
- * record names the Op run's id and carries its statement.
44
+ * the latest run of each Op decides, by its end time. Signing is opt-in
45
+ * (#4156): an unsigned run counts, with no signer, and a run whose statement
46
+ * a runner key holding the `runner` role at base signed counts with that
47
+ * signer (`./trust/op-run-statement.ts`). A run whose statement is there and
48
+ * does not verify is ignored and reported, so it neither greens nor revokes
49
+ * a commit. With `ci.green.signedSince`, an unsigned run counts only on a
50
+ * strict first-parent ancestor of that commit, as an unsigned tag does. A
51
+ * tick given a runner key signs the tags it makes, and each run in the
52
+ * tag's record names the Op run's id and carries its statement when it has
53
+ * one.
50
54
  */
51
55
 
52
56
  import { judgeCommits, mergeCommits, readWorkIndex, type CommitLink } from "./commit-links";
@@ -237,7 +241,7 @@ export function windowMs(window: string): number {
237
241
  */
238
242
  const runRecord = (r: RunVerdict) =>
239
243
  r.opRun
240
- ? { name: r.name, id: null, conclusion: r.conclusion, url: r.url, opRun: { id: r.opRun.id, env: r.opRun.env, signer: r.opRun.signer, statement: r.opRun.statement } }
244
+ ? { name: r.name, id: null, conclusion: r.conclusion, url: r.url, opRun: { id: r.opRun.id, env: r.opRun.env, signer: r.opRun.signer, ...(r.opRun.statement ? { statement: r.opRun.statement } : {}) } }
241
245
  : { name: r.name, id: r.id, conclusion: r.conclusion, url: r.url };
242
246
 
243
247
  /** `source: "runs"` on a record made from chant runs (#4044); nothing for check runs, so their records read as before. */
@@ -443,6 +447,21 @@ export function readCiTags(top: string, green: CiGreen | null = null, base: stri
443
447
  return out;
444
448
  }
445
449
 
450
+ /**
451
+ * Whether the workspace at `cwd` requires signed runs (#4156): its
452
+ * `ci.green.signedSince`, or null when it sets none, declares no
453
+ * `ci.green`, or has no declaration chant can read. Signing is opt-in, so
454
+ * only this makes `ci serve` refuse to start without a trusted runner key
455
+ * and `ci run` and `chant run` warn about a run they leave unsigned.
456
+ */
457
+ export function signedRunsRequired(cwd: string): string | null {
458
+ try {
459
+ return readDeclaration(locateWorkspace(cwd).tree).ci?.green?.signedSince ?? null;
460
+ } catch {
461
+ return null;
462
+ }
463
+ }
464
+
446
465
  /** The declaration's `ci.green`, read where `cwd` is, and the git top. */
447
466
  function readGreen(cwd: string): { green: CiGreen; top: string; workspace: { name: string; root: string; file: string } } {
448
467
  const located = locateWorkspace(cwd);
@@ -553,9 +572,6 @@ export async function ciTick(req: TickRequest): Promise<TickResult> {
553
572
  `ci.green.signedSince is ${green.signedSince}, so every tag the tick makes needs a statement signed by a runner key, and none was given: pass --key <runner key.pem> or set CHANT_RUNNER_KEY`,
554
573
  );
555
574
  }
556
- if (green.source === "runs" && req.runnerKey === undefined && !req.dryRun) {
557
- throw new Error("the tick reads chant runs (source runs), and every tag it makes from them is signed by a runner key, and none was given: pass --key <runner key.pem> or set CHANT_RUNNER_KEY");
558
- }
559
575
  const signer = req.runnerKey !== undefined ? ciTagSigner(top, base, req.runnerKey) : undefined;
560
576
  const links = await commitLinks(req.cwd, top, tracking, commits.map((c) => c.sha));
561
577
  const tags = readCiTags(top, green, base);
@@ -566,7 +582,7 @@ export async function ciTick(req: TickRequest): Promise<TickResult> {
566
582
  if (fetched.status === "diverged" || fetched.status === "unreachable") {
567
583
  throw new Error(`could not fetch chant/lifecycle, where the Op runs are recorded, from ${fetched.remote}: ${fetched.status === "diverged" ? "the local branch and the remote's have diverged" : fetched.stderr}`);
568
584
  }
569
- runs = await readOpRunSource(req.cwd, top, base);
585
+ runs = await readOpRunSource(req.cwd, top, base, green.signedSince);
570
586
  }
571
587
  const planned = await planTick({
572
588
  green,
@@ -605,7 +621,7 @@ export async function ciTick(req: TickRequest): Promise<TickResult> {
605
621
 
606
622
  /** The Op runs on the ledger, judged, as check runs by commit. */
607
623
  export interface OpRunSource {
608
- /** The signed runs on `sha`, each as a check run named for its Op. */
624
+ /** The runs on `sha` that count, each as a check run named for its Op. */
609
625
  checkRuns(sha: string): CheckRun[];
610
626
  /** The runs on each commit that do not count, by commit. */
611
627
  ignored: Map<string, IgnoredOpRun[]>;
@@ -619,7 +635,7 @@ const RUN_LEDGER_FILE = /^runs__(.+)\.jsonl$/;
619
635
  * going. The id is the run's end time in milliseconds, so the latest run of
620
636
  * an Op is its latest attempt.
621
637
  */
622
- export function opRunCheckRun(record: OpRunRecord, signer: string): CheckRun {
638
+ export function opRunCheckRun(record: OpRunRecord, signer: string | null): CheckRun {
623
639
  const settled = record.status === "ok" || record.status === "fail";
624
640
  const ended = Date.parse(record.ended);
625
641
  return {
@@ -629,7 +645,7 @@ export function opRunCheckRun(record: OpRunRecord, signer: string): CheckRun {
629
645
  conclusion: settled ? (record.status === "ok" ? "success" : "failure") : null,
630
646
  completedAt: settled ? record.ended : null,
631
647
  url: record.runOrigin?.url ?? null,
632
- opRun: { id: record.id, env: record.env, signer, statement: record.statement! },
648
+ opRun: { id: record.id, env: record.env, signer, ...(signer !== null && record.statement ? { statement: record.statement } : {}) },
633
649
  ...(record.status === "fail" && record.failure ? { failureCode: record.failure.code } : {}),
634
650
  };
635
651
  }
@@ -637,11 +653,16 @@ export function opRunCheckRun(record: OpRunRecord, signer: string): CheckRun {
637
653
  /**
638
654
  * Judge every Op run record that names a commit against `.chant/trust.json`
639
655
  * at `base` (#4044), from the run ledger the project at `cwd` writes to. A
640
- * record with a valid statement becomes a check run on its `gitSha`; any
641
- * other is kept as an ignored run. Reads local refs only.
656
+ * record with a valid statement becomes a check run on its `gitSha` with
657
+ * its signer, and an unsigned one becomes a check run with signer null
658
+ * (#4156). A record whose statement does not verify is kept as an ignored
659
+ * run. With `signedSince` (`ci.green.signedSince`) an unsigned record counts
660
+ * only on a strict first-parent ancestor of that commit, and is ignored
661
+ * elsewhere. Reads local refs only.
642
662
  */
643
- export async function readOpRunSource(cwd: string, top: string, base: string): Promise<OpRunSource> {
663
+ export async function readOpRunSource(cwd: string, top: string, base: string, signedSince: string | null = null): Promise<OpRunSource> {
644
664
  const policy = readTrustPolicy(gitRevisionSource(top, base), base);
665
+ const before = signedSince ? unsignedHistory(top, signedSince) : null;
645
666
  const byCommit = new Map<string, CheckRun[]>();
646
667
  const ignored = new Map<string, IgnoredOpRun[]>();
647
668
  for (const env of await listLedgerTrees({ cwd })) {
@@ -651,9 +672,15 @@ export async function readOpRunSource(cwd: string, top: string, base: string): P
651
672
  const { records } = await readRunLedger(env, op, { cwd });
652
673
  for (const record of records) {
653
674
  if (!record.gitSha) continue;
675
+ // #4161: a job `ci run --local` ran stays on the machine that ran it, and is never CI's verdict.
676
+ if (record.runOrigin?.forge === "local") continue;
654
677
  const v = verifyOpRunStatement(record, policy.runners, policy.roles);
655
- if (v.status === "signed") byCommit.set(record.gitSha, [...(byCommit.get(record.gitSha) ?? []), opRunCheckRun(record, v.signer)]);
656
- else ignored.set(record.gitSha, [...(ignored.get(record.gitSha) ?? []), { run: record.id, op: record.op, env: record.env, status: v.status, reason: v.reason }]);
678
+ const counts = v.status === "signed" || (v.status === "unsigned" && (before === null || before.has(record.gitSha)));
679
+ if (counts) byCommit.set(record.gitSha, [...(byCommit.get(record.gitSha) ?? []), opRunCheckRun(record, v.status === "signed" ? v.signer : null)]);
680
+ else {
681
+ const reason = v.status === "unsigned" ? `${v.reason}; ci.green.signedSince is ${signedSince}, so a run on a commit that is not a strict first-parent ancestor of it needs one` : v.reason;
682
+ ignored.set(record.gitSha, [...(ignored.get(record.gitSha) ?? []), { run: record.id, op: record.op, env: record.env, status: v.status, reason }]);
683
+ }
657
684
  }
658
685
  }
659
686
  }
@@ -836,7 +863,7 @@ export interface CiListRun {
836
863
  conclusion: string | null;
837
864
  url: string | null;
838
865
  /** The Op run, when the run is a chant run (#4044): its id, env and the runner whose statement it carries. Absent for a check run. */
839
- opRun?: { id: string; env: string; signer: string };
866
+ opRun?: { id: string; env: string; signer: string | null };
840
867
  }
841
868
 
842
869
  export interface CiListPhase {
@@ -895,7 +922,8 @@ const asRun = (r: unknown): CiListRun | null => {
895
922
  const o = r as Record<string, unknown>;
896
923
  if (typeof o.name !== "string") return null;
897
924
  const op = o.opRun as Record<string, unknown> | undefined;
898
- const opRun = op && typeof op === "object" && typeof op.id === "string" && typeof op.env === "string" && typeof op.signer === "string" ? { opRun: { id: op.id, env: op.env, signer: op.signer } } : {};
925
+ const signer = op && typeof op === "object" ? (typeof op.signer === "string" ? op.signer : op.signer === null ? null : undefined) : undefined;
926
+ const opRun = op && typeof op === "object" && typeof op.id === "string" && typeof op.env === "string" && signer !== undefined ? { opRun: { id: op.id, env: op.env, signer } } : {};
899
927
  return { name: o.name, id: typeof o.id === "number" ? o.id : null, conclusion: typeof o.conclusion === "string" ? o.conclusion : null, url: typeof o.url === "string" ? o.url : null, ...opRun };
900
928
  };
901
929
 
@@ -973,7 +1001,7 @@ export async function ciList(req: ListRequest): Promise<CiListDocument> {
973
1001
  const tags = readCiTags(top, green, base);
974
1002
  const source = req.source ?? green.source;
975
1003
  if (source === "runs" && req.forge) throw new Error("--forge reads check runs, and the source is chant runs (ci.green.source or --source runs); drop one");
976
- const runs = source === "runs" ? await readOpRunSource(req.cwd, top, base) : undefined;
1004
+ const runs = source === "runs" ? await readOpRunSource(req.cwd, top, base, green.signedSince) : undefined;
977
1005
  const out: CiListCommit[] = [];
978
1006
  for (const c of commits) {
979
1007
  const g = tags.green.get(c.sha);
@@ -240,8 +240,11 @@
240
240
  "description": "The env the run is recorded under."
241
241
  },
242
242
  "signer": {
243
- "type": "string",
244
- "description": "The runner principal whose statement the run carries."
243
+ "type": [
244
+ "string",
245
+ "null"
246
+ ],
247
+ "description": "The runner principal whose statement the run carries. null for an unsigned run, which counts unless ci.green.signedSince asks for a statement (#4156, chant 0.124.32 and newer)."
245
248
  }
246
249
  },
247
250
  "description": "Added within contract version 1 by #4044, written by chant 0.124.28 and newer. Present only when the run is a chant Op run (ci.green.source runs): name is then the Op's name and id is null. Absent for a check run."
@@ -334,8 +337,11 @@
334
337
  "description": "The env the run is recorded under."
335
338
  },
336
339
  "signer": {
337
- "type": "string",
338
- "description": "The runner principal whose statement the run carries."
340
+ "type": [
341
+ "string",
342
+ "null"
343
+ ],
344
+ "description": "The runner principal whose statement the run carries. null for an unsigned run, which counts unless ci.green.signedSince asks for a statement (#4156, chant 0.124.32 and newer)."
339
345
  }
340
346
  },
341
347
  "description": "Added within contract version 1 by #4044, written by chant 0.124.28 and newer. Present only when the run is a chant Op run (ci.green.source runs): name is then the Op's name and id is null. Absent for a check run."
@@ -432,7 +438,7 @@
432
438
  },
433
439
  "ignoredRuns": {
434
440
  "type": "array",
435
- "description": "Added within contract version 1 by #4044, written by chant 0.124.28 and newer. Present only when the source is chant runs (ci.green.source or --source runs): the Op runs on the commit that do not count for want of a valid statement signed by a runner key whose principal holds the runner role. They neither green nor revoke the commit.",
441
+ "description": "Added within contract version 1 by #4044, written by chant 0.124.28 and newer. Present only when the source is chant runs (ci.green.source or --source runs): the Op runs on the commit that do not count: a statement that does not verify (invalid, untrusted or mismatch), or, under ci.green.signedSince, no statement on a commit that needs one (#4156). They neither green nor revoke the commit.",
436
442
  "items": {
437
443
  "type": "object",
438
444
  "required": [
@@ -460,7 +466,7 @@
460
466
  "untrusted",
461
467
  "mismatch"
462
468
  ],
463
- "description": "unsigned: the record carries no statement. invalid: the statement is not an op-run statement, or names another signer than the key's. untrusted: no runner key .chant/trust.json lists at base signed it, or its principal holds no runner role. mismatch: it signs another run, commit or record than the ledger holds."
469
+ "description": "unsigned: the record carries no statement, on a commit ci.green.signedSince asks one for (#4156: without signedSince an unsigned run counts). invalid: the statement is not an op-run statement, or names another signer than the key's. untrusted: no runner key .chant/trust.json lists at base signed it, or its principal holds no runner role. mismatch: it signs another run, commit or record than the ledger holds."
464
470
  },
465
471
  "reason": {
466
472
  "type": "string"
@@ -0,0 +1,220 @@
1
+ /**
2
+ * #4160: the report URL a workspace declares as `ci.reportUrl`. `chant ci
3
+ * run`, `ci serve` and `ci runs` use it when `--report-url` is not given, the
4
+ * flag wins, and `chant workspace ls --json` carries it for readers.
5
+ *
6
+ * The jobs are real `ci run` jobs in a temp repository with a stub chant and
7
+ * file:// report URLs in other temp directories. Nothing is pushed and no
8
+ * test makes a network call: `ci serve` is stopped by its first poll, which
9
+ * has no forge to reach.
10
+ */
11
+
12
+ import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
13
+ import { execFileSync } from "node:child_process";
14
+ import { join } from "node:path";
15
+ import { pathToFileURL } from "node:url";
16
+ import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
17
+ import { cleanScratch, contract, declaration, git, repoFixture, scratchDir, writeFiles } from "./__fixtures__/contract-repo";
18
+ import { parseArgs } from "../cli/parse-args";
19
+ import type { CommandContext } from "../cli/registry";
20
+ import { declaredReportUrl, reportUrlOf } from "./ci-report-url";
21
+ import { runCiRun } from "./ci-run";
22
+ import { runCiServe } from "./ci-serve";
23
+ import { CI_RUN_INDEX_KEY, runCiRuns, type CiRunIndex } from "./ci-run-index";
24
+ import { listWorkspace, type LsDocument } from "./ls";
25
+ import lsSchema from "./ls.schema.json";
26
+
27
+ const home = process.cwd();
28
+ afterEach(() => {
29
+ process.chdir(home);
30
+ vi.restoreAllMocks();
31
+ vi.unstubAllEnvs();
32
+ cleanScratch();
33
+ });
34
+
35
+ const MEMBERS = [{ name: "app", dir: "app", kind: "other", because: "the app" }];
36
+ const urlOf = (dir: string) => pathToFileURL(dir).href;
37
+ const reportsDir = () => scratchDir("chant-report-url-reports-");
38
+ const indexOf = (dir: string): CiRunIndex => JSON.parse(readFileSync(join(dir, CI_RUN_INDEX_KEY), "utf-8"));
39
+
40
+ /** One member and two commits. The declaration in the working tree is rewritten per test. */
41
+ const repoTemplate = repoFixture(() => {
42
+ const root = scratchDir("chant-report-url-template-");
43
+ git(root, "init", "-q");
44
+ git(root, "symbolic-ref", "HEAD", "refs/heads/main");
45
+ git(root, "config", "user.name", "t");
46
+ git(root, "config", "user.email", "t@example.com");
47
+ writeFiles(root, { "chant.workspace.json": declaration(MEMBERS), ".gitignore": ".chant/pr/\n", "app/a.txt": "a\n" });
48
+ const commit = (msg: string) => {
49
+ execFileSync("git", ["-c", "commit.gpgsign=false", "add", "-A"], { cwd: root, stdio: "ignore" });
50
+ execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-q", "-m", msg], { cwd: root, stdio: "ignore" });
51
+ };
52
+ commit("init");
53
+ writeFiles(root, { "app/a.txt": "b\n" });
54
+ commit("change");
55
+ return root;
56
+ }, "chant-report-url-");
57
+
58
+ /** A copy of the repository whose declaration sets `ci` as given, and a stub chant that writes an empty report. */
59
+ function workspace(ci?: Record<string, unknown>): { root: string; app: string; base: string; chant: string } {
60
+ const root = repoTemplate();
61
+ writeFiles(root, { "chant.workspace.json": declaration(MEMBERS, ci ? { ci } : {}) });
62
+ const chant = join(scratchDir("chant-report-url-bin-"), "chant");
63
+ writeFileSync(chant, ["#!/bin/sh", "mkdir -p .chant/pr", 'echo "{}" > ".chant/pr/$2.json"', "exit 0", ""].join("\n"));
64
+ chmodSync(chant, 0o755);
65
+ return { root, app: join(root, "app"), base: git(root, "rev-parse", "HEAD~1"), chant };
66
+ }
67
+
68
+ /** Run a ci command line in `cwd`, with what it printed. */
69
+ async function cli(cwd: string, argv: string[], run: (ctx: CommandContext) => Promise<number>) {
70
+ process.chdir(cwd);
71
+ const out: string[] = [];
72
+ const err: string[] = [];
73
+ vi.spyOn(console, "log").mockImplementation((s: string) => void out.push(String(s)));
74
+ vi.spyOn(console, "error").mockImplementation((s: string) => void err.push(String(s)));
75
+ vi.spyOn(process.stderr, "write").mockImplementation(() => true);
76
+ const code = await run({ args: parseArgs(argv) } as unknown as CommandContext);
77
+ return { code, out: out.join("\n"), err: err.join("\n") };
78
+ }
79
+
80
+ const runPlan = (w: ReturnType<typeof workspace>, extra: string[] = []) =>
81
+ cli(
82
+ w.app,
83
+ ["ci", "run", "plan", "--env", "prod", "--base", w.base, "--pr", "7", "--forge", "forgejo", "--no-status", "--chant", w.chant, "-o", join(scratchDir("chant-report-url-out-"), "pr"), ...extra],
84
+ runCiRun,
85
+ );
86
+
87
+ describe("reportUrlOf", () => {
88
+ test("the flag wins, else the declared URL, else none", async () => {
89
+ const w = workspace({ reportUrl: "s3://acme-reports/prod/" });
90
+ expect(await declaredReportUrl(w.app)).toBe("s3://acme-reports/prod");
91
+ expect(await reportUrlOf(undefined, w.root)).toBe("s3://acme-reports/prod");
92
+ expect(await reportUrlOf("file:///elsewhere", w.app)).toBe("file:///elsewhere");
93
+ expect(await reportUrlOf(undefined, workspace().app)).toBeUndefined();
94
+ });
95
+
96
+ test("a directory outside any workspace reads nothing", async () => {
97
+ expect(await reportUrlOf(undefined, scratchDir("chant-report-url-plain-"))).toBeUndefined();
98
+ });
99
+
100
+ test("a declared URL no job can upload to fails with its place and reason code, unless the flag is given", async () => {
101
+ const w = workspace({ reportUrl: "https://reports.example/acme" });
102
+ await expect(declaredReportUrl(w.app)).rejects.toThrow(/chant\.workspace\.json:\d+:\d+: ci\.reportUrl https:\/\/reports\.example\/acme: takes an s3:\/\/<bucket>\/<prefix> or file:\/\/<directory> URL \[declaration-invalid\]/);
103
+ expect(await reportUrlOf("file:///elsewhere", w.app)).toBe("file:///elsewhere");
104
+ });
105
+ });
106
+
107
+ describe("chant workspace ls --json carries ci.reportUrl", () => {
108
+ const { expectValid } = contract(lsSchema);
109
+ const ciOf = (doc: LsDocument) => {
110
+ if ("error" in doc) throw new Error(doc.error.message);
111
+ return doc.ci;
112
+ };
113
+
114
+ test("present when declared, and the document validates", () => {
115
+ const doc = listWorkspace({ cwd: workspace({ reportUrl: "s3://acme-reports/prod" }).root });
116
+ expectValid(doc);
117
+ expect(ciOf(doc)).toEqual({ green: null, reportUrl: "s3://acme-reports/prod" });
118
+ });
119
+
120
+ test("absent when the ci block sets none, and ci stays null with no ci block", () => {
121
+ const green = { branch: "main", phases: { build: ["build"] }, require: ["build"] };
122
+ const withGreen = listWorkspace({ cwd: workspace({ green }).root });
123
+ expectValid(withGreen);
124
+ expect(ciOf(withGreen)).not.toHaveProperty("reportUrl");
125
+ expect(ciOf(listWorkspace({ cwd: workspace().root }))).toBeNull();
126
+ });
127
+
128
+ test("an invalid one is the declaration-invalid failure", () => {
129
+ const doc = listWorkspace({ cwd: workspace({ reportUrl: "ftp://x/y" }).root });
130
+ expectValid(doc);
131
+ expect(doc).toMatchObject({ error: { code: "declaration-invalid", location: { file: "chant.workspace.json" } } });
132
+ });
133
+ });
134
+
135
+ describe("chant ci run", () => {
136
+ // The two whole jobs run once, in beforeAll, and the tests below read what
137
+ // they left, as ci-run.test.ts does (#4115, #4124). The first job of a file
138
+ // pays for loading the job's modules, which on a loaded CI shard came to
139
+ // over the unit budget (#2817); a file's beforeAll is not counted in it.
140
+ const ran: Record<"declared" | "flag", { code: number; err: string; declared: CiRunIndex | null; flag: CiRunIndex | null }> = {} as never;
141
+ beforeAll(async () => {
142
+ const read = (dir: string) => (existsSync(join(dir, CI_RUN_INDEX_KEY)) ? indexOf(dir) : null);
143
+ for (const kind of ["declared", "flag"] as const) {
144
+ const declared = reportsDir();
145
+ const flag = reportsDir();
146
+ const w = workspace({ reportUrl: urlOf(declared) });
147
+ const r = await runPlan(w, kind === "flag" ? ["--report-url", urlOf(flag)] : []);
148
+ ran[kind] = { code: r.code, err: r.err, declared: read(declared), flag: read(flag) };
149
+ process.chdir(home);
150
+ vi.restoreAllMocks();
151
+ }
152
+ }, 60_000);
153
+
154
+ test("without --report-url the job's run index goes to the declared URL", () => {
155
+ const r = ran.declared;
156
+ expect(r.code, r.err).toBe(0);
157
+ expect(r.declared?.runs).toHaveLength(1);
158
+ expect(r.declared?.runs[0]).toMatchObject({ job: "plan", pr: 7 });
159
+ });
160
+
161
+ test("--report-url wins over the declared URL", () => {
162
+ const r = ran.flag;
163
+ expect(r.code, r.err).toBe(0);
164
+ expect(r.flag?.runs).toHaveLength(1);
165
+ expect(r.declared).toBeNull();
166
+ });
167
+
168
+ test("an invalid declared URL stops the job before it runs", async () => {
169
+ const w = workspace({ reportUrl: "https://reports.example/acme" });
170
+ const r = await runPlan(w);
171
+ expect(r.code).toBe(1);
172
+ expect(r.err).toMatch(/\[declaration-invalid\]/);
173
+ });
174
+ });
175
+
176
+ describe("chant ci runs", () => {
177
+ test("lists the index at the declared URL, and --report-url wins", async () => {
178
+ const declared = reportsDir();
179
+ const w = workspace({ reportUrl: urlOf(declared) });
180
+ expect((await runPlan(w)).code).toBe(0);
181
+
182
+ const listed = await cli(w.app, ["ci", "runs", "--json"], runCiRuns);
183
+ expect(listed.code, listed.err).toBe(0);
184
+ expect((JSON.parse(listed.out) as CiRunIndex).runs).toHaveLength(1);
185
+
186
+ const other = reportsDir();
187
+ const flagged = await cli(w.app, ["ci", "runs", "--report-url", urlOf(other)], runCiRuns);
188
+ expect(flagged.code).toBe(1);
189
+ expect(flagged.err).toContain(`there is no run index at ${urlOf(other)}`);
190
+ });
191
+
192
+ test("with neither a flag nor a declared URL it says how to name one", async () => {
193
+ const r = await cli(workspace().root, ["ci", "runs"], runCiRuns);
194
+ expect(r.code).toBe(1);
195
+ expect(r.err).toContain("--report-url names where the index is kept, unless the workspace declares ci.reportUrl");
196
+ });
197
+ });
198
+
199
+ describe("chant ci serve", () => {
200
+ // No token and no server: the first poll fails, so the serve stops without reaching a forge.
201
+ beforeEach(() => {
202
+ for (const name of ["CHANT_FORGE_TOKEN", "GITHUB_TOKEN", "GH_TOKEN", "GITHUB_SERVER_URL", "CHANT_RUNNER_KEY"]) vi.stubEnv(name, undefined);
203
+ });
204
+ const SERVE = ["ci", "serve", "--forge", "forgejo", "--repo", "demo/estate", "--env", "local", "--once", "--unsigned", "--image", "node:22-bookworm"];
205
+
206
+ test("says it uses the declared URL when --report-url is not given", async () => {
207
+ const declared = reportsDir();
208
+ const w = workspace({ reportUrl: urlOf(declared) });
209
+ const state = join(scratchDir("chant-report-url-state-"), "ci-serve");
210
+ const r = await cli(w.root, [...SERVE, "--state", state], runCiServe);
211
+ expect(r.err).toContain(`ci serve: reports and the run index go to ${urlOf(declared)}, the declaration's ci.reportUrl`);
212
+ });
213
+
214
+ test("says nothing of the declaration when --report-url is given", async () => {
215
+ const w = workspace({ reportUrl: urlOf(reportsDir()) });
216
+ const state = join(scratchDir("chant-report-url-state-"), "ci-serve");
217
+ const r = await cli(w.root, [...SERVE, "--state", state, "--report-url", urlOf(reportsDir())], runCiServe);
218
+ expect(r.err).not.toContain("ci.reportUrl");
219
+ });
220
+ });
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The report URL a command uses (#4160): `--report-url` when it is given,
3
+ * else the workspace declaration's `ci.reportUrl`, else none.
4
+ *
5
+ * `chant build --pr-loop`, `chant ci run`, `chant ci serve` and `chant ci
6
+ * runs` call this. The declaration is read from the working tree of the
7
+ * workspace that holds `cwd`. A directory with no declaration between it and
8
+ * the git root reads nothing and loads no declaration module, so a project
9
+ * outside a workspace behaves as before. A declaration that can't be read
10
+ * is an error naming its place and its reason code, such as
11
+ * `declaration-invalid`, since the command would otherwise run without the
12
+ * URL the workspace may declare.
13
+ */
14
+
15
+ import { findWorkspaceRoot } from "../project-root";
16
+
17
+ /** The declared `ci.reportUrl` of the workspace holding `cwd`, or undefined. */
18
+ export async function declaredReportUrl(cwd: string): Promise<string | undefined> {
19
+ const found = findWorkspaceRoot(cwd);
20
+ if (!found) return undefined;
21
+ const [{ readDeclaration, WorkspaceReadError }, { workingTree }] = await Promise.all([import("./declaration"), import("./tree")]);
22
+ try {
23
+ return readDeclaration(workingTree(found.dir)).ci?.reportUrl ?? undefined;
24
+ } catch (err) {
25
+ if (!(err instanceof WorkspaceReadError)) throw err;
26
+ throw Object.assign(new Error(`${err.describe()} [${err.code}]`), { code: err.code });
27
+ }
28
+ }
29
+
30
+ /** `flag` when given, else the declared `ci.reportUrl` (#4160). */
31
+ export async function reportUrlOf(flag: string | undefined, cwd: string): Promise<string | undefined> {
32
+ return flag ?? (await declaredReportUrl(cwd));
33
+ }