@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
@@ -15,7 +15,7 @@ import { join } from "node:path";
15
15
  import { afterAll, beforeAll, describe, expect, test } from "vitest";
16
16
  import { cleanScratch, declaration, git, repoFixture, scratchDir, writeFiles } from "./__fixtures__/contract-repo";
17
17
  import { ciServeInterval, ciServeKey, ciServePass, formatCiServeEvent, readCiServeState, type CiServeEvent, type CiServeForge, type CiServeOptions } from "./ci-serve";
18
- import { ciRun } from "./ci-run";
18
+ import { ciRun, type CiRunResult } from "./ci-run";
19
19
  import { acquireLease, releaseLease } from "../lifecycle/lease";
20
20
  import { prApplyGroup, prApplyRecordKey } from "../components/pr-pipeline";
21
21
  import { parseArgs } from "../cli/parse-args";
@@ -23,9 +23,9 @@ import { forgejoForge, githubForge, type ForgeFetch, type OpenPullRequest } from
23
23
 
24
24
  afterAll(cleanScratch);
25
25
 
26
- function commit(root: string, file: string): string {
26
+ function commit(root: string, file: string, message = file): string {
27
27
  writeFiles(root, { [file]: `${file} ${Math.random()}\n` });
28
- for (const args of [["add", "-A"], ["commit", "-q", "-m", file]]) {
28
+ for (const args of [["add", "-A"], ["commit", "-q", "-m", message]]) {
29
29
  execFileSync("git", ["-c", "user.name=t", "-c", "user.email=t@example.com", "-c", "commit.gpgsign=false", ...args], { cwd: root, stdio: "ignore" });
30
30
  }
31
31
  return git(root, "rev-parse", "HEAD");
@@ -53,11 +53,12 @@ const workspaceTemplate = repoFixture(() => {
53
53
  }, "chant-ci-serve-");
54
54
 
55
55
  /** A copy of the template, its origin pointed at the copy of the bare one. */
56
- function workspace(): { root: string; app: string; main: string } {
56
+ function workspace(): { root: string; app: string; main: string; origin: string } {
57
57
  const top = workspaceTemplate();
58
58
  const root = join(top, "root");
59
- git(root, "remote", "set-url", "origin", join(top, "origin"));
60
- return { root, app: join(root, "app"), main: git(root, "rev-parse", "HEAD") };
59
+ const origin = join(top, "origin");
60
+ git(root, "remote", "set-url", "origin", origin);
61
+ return { root, app: join(root, "app"), main: git(root, "rev-parse", "HEAD"), origin };
61
62
  }
62
63
 
63
64
  /** A pull request's head commit, on a branch off main, pushed to the forge's refs/pull/<n>/head. */
@@ -150,6 +151,29 @@ function serve(app: string, client: CiServeForge, extra: Partial<CiServeOptions>
150
151
  };
151
152
  }
152
153
 
154
+ const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
155
+
156
+ /** A commit on no branch, with `parent` when given, as a ledger commit stands in. */
157
+ const ledgerCommit = (root: string, message: string, parent?: string): string => git(root, "commit-tree", EMPTY_TREE, ...(parent ? ["-p", parent] : []), "-m", message);
158
+
159
+ /** Seed `chant/lifecycle` in the clone and on the forge, as an earlier apply left it. */
160
+ function seedLedger(root: string): string {
161
+ const seed = ledgerCommit(root, "seed");
162
+ git(root, "update-ref", "refs/heads/chant/lifecycle", seed);
163
+ git(root, "push", "-q", "origin", "chant/lifecycle");
164
+ expect(git(root, "rev-parse", "refs/remotes/origin/chant/lifecycle")).toBe(seed);
165
+ return seed;
166
+ }
167
+
168
+ const isAncestor = (cwd: string, a: string, b: string): boolean => {
169
+ try {
170
+ git(cwd, "merge-base", "--is-ancestor", a, b);
171
+ return true;
172
+ } catch {
173
+ return false;
174
+ }
175
+ };
176
+
153
177
  const argsOf = (report: string, stage: string): string[] => readFileSync(join(report, `${stage}.args`), "utf-8").trimEnd().split("\n");
154
178
  const ofType = <T extends CiServeEvent["type"]>(events: CiServeEvent[], type: T) => events.filter((e): e is Extract<CiServeEvent, { type: T }> => e.type === type);
155
179
 
@@ -365,3 +389,123 @@ describe("chant ci serve (#4095)", () => {
365
389
  expect(() => ciServeInterval("0")).toThrow(/a second or more/);
366
390
  });
367
391
  });
392
+
393
+ describe("ci serve follows the forge's chant/lifecycle (#4121)", () => {
394
+ /** A serve past its baseline pass, with one merge waiting to apply, and each run's result in `results`. */
395
+ async function mergeWaiting() {
396
+ const w = workspace();
397
+ const seed = seedLedger(w.root);
398
+ const forge = fakeForge(w.main);
399
+ const results: CiRunResult[] = [];
400
+ const { opts, events } = serve(w.app, forge, { run: async (req) => { const r = await ciRun(req); results.push(r); return r; } });
401
+ await ciServePass(opts);
402
+ const merged = commit(w.root, "app/merged.txt");
403
+ git(w.root, "push", "-q", "origin", "main");
404
+ forge.head = merged;
405
+ return { ...w, seed, opts, events, results };
406
+ }
407
+
408
+ test("a ledger the forge deleted is deleted here, and the next apply runs clean", async () => {
409
+ const { root, origin, seed, opts, events, results } = await mergeWaiting();
410
+ git(origin, "update-ref", "-d", "refs/heads/chant/lifecycle");
411
+
412
+ const pass = await ciServePass(opts);
413
+ expect(ofType(events, "ledger")).toEqual([{ type: "ledger", action: "deleted", local: seed }]);
414
+ expect(formatCiServeEvent(ofType(events, "ledger")[0]!)).toContain("was deleted");
415
+ expect(pass.jobs).toEqual([expect.objectContaining({ job: "apply", status: "ok" })]);
416
+ expect(results[0]).toMatchObject({ pushed: true });
417
+ const tip = git(origin, "rev-parse", "refs/heads/chant/lifecycle");
418
+ expect(git(root, "rev-parse", "chant/lifecycle")).toBe(tip);
419
+ expect(isAncestor(origin, seed, tip)).toBe(false);
420
+ });
421
+
422
+ test("a ledger the forge rewrote is reset to the forge's and not pushed back", async () => {
423
+ const { root, origin, seed, opts, events, results } = await mergeWaiting();
424
+ const rewritten = ledgerCommit(root, "rewritten");
425
+ git(root, "push", "-q", "origin", `${rewritten}:refs/scratch/rewritten`);
426
+ git(origin, "update-ref", "refs/heads/chant/lifecycle", rewritten);
427
+
428
+ const pass = await ciServePass(opts);
429
+ expect(ofType(events, "ledger")).toEqual([{ type: "ledger", action: "reset", local: seed, forge: rewritten }]);
430
+ expect(pass.jobs).toEqual([expect.objectContaining({ job: "apply", status: "ok" })]);
431
+ expect(results[0]).toMatchObject({ pushed: true });
432
+ const tip = git(origin, "rev-parse", "refs/heads/chant/lifecycle");
433
+ expect(isAncestor(origin, rewritten, tip)).toBe(true);
434
+ expect(isAncestor(root, seed, tip)).toBe(false);
435
+ });
436
+
437
+ test("a ledger ahead of the forge's keeps its facts, and the apply pushes them", async () => {
438
+ const { root, origin, seed, opts, events, results } = await mergeWaiting();
439
+ const ahead = ledgerCommit(root, "unpushed fact", seed);
440
+ git(root, "update-ref", "refs/heads/chant/lifecycle", ahead);
441
+
442
+ const pass = await ciServePass(opts);
443
+ expect(ofType(events, "ledger")).toEqual([]);
444
+ expect(pass.jobs).toEqual([expect.objectContaining({ job: "apply", status: "ok" })]);
445
+ expect(results[0]).toMatchObject({ pushed: true });
446
+ expect(isAncestor(origin, ahead, git(origin, "rev-parse", "refs/heads/chant/lifecycle"))).toBe(true);
447
+ });
448
+ });
449
+
450
+ describe("ci serve applies fast-forwards only, and honours [skip ci] (#4122)", () => {
451
+ test("a force push back to an older commit is recorded as seen and not applied, and the next fast-forward applies", async () => {
452
+ const { root, app, main } = workspace();
453
+ const later = commit(root, "app/later.txt");
454
+ git(root, "push", "-q", "origin", "main");
455
+ const forge = fakeForge(later);
456
+ const { opts, events, state } = serve(app, forge);
457
+ await ciServePass(opts);
458
+ expect(ofType(events, "baseline")).toEqual([{ type: "baseline", commit: later }]);
459
+
460
+ git(root, "reset", "-q", "--hard", main);
461
+ git(root, "push", "-q", "-f", "origin", "main");
462
+ forge.head = main;
463
+ const back = await ciServePass(opts);
464
+ expect(back.jobs).toEqual([]);
465
+ expect(ofType(events, "not-run")).toEqual([{ type: "not-run", job: "apply", commit: main, reason: "not-fast-forward", branch: "main", from: later }]);
466
+ expect(formatCiServeEvent(ofType(events, "not-run")[0]!)).toContain("not a fast-forward");
467
+ const file = join(state, `${prApplyGroup("prod", "app")}.json`);
468
+ expect(readCiServeState(file, { forge: "forgejo", repo: "demo/estate", env: "prod", branch: "main" }).target).toBe(main);
469
+
470
+ const next = commit(root, "app/next.txt");
471
+ git(root, "push", "-q", "origin", "main");
472
+ forge.head = next;
473
+ const pass = await ciServePass(opts);
474
+ expect(pass.jobs).toEqual([expect.objectContaining({ job: "apply", commit: next, base: main, status: "ok" })]);
475
+ });
476
+
477
+ test("a target head whose message says [skip ci] is recorded as seen and not applied", async () => {
478
+ const { root, app, main } = workspace();
479
+ const forge = fakeForge(main);
480
+ const { opts, events, state } = serve(app, forge);
481
+ await ciServePass(opts);
482
+ const skip = commit(root, "app/tidy.txt", "tidy the app [skip ci]");
483
+ git(root, "push", "-q", "origin", "main");
484
+ forge.head = skip;
485
+
486
+ expect((await ciServePass(opts)).jobs).toEqual([]);
487
+ expect(ofType(events, "not-run")).toEqual([{ type: "not-run", job: "apply", commit: skip, reason: "skip-ci" }]);
488
+ expect(formatCiServeEvent(ofType(events, "not-run")[0]!)).toContain("says [skip ci]");
489
+ const file = join(state, `${prApplyGroup("prod", "app")}.json`);
490
+ expect(readCiServeState(file, { forge: "forgejo", repo: "demo/estate", env: "prod", branch: "main" }).target).toBe(skip);
491
+ });
492
+
493
+ test("a pull request head whose message says [ci skip] is not planned", async () => {
494
+ const { root, app, main } = workspace();
495
+ git(root, "checkout", "-q", "-b", "pr-4");
496
+ const head = commit(root, "app/pr-4.txt", "draft [ci skip]");
497
+ git(root, "push", "-q", "origin", `${head}:refs/pull/4/head`);
498
+ git(root, "checkout", "-q", "main");
499
+ const forge = fakeForge(main);
500
+ forge.prs = [pr(4, head, main)];
501
+ const { opts, events, state } = serve(app, forge);
502
+
503
+ expect((await ciServePass(opts)).jobs).toEqual([]);
504
+ expect(ofType(events, "not-run")).toEqual([{ type: "not-run", job: "plan", commit: head, pr: 4, reason: "skip-ci" }]);
505
+ const file = join(state, `${prApplyGroup("prod", "app")}.json`);
506
+ expect(readCiServeState(file, { forge: "forgejo", repo: "demo/estate", env: "prod", branch: "main" }).skipped).toEqual({ [ciServeKey({ job: "plan", pr: 4, commit: head })]: "skip-ci" });
507
+ // Seen once: the next poll says nothing of it.
508
+ expect((await ciServePass(opts)).jobs).toEqual([]);
509
+ expect(ofType(events, "not-run")).toHaveLength(1);
510
+ });
511
+ });
@@ -23,8 +23,26 @@
23
23
  * as its `--resume` record, so a re-run of a failed apply at the same
24
24
  * commit finishes under the approval it has. This replaces the forge's CI
25
25
  * cache.
26
+ * - A gate per wave (#4003). With `--wave-gate` the apply job runs
27
+ * `pr-apply --wave-gate`, which applies the waves in order and stops at
28
+ * the first whose moved plan waits on its own gate. The job fails, and the
29
+ * resume record keeps the waves that applied; once the wave's plan is
30
+ * approved, `rerunFailed` runs the apply again and it carries on from
31
+ * that wave.
26
32
  * - Capacity. At most `maxJobs` jobs run at once. A job over the limit is not
27
33
  * recorded, so a later poll starts it.
34
+ * - Fast-forwards only (#4122). An apply runs only when the target's new head
35
+ * descends from the head seen before it. Any other move, such as a force
36
+ * push back to an older commit, is recorded as seen and not applied.
37
+ * - `[skip ci]` (#4122). A target head or pull request head whose commit
38
+ * message says `[skip ci]` or `[ci skip]` is recorded as seen and not run,
39
+ * as Actions does.
40
+ * - The forge's ledger (#4121). Before each job, when no other job of this
41
+ * serve runs, the clone's `chant/lifecycle` follows the forge's: it is
42
+ * deleted when the forge has none, and reset to the forge's when neither
43
+ * contains the other. Each is a warning. A local branch ahead of the
44
+ * forge's keeps its facts, and the job's push sends them. Nothing here
45
+ * pushes the ledger.
28
46
  *
29
47
  * Host mode only: each job runs in a fresh worktree on this host as this
30
48
  * user, with the clean environment `ciRun` gives it. A pull request from a
@@ -36,7 +54,7 @@
36
54
  * turn a checked payload into the same triggers and schedule them.
37
55
  */
38
56
 
39
- import { execFileSync } from "node:child_process";
57
+ import { execFileSync, spawnSync } from "node:child_process";
40
58
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
41
59
  import { join, resolve } from "node:path";
42
60
  import { prApplyGroup, prApplyRecordKey } from "../components/pr-pipeline";
@@ -45,8 +63,9 @@ import type { CommandContext } from "../cli/registry";
45
63
  import { formatError, formatWarning } from "../cli/format";
46
64
  import { acquireLease, currentHolderId, releaseLease, DEFAULT_LEASE_TTL_MS, type LeaseRecord } from "../lifecycle/lease";
47
65
  import { parseDuration } from "../op/duration";
48
- import { CI_JOB_OPS, ciJobPlace, ciSecretsOf, ciRun, ciStatusFromEnv, CiRunError, type CiJobName, type CiRunRequest, type CiRunResult } from "./ci-run";
49
- import { RUNNER_KEY_ENV } from "./ci-green";
66
+ import { reportUrlOf } from "./ci-report-url";
67
+ import { CI_JOB_OPS, ciJobPlace, ciLoopOptions, ciSecretsOf, ciRun, ciStatusFromEnv, CiRunError, type CiJobName, type CiRunRequest, type CiRunResult } from "./ci-run";
68
+ import { RUNNER_KEY_ENV, signedRunsRequired } from "./ci-green";
50
69
  import { gitTop } from "./tree";
51
70
  import { ciServeRunnerKey, formatRunnerKeySetup, setupRunnerKey } from "./ci-runner-key";
52
71
 
@@ -92,9 +111,11 @@ export interface CiServeState {
92
111
  env: string;
93
112
  branch: string;
94
113
  member?: string;
95
- /** The target branch's head the last apply was measured to, or the head the first poll found. */
114
+ /** The target branch's head the last apply was measured to, or the last head seen and not applied. */
96
115
  target?: string;
97
116
  jobs: Record<string, CiServeJobState>;
117
+ /** Plans seen and not run, by state key, with why (#4122). */
118
+ skipped?: Record<string, "skip-ci">;
98
119
  }
99
120
 
100
121
  /** What a pass did, in order, for the log. */
@@ -105,6 +126,8 @@ export type CiServeEvent =
105
126
  | { type: "held"; job: CiJobName; commit: string; pr?: number; reason: "capacity" | "lease"; heldBy?: string }
106
127
  | { type: "started"; job: CiJobName; commit: string; pr?: number; base: string; runId: string; attempt: number; resume?: string }
107
128
  | { type: "settled"; job: CiJobName; commit: string; pr?: number; status: "ok" | "fail"; failure?: string; report?: string }
129
+ | { type: "not-run"; job: CiJobName; commit: string; pr?: number; reason: "not-fast-forward" | "skip-ci"; branch?: string; from?: string }
130
+ | { type: "ledger"; action: "deleted" | "reset"; local: string; forge?: string }
108
131
  | { type: "error"; message: string };
109
132
 
110
133
  /** The `ciRun` request fields `ci serve` passes through to every job: the generator's options and the runner's. */
@@ -159,6 +182,78 @@ function tryGit(cwd: string, args: string[]): string | undefined {
159
182
  }
160
183
  }
161
184
 
185
+ const LEDGER = "chant/lifecycle";
186
+ /** Where the forge's ledger tip is fetched to, a ref only this module writes. */
187
+ const FORGE_LEDGER = "refs/chant/ci-serve/forge-ledger";
188
+
189
+ /** `git` with its exit status, for the calls whose answer is the status. */
190
+ function gitStatus(cwd: string, args: string[]): { code: number; out: string } {
191
+ const r = spawnSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, LC_ALL: "C" } });
192
+ return { code: r.status ?? -1, out: (r.stdout ?? "").trim() };
193
+ }
194
+
195
+ /**
196
+ * Make the clone's `chant/lifecycle` follow the forge's (#4121). The clone's
197
+ * branch outlives the forge's, so a ledger the forge deleted or rewrote would
198
+ * make the next pr-apply find the two diverged and stall.
199
+ *
200
+ * - The forge has none: the local branch and its remote-tracking ref go.
201
+ * - Neither contains the other: the local branch and its remote-tracking ref
202
+ * are reset to the forge's tip.
203
+ * - The local branch is ahead of the forge's, or behind it, or the same: it
204
+ * is left, and an apply's push sends what it holds that the forge lacks.
205
+ *
206
+ * Nothing is pushed, so a rewritten ledger never goes back to the forge. A
207
+ * forge that can't be reached leaves everything as it is; the job's own
208
+ * fetch says why.
209
+ */
210
+ export function reconcileCiServeLedger(top: string): CiServeEvent | undefined {
211
+ const local = tryGit(top, ["rev-parse", "--verify", "--quiet", `refs/heads/${LEDGER}^{commit}`]);
212
+ const listed = gitStatus(top, ["ls-remote", "--exit-code", "origin", `refs/heads/${LEDGER}`]);
213
+ if (listed.code === 2) {
214
+ // A remote-tracking ref left from a deleted branch would be the next push's lease, and refuse it.
215
+ tryGit(top, ["update-ref", "-d", `refs/remotes/origin/${LEDGER}`]);
216
+ if (!local) return undefined;
217
+ tryGit(top, ["update-ref", "-d", `refs/heads/${LEDGER}`, local]);
218
+ return { type: "ledger", action: "deleted", local };
219
+ }
220
+ if (listed.code !== 0 || !local) return undefined;
221
+ const forge = listed.out.split(/\s+/)[0];
222
+ if (!forge || forge === local) return undefined;
223
+ if (gitStatus(top, ["fetch", "--quiet", "--no-tags", "origin", `+refs/heads/${LEDGER}:${FORGE_LEDGER}`]).code !== 0) return undefined;
224
+ try {
225
+ const ahead = gitStatus(top, ["merge-base", "--is-ancestor", forge, local]).code === 0;
226
+ const behind = gitStatus(top, ["merge-base", "--is-ancestor", local, forge]).code === 0;
227
+ if (ahead || behind) return undefined;
228
+ tryGit(top, ["update-ref", `refs/heads/${LEDGER}`, forge, local]);
229
+ tryGit(top, ["update-ref", `refs/remotes/origin/${LEDGER}`, forge]);
230
+ return { type: "ledger", action: "reset", local, forge };
231
+ } finally {
232
+ tryGit(top, ["update-ref", "-d", FORGE_LEDGER]);
233
+ }
234
+ }
235
+
236
+ /** A commit message that asks for no CI run, as Actions and GitLab read it. */
237
+ const SKIP_CI = /\[(?:skip ci|ci skip)\]/i;
238
+
239
+ /** Whether `commit`'s message asks for no CI run. A commit this clone can't read is not skipped. */
240
+ function skipsCi(top: string, commit: string): boolean {
241
+ const body = tryGit(top, ["log", "-1", "--format=%B", commit]);
242
+ return body !== undefined && SKIP_CI.test(body);
243
+ }
244
+
245
+ /**
246
+ * How the target branch moved from `from` to `head` (#4122): `fast-forward`
247
+ * when `head` descends from `from`, `other` when it does not, and `unknown`
248
+ * when `head` could not be fetched. Once `head` is here its history is too,
249
+ * so a `from` this clone lacks is not in that history.
250
+ */
251
+ function targetMove(top: string, branch: string, from: string, head: string): "fast-forward" | "other" | "unknown" {
252
+ tryGit(top, ["fetch", "--quiet", "--no-tags", "origin", branch]);
253
+ if (gitStatus(top, ["cat-file", "-e", `${head}^{commit}`]).code !== 0) return "unknown";
254
+ return gitStatus(top, ["merge-base", "--is-ancestor", from, head]).code === 0 ? "fast-forward" : "other";
255
+ }
256
+
162
257
  /** Where a serve keeps its state when `--state` names nowhere: beside the run events, in the repository's common git directory. */
