@intentius/chant 0.124.30 → 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 (346) 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 +74 -12
  39. package/dist/cli/parse-args.d.ts.map +1 -1
  40. package/dist/cli/parse-args.js +65 -0
  41. package/dist/cli/registry.d.ts +29 -2
  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/local-executor.d.ts +12 -0
  111. package/dist/op/local-executor.d.ts.map +1 -1
  112. package/dist/op/local-executor.js +22 -3
  113. package/dist/op/op-waves-run.d.ts.map +1 -1
  114. package/dist/op/op-waves-run.js +20 -5
  115. package/dist/op/operator.d.ts +2 -2
  116. package/dist/op/operator.js +2 -2
  117. package/dist/op/redact.d.ts +34 -0
  118. package/dist/op/redact.d.ts.map +1 -0
  119. package/dist/op/redact.js +94 -0
  120. package/dist/op/step-console.d.ts +2 -0
  121. package/dist/op/step-console.d.ts.map +1 -1
  122. package/dist/op/step-console.js +5 -2
  123. package/dist/op/steward.d.ts +6 -4
  124. package/dist/op/steward.d.ts.map +1 -1
  125. package/dist/op/steward.js +5 -3
  126. package/dist/op/work-lease-run.d.ts +20 -7
  127. package/dist/op/work-lease-run.d.ts.map +1 -1
  128. package/dist/op/work-lease-run.js +56 -12
  129. package/dist/pr-forge.d.ts +27 -0
  130. package/dist/pr-forge.d.ts.map +1 -1
  131. package/dist/pr-forge.js +51 -6
  132. package/dist/pr-loop.d.ts +141 -4
  133. package/dist/pr-loop.d.ts.map +1 -1
  134. package/dist/pr-loop.js +122 -6
  135. package/dist/pr-report-url.d.ts +10 -2
  136. package/dist/pr-report-url.d.ts.map +1 -1
  137. package/dist/pr-report-url.js +52 -6
  138. package/dist/ts-loader.d.ts +38 -4
  139. package/dist/ts-loader.d.ts.map +1 -1
  140. package/dist/ts-loader.js +190 -16
  141. package/dist/wave-runtime.mjs +208 -208
  142. package/dist/workspace/chant-migrations/fountain-retired-kinds.d.ts +34 -0
  143. package/dist/workspace/chant-migrations/fountain-retired-kinds.d.ts.map +1 -0
  144. package/dist/workspace/chant-migrations/fountain-retired-kinds.js +176 -0
  145. package/dist/workspace/chant-migrations.d.ts.map +1 -1
  146. package/dist/workspace/chant-migrations.js +2 -0
  147. package/dist/workspace/checks/boxes.d.ts +6 -6
  148. package/dist/workspace/checks/boxes.js +9 -9
  149. package/dist/workspace/ci-green-forge.d.ts +6 -4
  150. package/dist/workspace/ci-green-forge.d.ts.map +1 -1
  151. package/dist/workspace/ci-green.d.ts +28 -12
  152. package/dist/workspace/ci-green.d.ts.map +1 -1
  153. package/dist/workspace/ci-green.js +49 -21
  154. package/dist/workspace/ci-list.schema.json +12 -6
  155. package/dist/workspace/ci-report-url.d.ts +18 -0
  156. package/dist/workspace/ci-report-url.d.ts.map +1 -0
  157. package/dist/workspace/ci-report-url.js +33 -0
  158. package/dist/workspace/ci-run-index.d.ts +108 -0
  159. package/dist/workspace/ci-run-index.d.ts.map +1 -0
  160. package/dist/workspace/ci-run-index.js +164 -0
  161. package/dist/workspace/ci-run-index.schema.json +52 -0
  162. package/dist/workspace/ci-run.d.ts +159 -9
  163. package/dist/workspace/ci-run.d.ts.map +1 -1
  164. package/dist/workspace/ci-run.js +419 -50
  165. package/dist/workspace/ci-runner-key.d.ts +133 -0
  166. package/dist/workspace/ci-runner-key.d.ts.map +1 -0
  167. package/dist/workspace/ci-runner-key.js +237 -0
  168. package/dist/workspace/ci-secrets.d.ts +65 -0
  169. package/dist/workspace/ci-secrets.d.ts.map +1 -0
  170. package/dist/workspace/ci-secrets.js +211 -0
  171. package/dist/workspace/ci-serve.d.ts +67 -4
  172. package/dist/workspace/ci-serve.d.ts.map +1 -1
  173. package/dist/workspace/ci-serve.js +240 -35
  174. package/dist/workspace/declaration.d.ts +8 -0
  175. package/dist/workspace/declaration.d.ts.map +1 -1
  176. package/dist/workspace/declaration.js +11 -2
  177. package/dist/workspace/declaration.schema.json +7 -2
  178. package/dist/workspace/export.d.ts +1 -1
  179. package/dist/workspace/export.d.ts.map +1 -1
  180. package/dist/workspace/export.js +7 -3
  181. package/dist/workspace/ls.d.ts +6 -1
  182. package/dist/workspace/ls.d.ts.map +1 -1
  183. package/dist/workspace/ls.js +1 -0
  184. package/dist/workspace/ls.schema.json +7 -1
  185. package/dist/workspace/pin-integrity.d.ts.map +1 -1
  186. package/dist/workspace/pin-integrity.js +3 -2
  187. package/dist/workspace/pr-report.schema.json +74 -7
  188. package/dist/workspace/reason-codes.d.ts +7 -3
  189. package/dist/workspace/reason-codes.d.ts.map +1 -1
  190. package/dist/workspace/reason-codes.js +9 -4
  191. package/dist/workspace/run-event.schema.json +3 -1
  192. package/dist/workspace/status.schema.json +3 -1
  193. package/dist/workspace/trust/trust-write.d.ts +109 -0
  194. package/dist/workspace/trust/trust-write.d.ts.map +1 -0
  195. package/dist/workspace/trust/trust-write.js +320 -0
  196. package/dist/workspace/trust-write.schema.json +265 -0
  197. package/dist/workspace/write-scope.d.ts +9 -0
  198. package/dist/workspace/write-scope.d.ts.map +1 -1
  199. package/dist/workspace/write-scope.js +11 -1
  200. package/package.json +1 -1
  201. package/src/__fixtures__/pr-loop/pr-apply-wave-waiting.note.golden.md +48 -0
  202. package/src/cli/commands/__fixtures__/init-lexicon-output/package.json +3 -3
  203. package/src/cli/commands/audit.test.ts +2 -2
  204. package/src/cli/commands/check-lexicon-plugin.test.ts +9 -0
  205. package/src/cli/commands/check-lexicon-plugin.ts +11 -8
  206. package/src/cli/commands/check-lexicon.test.ts +1 -1
  207. package/src/cli/commands/check-lexicon.ts +16 -11
  208. package/src/cli/commands/import.ts +40 -0
  209. package/src/cli/commands/init-lexicon/templates/project.ts +4 -3
  210. package/src/cli/commands/init-lexicon.test.ts +8 -5
  211. package/src/cli/commands/onboard.test.ts +45 -0
  212. package/src/cli/commands/onboard.ts +8 -4
  213. package/src/cli/commands/update.test.ts +6 -0
  214. package/src/cli/commands/update.ts +4 -1
  215. package/src/cli/compiled-cli.e2e.test.ts +76 -11
  216. package/src/cli/dependencies.e2e.test.ts +101 -0
  217. package/src/cli/dependencies.test.ts +93 -0
  218. package/src/cli/dependencies.ts +122 -0
  219. package/src/cli/handlers/build.test.ts +69 -0
  220. package/src/cli/handlers/build.ts +20 -4
  221. package/src/cli/handlers/fan-out.test.ts +73 -0
  222. package/src/cli/handlers/fan-out.ts +11 -4
  223. package/src/cli/handlers/graph.test.ts +76 -0
  224. package/src/cli/handlers/graph.ts +19 -12
  225. package/src/cli/handlers/lint.test.ts +42 -0
  226. package/src/cli/handlers/lint.ts +6 -0
  227. package/src/cli/handlers/operator.ts +3 -3
  228. package/src/cli/handlers/pr-waves.test.ts +449 -0
  229. package/src/cli/handlers/pr.test.ts +134 -0
  230. package/src/cli/handlers/pr.ts +523 -31
  231. package/src/cli/handlers/promote.test.ts +26 -1
  232. package/src/cli/handlers/promote.ts +3 -2
  233. package/src/cli/handlers/run-components-exit.test.ts +99 -0
  234. package/src/cli/handlers/run-signer.test.ts +79 -0
  235. package/src/cli/handlers/run.ts +34 -16
  236. package/src/cli/main.test.ts +19 -0
  237. package/src/cli/main.ts +73 -12
  238. package/src/cli/parse-args.ts +48 -0
  239. package/src/cli/registry.ts +35 -4
  240. package/src/codegen/generate-post-synth-barrel.ts +2 -2
  241. package/src/codegen/lexicon-regen.ts +1 -1
  242. package/src/codegen/pinned-upgrade.ts +2 -2
  243. package/src/codegen/post-synth-barrel-guards.test.ts +6 -11
  244. package/src/components/capability.ts +51 -3
  245. package/src/components/changed-files.test.ts +16 -1
  246. package/src/components/changed-files.ts +9 -1
  247. package/src/components/driver.test.ts +36 -1
  248. package/src/components/driver.ts +76 -4
  249. package/src/components/fan-out-record.ts +29 -1
  250. package/src/components/fan-out-run.test.ts +35 -0
  251. package/src/components/fan-out-run.ts +40 -1
  252. package/src/components/pr-pipeline.test.ts +100 -0
  253. package/src/components/pr-pipeline.ts +132 -17
  254. package/src/components/pr-run.test.ts +323 -18
  255. package/src/components/pr-run.ts +354 -75
  256. package/src/components/wave-plan.ts +16 -4
  257. package/src/discovery/fold-host-method.test.ts +87 -0
  258. package/src/discovery/fold-import.ts +15 -5
  259. package/src/discovery/fold-package-composite.test.ts +27 -0
  260. package/src/discovery/fold-specifiers.test.ts +8 -0
  261. package/src/discovery/sandbox/bundle.ts +6 -0
  262. package/src/discovery/specifier-resolve.ts +8 -2
  263. package/src/fold/fold.test.ts +72 -0
  264. package/src/fold/fold.ts +71 -0
  265. package/src/fold/subset.test.ts +31 -0
  266. package/src/fold/subset.ts +99 -0
  267. package/src/lexicon-dist.e2e.test.ts +254 -0
  268. package/src/lexicon-module.test.ts +47 -0
  269. package/src/lexicon-module.ts +49 -7
  270. package/src/lexicon-plugin-helpers.ts +6 -4
  271. package/src/lexicon.ts +2 -0
  272. package/src/lifecycle/affected.test.ts +7 -0
  273. package/src/lifecycle/affected.ts +6 -1
  274. package/src/lifecycle/release-ledger.ts +34 -0
  275. package/src/lifecycle/work-dir.test.ts +77 -0
  276. package/src/lifecycle/work-dir.ts +82 -0
  277. package/src/lint/discover.test.ts +46 -2
  278. package/src/lint/discover.ts +55 -49
  279. package/src/module-source.test.ts +85 -0
  280. package/src/module-source.ts +112 -0
  281. package/src/op/activities/factory.ts +1 -1
  282. package/src/op/discover-root.test.ts +31 -2
  283. package/src/op/discover.ts +14 -4
  284. package/src/op/gate.test.ts +30 -0
  285. package/src/op/gate.ts +3 -0
  286. package/src/op/local-executor.ts +31 -3
  287. package/src/op/op-waves-run.ts +20 -5
  288. package/src/op/op-waves.test.ts +49 -0
  289. package/src/op/operator.ts +2 -2
  290. package/src/op/redact.ts +93 -0
  291. package/src/op/step-console.ts +7 -2
  292. package/src/op/steward.ts +6 -4
  293. package/src/op/work-lease-run.test.ts +88 -2
  294. package/src/op/work-lease-run.ts +52 -12
  295. package/src/pr-forge-bundle.test.ts +2 -2
  296. package/src/pr-forge.test.ts +71 -0
  297. package/src/pr-forge.ts +70 -6
  298. package/src/pr-loop-bundle.test.ts +2 -1
  299. package/src/pr-loop.test.ts +117 -1
  300. package/src/pr-loop.ts +252 -10
  301. package/src/pr-report-url.ts +58 -6
  302. package/src/terraform/__fixtures__/advise.test.ts +21 -2
  303. package/src/ts-loader.test.ts +89 -1
  304. package/src/ts-loader.ts +180 -13
  305. package/src/workspace/changes.test.ts +1 -1
  306. package/src/workspace/chant-migrations/fountain-retired-kinds.test.ts +33 -0
  307. package/src/workspace/chant-migrations/fountain-retired-kinds.ts +190 -0
  308. package/src/workspace/chant-migrations.e2e.test.ts +5 -0
  309. package/src/workspace/chant-migrations.ts +2 -0
  310. package/src/workspace/checks/boxes.ts +9 -9
  311. package/src/workspace/ci-green-forge.ts +6 -4
  312. package/src/workspace/ci-green-runs.test.ts +53 -11
  313. package/src/workspace/ci-green.test.ts +1 -1
  314. package/src/workspace/ci-green.ts +50 -22
  315. package/src/workspace/ci-list.schema.json +12 -6
  316. package/src/workspace/ci-report-url.test.ts +220 -0
  317. package/src/workspace/ci-report-url.ts +33 -0
  318. package/src/workspace/ci-run-index.schema.json +52 -0
  319. package/src/workspace/ci-run-index.test.ts +392 -0
  320. package/src/workspace/ci-run-index.ts +206 -0
  321. package/src/workspace/ci-run.test.ts +520 -42
  322. package/src/workspace/ci-run.ts +483 -51
  323. package/src/workspace/ci-runner-key.test.ts +204 -0
  324. package/src/workspace/ci-runner-key.ts +275 -0
  325. package/src/workspace/ci-secrets.test.ts +106 -0
  326. package/src/workspace/ci-secrets.ts +213 -0
  327. package/src/workspace/ci-serve.test.ts +282 -39
  328. package/src/workspace/ci-serve.ts +231 -34
  329. package/src/workspace/declaration.schema.json +7 -2
  330. package/src/workspace/declaration.test.ts +23 -0
  331. package/src/workspace/declaration.ts +19 -3
  332. package/src/workspace/export.ts +6 -3
  333. package/src/workspace/ls.schema.json +7 -1
  334. package/src/workspace/ls.ts +7 -2
  335. package/src/workspace/pin-integrity.test.ts +5 -0
  336. package/src/workspace/pin-integrity.ts +3 -2
  337. package/src/workspace/pr-report.schema.json +74 -7
  338. package/src/workspace/reason-codes.test.ts +2 -0
  339. package/src/workspace/reason-codes.ts +9 -4
  340. package/src/workspace/run-event.schema.json +3 -1
  341. package/src/workspace/status.schema.json +3 -1
  342. package/src/workspace/trust/trust-write.test.ts +179 -0
  343. package/src/workspace/trust/trust-write.ts +377 -0
  344. package/src/workspace/trust-write.schema.json +265 -0
  345. package/src/workspace/write-scope.test.ts +29 -1
  346. package/src/workspace/write-scope.ts +12 -1
