@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
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://intentius.io/chant/schemas/workspace/ci-run-index/v1/ci-run-index.schema.json",
4
4
  "title": "chant ci run index",
5
- "description": "The runs the chant runner has run, kept at <report-url>/runs/index.json (#4098, epic #4101) so a reader outside CI finds them without a forge or a workflow file. `chant ci run` adds an entry to the front after each job settles when --report-url is set, so the list is newest first, and keeps the newest 200 entries unless --run-index-limit says otherwise. `chant ci runs --json` prints the same document. An entry's `key` is the folder the job's report is under, below the report URL. The update is a read, a change and a write of the whole file, through the same s3:// and file:// paths the report upload uses; a file:// write goes through a temporary name and a rename. Two runners updating one index at once can lose an entry. That is out of scope, because `chant ci serve` is one host. Version 1 of the workspace read contract. Readers ignore fields they do not know; a field is only ever added within a version. Written by chant 0.124.31 and newer.",
5
+ "description": "The runs the chant runner has run, kept at <report-url>/runs/index.json (#4098, epic #4101) so a reader outside CI finds them without a forge or a workflow file. `chant ci run` adds an entry to the front after each job settles when --report-url is set, so the list is newest first, and keeps the newest 200 entries unless --run-index-limit says otherwise. `chant ci runs --json` prints the same document. An entry's `key` is the folder the job's report is under, below the report URL. The update is a read, a change and a write of the whole file, through the same s3:// and file:// paths the report upload uses; a file:// write goes through a temporary name and a rename. Two runners updating one index at once can lose an entry. That is out of scope, because `chant ci serve` is one host. Version 1 of the workspace read contract. Readers ignore fields they do not know; a field is only ever added within a version. Written by chant 0.124.31 and newer. Added within contract 1 at chant 0.124.32: an entry's signer (#4156) and exitCode (#4165); an entry from an older chant lacks them.",
6
6
  "type": "object",
7
7
  "required": ["$schema", "contract", "chant", "updated", "runs"],
8
8
  "properties": {
@@ -38,11 +38,13 @@
38
38
  "ended": { "type": "string", "description": "ISO-8601 instant the job settled." },
39
39
  "runId": { "type": "string", "description": "The Op run id, as in the run ledger on chant/lifecycle." },
40
40
  "key": { "type": "string", "description": "reportKey(pr, commit): <pr>/<commit>, with /<member> after for a member, and unknown for a missing pr. The job's report is <report-url>/<key>/pr-<job>.json." },
41
+ "signer": { "type": ["string", "null"], "description": "Added within contract 1 by #4156, written by chant 0.124.32 and newer. The runner whose key signed the run's record, or null for an unsigned run. Signing is optional, and an unsigned run is listed like any other." },
42
+ "exitCode": { "type": "integer", "minimum": 0, "maximum": 255, "description": "Added within contract 1 by #4165, written by chant 0.124.32 and newer. The code chant ci run exited with for the job: 0 for a passed job, else the command's own code when it is 1 to 255, such as 3 for a plan that moved after review or a wave waiting at its gate, else 1. A reader tells a held run from a failed one by it when the report carries no refusal. An entry from an older chant lacks it." },
41
43
  "runOrigin": {
42
44
  "type": "object",
43
45
  "required": ["forge"],
44
- "description": "Where the run says it ran. { forge: \"chant\" } for a job the chant runner ran.",
45
- "properties": { "forge": { "type": "string" } }
46
+ "description": "Where the run says it ran. { forge: \"chant\" } for a job the chant runner ran. From chant 0.124.32 a forge job that runs chant ci run names the forge's run (#4156): forge github (GitHub and Forgejo) or gitlab, with repo, url, jobId and attempt when the forge gives them.",
47
+ "properties": { "forge": { "type": "string" }, "repo": { "type": "string" }, "url": { "type": "string" }, "jobId": { "type": "string" }, "attempt": { "type": "integer", "minimum": 1 } }
46
48
  }
47
49
  }
48
50
  }
@@ -12,7 +12,7 @@ import { join } from "node:path";
12
12
  import { pathToFileURL } from "node:url";
13
13
  import { afterAll, describe, expect, test } from "vitest";
14
14
  import { cleanScratch, contract, declaration, git, repoFixture, scratchDir, validSchema, writeFiles } from "./__fixtures__/contract-repo";
