@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
@@ -44,6 +44,30 @@
44
44
  * With a forge client (#4097) the job also sets a commit status at its
45
45
  * commit: `pending` when it starts, `success` or `failure` when it settles.
46
46
  * A status the forge refuses is a warning and never fails the job.
47
+ *
48
+ * Signing is opt-in (#4156). With no runner key the record is unsigned, and
49
+ * a reader counts it with no signer. A key `.chant/trust.json` does not list
50
+ * leaves it unsigned with a note `--verbose` prints, unless the declaration
51
+ * sets `ci.green.signedSince`; then a run left unsigned is a warning.
52
+ *
53
+ * In place (`--in-place`, #4156) the job runs in the checkout it is started
54
+ * in, as a generated forge job does: no worktree, no setup phase and no
55
+ * commit status of its own (the forge shows the job). The steps get the
56
+ * job's whole environment less `CHANT_RUNNER_KEY`, since the forge job is
57
+ * already the boundary, and the record's `runOrigin` names the forge's run,
58
+ * job and attempt (`resolveCiJob`).
59
+ *
60
+ * Local (`--local`, #4161) runs the job the same way on this machine and
61
+ * leaves nothing outside it. It implies `--no-push` and `--no-status`, and
62
+ * writes nothing under a report URL, given or declared: the command gets no
63
+ * `--report-url`, and gets `--no-status`, so pr-plan and pr-apply post no
64
+ * status or note either. The record is still written to the local
65
+ * `chant/lifecycle`, where a reader on this machine finds it beside CI's for
66
+ * the same commit. It says `runOrigin: { forge: "local" }` and its events say
67
+ * `runtime: "local"`. `ci tick --source runs` and `ci list` never count it.
68
+ * A pr-apply still pushes its own release records and gate facts, as the
69
+ * command always does, since a local apply changes the same infrastructure
70
+ * a CI one does.
47
71
  */
48
72
 
49
73
  import { execFileSync } from "node:child_process";
@@ -55,8 +79,8 @@ import { detectForge, ForgeEnvironmentError, forgeFromEnv, FORGE_KINDS, type Com
55
79
  import { PR_APPLY_GATE } from "../pr-loop";
56
80
  import type { CommandContext } from "../cli/registry";
57
81
  import { formatError, formatWarning } from "../cli/format";
58
- import type { RunOrigin } from "../lifecycle/release-ledger";
59
- import { pushLifecycleReport } from "../lifecycle/git";
82
+ import { resolveCiJob, type RunOrigin } from "../lifecycle/release-ledger";
83
+ import { fetchLifecycleStatus, pushLifecycleReport } from "../lifecycle/git";
60
84
  import { ACTIVITY_PROFILES } from "../op/activity-profiles";
61
85
  import type { ActivityFn, ActivityProfile } from "../op/activity-registry";
62
86
  import { shellCmd } from "../op/activities/shell";
@@ -66,7 +90,9 @@ import type { ActivityStep, OpConfig, PhaseDefinition } from "../op/types";
66
90
  import { redactText, type Redact } from "../op/redact";
67
91
  import { findWorkspaceRoot } from "../project-root";
68
92
  import { parseReportUrl, reportKey } from "../pr-report-url";
69
- import { ciTagSigner, RUNNER_KEY_ENV } from "./ci-green";
93
+ import { reportUrlOf } from "./ci-report-url";
94
+ import { ciTagSigner, RUNNER_KEY_ENV, signedRunsRequired } from "./ci-green";
95
+ import { loadRunnerKey } from "./trust/dsse";
70
96
  import { ciRedactor, ciSecretsFor, CI_SECRETS_ENV, readCiSecrets, type CiSecrets } from "./ci-secrets";
71
97
  import { updateCiRunIndex, type CiRunIndexEntry } from "./ci-run-index";
72
98
  import { resolveMemberContext } from "./member-pipeline";
@@ -84,6 +110,9 @@ export const CI_JOB_OPS: Record<CiJobName, string> = { plan: "pr-plan", apply: "
84
110
  /** Where a job the chant runner ran says it ran (#4094). */
85
111
  export const CHANT_RUNNER_ORIGIN: RunOrigin = { forge: "chant" };
86
112
 
113
+ /** Where a job `chant ci run --local` ran says it ran (#4161): this machine, outside CI. */
114
+ export const CHANT_LOCAL_ORIGIN: RunOrigin = { forge: "local" };
115
+
87
116
  /** The runner's variables every job gets, when the runner has them. */
88
117
  export const CI_RUN_BASE_ENV = ["PATH", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL", "LC_CTYPE", "TMPDIR", "TZ"] as const;
89
118
 
@@ -241,6 +270,17 @@ function exitCodeOf(error: string | undefined): number | undefined {
241
270
  return m ? Number(m[1]) : undefined;
242
271
  }
243
272
 
273
+ /**
274
+ * The code `chant ci run` exits with for a settled job: 0 when it passed,
275
+ * else its command's own code when that is 1 to 255, such as 3 for a moved
276
+ * plan or a waiting wave (#4156), else 1. The run index records the same
277
+ * code (#4165).
278
+ */
279
+ export function ciJobExitCode(result: { status: OpRunResult["status"]; exitCode?: number }): number {
280
+ if (result.status === "ok") return 0;
281
+ return result.exitCode && result.exitCode > 0 && result.exitCode < 256 ? result.exitCode : 1;
282
+ }
283
+
244
284
  /** The first line of a step's error, for a reason. */
245
285
  const firstLine = (text: string): string => text.split("\n").find((l) => l.trim() !== "")?.trim() ?? text;
246
286
 
@@ -378,7 +418,12 @@ export interface CiRunRequest {
378
418
  base?: string;
379
419
  pr?: number;
380
420
  commit?: string;
381
- /** The generator's options: `--gate`, `--branch`, `--events-json`, `--report-url`, `--pr-build` and `--pr-build-command`. */
421
+ /**
422
+ * The generator's options: `--gate`, `--branch`, `--events-json`,
423
+ * `--report-url`, `--pr-build` and `--pr-build-command`, and `--wave-gate`
424
+ * (#4003), with which the apply job runs the waves in order and stops at
425
+ * the first whose moved plan waits on its own gate.
426
+ */
382
427
  loop?: PrLoopPipelineOptions;
383
428
  /** `--image`: the image a generated pipeline names. Only the default gets setup lines. */
384
429
  image?: string;
@@ -425,6 +470,24 @@ export interface CiRunRequest {
425
470
  resumeRecord?: string;
426
471
  /** Set a commit status at the job's commit (#4097). Absent: none is set. `chant ci run` builds it from the environment. */
427
472
  status?: CiStatusConfig;
473
+ /**
474
+ * `--in-place` (#4156): run in the checkout `cwd` is in, at its `HEAD`, as
475
+ * a generated forge job does, with the runner's whole environment less
476
+ * `CHANT_RUNNER_KEY`, no setup phase, and the forge's run as `runOrigin`.
477
+ */
478
+ inPlace?: boolean;
479
+ /**
480
+ * `--local` (#4161): run on this machine and write nothing beyond it. It
481
+ * implies `push: false` and no status (`status` is ignored), writes
482
+ * nothing under `loop.reportUrl`, and the record and events say
483
+ * `runOrigin: { forge: "local" }` and `runtime: "local"`.
484
+ */
485
+ local?: boolean;
486
+ /**
487
+ * `--wave <k>` (#4003, #4156): the one wave a generated apply job runs, as
488
+ * `pr-apply --wave <k>`. Needs `--wave-gate`.
489
+ */
490
+ wave?: number;
428
491
  signal?: AbortSignal;
429
492
  }
430
493
 
@@ -443,8 +506,14 @@ export interface CiRunResult {
443
506
  pushWarning?: string;
444
507
  /** What went wrong beside the job, such as a record the key could not sign. */
445
508
  warnings: string[];
509
+ /** What `--verbose` prints: such as a run left unsigned when nothing asks for a signature (#4156). */
510
+ notes: string[];
511
+ /** The exit code of the job's command when it failed with one, such as pr-apply's 3 for a moved plan. */
512
+ exitCode?: number;
446
513
  /** The checkout the job ran in, removed by now. */
447
514
  checkout: string;
515
+ /** The job ran with `local` (#4161): its record stays on this machine. */
516
+ local?: true;
448
517
  }
449
518
 
450
519
  const message = (err: unknown): string => (err instanceof Error ? err.message : String(err));
@@ -516,6 +585,11 @@ async function recordInIndex(input: {
516
585
  member?: string;
517
586
  pr?: number;
518
587
  record: OpRunRecord;
588
+ runOrigin: RunOrigin;
589
+ /** The runner that signed the record, or null (#4156). */
590
+ signer: string | null;
591
+ /** The code `chant ci run` exits with for the job (#4165). */
592
+ exitCode: number;
519
593
  reportDir: string;
520
594
  limit?: number;
521
595
  /** The run's redactor (#4096): every string written to the index goes through it, as the copied report does. */
@@ -547,7 +621,9 @@ async function recordInIndex(input: {
547
621
  ended: input.record.ended,
548
622
  runId: clean(input.record.id),
549
623
  key: clean(reportKey(pr, input.commit, input.member)),
550
- runOrigin: CHANT_RUNNER_ORIGIN,
624
+ runOrigin: input.runOrigin,
625
+ signer: input.signer,
626
+ exitCode: input.exitCode,
551
627
  };
552
628
  await updateCiRunIndex({ target, entry, ...(input.limit ? { limit: input.limit } : {}), warn: input.warn });
553
629
  }
@@ -565,11 +641,17 @@ export async function ciRun(req: CiRunRequest): Promise<CiRunResult> {
565
641
  const top = gitTop(cwd);
566
642
  if (!top) throw new CiRunError("a job runs in a fresh checkout of a commit, and this directory is not in a git repository");
567
643
  const commit = resolveJobCommit(top, req.commit ?? "HEAD");
644
+ if (req.inPlace && commit !== resolveJobCommit(top, "HEAD")) throw new CiRunError(`--in-place runs the checkout's HEAD, and --commit names ${commit.slice(0, 12)}`);
645
+ if (req.local && req.inPlace) throw new CiRunError("--in-place runs a forge job in the forge's checkout, and --local runs one on this machine; pass one of them");
568
646
  const place = ciJobPlace(cwd, top);
569
- const jobs = prLoopJobs(req.forge, req.env, req.loop ?? {}, place.member);
570
- const job = jobs.find((j) => j.jobName === req.job);
571
- if (!job) throw new CiRunError(`the pull-request pipeline has no ${req.job} job; it has ${jobs.map((j) => j.jobName).join(" and ")}`);
572
- const jobEnv = ciJobEnv(job, {
647
+ const jobs = prLoopJobs(req.forge, req.env, req.local ? localLoop(req.loop) : (req.loop ?? {}), place.member);
648
+ const found = jobs.find((j) => j.jobName === req.job);
649
+ if (!found) throw new CiRunError(`the pull-request pipeline has no ${req.job} job; it has ${jobs.map((j) => j.jobName).join(" and ")}`);
650
+ if (req.wave !== undefined && (req.job !== "apply" || !req.loop?.waveGate)) throw new CiRunError("--wave names the one wave an apply runs, and takes --wave-gate: ci run apply --wave-gate <gate> --wave <k>");
651
+ // A generated wave job's command, as prLoopWaveJobs writes it. #4161: a local job's command posts no status or note.
652
+ const command = `${found.command}${req.wave !== undefined ? ` --wave ${req.wave}` : ""}${req.local ? " --no-status" : ""}`;
653
+ const job = { ...found, command, ...(req.wave !== undefined ? { wave: req.wave } : {}) };
654
+ const own = ciJobEnv(job, {
573
655
  forge: req.forge,
574
656
  env: req.env,
575
657
  values: { base: req.base, pr: req.pr, commit },
@@ -577,37 +659,90 @@ export async function ciRun(req: CiRunRequest): Promise<CiRunResult> {
577
659
  passEnv: req.passEnv,
578
660
  secrets: ciSecretsFor(req.secrets, req.job),
579
661
  });
662
+ // #4156: in place, the forge job's environment is the job's, less the runner key.
663
+ const jobEnv = req.inPlace ? { ...definedEnv(hostEnv), ...own } : own;
664
+ delete jobEnv[RUNNER_KEY_ENV];
580
665
  // #4096: the secrets and the job's secret-looking variables, redacted from everything the run writes and returns.
581
- const redact = ciRedactor(jobEnv, req.secrets);
666
+ // In place the forge's own variables come too; one whose value is short, such as a flag, is left out, so it can't blank common words.
667
+ const redact = ciRedactor(req.inPlace ? { ...longValues(jobEnv), ...own } : jobEnv, req.secrets);
582
668
  try {
583
669
  const result = await runCiJob(req, { cwd, top, commit, place, job, jobEnv, ...(redact ? { redact } : {}) });
584
- return redact ? { ...result, warnings: result.warnings.map(redact), ...(result.pushWarning ? { pushWarning: redact(result.pushWarning) } : {}) } : result;
670
+ return redact ? { ...result, warnings: result.warnings.map(redact), notes: result.notes.map(redact), ...(result.pushWarning ? { pushWarning: redact(result.pushWarning) } : {}) } : result;
585
671
  } catch (err) {
586
672
  if (redact && err instanceof Error) err.message = redact(err.message);
587
673
  throw err;
588
674
  }
589
675
  }
590
676
 
677
+ /** The generator's options for a `--local` job (#4161): the same, with no report URL. */
678
+ function localLoop(loop: PrLoopPipelineOptions | undefined): PrLoopPipelineOptions {
679
+ const { reportUrl: _dropped, ...rest } = loop ?? {};
680
+ return rest;
681
+ }
682
+
683
+ /** The variables of `env` whose value is long enough to be a credential, 8 characters or more. */
684
+ function longValues(env: Record<string, string>): Record<string, string> {
685
+ return Object.fromEntries(Object.entries(env).filter(([, value]) => value.length >= 8));
686
+ }
687
+
688
+ /** The variables of `env` that have a value. */
689
+ function definedEnv(env: Record<string, string | undefined>): Record<string, string> {
690
+ const out: Record<string, string> = {};
691
+ for (const [name, value] of Object.entries(env)) if (value !== undefined) out[name] = value;
692
+ return out;
693
+ }
694
+
695
+ /**
696
+ * Who signs the run (#4156): the runner key, checked against
697
+ * `.chant/trust.json` at the commit. A key that isn't an Ed25519 PEM is an
698
+ * error, and so is a `--key` the file does not list. A key from the
699
+ * environment that it does not list, and no key at all, leave the run
700
+ * unsigned: a warning when the declaration's `ci.green.signedSince` asks
701
+ * for signed runs, a note for `--verbose` otherwise.
702
+ */
703
+ function ciRunSigner(req: CiRunRequest, at: { cwd: string; top: string; commit: string }): { signer?: ReturnType<typeof ciTagSigner>; warnings: string[]; notes: string[] } {
704
+ // #4161: a local run never counts, signed or not, so nothing about its signature warns.
705
+ const required = req.local ? null : signedRunsRequired(at.cwd);
706
+ const say = (text: string) => (required ? { warnings: [`${text}, and ci.green.signedSince is ${required}, so it will not count`], notes: [] } : { warnings: [], notes: [text] });
707
+ if (req.runnerKey === undefined) return say("there is no runner key, so the run's record is unsigned");
708
+ try {
709
+ loadRunnerKey(req.runnerKey);
710
+ } catch (err) {
711
+ throw new CiRunError(`the runner key${req.runnerKeyFromEnv ? ` in ${RUNNER_KEY_ENV}` : ""} is not an Ed25519 private key in PEM: ${message(err)}`);
712
+ }
713
+ try {
714
+ return { signer: ciTagSigner(at.top, at.commit, req.runnerKey, "the runs it signed"), warnings: [], notes: [] };
715
+ } catch (err) {
716
+ if (!req.runnerKeyFromEnv) throw new CiRunError(`the run would not be signed: ${message(err)}`);
717
+ return say(`${RUNNER_KEY_ENV} is set, and the run will not be signed: ${message(err)}`);
718
+ }
719
+ }
720
+
721
+ /** Where the job runs: a fresh worktree of the commit, or with `inPlace` the checkout itself, which is left as it is. */
722
+ function jobCheckout(top: string, commit: string, inPlace?: boolean): { dir: string; remove(): void } {
723
+ return inPlace ? { dir: top, remove() {} } : freshCheckout(top, commit);
724
+ }
725
+
591
726
  /** {@link ciRun} once the job and its environment are known. */
592
727
  async function runCiJob(
593
728
  req: CiRunRequest,
594
729
  at: { cwd: string; top: string; commit: string; place: { member?: string; dir: string }; job: PrLoopJob; jobEnv: Record<string, string>; redact?: Redact },
595
730
  ): Promise<CiRunResult> {
596
731
  const { cwd, top, commit, place, job, jobEnv, redact } = at;
597
-
598
- // The record is signed against .chant/trust.json at the commit the job runs, so a key that commit does not list is refused before anything runs.
599
- let signer: ReturnType<typeof ciTagSigner> | undefined;
600
- const early: string[] = [];
601
- if (req.runnerKey !== undefined) {
602
- try {
603
- signer = ciTagSigner(top, commit, req.runnerKey, "the runs it signed");
604
- } catch (err) {
605
- if (!req.runnerKeyFromEnv) throw new CiRunError(`the run would not be signed: ${message(err)}`);
606
- early.push(`${RUNNER_KEY_ENV} is set, and the run will not be signed: ${message(err)}`);
607
- }
608
- }
609
-
610
- const checkout = freshCheckout(top, commit);
732
+ const local = req.local === true;
733
+ const status = local ? undefined : req.status;
734
+ const reportUrl = local ? undefined : req.loop?.reportUrl;
735
+
736
+ // The record is signed against .chant/trust.json at the commit the job runs, so a --key that commit does not list is refused before anything runs.
737
+ const signing = ciRunSigner(req, { cwd, top, commit });
738
+ const signer = signing.signer;
739
+ const early = signing.warnings;
740
+ const notes = signing.notes;
741
+ const origin: RunOrigin = local ? CHANT_LOCAL_ORIGIN : req.inPlace ? (resolveCiJob(req.hostEnv ?? process.env)?.runOrigin ?? CHANT_RUNNER_ORIGIN) : CHANT_RUNNER_ORIGIN;
742
+ // In place, the job writes its record on the checkout's chant/lifecycle, which a forge checkout may not have fetched.
743
+ if (req.inPlace && req.push !== false) await fetchLifecycleStatus({ cwd: top }).catch(() => undefined);
744
+
745
+ const checkout = jobCheckout(top, commit, req.inPlace);
611
746
  try {
612
747
  const jobDir = place.dir === "." ? checkout.dir : join(checkout.dir, place.dir);
613
748
  const op = ciJobOp({
@@ -615,7 +750,7 @@ async function runCiJob(
615
750
  env: req.env,
616
751
  cwd: jobDir,
617
752
  jobEnv,
618
- setup: prLoopSetup(req.image ?? PR_LOOP_IMAGE),
753
+ setup: req.inPlace ? [] : prLoopSetup(req.image ?? PR_LOOP_IMAGE),
619
754
  build: [...(req.beforeScript ?? []), ...(job.build ?? [])],
620
755
  ...(req.chant ? { chant: req.chant } : {}),
621
756
  });
@@ -627,13 +762,13 @@ async function runCiJob(
627
762
  copyFileSync(resume.host, resume.job);
628
763
  }
629
764
  const statusContext = ciStatusContext(req.job, req.loop?.gate);
630
- const statusUrl = req.status
631
- ? ciStatusUrl({ ...(req.status.urlTemplate ? { urlTemplate: req.status.urlTemplate } : {}), ...(req.loop?.reportUrl ? { reportUrl: req.loop.reportUrl } : {}), ...(req.pr !== undefined ? { pr: req.pr } : {}), commit, ...(place.member ? { member: place.member } : {}) })
765
+ const statusUrl = status
766
+ ? ciStatusUrl({ ...(status.urlTemplate ? { urlTemplate: status.urlTemplate } : {}), ...(reportUrl ? { reportUrl } : {}), ...(req.pr !== undefined ? { pr: req.pr } : {}), commit, ...(place.member ? { member: place.member } : {}) })
632
767
  : undefined;
633
768
  const postStatus = async (state: CommitStatus["state"], description: string): Promise<void> => {
634
- if (!req.status) return;
769
+ if (!status) return;
635
770
  try {
636
- await req.status.client.setCommitStatus(commit, { context: statusContext, state, description: cut(redact ? redact(description) : description), ...(statusUrl ? { targetUrl: statusUrl } : {}) });
771
+ await status.client.setCommitStatus(commit, { context: statusContext, state, description: cut(redact ? redact(description) : description), ...(statusUrl ? { targetUrl: statusUrl } : {}) });
637
772
  } catch (err) {
638
773
  warnings.push(`could not set the ${statusContext} status: ${message(err)}`);
639
774
  }
@@ -649,7 +784,7 @@ async function runCiJob(
649
784
  cwd: jobDir,
650
785
  commit,
651
786
  ledger: { cwd: jobDir },
652
- runner: { kind: "pipeline", runtime: "ci", runOrigin: CHANT_RUNNER_ORIGIN },
787
+ runner: { kind: "pipeline", runtime: local ? "local" : "ci", runOrigin: origin },
653
788
  describeFailure: (records) => ciJobFailure({ job: req.job, op, records, reportDir: join(jobDir, PR_LOOP_REPORT_DIR) }),
654
789
  ...(req.runEvents ? { runEvents: { write: req.runEvents } } : {}),
655
790
  ...(req.stepOutput ? { stepOutput: req.stepOutput } : {}),
@@ -687,26 +822,37 @@ async function runCiJob(
687
822
  let report: string | null = null;
688
823
  if (existsSync(written)) {
689
824
  report = resolve(cwd, req.output ?? PR_LOOP_REPORT_DIR);
690
- cpSync(written, report, { recursive: true });
691
- if (redact) redactTree(report, redact);
692
- }
693
- if (req.loop?.reportUrl) {
694
- await recordInIndex({ url: req.loop.reportUrl, job: req.job, commit, member: place.member, pr: req.pr, record: result.record, reportDir: written, limit: req.runIndexLimit, ...(redact ? { redact } : {}), warn: (w) => warnings.push(w) });
825
+ // In place the report is already where the forge keeps it, and stays as the command wrote it.
826
+ if (!(existsSync(report) && realpathSync(report) === realpathSync(written))) {
827
+ cpSync(written, report, { recursive: true });
828
+ if (redact) redactTree(report, redact);
829
+ }
695
830
  }
696
- if (ledgerError) throw new CiRunError(ledgerError);
697
- const pushed: { pushed: boolean; pushWarning?: string } = req.push === false ? { pushed: false, pushWarning: "chant/lifecycle was not pushed" } : await pushLifecycleReport({ cwd: top });
698
831
  const failed = result.records.find((r) => r.status === "fail");
832
+ const exitCode = failed?.phase === req.job ? exitCodeOf(failed.error) : undefined;
833
+ if (reportUrl) {
834
+ await recordInIndex({ url: reportUrl, job: req.job, commit, member: place.member, pr: req.pr, record: result.record, runOrigin: origin, signer: result.record.statement && signer ? signer.principal : null, exitCode: ciJobExitCode({ status: result.status, ...(exitCode !== undefined ? { exitCode } : {}) }), reportDir: written, limit: req.runIndexLimit, ...(redact ? { redact } : {}), warn: (w) => warnings.push(w) });
835
+ }
836
+ // In place the forge job's outcome is the command's, so a record that could not be written is a warning there.
837
+ if (ledgerError && req.inPlace) warnings.push(ledgerError);
838
+ else if (ledgerError) throw new CiRunError(ledgerError);
839
+ const pushed: { pushed: boolean; pushWarning?: string } = local
840
+ ? { pushed: false }
841
+ : req.push === false ? { pushed: false, pushWarning: "chant/lifecycle was not pushed" } : await pushLifecycleReport({ cwd: top });
699
842
  return {
700
843
  op: op.name,
701
844
  status: result.status,
702
845
  record: result.record,
703
846
  ...(failed ? { failure: { phase: failed.phase, error: failed.error ?? "the step failed" } } : {}),
847
+ ...(exitCode !== undefined ? { exitCode } : {}),
704
848
  report,
705
- signer: signer?.principal ?? null,
849
+ signer: result.record.statement && signer ? signer.principal : null,
706
850
  pushed: pushed.pushed,
707
851
  ...(pushed.pushWarning ? { pushWarning: pushed.pushWarning } : {}),
708
852
  warnings,
853
+ notes,
709
854
  checkout: checkout.dir,
855
+ ...(local ? { local: true as const } : {}),
710
856
  };
711
857
  } finally {
712
858
  checkout.remove();
@@ -740,19 +886,40 @@ export function formatCiRun(r: CiRunResult): string {
740
886
  const why = r.record.failure;
741
887
  if (why) lines.push(` failure: ${why.code}${why.phase ? ` in ${why.phase}` : ""}${why.exitCode !== undefined ? `, exit ${why.exitCode}` : ""}: ${why.reason}`);
742
888
  else if (r.failure) lines.push(` ${r.failure.phase}: ${r.failure.error}`);
743
- lines.push(` record: ${r.record.env}/runs__${r.op}.jsonl on chant/lifecycle, ${r.signer ? `signed by ${r.signer}` : "unsigned, so ci tick from runs does not count it"}`);
889
+ lines.push(` record: ${r.record.env}/runs__${r.op}.jsonl on chant/lifecycle, ${r.signer ? `signed by ${r.signer}` : "unsigned"}`);
744
890
  if (r.report) lines.push(` report: ${r.report}`);
745
- if (!r.pushed) lines.push(` ${r.pushWarning ?? "chant/lifecycle was not pushed"}`);
891
+ if (r.local) lines.push(" local run: the record stays on this machine, with no push, commit status or report upload");
892
+ else if (!r.pushed) lines.push(` ${r.pushWarning ?? "chant/lifecycle was not pushed"}`);
746
893
  return lines.join("\n");
747
894
  }
748
895
 
749
896
  // ── The command ──────────────────────────────────────────────────────────────
750
897
 
898
+ /**
899
+ * The generator's options from `ci run`'s and `ci serve`'s flags, as `chant
900
+ * build --components --generate <lexicon> --pr-loop` takes them: `--gate`,
901
+ * `--branch`, `--events-json`, `--report-url`, `--pr-build`,
902
+ * `--pr-build-command`, `--wave-gate` (#4003) and `--no-require-review`
903
+ * (#4156).
904
+ */
905
+ export function ciLoopOptions(args: CommandContext["args"]): PrLoopPipelineOptions {
906
+ return {
907
+ ...(args.gate ? { gate: args.gate } : {}),
908
+ ...(args.branch ? { branch: args.branch } : {}),
909
+ ...(args.eventsJson ? { events: true } : {}),
910
+ ...(args.reportUrl ? { reportUrl: args.reportUrl } : {}),
911
+ ...(args.prBuild ? { build: true } : {}),
912
+ ...(args.prBuildCommand ? { buildCommand: args.prBuildCommand } : {}),
913
+ ...(args.waveGate ? { waveGate: args.waveGate } : {}),
914
+ ...(args.requireReview === false ? { requireReview: false } : {}),
915
+ };
916
+ }
917
+
751
918
  const USAGE =
752
919
  "chant ci run plan|apply --env <env> --base <sha> [--pr <n>] [--commit <sha>] [--forge github|gitlab|forgejo] " +
753
- "[--gate <name>] [--branch <name>] [--events-json] [--report-url <url>] [--run-index-limit <n>] [--image <image>] [--before-script <line>]... " +
754
- "[--pr-build] [--pr-build-command <line>] [--no-status] [--status-url <url template>] " +
755
- "[--chant <command>] [--pass-env <NAME>]... [--secrets <file>] [--key <runner key.pem>] [-o <dir>] | chant ci run --setup-key [--trust [--principal <name>]] [--state <dir>] [--branch <name>]";
920
+ "[--gate <name>] [--wave-gate <name>] [--wave <k>] [--branch <name>] [--events-json] [--report-url <url>] [--run-index-limit <n>] [--image <image>] [--before-script <line>]... " +
921
+ "[--pr-build] [--pr-build-command <line>] [--no-require-review] [--no-status] [--status-url <url template>] " +
922
+ "[--chant <command>] [--pass-env <NAME>]... [--secrets <file>] [--key <runner key.pem>] [-o <dir>] [--in-place | --local] [--no-push] [--verbose] | chant ci run --setup-key [--trust [--principal <name>]] [--state <dir>] [--branch <name>]";
756
923
 
757
924
  const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
758
925
 
@@ -792,9 +959,13 @@ export async function runCiRun(ctx: CommandContext): Promise<number> {
792
959
  const hostKey = fromFlag || process.env[RUNNER_KEY_ENV] ? undefined : hostRunnerKeyAt(process.cwd(), args.statePath);
793
960
  const runnerKey = fromFlag ? readFileSync(resolve(args.key!), "utf-8") : process.env[RUNNER_KEY_ENV] || hostKey?.pem;
794
961
  const hostEnv = process.env;
795
- const { status, warning } = ciStatusFromEnv({ forge: forge as ForgeKind, hostEnv, noStatus: args.noStatus, statusUrl: args.statusUrl });
962
+ // #4156: in place the forge shows the job, so ci run sets no status of its own.
963
+ // #4161: a local run sets no status, so it builds no client and warns about none.
964
+ const { status, warning } = ciStatusFromEnv({ forge: forge as ForgeKind, hostEnv, noStatus: args.noStatus || args.ciInPlace || args.local, statusUrl: args.statusUrl });
796
965
  if (warning) console.error(formatWarning({ message: warning }));
797
966
  const secrets = ciSecretsOf(args.ciSecrets);
967
+ // #4160: without --report-url, the declaration's ci.reportUrl.
968
+ const reportUrl = await reportUrlOf(args.reportUrl, process.cwd());
798
969
  const result = await ciRun({
799
970
  cwd: process.cwd(),
800
971
  ...(status ? { status } : {}),
@@ -804,14 +975,11 @@ export async function runCiRun(ctx: CommandContext): Promise<number> {
804
975
  ...(args.base ? { base: args.base } : {}),
805
976
  ...(args.pr !== undefined ? { pr: args.pr } : {}),
806
977
  ...(args.commit ? { commit: args.commit } : {}),
807
- loop: {
808
- ...(args.gate ? { gate: args.gate } : {}),
809
- ...(args.branch ? { branch: args.branch } : {}),
810
- ...(args.eventsJson ? { events: true } : {}),
811
- ...(args.reportUrl ? { reportUrl: args.reportUrl } : {}),
812
- ...(args.prBuild ? { build: true } : {}),
813
- ...(args.prBuildCommand ? { buildCommand: args.prBuildCommand } : {}),
814
- },
978
+ loop: ciLoopOptions(reportUrl ? { ...args, reportUrl } : args),
979
+ ...(args.ciInPlace ? { inPlace: true } : {}),
980
+ ...(args.local ? { local: true } : {}),
981
+ ...(args.noPush ? { push: false } : {}),
982
+ ...(args.wave !== undefined ? { wave: args.wave } : {}),
815
983
  ...(args.runIndexLimit ? { runIndexLimit: args.runIndexLimit } : {}),
816
984
  ...(args.image ? { image: args.image } : {}),
817
985
  ...(args.beforeScript?.length ? { beforeScript: args.beforeScript } : {}),
@@ -824,8 +992,10 @@ export async function runCiRun(ctx: CommandContext): Promise<number> {
824
992
  stepOutput: (line) => void process.stderr.write(`${line}\n`),
825
993
  });
826
994
  for (const w of result.warnings) console.error(formatWarning({ message: w }));
995
+ if (args.verbose) for (const n of result.notes) console.error(`ci run: ${n}`);
827
996
  console.error(formatCiRun(result));
828
- return result.status === "ok" ? 0 : 1;
997
+ // #4156: a failed command's own exit code, such as 3 for a moved plan, is the job's, as it was before the job ran through ci run.
998
+ return ciJobExitCode(result);
829
999
  } catch (err) {
830
1000
  console.error(formatError({ message: `chant ci run: ${message(err)}`, hint: USAGE }));
831
1001
  return 1;
@@ -2,9 +2,10 @@
2
2
  * #4099: the runner key on a `chant ci serve` or `chant ci run` host. The
3
3
  * key is made in the state directory with mode 0600, `--setup-key --trust`
4
4
  * proposes its runner entry on a branch through `trust runner add` (#3510)
5
- * and never writes the target branch, and `ci serve` refuses to start when
6
- * `.chant/trust.json` at the target's head does not list its key, unless
7
- * `--unsigned`.
5
+ * and never writes the target branch. Signing is opt-in (#4156): `ci serve`
6
+ * starts with no key and says nothing about it, and serves unsigned with one
7
+ * warning when `.chant/trust.json` at the target's head does not list its
8
+ * key. With `ci.green.signedSince` it refuses to start in either case.
8
9
  *
9
10
  * Every test works in its own temp repository with a bare origin and its own
10
11
  * state directory. The jobs' `chant` is a stub script, and no job runs: the
@@ -126,9 +127,12 @@ describe("the host's runner key (#4099)", () => {
126
127
  expect(trust.roles.runner).toEqual(["chant-runner:demo"]);
127
128
  expect(git(origin, "rev-parse", `${branch}^`)).toBe(main);
128
129
 
129
- // Until it merges, ci serve refuses the key; once it has, the key signs as the principal.
130
+ // Until it merges, ci serve serves unsigned with a warning (refuses with the opt-in); once it has, the key signs as the principal.
130
131
  const key = hostRunnerKey(state)!;
131
- expect(() => ciServeRunnerKey({ top: root, stateDir: state, branch: "main" })).toThrow(/not trusted at main/);
132
+ const pending = ciServeRunnerKey({ top: root, stateDir: state, branch: "main" });
133
+ expect(pending.pem).toBeUndefined();
134
+ expect(pending.warning).toMatch(/not trusted at main/);
135
+ expect(() => ciServeRunnerKey({ top: root, stateDir: state, branch: "main", required: main })).toThrow(/not trusted at main/);
132
136
  git(root, "push", "-q", "origin", `refs/remotes/origin/${branch}:refs/heads/main`);
133
137
  expect(ciServeRunnerKey({ top: root, stateDir: state, branch: "main" })).toMatchObject({ pem: key.pem, source: join(state, RUNNER_KEY_FILE) });
134
138
  expect(ciTagSigner(root, git(root, "rev-parse", "refs/remotes/origin/main"), key.pem).principal).toBe("chant-runner:demo");
@@ -138,29 +142,63 @@ describe("the host's runner key (#4099)", () => {
138
142
  expect(listed.err).toContain("lists it for chant-runner:demo, with the runner role");
139
143
  });
140
144
 
141
- test("ci serve refuses to start with a key trust.json does not list at the target's head, and --unsigned starts without one", async () => {
145
+ test("ci serve starts with no key and says nothing, and serves unsigned with one warning for a key trust.json does not list (#4156)", async () => {
142
146
  const { root } = workspace();
143
147
  const state = scratchDir("chant-runner-key-state-");
144
148
 
149
+ // No key: nothing about keys is printed. The serve goes on to the forge, which this test has no token for.
145
150
  const none = await cli(root, SERVE(state));
146
- expect(none.code).toBe(1);
147
- expect(none.err).toContain("there is no runner key");
148
-
151
+ expect(none.err.split("\n")).toHaveLength(2);
152
+ expect(none.err.split("\n")[0]).toContain("chant ci serve: --forge forgejo needs GITHUB_TOKEN");
153
+ const keyless = ciServeRunnerKey({ top: root, stateDir: state, branch: "main" });
154
+ expect(keyless.pem).toBeUndefined();
155
+ expect(keyless.warning).toBeUndefined();
156
+ expect(keyless.note).toContain("not signed");
157
+ // --verbose prints that one line.
158
+ const verbose = await cli(root, [...SERVE(state), "--verbose"]);
159
+ expect(verbose.err).toContain("ci serve: runs are not signed: there is no runner key");
160
+
161
+ // A key the head does not list: one warning at startup, and no key goes to the jobs.
149
162
  ensureHostRunnerKey(state);
150
163
  const unlisted = await cli(root, SERVE(state));
151
- expect(unlisted.code).toBe(1);
152
- expect(unlisted.err).toMatch(/not trusted at main [0-9a-f]{12}/);
164
+ expect(unlisted.err.match(/not trusted at main [0-9a-f]{12}/g)).toHaveLength(1);
165
+ expect(unlisted.err).toContain("Runs are not signed until it is");
153
166
  expect(unlisted.err).toContain("chant ci serve --setup-key --trust");
154
- expect(unlisted.err).toContain("chant workspace trust runner add");
167
+ expect(unlisted.err).not.toContain("signing runs with");
155
168
 
156
- // A key listed only in the working tree, not at the head, is still refused.
169
+ // A key listed only in the working tree, not at the head, is not trusted either.
157
170
  const other = generateKeyPairSync("ed25519").privateKey.export({ type: "pkcs8", format: "pem" }).toString();
158
171
  writeFileSync(join(state, "other.pem"), other, { mode: 0o600 });
159
172
  expect(trustWrite({ cwd: root, action: "runner add", name: "ci-bot", key: join(state, "other.pem") })).toMatchObject({ changed: true });
160
- const flagged = await cli(root, [...SERVE(state), "--key", join(state, "other.pem")]);
161
- expect(flagged.code).toBe(1);
162
- expect(flagged.err).toContain("lists no runner with this key");
173
+ const flagged = ciServeRunnerKey({ top: root, stateDir: state, branch: "main", keyFile: join(state, "other.pem") });
174
+ expect(flagged.pem).toBeUndefined();
175
+ expect(flagged.warning).toContain("lists no runner with this key");
176
+
177
+ // A key file that is not a key is an error either way.
178
+ writeFileSync(join(state, "bad.pem"), "not a key", { mode: 0o600 });
179
+ expect(() => ciServeRunnerKey({ top: root, stateDir: state, branch: "main", keyFile: join(state, "bad.pem") })).toThrow(/not an Ed25519 private key in PEM/);
180
+
181
+ // --unsigned is accepted and serves unsigned.
182
+ expect(ciServeRunnerKey({ top: root, stateDir: state, branch: "main", unsigned: true })).toMatchObject({ fromFlag: false, source: "none (--unsigned)" });
183
+ });
184
+
185
+ test("with ci.green.signedSince, ci serve refuses to start without a trusted key, --unsigned or not (#4156)", async () => {
186
+ const { root, main } = workspace();
187
+ const state = scratchDir("chant-runner-key-state-");
188
+ const green = { branch: "main", phases: { plan: ["pr-plan"] }, require: ["plan"], source: "runs", signedSince: main };
189
+ writeFiles(root, { "chant.workspace.json": declaration([{ name: "app", dir: "app", kind: "other", because: "the app" }], { ci: { green } }) });
163
190
 
164
- expect(ciServeRunnerKey({ top: root, stateDir: state, branch: "main", unsigned: true })).toEqual({ fromFlag: false, source: "none (--unsigned)" });
191
+ const none = await cli(root, SERVE(state));
192
+ expect(none.code).toBe(1);
193
+ expect(none.err).toContain(`ci.green.signedSince is ${main}, so every run must be signed, and there is no runner key`);
194
+ const unsigned = await cli(root, [...SERVE(state), "--unsigned"]);
195
+ expect(unsigned.code).toBe(1);
196
+ expect(unsigned.err).toContain("there is no runner key");
197
+
198
+ ensureHostRunnerKey(state);
199
+ const unlisted = await cli(root, SERVE(state));
200
+ expect(unlisted.code).toBe(1);
201
+ expect(unlisted.err).toMatch(/not trusted at main [0-9a-f]{12}/);
202
+ expect(unlisted.err).toContain("chant workspace trust runner add");
165
203
  });
166
204
  });
@@ -217,17 +217,38 @@ export function formatRunnerKeySetup(s: RunnerKeySetup, branch: string): string
217
217
  /**
218
218
  * The key `ci serve` signs with, checked before its first poll (#4099): the
219
219
  * `--key` file, else `CHANT_RUNNER_KEY`, else the host's key in the state
220
- * directory. Throws, with the command that lists it, when there is none or
221
- * `.chant/trust.json` at the target's head does not list it with the runner
222
- * role. With `unsigned` there is no key and nothing is checked.
220
+ * directory. Signing is opt-in (#4156):
221
+ *
222
+ * - With no key, `ci serve` serves unsigned and says so only in `note`.
223
+ * - A key `.chant/trust.json` at the target's head does not list with the
224
+ * runner role, or a target branch it can't find to check against, leaves
225
+ * it serving unsigned, with one `warning`.
226
+ * - With `required` (the declaration's `ci.green.signedSince`) each of
227
+ * those refuses to start, as before #4156.
228
+ *
229
+ * A key that is not an Ed25519 private key in PEM, or a `--key` file that
230
+ * can't be read, refuses either way. `unsigned` (`--unsigned`) skips the
231
+ * key without `required` and is ignored with it.
223
232
  */
224
- export function ciServeRunnerKey(req: { top: string; stateDir: string; branch: string; keyFile?: string; envKey?: string; unsigned?: boolean }): { pem?: string; fromFlag: boolean; source: string } {
225
- if (req.unsigned) return { fromFlag: false, source: "none (--unsigned)" };
233
+ export function ciServeRunnerKey(req: {
234
+ top: string;
235
+ stateDir: string;
236
+ branch: string;
237
+ keyFile?: string;
238
+ envKey?: string;
239
+ unsigned?: boolean;
240
+ /** The declaration's `ci.green.signedSince`, when it sets one: every run must then be signed. */
241
+ required?: string | null;
242
+ }): { pem?: string; fromFlag: boolean; source: string; warning?: string; note?: string } {
243
+ const required = req.required ?? null;
244
+ const optIn = `ci.green.signedSince is ${required}, so every run must be signed`;
245
+ if (req.unsigned && !required) return { fromFlag: false, source: "none (--unsigned)", note: "runs are not signed (--unsigned)" };
226
246
  const host = req.keyFile === undefined && !req.envKey ? hostRunnerKey(req.stateDir) : undefined;
227
247
  const pem = req.keyFile !== undefined ? readFileSync(req.keyFile, "utf-8") : req.envKey || host?.pem;
228
248
  const source = req.keyFile !== undefined ? req.keyFile : req.envKey ? "CHANT_RUNNER_KEY" : (host?.path ?? join(req.stateDir, RUNNER_KEY_FILE));
229
249
  if (pem === undefined) {
230
- throw new CiRunError(`there is no runner key: no --key, no CHANT_RUNNER_KEY and no ${source}. Make one with chant ci serve --setup-key, list it with --trust, or start with --unsigned`);
250
+ if (!required) return { fromFlag: false, source: "none", note: "runs are not signed: there is no runner key. To verify who ran a job, add one with chant ci serve --setup-key --trust" };
251
+ throw new CiRunError(`${optIn}, and there is no runner key: no --key, no CHANT_RUNNER_KEY and no ${source}. Make one with chant ci serve --setup-key and list it with --trust`);
231
252
  }
232
253
  let publicKey: string;
233
254
  try {
@@ -236,13 +257,17 @@ export function ciServeRunnerKey(req: { top: string; stateDir: string; branch: s
236
257
  throw new CiRunError(`the runner key from ${source} is not an Ed25519 private key in PEM: ${message(err)}`);
237
258
  }
238
259
  const head = targetHead(req.top, req.branch);
239
- if (!head) throw new CiRunError(`there is no branch ${req.branch} here or on origin, so the runner key can't be checked against its .chant/trust.json; fetch it, or start with --unsigned`);
260
+ if (!head) {
261
+ const why = `there is no branch ${req.branch} here or on origin, so the runner key can't be checked against its .chant/trust.json`;
262
+ if (required) throw new CiRunError(`${optIn}, and ${why}; fetch it`);
263
+ return { fromFlag: false, source, warning: `${why}, so runs are not signed; fetch it to sign them` };
264
+ }
240
265
  const listed = runnerKeyListed(req.top, head.commit, pem);
241
266
  if (!listed.ok) {
242
- throw new CiRunError(
243
- `the runner key from ${source} is not trusted at ${branchAt(req.branch, head.commit)}: ${listed.reason}\n` +
244
- ` List it on a branch for review with chant ci serve --setup-key --trust, or run ${trustCommand(defaultRunnerPrincipal(), publicKey)} in a checkout of ${req.branch} and merge the change. Start with --unsigned to serve without signing.`,
245
- );
267
+ const why = `the runner key from ${source} is not trusted at ${branchAt(req.branch, head.commit)}: ${listed.reason}`;
268
+ const fix = `List it on a branch for review with chant ci serve --setup-key --trust, or run ${trustCommand(defaultRunnerPrincipal(), publicKey)} in a checkout of ${req.branch} and merge the change.`;
269
+ if (required) throw new CiRunError(`${optIn}, and ${why}\n ${fix}`);
270
+ return { fromFlag: false, source, warning: `${why}. Runs are not signed until it is. ${fix}` };
246
271
  }
247
272
  return { pem, fromFlag: req.keyFile !== undefined, source };
248
273
  }