163
258
  export function defaultCiServeStateDir(cwd: string): string {
164
259
  const common = tryGit(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
@@ -209,15 +304,23 @@ const failedEntry = (entry: CiServeJobState | undefined, key: string, rt: CiServ
209
304
 
210
305
  /**
211
306
  * One poll of the forge, as triggers: an apply when the target's head moved
212
- * since the last apply (none on the first poll, which only notes the head),
307
+ * since the last apply by a fast-forward (none on the first poll, which only
308
+ * notes the head, and none for a head whose message says `[skip ci]`),
213
309
  * then a plan for each open pull request head not yet planned, oldest pull
214
310
  * request first. A job already in the state file is left out unless it
215
- * failed and `rerunFailed` is set. The first poll's head is written to the
216
- * state file.
311
+ * failed and `rerunFailed` is set. The first poll's head, and a head seen
312
+ * and not applied, are written to the state file.
217
313
  */
218
314
  export async function ciServeObserve(opts: CiServeOptions, rt: CiServeRuntime = newCiServeRuntime()): Promise<{ triggers: CiServeTrigger[]; events: CiServeEvent[] }> {
219
315
  const place = servePlace(opts);
220
- const state = readCiServeState(place.stateFile, { forge: opts.forge, repo: opts.repo, env: opts.env, branch: place.branch, ...(place.member ? { member: place.member } : {}) });
316
+ const fresh = { forge: opts.forge, repo: opts.repo, env: opts.env, branch: place.branch, ...(place.member ? { member: place.member } : {}) };
317
+ const state = readCiServeState(place.stateFile, fresh);
318
+ // Read again at the write: a job of this serve may have settled while the forge answered.
319
+ const update = (fn: (s: CiServeState) => void): void => {
320
+ const s = readCiServeState(place.stateFile, fresh);
321
+ fn(s);
322
+ writeState(place.stateFile, s);
323
+ };
221
324
  const triggers: CiServeTrigger[] = [];
222
325
  const events: CiServeEvent[] = [];
223
326
  const [head, prs] = await Promise.all([opts.client.branchHead(place.branch), opts.client.openPullRequests(place.branch)]);
@@ -226,12 +329,23 @@ export async function ciServeObserve(opts: CiServeOptions, rt: CiServeRuntime =
226
329
  events.push({ type: "no-branch", branch: place.branch });
227
330
  } else if (state.target === undefined) {
228
331
  // What the branch held before this runner watched it was applied by whatever ran then.
229
- state.target = head;
230
- writeState(place.stateFile, state);
332
+ update((s) => {
333
+ s.target = head;
334
+ });
231
335
  events.push({ type: "baseline", commit: head });
232
336
  } else if (head !== state.target) {
337
+ const from = state.target;
338
+ const move = targetMove(place.top, place.branch, from, head);
233
339
  const entry = state.jobs[ciServeKey({ job: "apply", commit: head })];
234
- if (!entry) triggers.push({ job: "apply", commit: head, base: state.target });
340
+ if (move === "unknown") {
341
+ events.push({ type: "error", message: `${place.branch} is at ${head.slice(0, 12)} on the forge, and that commit could not be fetched; the next poll tries again` });
342
+ } else if (move === "other" || (!entry && skipsCi(place.top, head))) {
343
+ // #4122: a move that is not a fast-forward, or a head that asks for no run, is seen and not applied.
344
+ update((s) => {
345
+ s.target = head;
346
+ });
347
+ events.push({ type: "not-run", job: "apply", commit: head, reason: move === "other" ? "not-fast-forward" : "skip-ci", ...(move === "other" ? { branch: place.branch, from } : {}) });
348
+ } else if (!entry) triggers.push({ job: "apply", commit: head, base: from });
235
349
  else if (opts.rerunFailed && failedEntry(entry, ciServeKey(entry), rt)) triggers.push({ job: "apply", commit: head, base: entry.base, rerun: true });
236
350
  } else {
237
351
  const key = ciServeKey({ job: "apply", commit: head });
@@ -252,8 +366,17 @@ export async function ciServeObserve(opts: CiServeOptions, rt: CiServeRuntime =
252
366
  const key = ciServeKey({ job: "plan", commit: pr.headSha, pr: pr.number });
253
367
  const entry = state.jobs[key];
254
368
  const base = pr.baseSha ?? head;
255
- if (!base) continue;
256
- if (!entry) triggers.push({ job: "plan", commit: pr.headSha, base, pr: pr.number });
369
+ if (!base || state.skipped?.[key]) continue;
370
+ if (!entry) {
371
+ // #4122: a head whose message asks for no run is seen and not planned.
372
+ if (gitStatus(place.top, ["cat-file", "-e", `${pr.headSha}^{commit}`]).code !== 0) tryGit(place.top, ["fetch", "--quiet", "--no-tags", "origin", `refs/pull/${pr.number}/head`]);
373
+ if (skipsCi(place.top, pr.headSha)) {
374
+ update((s) => {
375
+ s.skipped = { ...s.skipped, [key]: "skip-ci" };
376
+ });
377
+ events.push({ type: "not-run", job: "plan", commit: pr.headSha, pr: pr.number, reason: "skip-ci" });
378
+ } else triggers.push({ job: "plan", commit: pr.headSha, base, pr: pr.number });
379
+ }
257
380
  else if (opts.rerunFailed && failedEntry(entry, key, rt)) triggers.push({ job: "plan", commit: pr.headSha, base: entry.base, pr: pr.number, rerun: true });
258
381
  }
259
382
  return { triggers, events };
@@ -316,6 +439,12 @@ export async function ciServeSchedule(opts: CiServeOptions, triggers: readonly C
316
439
  lease = got.lease;
317
440
  }
318
441
 
442
+ // #4121: with no other job of this serve writing it, the clone's ledger follows the forge's.
443
+ if (rt.inflight.size === 0) {
444
+ const ledger = reconcileCiServeLedger(place.top);
445
+ if (ledger) emit(ledger);
446
+ }
447
+
319
448
  const previous = readCiServeState(place.stateFile, fresh).jobs[key];
320
449
  const attempt = (previous?.attempts ?? 0) + 1;
321
450
  const runId = `chant-serve-${now().getTime()}-${t.job}-${t.commit.slice(0, 8)}`;
@@ -442,6 +571,14 @@ export function formatCiServeEvent(e: CiServeEvent): string {
442
571
  return `${at(e.job, e.commit, e.pr)} started, run ${e.runId}, attempt ${e.attempt}${e.resume ? ", resuming" : ""}`;
443
572
  case "settled":
444
573
  return `${at(e.job, e.commit, e.pr)} ${e.status === "ok" ? "passed" : `failed${e.failure ? `: ${e.failure}` : ""}`}${e.report ? `; report ${e.report}` : ""}`;
574
+ case "not-run":
575
+ return e.reason === "skip-ci"
576
+ ? `${at(e.job, e.commit, e.pr)} is not run: its commit message says [skip ci]`
577
+ : `${e.branch ?? "the target branch"} moved from ${e.from?.slice(0, 12) ?? "a commit"} to ${e.commit.slice(0, 12)}, which is not a fast-forward, so ${e.commit.slice(0, 12)} is recorded as seen and not applied`;
578
+ case "ledger":
579
+ return e.action === "deleted"
580
+ ? `the forge has no ${LEDGER}, so this clone's, at ${e.local.slice(0, 12)}, was deleted`
581
+ : `the forge's ${LEDGER} at ${e.forge!.slice(0, 12)} does not contain this clone's at ${e.local.slice(0, 12)}, so this clone's was reset to the forge's and not pushed`;
445
582
  case "error":
446
583
  return `error: ${e.message}`;
447
584
  }
@@ -478,7 +615,7 @@ export function runnerKeySetupCommand(ctx: CommandContext): number {
478
615
  const USAGE =
479
616
  "chant ci serve --setup-key [--trust [--principal <name>]] [--state <dir>] [--branch <name>] | " +
480
617
  "chant ci serve --forge forgejo|github --repo <owner/repo> --env <env> [--interval <s>] [--max-jobs <n>] [--state <dir>] " +
481
- "[--author <login>]... [--once] [--rerun-failed] [--gate <name>] [--branch <name>] [--events-json] [--report-url <url>] [--no-status] [--status-url <url template>] " +
618
+ "[--author <login>]... [--once] [--rerun-failed] [--gate <name>] [--wave-gate <name>] [--branch <name>] [--events-json] [--report-url <url>] [--no-status] [--status-url <url template>] " +
482
619
  "[--image <image>] [--before-script <line>]... [--pr-build] [--pr-build-command <line>] [--chant <command>] [--pass-env <NAME>]... [--secrets <file>] [--key <runner key.pem>] [--unsigned]";
483
620
 
484
621
  const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -508,12 +645,26 @@ export async function runCiServe(ctx: CommandContext): Promise<number> {
508
645
  const stateDir = args.statePath ? resolve(args.statePath) : defaultCiServeStateDir(cwd);
509
646
  const branch = args.branch ?? "main";
510
647
  // #4099: the host's key, checked against .chant/trust.json at the target's head before the first poll.
511
- const keyed = ciServeRunnerKey({ top, stateDir, branch, ...(args.key !== undefined ? { keyFile: resolve(args.key) } : {}), envKey: process.env[RUNNER_KEY_ENV] || undefined, unsigned: args.unsigned });
648
+ // #4156: signing is opt-in. Without ci.green.signedSince no key is quiet, and an untrusted one warns once and serves unsigned.
649
+ const keyed = ciServeRunnerKey({
650
+ top,
651
+ stateDir,
652
+ branch,
653
+ ...(args.key !== undefined ? { keyFile: resolve(args.key) } : {}),
654
+ envKey: process.env[RUNNER_KEY_ENV] || undefined,
655
+ unsigned: args.unsigned,
656
+ required: signedRunsRequired(cwd),
657
+ });
512
658
  const fromFlag = keyed.fromFlag;
513
659
  const runnerKey = keyed.pem;
514
- console.error(`ci serve: ${runnerKey === undefined ? "runs are not signed (--unsigned)" : `signing runs with the runner key from ${keyed.source}`}`);
660
+ if (keyed.warning) console.error(formatWarning({ message: keyed.warning }));
661
+ if (runnerKey !== undefined) console.error(`ci serve: signing runs with the runner key from ${keyed.source}`);
662
+ else if (keyed.note && args.verbose) console.error(`ci serve: ${keyed.note}`);
515
663
  // #4096: read once, so a loose or broken secrets file stops the serve before its first poll.
516
664
  const secrets = ciSecretsOf(args.ciSecrets);
665
+ // #4160: without --report-url, the declaration's ci.reportUrl.
666
+ const reportUrl = await reportUrlOf(args.reportUrl, cwd);
667
+ if (reportUrl && !args.reportUrl) console.error(`ci serve: reports and the run index go to ${reportUrl}, the declaration's ci.reportUrl`);
517
668
  const hostEnv = { ...process.env, GITHUB_REPOSITORY: args.ciRepo };
518
669
  const client = forgeFromEnv(forge, hostEnv);
519
670
  const { status, warning } = ciStatusFromEnv({ forge, hostEnv, noStatus: args.noStatus, statusUrl: args.statusUrl });
@@ -530,14 +681,7 @@ export async function runCiServe(ctx: CommandContext): Promise<number> {
530
681
  ...(args.ciAuthors?.length ? { authors: args.ciAuthors } : {}),
531
682
  ...(args.rerunFailed ? { rerunFailed: true } : {}),
532
683
  job: {
533
- loop: {
534
- ...(args.gate ? { gate: args.gate } : {}),
535
- ...(args.branch ? { branch: args.branch } : {}),
536
- ...(args.eventsJson ? { events: true } : {}),
537
- ...(args.reportUrl ? { reportUrl: args.reportUrl } : {}),
538
- ...(args.prBuild ? { build: true } : {}),
539
- ...(args.prBuildCommand ? { buildCommand: args.prBuildCommand } : {}),
540
- },
684
+ loop: ciLoopOptions(reportUrl ? { ...args, reportUrl } : args),
541
685
  ...(args.runIndexLimit ? { runIndexLimit: args.runIndexLimit } : {}),
542
686
  ...(args.image ? { image: args.image } : {}),
543
687
  ...(args.beforeScript?.length ? { beforeScript: args.beforeScript } : {}),
@@ -550,7 +694,7 @@ export async function runCiServe(ctx: CommandContext): Promise<number> {
550
694
  hostEnv,
551
695
  stepOutput: (line) => void process.stderr.write(`${line}\n`),
552
696
  },
553
- log: (e) => console.error(e.type === "error" ? formatWarning({ message: formatCiServeEvent(e) }) : `ci serve: ${formatCiServeEvent(e)}`),
697
+ log: (e) => console.error(e.type === "error" || e.type === "ledger" ? formatWarning({ message: formatCiServeEvent(e) }) : `ci serve: ${formatCiServeEvent(e)}`),
554
698
  };
555
699
  if (args.once) {
556
700
  const pass = await ciServePass(opts);
@@ -177,6 +177,11 @@
177
177
  "properties": {
178
178
  "green": {
179
179
  "$ref": "#/$defs/ciGreen"
180
+ },
181
+ "reportUrl": {
182
+ "type": "string",
183
+ "minLength": 1,
184
+ "description": "Where the pull-request jobs copy their reports and events, and where chant ci run keeps the run index, <reportUrl>/runs/index.json (#4160): an s3://<bucket>/<prefix> or file://<directory> URL, as --report-url takes. chant build --pr-loop, chant ci run, chant ci serve and chant ci runs use it when --report-url is not given, and the flag wins. Readers find it in chant workspace ls --json as ci.reportUrl. Added in schema 1 by chant 0.124.32, so a declaration that uses it sets minReader to 0.124.32 or newer."
180
185
  }
181
186
  }
182
187
  },
@@ -581,7 +586,7 @@
581
586
  },
582
587
  "box": {
583
588
  "type": "object",
584
- "description": "The member is a box, or holds a box's declarations (#2726). A box holds no credential: each capability it needs outside itself is reached through a broker, a runtime such as a lobby or a door, which holds the credential and enforces the scope. chant workspace check fails when a file in the member's directory carries a literal secret (WSP121) and when a capability names no broker (WSP122). chant workspace status --json lists the capabilities, so a broker can read the scopes. Its host, slot, ports, state and cookies state the box's isolation (#2727): chant derives each value from the box's identity, its host, the member's name and its slot, and prints them in chant workspace status --json, so a runtime reads them instead of choosing its own. chant workspace check fails when two boxes on a host resolve to the same value (WSP123) and when a box hard-codes a machine path (WSP124). A member that builds the fountain lexicon's Box declares the callback token fountain gives its persistent sandbox as the capability fountain-callback, brokered by fountain, with scope owner, or chant workspace check fails (WSP125, #2780). Its intent names the decision record that says what the box is for (#2850): chant workspace status --json reports the record's state and answer, and chant workspace check fails when no decision record has the id (WSP126) and warns when the record constrains no member or path of this workspace (WSP127, #2857). Its services list what the box runs under its supervisor (#2880), which the fly lexicon observes, restarts and applies through sprite-env, and which chant workspace status --json prints. Its factory declares what the box builds, how a build is checked, which member declares the builder agents and where a finished build is published (#3146, ws-077), its listing what the box shows of itself (#3146), and its replicate where the box's work in progress is pushed so a lost box loses none of it (#3172, ws-085). Added in schema 1 by chant 0.91.0, so a declaration that uses it sets minReader to 0.91.0 or newer.",
589
+ "description": "The member is a box, or holds a box's declarations (#2726). A box holds no credential: each capability it needs outside itself is reached through a broker, a runtime such as a lobby or a door, which holds the credential and enforces the scope. chant workspace check fails when a file in the member's directory carries a literal secret (WSP121) and when a capability names no broker (WSP122). chant workspace status --json lists the capabilities, so a broker can read the scopes. Its host, slot, ports, state and cookies state the box's isolation (#2727): chant derives each value from the box's identity, its host, the member's name and its slot, and prints them in chant workspace status --json, so a runtime reads them instead of choosing its own. chant workspace check fails when two boxes on a host resolve to the same value (WSP123) and when a box hard-codes a machine path (WSP124). A member that builds the fountain lexicon's Box declares the callback token fountain gives its sandbox as the capability fountain-callback, brokered by fountain, with scope owner, or chant workspace check fails (WSP125, #2780). Its intent names the decision record that says what the box is for (#2850): chant workspace status --json reports the record's state and answer, and chant workspace check fails when no decision record has the id (WSP126) and warns when the record constrains no member or path of this workspace (WSP127, #2857). Its services list what the box runs under its supervisor (#2880), which the fly lexicon observes, restarts and applies through sprite-env, and which chant workspace status --json prints. Its factory declares what the box builds, how a build is checked, which member declares the builder agents and where a finished build is published (#3146, ws-077), its listing what the box shows of itself (#3146), and its replicate where the box's work in progress is pushed so a lost box loses none of it (#3172, ws-085). Added in schema 1 by chant 0.91.0, so a declaration that uses it sets minReader to 0.91.0 or newer.",
585
590
  "properties": {
586
591
  "intent": {
587
592
  "type": "string",
@@ -1518,7 +1523,7 @@
1518
1523
  "signedSince": {
1519
1524
  "type": "string",
1520
1525
  "pattern": "^[0-9a-f]{40}([0-9a-f]{24})?$",
1521
- "description": "Added by #4031, read by chant 0.124.28 and newer. The full sha of the first commit on branch whose ci/green and ci/revoked tags must carry a DSSE statement in their annotation, signed by a key .chant/trust.json at base lists under runners for a principal holding the runner role. A tag on a strict first-parent ancestor of this commit counts unsigned, so existing tags keep working; a tag on any other commit, this one included, needs a statement. Position in history decides, never a date, which whoever makes a commit or a tag sets. A reader refuses a sha that names no commit in its clone with declaration-invalid. Absent, no tag needs a statement."
1526
+ "description": "Added by #4031, read by chant 0.124.28 and newer. The full sha of the first commit on branch whose ci/green and ci/revoked tags must carry a DSSE statement in their annotation, signed by a key .chant/trust.json at base lists under runners for a principal holding the runner role. A tag on a strict first-parent ancestor of this commit counts unsigned, so existing tags keep working; a tag on any other commit, this one included, needs a statement. Position in history decides, never a date, which whoever makes a commit or a tag sets. A reader refuses a sha that names no commit in its clone with declaration-invalid. Absent, no tag needs a statement. Since chant 0.124.32 (#4156) it covers Op run records the same way, when ci.green.source is runs: an unsigned run counts on a strict first-parent ancestor of this commit and needs a statement on any other, ci serve refuses to start without a runner key trusted at the target's head, and ci run and chant run warn about a run they leave unsigned. Absent, an unsigned run counts and nothing asks for a key."
1522
1527
  },
1523
1528
  "source": {
1524
1529
  "enum": [
@@ -436,6 +436,29 @@ describe("retention (#4036)", () => {
436
436
  });
437
437
  });
438
438
 
439
+ describe("ci.reportUrl (#4160)", () => {
440
+ test("an s3:// or file:// URL is read without its trailing slash, beside green or alone, and is null when absent", () => {
441
+ expect(parse(base([])).ci).toBeNull();
442
+ expect(parse(base([], { ci: {} })).ci).toEqual({ green: null, reportUrl: null });
443
+ expect(parse(base([], { ci: { reportUrl: "s3://acme-reports/prod/" } })).ci).toEqual({ green: null, reportUrl: "s3://acme-reports/prod" });
444
+ const green = { branch: "main", phases: { build: ["build"] }, require: ["build"] };
445
+ const d = parse(base([], { ci: { green, reportUrl: "file:///srv/chant-reports" } }));
446
+ expect(d.ci?.reportUrl).toBe("file:///srv/chant-reports");
447
+ expect(d.ci?.green?.branch).toBe("main");
448
+ });
449
+
450
+ test("a URL no job can upload to is declaration-invalid, at ci.reportUrl, with the reason --report-url gives", () => {
451
+ for (const reportUrl of ["https://reports.example/acme", "ftp://example/x", "reports/"]) {
452
+ const err = failure(base([], { ci: { reportUrl } }));
453
+ expect(err.code).toBe("declaration-invalid");
454
+ expect(err.message).toBe(`ci.reportUrl ${reportUrl}: takes an s3://<bucket>/<prefix> or file://<directory> URL`);
455
+ expect(err.location?.line).toBeGreaterThan(1);
456
+ }
457
+ expect(failure(base([], { ci: { reportUrl: "" } })).code).toBe("declaration-invalid");
458
+ expect(failure(base([], { ci: { reportUrl: 7 } })).code).toBe("declaration-invalid");
459
+ });
460
+ });
461
+
439
462
  describe("metrics (#4039)", () => {
440
463
  test("failureWindowDays is read as written, without x- keys, and the block is null when absent", () => {
441
464
  expect(parse(base([])).metrics).toBeNull();
@@ -17,6 +17,7 @@
17
17
  */
18
18
 
19
19
  import { RETENTION_KINDS, type RetentionOverride } from "../lifecycle/retention-policy";
20
+ import { parseReportUrl } from "../pr-report-url";
20
21
  import schema from "./declaration.schema.json" with { type: "json" };
21
22
  import { expandGlob } from "./glob";
22
23
  import { EXAMPLES_KIND, holdsChantProject } from "./kinds";
@@ -232,6 +233,14 @@ export type CiSource = "checks" | "runs";
232
233
  /** The declaration's `ci` block (#3573). */
233
234
  export interface CiDeclaration {
234
235
  green: CiGreen | null;
236
+ /**
237
+ * `ci.reportUrl` (#4160): where the pull-request jobs copy their reports
238
+ * and `chant ci run` keeps the run index, `<reportUrl>/runs/index.json`.
239
+ * An `s3://` or `file://` URL, as `--report-url` takes, without a trailing
240
+ * slash. The commands that take `--report-url` default to it. Null when
241
+ * the declaration sets none.
242
+ */
243
+ reportUrl: string | null;
235
244
  }
236
245
 
237
246
  export interface MemberRole {
@@ -1087,8 +1096,15 @@ function retentionOf(raw: unknown): RetentionOverride | null {
1087
1096
 
1088
1097
  function ciOf(raw: unknown, at: (pointer: string, key?: boolean) => ErrorLocation): CiDeclaration | null {
1089
1098
  if (raw === undefined) return null;
1090
- const block = raw as { green?: { branch: string; window?: string; phases: Record<string, string[] | { runs: string[]; skipped?: CiSkipped }>; require: string[]; signedSince?: string; source?: CiSource } };
1091
- if (block.green === undefined) return { green: null };
1099
+ const block = raw as { reportUrl?: string; green?: { branch: string; window?: string; phases: Record<string, string[] | { runs: string[]; skipped?: CiSkipped }>; require: string[]; signedSince?: string; source?: CiSource } };
1100
+ let reportUrl: string | null = null;
1101
+ if (block.reportUrl !== undefined) {
1102
+ // #4160: refused here as pr-plan and ci run refuse the flag, so a reader never gets a URL no job can write to.
1103
+ const target = parseReportUrl(block.reportUrl, "ci.reportUrl");
1104
+ if ("error" in target) throw new WorkspaceReadError("declaration-invalid", target.error, at("/ci/reportUrl"));
1105
+ reportUrl = target.url;
1106
+ }
1107
+ if (block.green === undefined) return { green: null, reportUrl };
1092
1108
  const g = block.green;
1093
1109
  const phases: CiPhase[] = Object.entries(g.phases).map(([name, p]) => ({
1094
1110
  name,
@@ -1104,7 +1120,7 @@ function ciOf(raw: unknown, at: (pointer: string, key?: boolean) => ErrorLocatio
1104
1120
  at(`/ci/green/require/${i}`),
1105
1121
  );
1106
1122
  }
1107
- return { green: { branch: g.branch, window: g.window ?? DEFAULT_CI_WINDOW, phases, require: [...g.require], signedSince: g.signedSince ?? null, source: g.source ?? "checks", pointer: "/ci/green" } };
1123
+ return { green: { branch: g.branch, window: g.window ?? DEFAULT_CI_WINDOW, phases, require: [...g.require], signedSince: g.signedSince ?? null, source: g.source ?? "checks", pointer: "/ci/green" }, reportUrl };
1108
1124
  }
1109
1125
 
1110
1126
  function agentsOf(raw: unknown, members: Member[], at: (pointer: string, key?: boolean) => ErrorLocation): AgentDeclaration[] {