15
- import { ciRun } from "./ci-run";
15
+ import { ciJobExitCode, ciRun } from "./ci-run";
16
16
  import { readCiSecrets } from "./ci-secrets";
17
17
  import {
18
18
  addRunEntry,
@@ -123,6 +123,13 @@ describe("the ci-run-index schema (#4098)", () => {
123
123
  expect(contract(schema).validate(doc)).toBe(false);
124
124
  expectValid({ ...doc, runs: [{ ...ENTRY, status: "fail", failure: { code: "command-failed" } }] });
125
125
  });
126
+
127
+ test("exitCode is an integer from 0 to 255, and an index whose older entries lack it validates (#4165)", () => {
128
+ const doc = { $schema: CI_RUN_INDEX_SCHEMA_ID, contract: 1, chant: "0.124.32", updated: "2026-10-18T12:00:00.000Z" };
129
+ // A newer entry beside two from chant 0.124.31, which wrote neither signer nor exitCode.
130
+ expectValid({ ...doc, runs: [{ ...ENTRY, runId: "r2", signer: null, exitCode: 0 }, { ...ENTRY, runId: "r1" }, { ...ENTRY, status: "fail", failure: { code: "command-failed" } }] });
131
+ for (const exitCode of [-1, 256, 1.5, "3"]) expect(contract(schema).validate({ ...doc, runs: [{ ...ENTRY, exitCode }] })).toBe(false);
132
+ });
126
133
  });
127
134
 