@@ -22,7 +22,10 @@
22
22
  * own variables. The forge values a generated job reads from `${{ ... }}`
23
23
  * expressions or GitLab's predefined variables come from flags here:
24
24
  * `BASE_SHA` from `--base`, `PR_NUMBER` from `--pr`, and the commit from
25
- * `--commit`.
25
+ * `--commit`. The values a forge job gets from repository secrets come from
26
+ * the host's secrets file (`--secrets`, ./ci-secrets.ts, #4096), and every
27
+ * secret value is redacted from the run's events, record, copied report and
28
+ * output by the executor's `redact` option.
26
29
  *
27
30
  * The steps run in a fresh checkout of the commit: a detached `git worktree`
28
31
  * in a temporary directory outside the repository, removed when the run
@@ -37,29 +40,65 @@
37
40
  * key is given, so `chant ci tick --source runs` (#4044) judges the commit
38
41
  * from it. Its events are `kind: "pipeline"` and `runtime: "ci"`, and the
39
42
  * record and the events carry `runOrigin: { forge: "chant" }`.
43
+ *
44
+ * With a forge client (#4097) the job also sets a commit status at its
45
+ * commit: `pending` when it starts, `success` or `failure` when it settles.
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.
40
71
  */
41
72
 
42
73
  import { execFileSync } from "node:child_process";
43
- import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
74
+ import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
44
75
  import { tmpdir } from "node:os";
45
76
  import { dirname, join, relative, resolve, sep } from "node:path";
46
77
  import { PR_APPLY_RECORD, PR_LOOP_REPORT_DIR, prLoopJobs, prLoopSetup, PR_LOOP_IMAGE, type PrLoopJob, type PrLoopPipelineOptions } from "../components/pr-pipeline";
47
- import { detectForge, FORGE_KINDS, type ForgeKind } from "../pr-forge";
78
+ import { detectForge, ForgeEnvironmentError, forgeFromEnv, FORGE_KINDS, type CommitStatus, type ForgeKind } from "../pr-forge";
79
+ import { PR_APPLY_GATE } from "../pr-loop";
48
80
  import type { CommandContext } from "../cli/registry";
49
81
  import { formatError, formatWarning } from "../cli/format";
50
- import type { RunOrigin } from "../lifecycle/release-ledger";
51
- import { pushLifecycleReport } from "../lifecycle/git";
82
+ import { resolveCiJob, type RunOrigin } from "../lifecycle/release-ledger";
83
+ import { fetchLifecycleStatus, pushLifecycleReport } from "../lifecycle/git";
52
84
  import { ACTIVITY_PROFILES } from "../op/activity-profiles";
53
85
  import type { ActivityFn, ActivityProfile } from "../op/activity-registry";
54
86
  import { shellCmd } from "../op/activities/shell";
55
87
  import { OpRunFailure, runOpLocally, type OpRunResult, type StepRecord } from "../op/local-executor";
56
88
  import type { OpRunFailureReason, OpRunRecord } from "../op/runtime";
57
89
  import type { ActivityStep, OpConfig, PhaseDefinition } from "../op/types";
90
+ import { redactText, type Redact } from "../op/redact";
58
91
  import { findWorkspaceRoot } from "../project-root";
59
- import { ciTagSigner, RUNNER_KEY_ENV } from "./ci-green";
92
+ import { parseReportUrl, reportKey } from "../pr-report-url";
93
+ import { reportUrlOf } from "./ci-report-url";
94
+ import { ciTagSigner, RUNNER_KEY_ENV, signedRunsRequired } from "./ci-green";
95
+ import { loadRunnerKey } from "./trust/dsse";
96
+ import { ciRedactor, ciSecretsFor, CI_SECRETS_ENV, readCiSecrets, type CiSecrets } from "./ci-secrets";
97
+ import { updateCiRunIndex, type CiRunIndexEntry } from "./ci-run-index";
60
98
  import { resolveMemberContext } from "./member-pipeline";
61
99
  import { signOpRunRecord } from "./trust/op-run-statement";
62
100
  import { gitTop } from "./tree";
101
+ import { hostRunnerKey } from "./ci-runner-key";
63
102
 
64
103
  /** The two jobs a pull-request pipeline has. */
65
104
  export type CiJobName = PrLoopJob["jobName"];
@@ -71,6 +110,9 @@ export const CI_JOB_OPS: Record<CiJobName, string> = { plan: "pr-plan", apply: "
71
110
  /** Where a job the chant runner ran says it ran (#4094). */
72
111
  export const CHANT_RUNNER_ORIGIN: RunOrigin = { forge: "chant" };
73
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
+
74
116
  /** The runner's variables every job gets, when the runner has them. */
75
117
  export const CI_RUN_BASE_ENV = ["PATH", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL", "LC_CTYPE", "TMPDIR", "TZ"] as const;
76
118
 
@@ -115,21 +157,34 @@ export class CiRunError extends Error {
115
157
 
116
158
  /**
117
159
  * The job's environment: the runner's base variables, the forge variables
118
- * and `passEnv` names it has, `CHANT_ENV` as a generated workflow sets it,
119
- * the commit as the forge spells it, and the job's own variables. A job
120
- * variable the forge fills from an expression comes from a flag, or from
121
- * the runner's environment for a token such as `GITHUB_TOKEN`; a literal
122
- * one, such as `CHANT_RUN_EVENTS_DIR`, is kept as it is.
160
+ * and `passEnv` names it has, the job's secrets from the host's secrets file
161
+ * (#4096), `CHANT_ENV` as a generated workflow sets it, the commit as the
162
+ * forge spells it, and the job's own variables. A job variable the forge
163
+ * fills from an expression comes from a flag, or from a secret or the
164
+ * runner's environment for a token such as `GITHUB_TOKEN`; a literal one,
165
+ * such as `CHANT_RUN_EVENTS_DIR`, is kept as it is. A secret replaces the
166
+ * runner's variable of the same name, and none replaces a variable the
167
+ * runner sets for the job. `CHANT_RUNNER_KEY` is never in it.
123
168
  */
124
169
  export function ciJobEnv(
125
170
  job: PrLoopJob,
126
- input: { forge: ForgeKind; env: string; values: CiRunValues; hostEnv: Record<string, string | undefined>; passEnv?: readonly string[] },
171
+ input: {
172
+ forge: ForgeKind;
173
+ env: string;
174
+ values: CiRunValues;
175
+ hostEnv: Record<string, string | undefined>;
176
+ passEnv?: readonly string[];
177
+ /** The secrets this job gets, from {@link ciSecretsFor}. */
178
+ secrets?: Record<string, string>;
179
+ },
127
180
  ): Record<string, string> {
181
+ if (input.passEnv?.includes(RUNNER_KEY_ENV)) throw new CiRunError(`--pass-env names ${RUNNER_KEY_ENV}, and the runner key is never passed to a job`);
128
182
  const out: Record<string, string> = {};
129
183
  for (const name of [...CI_RUN_BASE_ENV, ...CI_RUN_FORGE_ENV, ...(input.passEnv ?? [])]) {
130
184
  const value = input.hostEnv[name];
131
185
  if (value !== undefined) out[name] = value;
132
186
  }
187
+ for (const [name, value] of Object.entries(input.secrets ?? {})) if (name !== RUNNER_KEY_ENV) out[name] = value;
133
188
  out.CHANT_ENV = input.env;
134
189
  out[input.forge === "gitlab" ? "CI_COMMIT_SHA" : "GITHUB_SHA"] = input.values.commit;
135
190
  const flags: Record<string, { value: string | undefined; flag: string }> = {
@@ -143,10 +198,13 @@ export function ciJobEnv(
143
198
  out[name] = given.value;
144
199
  } else if (!value.includes("$")) {
145
200
  out[name] = value;
201
+ } else if (input.secrets?.[name] !== undefined) {
202
+ out[name] = input.secrets[name]!;
146
203
  } else if (input.hostEnv[name] !== undefined) {
147
204
  out[name] = input.hostEnv[name]!;
148
205
  }
149
206
  }
207
+ delete out[RUNNER_KEY_ENV];
150
208
  return out;
151
209
  }
152
210
 
@@ -212,6 +270,17 @@ function exitCodeOf(error: string | undefined): number | undefined {
212
270
  return m ? Number(m[1]) : undefined;
213
271
  }
214
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
+
215
284
  /** The first line of a step's error, for a reason. */
216
285
  const firstLine = (text: string): string => text.split("\n").find((l) => l.trim() !== "")?.trim() ?? text;
217
286
 
@@ -266,6 +335,77 @@ export function ciJobFailure(input: { job: CiJobName; op: OpConfig; records: rea
266
335
  return { code: "command-failed", reason: firstLine(failed.error ?? "the command failed"), phase: failed.phase, ...exit };
267
336
  }
268
337
 
338
+ /** What posts a job's commit status (#4097): the `setCommitStatus` of a GitHub or Forgejo client. */
339
+ export interface CiStatusClient {
340
+ setCommitStatus(sha: string, status: CommitStatus): Promise<void>;
341
+ }
342
+
343
+ /** Where a job's status goes (#4097). */
344
+ export interface CiStatusConfig {
345
+ client: CiStatusClient;
346
+ /** `--status-url`: the link the status carries, `{key}` standing for `<pr>/<commit>[/<member>]`. Otherwise an https `--report-url` is used. */
347
+ urlTemplate?: string;
348
+ }
349
+
350
+ /** A status description is cut to what GitHub and Forgejo keep. */
351
+ const STATUS_DESCRIPTION = 140;
352
+
353
+ const cut = (text: string, max = STATUS_DESCRIPTION): string => (text.length > max ? `${text.slice(0, max - 3)}...` : text);
354
+
355
+ /**
356
+ * A job's status context: `chant/pr-plan` for the plan, and for the apply
357
+ * `chant/<gate>`, which is `chant/pr-apply` unless `--gate` renamed the gate.
358
+ * pr-plan's own status is `chant/plan` (../pr-loop.ts), a different context.
359
+ */
360
+ export function ciStatusContext(job: CiJobName, gate?: string): string {
361
+ return job === "apply" ? `chant/${gate ?? PR_APPLY_GATE}` : `chant/${CI_JOB_OPS.plan}`;
362
+ }
363
+
364
+ /**
365
+ * The link a job's status carries: the `--status-url` template with `{key}`
366
+ * filled, else the https `--report-url` followed by the key and a slash, else
367
+ * none (an `s3://` or `file://` URL is not one a browser opens).
368
+ */
369
+ export function ciStatusUrl(input: { urlTemplate?: string; reportUrl?: string; pr?: number; commit: string; member?: string }): string | undefined {
370
+ const key = reportKey(input.pr ?? null, input.commit, input.member);
371
+ if (input.urlTemplate) return input.urlTemplate.split("{key}").join(key);
372
+ const url = input.reportUrl?.trim();
373
+ if (url && /^https:\/\//.test(url)) return `${url.replace(/\/+$/, "")}/${key}/`;
374
+ return undefined;
375
+ }
376
+
377
+ /** What the settled status says: a short summary, or on failure the record's code and reason. */
378
+ export function ciStatusSettled(op: string, result: Pick<CiRunResult, "status" | "record" | "failure">, seconds: number): Pick<CommitStatus, "state" | "description"> {
379
+ if (result.status === "ok") return { state: "success", description: cut(`${op} passed in ${seconds}s`) };
380
+ const why = result.record.failure;
381
+ const text = why ? `${why.code}: ${why.reason}` : result.failure ? `${result.failure.phase}: ${firstLine(result.failure.error)}` : `${op} failed`;
382
+ return { state: "failure", description: cut(text) };
383
+ }
384
+
385
+ /**
386
+ * The status client for `chant ci run` and `chant ci serve` (#4097), or none:
387
+ * off with `--no-status`, on GitLab (its client has no commit status call
388
+ * here), and without a forge token. A token with the rest of the forge's
389
+ * variables missing gives a warning instead.
390
+ */
391
+ export function ciStatusFromEnv(input: { forge: ForgeKind; hostEnv: Record<string, string | undefined>; noStatus?: boolean; statusUrl?: string }): { status?: CiStatusConfig; warning?: string } {
392
+ if (input.noStatus || input.forge === "gitlab") return {};
393
+ if (input.statusUrl !== undefined && !/^https?:\/\/\S+$/.test(input.statusUrl)) {
394
+ throw new CiRunError(`--status-url takes an http or https URL template such as https://host/reports/{key}, not ${JSON.stringify(input.statusUrl)}`);
395
+ }
396
+ const env = input.hostEnv;
397
+ const hasToken = Boolean(env.CHANT_FORGE_TOKEN ?? env.GITHUB_TOKEN ?? env.GH_TOKEN);
398
+ if (!hasToken) return {};
399
+ try {
400
+ const client = forgeFromEnv(input.forge, env);
401
+ if (!client.setCommitStatus) return {};
402
+ return { status: { client: { setCommitStatus: client.setCommitStatus.bind(client) }, ...(input.statusUrl ? { urlTemplate: input.statusUrl } : {}) } };
403
+ } catch (err) {
404
+ if (err instanceof ForgeEnvironmentError) return { warning: `no commit status will be set: ${err.message}` };
405
+ throw err;
406
+ }
407
+ }
408
+
269
409
  /** What `chant ci run` runs. */
270
410
  export interface CiRunRequest {
271
411
  /** Where the command was run: the project, or a workspace member's directory. */
@@ -278,7 +418,12 @@ export interface CiRunRequest {
278
418
  base?: string;
279
419
  pr?: number;
280
420
  commit?: string;
281
- /** 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
+ */
282
427
  loop?: PrLoopPipelineOptions;
283
428
  /** `--image`: the image a generated pipeline names. Only the default gets setup lines. */
284
429
  image?: string;
@@ -290,8 +435,16 @@ export interface CiRunRequest {
290
435
  passEnv?: readonly string[];
291
436
  /** The runner's environment. Default `process.env`. */
292
437
  hostEnv?: Record<string, string | undefined>;
438
+ /**
439
+ * The host's secrets file, read (#4096, `--secrets`): the job gets the
440
+ * secrets its allowlist names, and every value in it is redacted from the
441
+ * run's events, record, copied report and output.
442
+ */
443
+ secrets?: CiSecrets;
293
444
  /** `--output`: where the job's report is copied, from `cwd`. Default `.chant/pr`. */
294
445
  output?: string;
446
+ /** `--run-index-limit`: how many runs the index at `loop.reportUrl` keeps (#4098). Default 200. */
447
+ runIndexLimit?: number;
295
448
  /** A runner key, PEM, that signs the run's record. */
296
449
  runnerKey?: string;
297
450
  /**
@@ -315,6 +468,26 @@ export interface CiRunRequest {
315
468
  * failed. `chant ci serve` names one per `prApplyRecordKey`.
316
469
  */
317
470
  resumeRecord?: string;
471
+ /** Set a commit status at the job's commit (#4097). Absent: none is set. `chant ci run` builds it from the environment. */
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;
318
491
  signal?: AbortSignal;
319
492
  }
320
493
 
@@ -333,8 +506,14 @@ export interface CiRunResult {
333
506
  pushWarning?: string;
334
507
  /** What went wrong beside the job, such as a record the key could not sign. */
335
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;
336
513
  /** The checkout the job ran in, removed by now. */
337
514
  checkout: string;
515
+ /** The job ran with `local` (#4161): its record stays on this machine. */
516
+ local?: true;
338
517
  }
339
518
 
340
519
  const message = (err: unknown): string => (err instanceof Error ? err.message : String(err));
@@ -393,6 +572,62 @@ function freshCheckout(top: string, commit: string): { dir: string; remove(): vo
393
572
  };
394
573
  }
395
574
 
575
+ /**
576
+ * Add the settled job to the run index at its report URL (#4098). The pull
577
+ * request is the one the job's own report names, since a pr-apply finds it
578
+ * itself, else `--pr`. A warning, never a failure, when the index can't be
579
+ * updated.
580
+ */
581
+ async function recordInIndex(input: {
582
+ url: string;
583
+ job: CiJobName;
584
+ commit: string;
585
+ member?: string;
586
+ pr?: number;
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;
593
+ reportDir: string;
594
+ limit?: number;
595
+ /** The run's redactor (#4096): every string written to the index goes through it, as the copied report does. */
596
+ redact?: Redact;
597
+ warn: (message: string) => void;
598
+ }): Promise<void> {
599
+ const target = parseReportUrl(input.url);
600
+ if ("error" in target) return input.warn(`the run index is not updated: ${target.error}`);
601
+ let pr: number | null = input.pr ?? null;
602
+ try {
603
+ const file = join(input.reportDir, `${CI_JOB_OPS[input.job]}.json`);
604
+ if (existsSync(file)) {
605
+ const said = (JSON.parse(readFileSync(file, "utf-8")) as { pr?: unknown }).pr;
606
+ if (Number.isInteger(said) && (said as number) > 0) pr = said as number;
607
+ }
608
+ } catch {
609
+ // The report is not readable; the flag stands.
610
+ }
611
+ const failure = input.record.failure;
612
+ const clean = (text: string): string => (input.redact ? input.redact(text) : text);
613
+ const entry: CiRunIndexEntry = {
614
+ job: input.job,
615
+ pr,
616
+ commit: input.commit,
617
+ status: input.record.status === "ok" ? "ok" : "fail",
618
+ ...(input.record.status !== "ok" ? { failure: { code: clean(failure?.code ?? "command-failed") } } : {}),
619
+ ...(input.member ? { member: clean(input.member) } : {}),
620
+ started: input.record.started,
621
+ ended: input.record.ended,
622
+ runId: clean(input.record.id),
623
+ key: clean(reportKey(pr, input.commit, input.member)),
624
+ runOrigin: input.runOrigin,
625
+ signer: input.signer,
626
+ exitCode: input.exitCode,
627
+ };
628
+ await updateCiRunIndex({ target, entry, ...(input.limit ? { limit: input.limit } : {}), warn: input.warn });
629
+ }
630
+
396
631
  const ACTIVITIES = new Map<string, ActivityFn>([["shellCmd", shellCmd as unknown as ActivityFn]]);
397
632
 
398
633
  /**
@@ -406,25 +641,108 @@ export async function ciRun(req: CiRunRequest): Promise<CiRunResult> {
406
641
  const top = gitTop(cwd);
407
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");
408
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");
409
646
  const place = ciJobPlace(cwd, top);
410
- const jobs = prLoopJobs(req.forge, req.env, req.loop ?? {}, place.member);
411
- const job = jobs.find((j) => j.jobName === req.job);
412
- if (!job) throw new CiRunError(`the pull-request pipeline has no ${req.job} job; it has ${jobs.map((j) => j.jobName).join(" and ")}`);
413
- const jobEnv = ciJobEnv(job, { forge: req.forge, env: req.env, values: { base: req.base, pr: req.pr, commit }, hostEnv, passEnv: req.passEnv });
414
-
415
- // 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.
416
- let signer: ReturnType<typeof ciTagSigner> | undefined;
417
- const early: string[] = [];
418
- if (req.runnerKey !== undefined) {
419
- try {
420
- signer = ciTagSigner(top, commit, req.runnerKey, "the runs it signed");
421
- } catch (err) {
422
- if (!req.runnerKeyFromEnv) throw new CiRunError(`the run would not be signed: ${message(err)}`);
423
- early.push(`${RUNNER_KEY_ENV} is set, and the run will not be signed: ${message(err)}`);
424
- }
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, {
655
+ forge: req.forge,
656
+ env: req.env,
657
+ values: { base: req.base, pr: req.pr, commit },
658
+ hostEnv,
659
+ passEnv: req.passEnv,
660
+ secrets: ciSecretsFor(req.secrets, req.job),
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];
665
+ // #4096: the secrets and the job's secret-looking variables, redacted from everything the run writes and returns.
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);
668
+ try {
669
+ const result = await runCiJob(req, { cwd, top, commit, place, job, jobEnv, ...(redact ? { redact } : {}) });
670
+ return redact ? { ...result, warnings: result.warnings.map(redact), notes: result.notes.map(redact), ...(result.pushWarning ? { pushWarning: redact(result.pushWarning) } : {}) } : result;
671
+ } catch (err) {
672
+ if (redact && err instanceof Error) err.message = redact(err.message);
673
+ throw err;
425
674
  }
675
+ }
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
+ }
426
720
 
427
- const checkout = freshCheckout(top, commit);
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
+
726
+ /** {@link ciRun} once the job and its environment are known. */
727
+ async function runCiJob(
728
+ req: CiRunRequest,
729
+ at: { cwd: string; top: string; commit: string; place: { member?: string; dir: string }; job: PrLoopJob; jobEnv: Record<string, string>; redact?: Redact },
730
+ ): Promise<CiRunResult> {
731
+ const { cwd, top, commit, place, job, jobEnv, redact } = at;
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);
428
746
  try {
429
747
  const jobDir = place.dir === "." ? checkout.dir : join(checkout.dir, place.dir);
430
748
  const op = ciJobOp({
@@ -432,7 +750,7 @@ export async function ciRun(req: CiRunRequest): Promise<CiRunResult> {
432
750
  env: req.env,
433
751
  cwd: jobDir,
434
752
  jobEnv,
435
- setup: prLoopSetup(req.image ?? PR_LOOP_IMAGE),
753
+ setup: req.inPlace ? [] : prLoopSetup(req.image ?? PR_LOOP_IMAGE),
436
754
  build: [...(req.beforeScript ?? []), ...(job.build ?? [])],
437
755
  ...(req.chant ? { chant: req.chant } : {}),
438
756
  });
@@ -443,6 +761,20 @@ export async function ciRun(req: CiRunRequest): Promise<CiRunResult> {
443
761
  mkdirSync(dirname(resume.job), { recursive: true });
444
762
  copyFileSync(resume.host, resume.job);
445
763
  }
764
+ const statusContext = ciStatusContext(req.job, req.loop?.gate);
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 } : {}) })
767
+ : undefined;
768
+ const postStatus = async (state: CommitStatus["state"], description: string): Promise<void> => {
769
+ if (!status) return;
770
+ try {
771
+ await status.client.setCommitStatus(commit, { context: statusContext, state, description: cut(redact ? redact(description) : description), ...(statusUrl ? { targetUrl: statusUrl } : {}) });
772
+ } catch (err) {
773
+ warnings.push(`could not set the ${statusContext} status: ${message(err)}`);
774
+ }
775
+ };
776
+ const startedAt = Date.now();
777
+ await postStatus("pending", `${op.name} running`);
446
778
  let ledgerError: string | undefined;
447
779
  let result: OpRunResult;
448
780
  try {
@@ -452,20 +784,31 @@ export async function ciRun(req: CiRunRequest): Promise<CiRunResult> {
452
784
  cwd: jobDir,
453
785
  commit,
454
786
  ledger: { cwd: jobDir },
455
- runner: { kind: "pipeline", runtime: "ci", runOrigin: CHANT_RUNNER_ORIGIN },
787
+ runner: { kind: "pipeline", runtime: local ? "local" : "ci", runOrigin: origin },
456
788
  describeFailure: (records) => ciJobFailure({ job: req.job, op, records, reportDir: join(jobDir, PR_LOOP_REPORT_DIR) }),
457
789
  ...(req.runEvents ? { runEvents: { write: req.runEvents } } : {}),
458
790
  ...(req.stepOutput ? { stepOutput: req.stepOutput } : {}),
459
791
  ...(signer ? { signRecord: (record: OpRunRecord) => signOpRunRecord(record, signer!) } : {}),
460
792
  onSignError: (err) => warnings.push(`the run was not signed: ${message(err)}`),
793
+ ...(redact ? { redact } : {}),
461
794
  onLedgerError: (err) => {
462
795
  ledgerError = `the job ran, and its record could not be written to chant/lifecycle: ${message(err)}`;
463
796
  },
464
797
  });
465
798
  } catch (err) {
466
- if (!(err instanceof OpRunFailure)) throw err;
799
+ if (!(err instanceof OpRunFailure)) {
800
+ await postStatus("error", `${op.name} stopped: ${firstLine(message(err))}`);
801
+ throw err;
802
+ }
467
803
  result = err.result;
468
804
  }
805
+ const failedStep = result.records.find((r) => r.status === "fail");
806
+ const settled = ciStatusSettled(
807
+ op.name,
808
+ { status: result.status, record: result.record, ...(failedStep ? { failure: { phase: failedStep.phase, error: failedStep.error ?? "the step failed" } } : {}) },
809
+ Math.round((Date.now() - startedAt) / 1000),
810
+ );
811
+ await postStatus(settled.state, settled.description);
469
812
 
470
813
  if (resume && existsSync(resume.job)) {
471
814
  try {
@@ -479,54 +822,131 @@ export async function ciRun(req: CiRunRequest): Promise<CiRunResult> {
479
822
  let report: string | null = null;
480
823
  if (existsSync(written)) {
481
824
  report = resolve(cwd, req.output ?? PR_LOOP_REPORT_DIR);
482
- cpSync(written, report, { recursive: true });
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
+ }
483
830
  }
484
- if (ledgerError) throw new CiRunError(ledgerError);
485
- const pushed: { pushed: boolean; pushWarning?: string } = req.push === false ? { pushed: false, pushWarning: "chant/lifecycle was not pushed" } : await pushLifecycleReport({ cwd: top });
486
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 });
487
842
  return {
488
843
  op: op.name,
489
844
  status: result.status,
490
845
  record: result.record,
491
846
  ...(failed ? { failure: { phase: failed.phase, error: failed.error ?? "the step failed" } } : {}),
847
+ ...(exitCode !== undefined ? { exitCode } : {}),
492
848
  report,
493
- signer: signer?.principal ?? null,
849
+ signer: result.record.statement && signer ? signer.principal : null,
494
850
  pushed: pushed.pushed,
495
851
  ...(pushed.pushWarning ? { pushWarning: pushed.pushWarning } : {}),
496
852
  warnings,
853
+ notes,
497
854
  checkout: checkout.dir,
855
+ ...(local ? { local: true as const } : {}),
498
856
  };
499
857
  } finally {
500
858
  checkout.remove();
501
859
  }
502
860
  }
503
861
 
862
+ /**
863
+ * Redact every text file under `dir` in place (#4096): the job's report, its
864
+ * change set and its component events, as `ci run` copied them out. A
865
+ * JSON file stays JSON. A file with a NUL byte is taken as binary and left.
866
+ */
867
+ export function redactTree(dir: string, redact: Redact): void {
868
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
869
+ const path = join(dir, entry.name);
870
+ if (entry.isDirectory()) {
871
+ redactTree(path, redact);
872
+ continue;
873
+ }
874
+ if (!entry.isFile()) continue;
875
+ const bytes = readFileSync(path);
876
+ if (bytes.includes(0)) continue;
877
+ const text = bytes.toString("utf-8");
878
+ const clean = redactText(text, redact);
879
+ if (clean !== text) writeFileSync(path, clean);
880
+ }
881
+ }
882
+
504
883
  /** The text `chant ci run` prints when the run settles. */
505
884
  export function formatCiRun(r: CiRunResult): string {
506
885
  const lines = [`${r.op} ${r.status === "ok" ? "passed" : r.status === "fail" ? "failed" : r.status} at ${r.record.gitSha?.slice(0, 12) ?? "an unknown commit"}, run ${r.record.id} in ${r.record.env}`];
507
886
  const why = r.record.failure;
508
887
  if (why) lines.push(` failure: ${why.code}${why.phase ? ` in ${why.phase}` : ""}${why.exitCode !== undefined ? `, exit ${why.exitCode}` : ""}: ${why.reason}`);
509
888
  else if (r.failure) lines.push(` ${r.failure.phase}: ${r.failure.error}`);
510
- 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"}`);
511
890
  if (r.report) lines.push(` report: ${r.report}`);
512
- 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"}`);
513
893
  return lines.join("\n");
514
894
  }
515
895
 
516
896
  // ── The command ──────────────────────────────────────────────────────────────
517
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
+
518
918
  const USAGE =
519
919
  "chant ci run plan|apply --env <env> --base <sha> [--pr <n>] [--commit <sha>] [--forge github|gitlab|forgejo] " +
520
- "[--gate <name>] [--branch <name>] [--events-json] [--report-url <url>] [--image <image>] [--before-script <line>]... " +
521
- "[--pr-build] [--pr-build-command <line>] " +
522
- "[--chant <command>] [--pass-env <NAME>]... [--key <runner key.pem>] [-o <dir>]";
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>]";
523
923
 
524
924
  const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
525
925
 
926
+ /** The host's runner key in `--state`, or in ci serve's default state directory, when `--setup-key` made one there (#4099). */
927
+ function hostRunnerKeyAt(cwd: string, statePath: string | undefined): { pem: string } | undefined {
928
+ const common = statePath ? null : (() => {
929
+ try {
930
+ return execFileSync("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }).trim();
931
+ } catch {
932
+ return null;
933
+ }
934
+ })();
935
+ const dir = statePath ? resolve(statePath) : common ? join(common, "chant", "ci-serve") : null;
936
+ return dir ? hostRunnerKey(dir) : undefined;
937
+ }
938
+
939
+ /** The secrets file `--secrets` names, else the one `CHANT_CI_SECRETS` names, read; undefined when neither names one (#4096). */
940
+ export function ciSecretsOf(flag: string | undefined, env: Record<string, string | undefined> = process.env): CiSecrets | undefined {
941
+ const file = flag ?? (env[CI_SECRETS_ENV] || undefined);
942
+ return file === undefined ? undefined : readCiSecrets(resolve(file));
943
+ }
944
+
526
945
  /** `chant ci run plan|apply` (#4094). */
527
946
  export async function runCiRun(ctx: CommandContext): Promise<number> {
528
947
  const { args } = ctx;
529
948
  try {
949
+ if (args.setupKey) return (await import("./ci-serve")).runnerKeySetupCommand(ctx);
530
950
  const job = args.extraPositional;
531
951
  if (job !== "plan" && job !== "apply") throw new CiRunError(`ci run takes the job to run, plan or apply${job ? `, not ${JSON.stringify(job)}` : ""}`);
532
952
  if (!args.env) throw new CiRunError("--env names the environment the pipeline is for, as chant build --components --generate <lexicon> --pr-loop took it: --env <env>");
@@ -535,35 +955,47 @@ export async function runCiRun(ctx: CommandContext): Promise<number> {
535
955
  if (!(FORGE_KINDS as readonly string[]).includes(forge)) throw new CiRunError(`--forge takes github, gitlab or forgejo, not ${JSON.stringify(forge)}`);
536
956
  for (const name of args.passEnv ?? []) if (!ENV_NAME.test(name)) throw new CiRunError(`--pass-env takes a variable's name, not ${JSON.stringify(name)}`);
537
957
  const fromFlag = args.key !== undefined;
538
- const runnerKey = fromFlag ? readFileSync(resolve(args.key!), "utf-8") : process.env[RUNNER_KEY_ENV] || undefined;
958
+ // #4099: without --key or CHANT_RUNNER_KEY, the host's key that --setup-key made, if there is one. Like the variable, an unlisted one only warns.
959
+ const hostKey = fromFlag || process.env[RUNNER_KEY_ENV] ? undefined : hostRunnerKeyAt(process.cwd(), args.statePath);
960
+ const runnerKey = fromFlag ? readFileSync(resolve(args.key!), "utf-8") : process.env[RUNNER_KEY_ENV] || hostKey?.pem;
961
+ const hostEnv = process.env;
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 });
965
+ if (warning) console.error(formatWarning({ message: warning }));
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());
539
969
  const result = await ciRun({
540
970
  cwd: process.cwd(),
971
+ ...(status ? { status } : {}),
541
972
  job,
542
973
  env: args.env,
543
974
  forge: forge as ForgeKind,
544
975
  ...(args.base ? { base: args.base } : {}),
545
976
  ...(args.pr !== undefined ? { pr: args.pr } : {}),
546
977
  ...(args.commit ? { commit: args.commit } : {}),
547
- loop: {
548
- ...(args.gate ? { gate: args.gate } : {}),
549
- ...(args.branch ? { branch: args.branch } : {}),
550
- ...(args.eventsJson ? { events: true } : {}),
551
- ...(args.reportUrl ? { reportUrl: args.reportUrl } : {}),
552
- ...(args.prBuild ? { build: true } : {}),
553
- ...(args.prBuildCommand ? { buildCommand: args.prBuildCommand } : {}),
554
- },
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 } : {}),
983
+ ...(args.runIndexLimit ? { runIndexLimit: args.runIndexLimit } : {}),
555
984
  ...(args.image ? { image: args.image } : {}),
556
985
  ...(args.beforeScript?.length ? { beforeScript: args.beforeScript } : {}),
557
986
  ...(args.ciChant ? { chant: args.ciChant } : {}),
558
987
  ...(args.passEnv?.length ? { passEnv: args.passEnv } : {}),
988
+ ...(secrets ? { secrets } : {}),
559
989
  ...(args.output ? { output: args.output } : {}),
560
990
  ...(runnerKey !== undefined ? { runnerKey, runnerKeyFromEnv: !fromFlag } : {}),
561
991
  ...(args.eventsJson ? { runEvents: (line: string) => void process.stdout.write(line) } : {}),
562
992
  stepOutput: (line) => void process.stderr.write(`${line}\n`),
563
993
  });
564
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}`);
565
996
  console.error(formatCiRun(result));
566
- 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);
567
999
  } catch (err) {
568
1000
  console.error(formatError({ message: `chant ci run: ${message(err)}`, hint: USAGE }));
569
1001
  return 1;