128
135
  describe("chant ci run with --report-url keeps the run index (#4098)", () => {
@@ -149,6 +156,10 @@ describe("chant ci run with --report-url keeps the run index (#4098)", () => {
149
156
  key: `7/${w.sha}/app`,
150
157
  member: "app",
151
158
  runOrigin: { forge: "chant" },
159
+ // #4156: no key, and the run is listed all the same, with no signer.
160
+ signer: null,
161
+ // #4165: the code ci run exited with.
162
+ exitCode: 0,
152
163
  });
153
164
  // The apply found its pull request in its own report.
154
165
  expect(index.runs[0]).toMatchObject({ job: "apply", pr: 7, key: `7/${w.sha}/app` });
@@ -179,7 +190,7 @@ describe("chant ci run with --report-url keeps the run index (#4098)", () => {
179
190
  const failed = await job(w, dir, { runId: "run-fail", hostEnv: { ...HOST, FAKE_EXIT: "3" }, passEnv: ["FAKE_EXIT"] });
180
191
  expect(failed.status).toBe("fail");
181
192
  const [run] = indexOf(dir).runs;
182
- expect(run).toMatchObject({ status: "fail", failure: { code: "command-failed" }, runId: "run-fail" });
193
+ expect(run).toMatchObject({ status: "fail", failure: { code: "command-failed" }, runId: "run-fail", exitCode: 3 });
183
194
  expectValid(indexOf(dir));
184
195
 
185
196
  const refused = await job(w, dir, {
@@ -189,7 +200,35 @@ describe("chant ci run with --report-url keeps the run index (#4098)", () => {
189
200
  passEnv: ["FAKE_EXIT", "FAKE_REPORT"],
190
201
  });
191
202
  expect(refused.status).toBe("fail");
192
- expect(indexOf(dir).runs[0]).toMatchObject({ failure: { code: "not-approved" } });
203
+ expect(indexOf(dir).runs[0]).toMatchObject({ failure: { code: "not-approved" }, exitCode: 1 });
204
+ });
205
+
206
+ test("each entry records the code ci run exited with: 0, 1, and 3 for a moved plan or a waiting wave (#4165)", async () => {
207
+ const w = workspace();
208
+ const dir = reportsDir();
209
+ const run = (runId: string, exit: string, report: object) =>
210
+ job(w, dir, { job: "apply", runId, hostEnv: { ...HOST, FAKE_EXIT: exit, FAKE_REPORT: JSON.stringify({ pr: 7, ...report }) }, passEnv: ["FAKE_EXIT", "FAKE_REPORT"] });
211
+ const passed = await run("run-ok", "0", { status: "applied" });
212
+ const failed = await run("run-failed", "1", { status: "failed" });
213
+ const moved = await run("run-moved", "3", { status: "refused", refusal: "plan-changed", message: "the plan changed after review" });
214
+ const waiting = await run("run-waiting", "3", { status: "waiting", waves: [{ wave: 2, status: "waiting" }] });
215
+ expect([passed, failed, moved, waiting].map((r) => ciJobExitCode(r))).toEqual([0, 1, 3, 3]);
216
+ const index = indexOf(dir);
217
+ expectValid(index);
218
+ expect(index.runs.map((r) => [r.runId, r.status, r.exitCode])).toEqual([
219
+ ["run-waiting", "fail", 3],
220
+ ["run-moved", "fail", 3],
221
+ ["run-failed", "fail", 1],
222
+ ["run-ok", "ok", 0],
223
+ ]);
224
+ expect(index.runs[1]!.failure).toEqual({ code: "plan-changed" });
225
+ });
226
+
227
+ test("a fail with no code from its command, or one outside 1 to 255, is 1, and a pass is 0 (#4165)", () => {
228
+ expect(ciJobExitCode({ status: "fail" })).toBe(1);
229
+ expect(ciJobExitCode({ status: "fail", exitCode: 0 })).toBe(1);
230
+ expect(ciJobExitCode({ status: "fail", exitCode: 300 })).toBe(1);
231
+ expect(ciJobExitCode({ status: "ok", exitCode: 3 })).toBe(0);
193
232
  });
194
233
 
195
234
  test("a secret the job's report names as its failure never reaches the index (#4096)", async () => {
@@ -29,6 +29,7 @@
29
29
  import type { RunOrigin } from "../lifecycle/release-ledger";
30
30
  import type { CommandContext } from "../cli/registry";
31
31
  import { formatError } from "../cli/format";
32
+ import { reportUrlOf } from "./ci-report-url";
32
33
  import { parseReportUrl, reportGet, reportPut, type ReportGet, type ReportPut, type ReportTarget } from "../pr-report-url";
33
34
  import { readerVersion } from "./declaration";
34
35
 
@@ -57,6 +58,18 @@ export interface CiRunIndexEntry {
57
58
  /** The folder the job's report is under, below the report URL: `<pr>/<commit>[/<member>]`. */
58
59
  key: string;
59
60
  runOrigin: RunOrigin;
61
+ /**
62
+ * The runner whose key signed the run's record, or null for an unsigned
63
+ * run (#4156). Written by chant 0.124.32 and newer; an entry from an older
64
+ * chant lacks it.
65
+ */
66
+ signer?: string | null;
67
+ /**
68
+ * The code `chant ci run` exited with for the job: 0, its command's own
69
+ * code such as 3 for a moved plan or a waiting wave, else 1 (#4165).
70
+ * Written by chant 0.124.32 and newer; an entry from an older chant lacks it.
71
+ */
72
+ exitCode?: number;
60
73
  }
61
74
 
62
75
  export interface CiRunIndex {
@@ -167,18 +180,19 @@ export function formatCiRunIndex(index: CiRunIndex): string {
167
180
  .map((r) => {
168
181
  const pr = r.pr === null ? "no PR" : `PR ${r.pr}`;
169
182
  const why = r.status === "fail" ? ` (${r.failure?.code ?? "failed"})` : "";
170
- return `${r.ended} ${r.job.padEnd(5)} ${r.status}${why} ${pr} ${r.commit.slice(0, 12)} ${r.runId} ${r.key}`;
183
+ return `${r.ended} ${r.job.padEnd(5)} ${r.status}${why} ${pr} ${r.commit.slice(0, 12)} ${r.runId} ${r.key}${r.signer ? ` signed by ${r.signer}` : ""}`;
171
184
  })
172
185
  .join("\n");
173
186
  }
174
187
 
175
- const RUNS_USAGE = "chant ci runs --report-url <s3://<bucket>/<prefix> | file://<directory>> [--json]";
188
+ const RUNS_USAGE = "chant ci runs [--report-url <s3://<bucket>/<prefix> | file://<directory>>] [--json]";
176
189
 
177
190
  /** `chant ci runs` (#4098): list the index at the report URL. */
178
191
  export async function runCiRuns(ctx: CommandContext): Promise<number> {
179
192
  try {
180
- const raw = ctx.args.reportUrl;
181
- if (!raw) throw new Error("--report-url names where the index is kept");
193
+ // #4160: without --report-url, the declaration's ci.reportUrl.
194
+ const raw = await reportUrlOf(ctx.args.reportUrl, process.cwd());
195
+ if (!raw) throw new Error("--report-url names where the index is kept, unless the workspace declares ci.reportUrl");
182
196
  const target = parseReportUrl(raw);
183
197
  if ("error" in target) throw new Error(target.error);
184
198
  const index = await readCiRunIndex(target);
@@ -17,9 +17,9 @@ import { join } from "node:path";
17
17
  import { afterAll, beforeAll, describe, expect, test } from "vitest";
18
18
  import { cleanScratch, declaration, git, repoFixture, scratchDir, writeFiles } from "./__fixtures__/contract-repo";
19
19
  import { checkRunEventLines } from "./__fixtures__/run-event-check";
20
- import { ciJobCommand, ciJobEnv, ciJobFailure, ciJobOp, ciRun, CiRunError, ciStatusContext, ciStatusFromEnv, ciStatusUrl, formatCiRun } from "./ci-run";
20
+ import { ciJobCommand, ciJobEnv, ciJobFailure, ciJobOp, ciLoopOptions, ciRun, CiRunError, ciStatusContext, ciStatusFromEnv, ciStatusUrl, formatCiRun } from "./ci-run";
21
21
  import type { CommitStatus } from "../pr-forge";
22
- import { ciTick, evaluateCommit, readOpRunSource, revokedAnnotation } from "./ci-green";
22
+ import { ciList, ciTick, evaluateCommit, readOpRunSource, revokedAnnotation } from "./ci-green";
23
23
  import type { StepRecord } from "../op/local-executor";
24
24
  import { sshFromEd25519 } from "./trust/dsse";
25
25
  import { verifyOpRunStatement } from "./trust/op-run-statement";
@@ -199,6 +199,20 @@ describe("the job as an Op (#4094)", () => {
199
199
  expect(args).toMatchObject({ command: "ci", path: "run", extraPositional: "apply", env: "prod", base: "abc", commit: "def", passEnv: ["AWS_PROFILE", "AWS_REGION"], ciChant: "npx chant", key: "k.pem" });
200
200
  expect(() => parseArgs(["ci", "run", "plan", "--commit"])).toThrow(/--commit needs/);
201
201
  });
202
+
203
+ test("--wave-gate (#4003): the apply job runs pr-apply --wave-gate, the waves in order with no --wave, and the plan passes the gate for its note", () => {
204
+ for (const path of ["run", "serve"]) {
205
+ const args = parseArgs(["ci", path, ...(path === "run" ? ["apply"] : []), "--env", "prod", "--gate", "infra", "--wave-gate", "release"]);
206
+ expect(ciLoopOptions(args)).toEqual({ gate: "infra", waveGate: "release" });
207
+ }
208
+ const [plan, apply] = prLoopJobs("forgejo", "prod", ciLoopOptions(parseArgs(["ci", "run", "apply", "--env", "prod", "--wave-gate", "release"])));
209
+ expect(apply!.command).toContain("--require-review --wave-gate release --resume .chant/pr-resume/pr-apply.json");
210
+ expect(apply!.command).not.toContain("--wave ");
211
+ expect(plan!.command).toContain("--wave-gate release");
212
+ expect(plan!.command).not.toContain("--wave ");
213
+ const op = ciJobOp({ job: apply!, env: "prod", cwd: "/w", jobEnv: {}, setup: [], build: [] });
214
+ expect(op.phases.map((p) => p.name)).toEqual(["apply"]);
215
+ });
202
216
  });
203
217
 
204
218
  describe("chant ci run, then ci tick from runs (#4094)", () => {
@@ -296,8 +310,8 @@ describe("chant ci run, then ci tick from runs (#4094)", () => {
296
310
  expect(record.phases.apply![0]).toMatchObject({ name: "pr-apply", opRun: { id: apply.record.id, signer: "chant-runner" } });
297
311
  });
298
312
 
299
- test("a failing job is recorded failed, and a key from the environment that trust.json does not list leaves it unsigned", async () => {
300
- const { app, base } = workspace();
313
+ test("a failing job is recorded failed, and a key from the environment that trust.json does not list leaves it unsigned, quietly", async () => {
314
+ const { root, app, base } = workspace();
301
315
  const stranger = runnerKey();
302
316
  const lines: string[] = [];
303
317
  const r = await ciRun({ image: JOB_IMAGE,
@@ -319,17 +333,30 @@ describe("chant ci run, then ci tick from runs (#4094)", () => {
319
333
  expect(r.record.status).toBe("fail");
320
334
  expect(r.record.statement).toBeUndefined();
321
335
  expect(r.signer).toBeNull();
322
- expect(r.warnings[0]).toMatch(/CHANT_RUNNER_KEY is set, and the run will not be signed: .*lists no runner with this key/);
336
+ // #4156: signing is opt-in, so an unlisted key is a note for --verbose, not a warning.
337
+ expect(r.warnings).toEqual([]);
338
+ expect(r.notes[0]).toMatch(/CHANT_RUNNER_KEY is set, and the run will not be signed: .*lists no runner with this key/);
339
+ // The command's exit code is the job's, as a forge job's was before it ran through ci run.
340
+ expect(r.exitCode).toBe(3);
323
341
  expect(r.failure).toMatchObject({ phase: "apply", error: expect.stringContaining("command exited 3") });
324
342
  // The report says nothing about a failure, so the record names the command.
325
343
  expect(r.record.failure).toMatchObject({ code: "command-failed", phase: "apply", exitCode: 3, reason: expect.stringContaining("command exited 3") });
326
344
  expect(lines.some((l) => l.includes("fake pr-apply ran"))).toBe(true);
327
345
  expect(r.report).not.toBeNull();
328
- expect(formatCiRun(r)).toContain("unsigned, so ci tick from runs does not count it");
346
+ expect(formatCiRun(r)).toContain("on chant/lifecycle, unsigned");
329
347
 
330
- // The same key given with --key refuses the run before anything runs.
348
+ // The same key given with --key refuses the run before anything runs, and so does a key that is not one.
331
349
  await expect(ciRun({ image: JOB_IMAGE, cwd: app, job: "apply", env: "prod", forge: "github", base, chant: fakeChant(), hostEnv: HOST, runnerKey: stranger.pem, push: false })).rejects.toThrow(/would not be signed/);
350
+ await expect(ciRun({ image: JOB_IMAGE, cwd: app, job: "apply", env: "prod", forge: "github", base, chant: fakeChant(), hostEnv: HOST, runnerKey: "not a key", runnerKeyFromEnv: true, push: false })).rejects.toThrow(/in CHANT_RUNNER_KEY is not an Ed25519 private key in PEM/);
332
351
  expect((await readRunLedger("prod", "pr-apply", { cwd: app })).records).toHaveLength(1);
352
+
353
+ // With ci.green.signedSince the same run warns, and so does one with no key.
354
+ writeFiles(root, { "chant.workspace.json": declaration([{ name: "app", dir: "app", kind: "other", because: "the app" }], { ci: { green: { ...GREEN, signedSince: base } } }) });
355
+ const opted = { image: JOB_IMAGE, cwd: app, job: "apply" as const, env: "prod", forge: "github" as const, base, chant: fakeChant(), hostEnv: HOST, push: false, output: join(scratchDir("chant-ci-run-out-"), "pr") };
356
+ const warned = await ciRun({ ...opted, runnerKey: stranger.pem, runnerKeyFromEnv: true });
357
+ expect(warned.warnings[0]).toMatch(/CHANT_RUNNER_KEY is set, and the run will not be signed: [\s\S]*, and ci.green.signedSince is [0-9a-f]{40}, so it will not count/);
358
+ const keyless = await ciRun(opted);
359
+ expect(keyless.warnings).toEqual([expect.stringMatching(/^there is no runner key, so the run's record is unsigned, and ci.green.signedSince is/)]);
333
360
  });
334
361
 
335
362
  test("--pr-build runs the job's build and lint lines after --before-script and before the command, and a failing build fails the job", async () => {
@@ -714,3 +741,154 @@ describe("the job's secrets from the host (#4096)", () => {
714
741
  expect(formatCiRun(r)).toContain("not-approved in apply, exit 3: the deploy failed with ***");
715
742
  });
716
743
  });
744
+
745
+ describe("a generated forge job runs ci run --in-place (#4156)", () => {
746
+ // What a GitHub or Forgejo job has in its environment: the forge's run, job and attempt, and the secret.
747
+ const FORGE = { GITHUB_RUN_ID: "4242", GITHUB_RUN_ATTEMPT: "2", GITHUB_JOB: "apply", GITHUB_REPOSITORY: "acme/estate", GITHUB_SERVER_URL: "http://forgejo.localhost" };
748
+
749
+ test("the generated job's line parses to the same job, and --in-place, --no-push, --no-require-review and --wave reach it", () => {
750
+ const [plan, apply] = prLoopJobs("forgejo", "prod", { events: true, reportUrl: "s3://b/p", requireReview: false, waveGate: "release", branch: "trunk" });
751
+ expect(plan!.run.command).toBe('chant ci run plan --in-place --no-push --base "$BASE_SHA" --pr "$PR_NUMBER" --env prod --gate pr-apply --forge forgejo --wave-gate release --events-json --report-url "s3://b/p"');
752
+ const args = parseArgs(apply!.run.command.replace(/"\$BASE_SHA"/, "b".repeat(40)).replace(/"/g, "").split(" ").slice(1).concat(["--wave", "2"]));
753
+ expect(args).toMatchObject({ ciInPlace: true, requireReview: false, wave: 2, waveGate: "release", branch: "trunk", eventsJson: true, reportUrl: "s3://b/p" });
754
+ expect(ciLoopOptions(args)).toEqual({ gate: "pr-apply", branch: "trunk", events: true, reportUrl: "s3://b/p", waveGate: "release", requireReview: false });
755
+ expect(parseArgs(["ci", "run", "plan", "--no-push"]).noPush).toBe(true);
756
+ // ci run rebuilds pr-apply from those options: the very command the job ran before #4156.
757
+ expect(prLoopJobs("forgejo", "prod", ciLoopOptions(args))[1]!.command).toBe(apply!.command);
758
+ expect(apply!.run.env.CHANT_RUNNER_KEY).toBe("${{ secrets.CHANT_RUNNER_KEY }}");
759
+ });
760
+
761
+ test("in the job's own checkout, with its environment less the key, a signed record names the forge's run and verifies, and ci list sees it", async () => {
762
+ const { root, app, base, sha } = workspace();
763
+ const chant = fakeChant();
764
+ const before = git(root, "worktree", "list", "--porcelain");
765
+ const r = await ciRun({ cwd: app, job: "apply", env: "prod", forge: "forgejo", base, chant, hostEnv: { ...HOST, ...FORGE }, runnerKey: BOT.pem, runnerKeyFromEnv: true, inPlace: true, runId: "forge-apply-1" });
766
+ expect(r.status).toBe("ok");
767
+ expect(r.warnings).toEqual([]);
768
+ expect(r.signer).toBe("chant-runner");
769
+ expect(r.pushed).toBe(true);
770
+
771
+ // In place: no worktree, the report where the forge keeps it, and no setup phase.
772
+ expect(git(root, "worktree", "list", "--porcelain")).toBe(before);
773
+ expect(readFileSync(join(app, ".chant/pr/pr-apply.pwd"), "utf-8").trim()).toBe(realpathSync(app));
774
+ expect(r.report).toBe(join(realpathSync(app), ".chant/pr"));
775
+ expect(r.record.phases.map((p) => p.name)).toEqual(["apply"]);
776
+ const argv = readFileSync(join(app, ".chant/pr/pr-apply.args"), "utf-8").trimEnd().split("\n");
777
+ expect(argv).toEqual(["components", "pr-apply", "--base", base, "--env", "prod", "--gate", "pr-apply", "--output", ".chant/pr", "--forge", "forgejo", "--member", "app", "--require-review", "--resume", ".chant/pr-resume/pr-apply.json"]);
778
+ // The forge job's whole environment reaches the step, but never the runner key.
779
+ const env = envFile(join(app, ".chant/pr"), "pr-apply");
780
+ expect(env).toMatchObject({ NOT_FOR_JOBS: "leak", GITHUB_RUN_ID: "4242", BASE_SHA: base, GITHUB_SHA: sha });
781
+ expect(env).not.toHaveProperty("CHANT_RUNNER_KEY");
782
+
783
+ // The record: the forge's run, job and attempt as runOrigin, signed, and it verifies against trust.json.
784
+ const origin = { forge: "github", repo: "acme/estate", url: "http://forgejo.localhost/acme/estate/actions/runs/4242", jobId: "apply", attempt: 2 };
785
+ expect(r.record).toMatchObject({ op: "pr-apply", gitSha: sha, id: "forge-apply-1", runOrigin: origin });
786
+ const stored = (await readRunLedger("prod", "pr-apply", { cwd: app })).records.at(-1)!;
787
+ expect(verifyOpRunStatement(stored, RUNNERS, { runner: ["chant-runner"] })).toMatchObject({ status: "signed", signer: "chant-runner" });
788
+ expect(git(root, "ls-remote", "origin", "chant/lifecycle")).toContain(git(root, "rev-parse", "chant/lifecycle"));
789
+
790
+ const list = (await ciList({ cwd: app, now: NOW })) as Extract<Awaited<ReturnType<typeof ciList>>, { commits: unknown }>;
791
+ const listed = list.commits.find((c) => c.sha === sha)!;
792
+ expect(listed.phases.find((p) => p.phase === "apply")!.runs).toEqual([expect.objectContaining({ name: "pr-apply", opRun: { id: "forge-apply-1", env: "prod", signer: "chant-runner" } })]);
793
+ });
794
+
795
+ test("with no key the job runs, warns of nothing, and its unsigned record counts in ci list and the run index", async () => {
796
+ const { app, base, sha } = workspace();
797
+ const reports = scratchDir("chant-ci-run-index-");
798
+ const { CHANT_RUNNER_KEY: _key, ...keyless } = HOST;
799
+ const r = await ciRun({ cwd: app, job: "plan", env: "prod", forge: "forgejo", base, pr: 7, chant: fakeChant(), hostEnv: { ...keyless, ...FORGE, GITHUB_JOB: "plan" }, inPlace: true, push: false, runId: "forge-plan-1", loop: { reportUrl: `file://${reports}` } });
800
+ expect(r.status).toBe("ok");
801
+ expect(r.warnings).toEqual([]);
802
+ expect(r.notes).toEqual(["there is no runner key, so the run's record is unsigned"]);
803
+ expect(r.signer).toBeNull();
804
+ expect(r.record.statement).toBeUndefined();
805
+ expect(r.record.runOrigin).toMatchObject({ forge: "github", jobId: "plan", attempt: 2 });
806
+ // --no-push: the plan's record stays on this checkout's chant/lifecycle.
807
+ expect(r.pushed).toBe(false);
808
+
809
+ const list = (await ciList({ cwd: app, now: NOW })) as Extract<Awaited<ReturnType<typeof ciList>>, { commits: unknown }>;
810
+ const plan = list.commits.find((c) => c.sha === sha)!.phases.find((p) => p.phase === "plan")!;
811
+ expect(plan.result).toBe("pass");
812
+ expect(plan.runs[0]).toMatchObject({ name: "pr-plan", opRun: { id: "forge-plan-1", signer: null } });
813
+
814
+ const index = JSON.parse(readFileSync(join(reports, "runs", "index.json"), "utf-8")) as { runs: Record<string, unknown>[] };
815
+ expect(index.runs[0]).toMatchObject({ job: "plan", runId: "forge-plan-1", signer: null, runOrigin: { forge: "github", jobId: "plan", attempt: 2 } });
816
+ });
817
+
818
+ test("a failed command's exit code is the job's, and --wave reaches pr-apply", async () => {
819
+ const { app, base } = workspace();
820
+ const r = await ciRun({ cwd: app, job: "apply", env: "prod", forge: "github", base, chant: fakeChant(), hostEnv: { ...HOST, ...FORGE, FAKE_EXIT: "3" }, inPlace: true, push: false, loop: { waveGate: "release" }, wave: 2 });
821
+ expect(r.status).toBe("fail");
822
+ expect(r.exitCode).toBe(3);
823
+ const argv = readFileSync(join(app, ".chant/pr/pr-apply.args"), "utf-8").trimEnd().split("\n");
824
+ expect(argv.slice(-4)).toEqual(["--resume", ".chant/pr-resume/pr-apply.json", "--wave", "2"]);
825
+ await expect(ciRun({ cwd: app, job: "plan", env: "prod", forge: "github", base, pr: 1, chant: fakeChant(), hostEnv: HOST, inPlace: true, push: false, wave: 2 })).rejects.toThrow(/--wave names the one wave an apply runs/);
826
+ // In place runs HEAD only.
827
+ await expect(ciRun({ cwd: app, job: "apply", env: "prod", forge: "github", base, commit: base, chant: fakeChant(), hostEnv: HOST, inPlace: true, push: false })).rejects.toThrow(/--in-place runs the checkout's HEAD/);
828
+ });
829
+ });
830
+
831
+ describe("ci run --local (#4161)", () => {
832
+ test("the job runs on this machine: no push, no status, no report upload or run index, a local record ci tick ignores, and events that say local", async () => {
833
+ const { root, app, base, sha } = workspace();
834
+ const reports = scratchDir("chant-ci-run-local-");
835
+ const remoteBefore = git(root, "ls-remote", "origin");
836
+ const posts: CommitStatus[] = [];
837
+ const events: string[] = [];
838
+ const r = await ciRun({
839
+ image: JOB_IMAGE,
840
+ cwd: app,
841
+ job: "plan",
842
+ env: "prod",
843
+ forge: "forgejo",
844
+ base,
845
+ pr: 7,
846
+ chant: fakeChant(),
847
+ hostEnv: HOST,
848
+ runnerKey: BOT.pem,
849
+ runnerKeyFromEnv: true,
850
+ local: true,
851
+ runId: "local-plan-1",
852
+ output: join(scratchDir("chant-ci-run-local-out-"), "pr"),
853
+ loop: { reportUrl: `file://${reports}`, events: true },
854
+ status: { client: { async setCommitStatus(_sha, status) { posts.push(status); } } },
855
+ runEvents: (line) => events.push(line),
856
+ });
857
+ expect(r.status).toBe("ok");
858
+ expect(r.warnings).toEqual([]);
859
+ expect(r.local).toBe(true);
860
+
861
+ // Nothing left this machine: origin is as it was, the fake forge saw no status, and the report URL holds nothing.
862
+ expect(r.pushed).toBe(false);
863
+ expect(git(root, "ls-remote", "origin")).toBe(remoteBefore);
864
+ expect(posts).toEqual([]);
865
+ expect(readdirSync(reports)).toEqual([]);
866
+ // The command got --no-status and no --report-url, so pr-plan posts and uploads nothing either.
867
+ const argv = readFileSync(join(r.report!, "pr-plan.args"), "utf-8").trimEnd().split("\n");
868
+ expect(argv).toContain("--no-status");
869
+ expect(argv).not.toContain("--report-url");
870
+ expect(argv).toContain("--events-json");
871
+
872
+ // The record is on this checkout's chant/lifecycle, and says local.
873
+ expect(r.record).toMatchObject({ op: "pr-plan", gitSha: sha, id: "local-plan-1", runOrigin: { forge: "local" } });
874
+ const stored = (await readRunLedger("prod", "pr-plan", { cwd: app })).records.at(-1)!;
875
+ expect(stored).toMatchObject({ id: "local-plan-1", runOrigin: { forge: "local" } });
876
+ expect(formatCiRun(r)).toContain("local run: the record stays on this machine");
877
+
878
+ // The events stream as the job runs, and say where it ran.
879
+ const stream = checkRunEventLines(events);
880
+ expect(stream[0]).toMatchObject({ type: "run-start", kind: "pipeline", runtime: "local", runId: "local-plan-1", gitSha: sha, runOrigin: { forge: "local" } });
881
+ expect(stream.at(-1)).toMatchObject({ type: "run-settle", status: "ok" });
882
+
883
+ // A local run is never CI's verdict, signed or not.
884
+ const runs = await readOpRunSource(app, root, git(root, "rev-parse", "HEAD"));
885
+ expect(runs.checkRuns(sha)).toEqual([]);
886
+ expect(runs.ignored.get(sha)).toBeUndefined();
887
+ });
888
+
889
+ test("--local with --in-place is refused before anything runs, and the flag parses", async () => {
890
+ const { app, base } = workspace();
891
+ await expect(ciRun({ cwd: app, job: "plan", env: "prod", forge: "github", base, pr: 1, chant: fakeChant(), hostEnv: HOST, inPlace: true, local: true })).rejects.toThrow(/--in-place .* --local/);
892
+ expect(parseArgs(["ci", "run", "plan", "--local"]).local).toBe(true);
893
+ });
894
+ });