@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
@@ -0,0 +1,204 @@
1
+ /**
2
+ * #4099: the runner key on a `chant ci serve` or `chant ci run` host. The
3
+ * key is made in the state directory with mode 0600, `--setup-key --trust`
4
+ * proposes its runner entry on a branch through `trust runner add` (#3510)
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.
9
+ *
10
+ * Every test works in its own temp repository with a bare origin and its own
11
+ * state directory. The jobs' `chant` is a stub script, and no job runs: the
12
+ * refusals come before the first poll.
13
+ */
14
+
15
+ import { chmodSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
16
+ import { generateKeyPairSync } from "node:crypto";
17
+ import { join } from "node:path";
18
+ import { afterEach, describe, expect, test, vi } from "vitest";
19
+ import { cleanScratch, declaration, git, repoFixture, scratchDir, writeFiles } from "./__fixtures__/contract-repo";
20
+ import { parseArgs } from "../cli/parse-args";
21
+ import type { CommandContext } from "../cli/registry";
22
+ import { ciServeRunnerKey, ensureHostRunnerKey, hostRunnerKey, RUNNER_KEY_FILE } from "./ci-runner-key";
23
+ import { runCiServe } from "./ci-serve";
24
+ import { runCiRun } from "./ci-run";
25
+ import { ciTagSigner } from "./ci-green";
26
+ import { trustWrite } from "./trust/trust-write";
27
+
28
+ const home = process.cwd();
29
+ afterEach(() => {
30
+ process.chdir(home);
31
+ vi.restoreAllMocks();
32
+ cleanScratch();
33
+ });
34
+
35
+ /** The repository every test starts from, built once: a checkout on main with a bare origin, with commits made by a fixed identity and never signed. */
36
+ const workspaceTemplate = repoFixture(() => {
37
+ const top = scratchDir("chant-runner-key-template-");
38
+ const origin = join(top, "origin");
39
+ const root = join(top, "root");
40
+ mkdirSync(origin);
41
+ mkdirSync(root);
42
+ git(origin, "init", "-q", "--bare");
43
+ git(root, "init", "-q");
44
+ git(root, "symbolic-ref", "HEAD", "refs/heads/main");
45
+ for (const [k, v] of [["user.name", "t"], ["user.email", "t@example.com"], ["commit.gpgsign", "false"]]) git(root, "config", k!, v!);
46
+ writeFiles(root, { "chant.workspace.json": declaration([{ name: "app", dir: "app", kind: "other", because: "the app" }]), "app/a.txt": "a\n" });
47
+ git(root, "remote", "add", "origin", origin);
48
+ git(root, "add", "-A");
49
+ git(root, "commit", "-q", "-m", "init");
50
+ git(root, "push", "-q", "origin", "main");
51
+ return top;
52
+ }, "chant-runner-key-");
53
+
54
+ /** A copy of the template, its origin pointed at the copy of the bare one. */
55
+ function workspace(): { root: string; origin: string; main: string } {
56
+ const top = workspaceTemplate();
57
+ const root = join(top, "root");
58
+ const origin = join(top, "origin");
59
+ git(root, "remote", "set-url", "origin", origin);
60
+ return { root, origin, main: git(root, "rev-parse", "HEAD") };
61
+ }
62
+
63
+ /** Run a ci command line in `cwd`, with what it printed. */
64
+ async function cli(cwd: string, argv: string[], run: (ctx: CommandContext) => Promise<number> = runCiServe) {
65
+ process.chdir(cwd);
66
+ const out: string[] = [];
67
+ const err: string[] = [];
68
+ vi.spyOn(console, "log").mockImplementation((s: string) => void out.push(String(s)));
69
+ vi.spyOn(console, "error").mockImplementation((s: string) => void err.push(String(s)));
70
+ const code = await run({ args: parseArgs(argv) } as unknown as CommandContext);
71
+ return { code, out: out.join("\n"), err: err.join("\n") };
72
+ }
73
+
74
+ /** A stand-in for `chant` the jobs would run; no test here reaches a job. */
75
+ function stubChant(): string {
76
+ const file = join(scratchDir("chant-runner-key-bin-"), "chant");
77
+ writeFileSync(file, "#!/bin/sh\nexit 0\n");
78
+ chmodSync(file, 0o755);
79
+ return file;
80
+ }
81
+
82
+ const SERVE = (state: string) => ["ci", "serve", "--forge", "forgejo", "--repo", "demo/estate", "--env", "local", "--once", "--state", state, "--chant", stubChant(), "--image", "node:22-bookworm"];
83
+
84
+ describe("the host's runner key (#4099)", () => {
85
+ test("--setup-key makes an Ed25519 key with mode 0600, prints its public half, and reuses it", async () => {
86
+ const { root } = workspace();
87
+ const state = join(scratchDir("chant-runner-key-state-"), "ci-serve");
88
+ const first = await cli(root, ["ci", "serve", "--setup-key", "--state", state]);
89
+ expect(first.code).toBe(0);
90
+ const path = join(state, RUNNER_KEY_FILE);
91
+ expect(statSync(path).mode & 0o777).toBe(0o600);
92
+ expect(statSync(state).mode & 0o777).toBe(0o700);
93
+ expect(readFileSync(path, "utf-8")).toContain("BEGIN PRIVATE KEY");
94
+ expect(first.out).toMatch(/^ssh-ed25519 [A-Za-z0-9+/]+=*$/);
95
+ expect(first.err).toContain("made now, mode 0600");
96
+ // It is not listed yet, so the exact command is printed.
97
+ expect(first.err).toContain(`chant workspace trust runner add chant-runner:`);
98
+ expect(first.err).toContain(`--key "${first.out}"`);
99
+
100
+ // ci run --setup-key finds the same key and makes no other.
101
+ const again = await cli(root, ["ci", "run", "--setup-key", "--state", state], runCiRun);
102
+ expect(again.code).toBe(0);
103
+ expect(again.out).toBe(first.out);
104
+ expect(again.err).toContain("already here");
105
+
106
+ // A key other users can read is refused.
107
+ chmodSync(path, 0o644);
108
+ expect(() => hostRunnerKey(state)).toThrow(/can be read by other users/);
109
+ chmodSync(path, 0o600);
110
+ expect(ensureHostRunnerKey(state).created).toBe(false);
111
+ });
112
+
113
+ test("--setup-key --trust pushes the runner entry on a branch of its own and leaves main as it was", async () => {
114
+ const { root, origin, main } = workspace();
115
+ const state = scratchDir("chant-runner-key-state-");
116
+ const r = await cli(root, ["ci", "serve", "--setup-key", "--trust", "--principal", "chant-runner:demo", "--by", "github:alex", "--state", state]);
117
+ expect(r.code, r.err).toBe(0);
118
+ expect(r.err).toContain("committed the runner entry for chant-runner:demo on chant/trust-runner/chant-runner-demo");
119
+ const branch = "chant/trust-runner/chant-runner-demo";
120
+
121
+ // main is untouched, here and on the remote; the branch holds the entry and the role.
122
+ expect(git(origin, "rev-parse", "refs/heads/main")).toBe(main);
123
+ expect(git(root, "rev-parse", "HEAD")).toBe(main);
124
+ expect(git(root, "status", "--short")).toBe("");
125
+ const trust = JSON.parse(git(origin, "show", `${branch}:.chant/trust.json`)) as { runners: { principal: string; key: string }[]; roles: { runner: string[] } };
126
+ expect(trust.runners).toEqual([{ principal: "chant-runner:demo", class: "runner", key: r.out }]);
127
+ expect(trust.roles.runner).toEqual(["chant-runner:demo"]);
128
+ expect(git(origin, "rev-parse", `${branch}^`)).toBe(main);
129
+
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.
131
+ const key = hostRunnerKey(state)!;
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/);
136
+ git(root, "push", "-q", "origin", `refs/remotes/origin/${branch}:refs/heads/main`);
137
+ expect(ciServeRunnerKey({ top: root, stateDir: state, branch: "main" })).toMatchObject({ pem: key.pem, source: join(state, RUNNER_KEY_FILE) });
138
+ expect(ciTagSigner(root, git(root, "rev-parse", "refs/remotes/origin/main"), key.pem).principal).toBe("chant-runner:demo");
139
+
140
+ // Run again, it says the key is listed and proposes nothing.
141
+ const listed = await cli(root, ["ci", "serve", "--setup-key", "--trust", "--state", state]);
142
+ expect(listed.err).toContain("lists it for chant-runner:demo, with the runner role");
143
+ });
144
+
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 () => {
146
+ const { root } = workspace();
147
+ const state = scratchDir("chant-runner-key-state-");
148
+
149
+ // No key: nothing about keys is printed. The serve goes on to the forge, which this test has no token for.
150
+ const none = await cli(root, SERVE(state));
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.
162
+ ensureHostRunnerKey(state);
163
+ const unlisted = await cli(root, SERVE(state));
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");
166
+ expect(unlisted.err).toContain("chant ci serve --setup-key --trust");
167
+ expect(unlisted.err).not.toContain("signing runs with");
168
+
169
+ // A key listed only in the working tree, not at the head, is not trusted either.
170
+ const other = generateKeyPairSync("ed25519").privateKey.export({ type: "pkcs8", format: "pem" }).toString();
171
+ writeFileSync(join(state, "other.pem"), other, { mode: 0o600 });
172
+ expect(trustWrite({ cwd: root, action: "runner add", name: "ci-bot", key: join(state, "other.pem") })).toMatchObject({ changed: true });
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 } }) });
190
+
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");
203
+ });
204
+ });
@@ -0,0 +1,275 @@
1
+ /**
2
+ * The runner key on a `chant ci serve` or `chant ci run` host (#4099, epic
3
+ * #4101).
4
+ *
5
+ * The host keeps its runner key in the serve's state directory, by default
6
+ * `<git common dir>/chant/ci-serve/runner-key.pem`: an Ed25519 private key in
7
+ * PEM, mode 0600, made by `--setup-key` when there is none. Its public half
8
+ * is the `ssh-ed25519` line `.chant/trust.json` lists under `runners`, and
9
+ * `--setup-key --trust` proposes that entry through `chant workspace trust
10
+ * runner add` (#3510) on a branch of its own, pushed for review. It never
11
+ * commits to the target branch: changing the trust config is a protected
12
+ * write, and the change is reviewed and merged like any other.
13
+ *
14
+ * `ci serve` refuses to start when its key is not listed, with the runner
15
+ * role, in `.chant/trust.json` at the target branch's head, since every run
16
+ * it signed would then be ignored by `ci tick --source runs`. `--unsigned`
17
+ * starts it without a key.
18
+ */
19
+
20
+ import { execFileSync } from "node:child_process";
21
+ import { generateKeyPairSync } from "node:crypto";
22
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
23
+ import { hostname, tmpdir } from "node:os";
24
+ import { join } from "node:path";
25
+ import { ciTagSigner } from "./ci-green";
26
+ import { CiRunError } from "./ci-run";
27
+ import { loadRunnerKey } from "./trust/dsse";
28
+ import { trustWrite } from "./trust/trust-write";
29
+
30
+ /** The runner key's file in the state directory. */
31
+ export const RUNNER_KEY_FILE = "runner-key.pem";
32
+
33
+ /** The principal `--setup-key --trust` lists the key for when `--principal` names none. */
34
+ export function defaultRunnerPrincipal(): string {
35
+ return `chant-runner:${hostname().split(".")[0]!.toLowerCase().replace(/[^a-z0-9-]/g, "-") || "host"}`;
36
+ }
37
+
38
+ export interface HostRunnerKey {
39
+ path: string;
40
+ pem: string;
41
+ /** `ssh-ed25519 <base64>`. */
42
+ publicKey: string;
43
+ /** Whether this call made it. */
44
+ created: boolean;
45
+ }
46
+
47
+ const message = (err: unknown): string => (err instanceof Error ? err.message : String(err));
48
+
49
+ function git(cwd: string, args: string[]): string {
50
+ return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }).trim();
51
+ }
52
+
53
+ function tryGit(cwd: string, args: string[]): string | undefined {
54
+ try {
55
+ return git(cwd, args);
56
+ } catch {
57
+ return undefined;
58
+ }
59
+ }
60
+
61
+ /** Read the key at `path`, refusing one others can read or one that is not an Ed25519 private key in PEM. */
62
+ function readHostKey(path: string): HostRunnerKey {
63
+ const mode = statSync(path).mode & 0o777;
64
+ if (mode & 0o077) throw new CiRunError(`${path} can be read by other users (mode ${mode.toString(8)}); chmod 600 it, since it signs for this runner`);
65
+ const pem = readFileSync(path, "utf-8");
66
+ try {
67
+ return { path, pem, publicKey: loadRunnerKey(pem).publicKey, created: false };
68
+ } catch (err) {
69
+ throw new CiRunError(`${path} is not an Ed25519 private key in PEM: ${message(err)}`);
70
+ }
71
+ }
72
+
73
+ /** The host's runner key in `stateDir`, or undefined when there is none. */
74
+ export function hostRunnerKey(stateDir: string): HostRunnerKey | undefined {
75
+ const path = join(stateDir, RUNNER_KEY_FILE);
76
+ return existsSync(path) ? readHostKey(path) : undefined;
77
+ }
78
+
79
+ /** The host's runner key in `stateDir`, made when there is none: Ed25519, PEM, mode 0600 in a directory of mode 0700. */
80
+ export function ensureHostRunnerKey(stateDir: string): HostRunnerKey {
81
+ const found = hostRunnerKey(stateDir);
82
+ if (found) return found;
83
+ mkdirSync(stateDir, { recursive: true, mode: 0o700 });
84
+ const path = join(stateDir, RUNNER_KEY_FILE);
85
+ const { privateKey } = generateKeyPairSync("ed25519");
86
+ const pem = privateKey.export({ type: "pkcs8", format: "pem" }).toString();
87
+ // wx: never replace a key another process made in the meantime.
88
+ writeFileSync(path, pem, { mode: 0o600, flag: "wx" });
89
+ chmodSync(path, 0o600);
90
+ return { path, pem, publicKey: loadRunnerKey(pem).publicKey, created: true };
91
+ }
92
+
93
+ /**
94
+ * The target branch's head, as the readers of runners take it: the remote's
95
+ * branch after a fetch (best effort), else the local branch. Undefined when
96
+ * neither exists.
97
+ */
98
+ export function targetHead(top: string, branch: string, remote = "origin"): { commit: string; ref: string } | undefined {
99
+ if (tryGit(top, ["remote", "get-url", remote]) !== undefined) tryGit(top, ["fetch", "-q", remote, `+refs/heads/${branch}:refs/remotes/${remote}/${branch}`]);
100
+ for (const ref of [`refs/remotes/${remote}/${branch}`, `refs/heads/${branch}`]) {
101
+ const commit = tryGit(top, ["rev-parse", "--verify", "-q", `${ref}^{commit}`]);
102
+ if (commit) return { commit, ref };
103
+ }
104
+ return undefined;
105
+ }
106
+
107
+ /** The exact command that lists `publicKey` for `principal`. */
108
+ export function trustCommand(principal: string, publicKey: string): string {
109
+ return `chant workspace trust runner add ${principal} --key "${publicKey}"`;
110
+ }
111
+
112
+ /** Whether `.chant/trust.json` at `commit` lists the key as a runner holding the runner role: its principal, or why not. */
113
+ export function runnerKeyListed(top: string, commit: string, pem: string): { ok: true; principal: string } | { ok: false; reason: string } {
114
+ try {
115
+ return { ok: true, principal: ciTagSigner(top, commit, pem, "the runs it signs").principal };
116
+ } catch (err) {
117
+ return { ok: false, reason: message(err) };
118
+ }
119
+ }
120
+
121
+ export interface RunnerTrustProposal {
122
+ /** The branch holding the commit, or null when the key was already listed at the target's head. */
123
+ branch: string | null;
124
+ commit: string | null;
125
+ /** Whether the branch was pushed to the remote. */
126
+ pushed: boolean;
127
+ /** The trust config's digest on the branch. */
128
+ digest: string | null;
129
+ }
130
+
131
+ /**
132
+ * Propose the runner entry for review (#4099): in a temporary worktree at
133
+ * the target's head, run `trust runner add` (#3510), commit the change on
134
+ * `chant/trust-runner/<principal>`, and push that branch to `remote` when
135
+ * the clone has it. The target branch is never written. The commit is made
136
+ * with the host's git identity and signing settings; when the policy at base
137
+ * has signers, the change needs a signature by a policy writer before it
138
+ * merges.
139
+ */
140
+ export function proposeRunnerTrust(req: { top: string; branch: string; principal: string; publicKey: string; by?: string; remote?: string }): RunnerTrustProposal {
141
+ const remote = req.remote ?? "origin";
142
+ const head = targetHead(req.top, req.branch, remote);
143
+ if (!head) throw new CiRunError(`there is no branch ${req.branch} here or on ${remote}, so there is no trust config to add the runner to`);
144
+ const branch = `chant/trust-runner/${req.principal.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "")}`;
145
+ if (tryGit(req.top, ["rev-parse", "--verify", "-q", `refs/heads/${branch}`])) {
146
+ throw new CiRunError(`the branch ${branch} exists already; review or delete it, then run --setup-key --trust again`);
147
+ }
148
+ const dir = mkdtempSync(join(tmpdir(), "chant-trust-runner-"));
149
+ try {
150
+ git(req.top, ["worktree", "add", "-q", "-b", branch, dir, head.commit]);
151
+ const doc = trustWrite({ cwd: dir, action: "runner add", name: req.principal, key: req.publicKey, ...(req.by ? { by: req.by } : {}) });
152
+ if ("error" in doc) throw new CiRunError(`trust runner add refused (${doc.error.code}): ${doc.error.message}`);
153
+ if (!doc.changed) {
154
+ git(req.top, ["worktree", "remove", "--force", dir]);
155
+ git(req.top, ["branch", "-q", "-D", branch]);
156
+ return { branch: null, commit: null, pushed: false, digest: doc.digest };
157
+ }
158
+ git(dir, ["add", "--", ".chant/trust.json"]);
159
+ git(dir, ["commit", "-q", "-m", `trust the ${req.principal} runner key\n\nchant ci --setup-key --trust: list this host's runner key in .chant/trust.json (#4099).`]);
160
+ const commit = git(dir, ["rev-parse", "HEAD"]);
161
+ let pushed = false;
162
+ if (tryGit(req.top, ["remote", "get-url", remote]) !== undefined) {
163
+ try {
164
+ git(dir, ["push", "-q", remote, `refs/heads/${branch}:refs/heads/${branch}`]);
165
+ pushed = true;
166
+ } catch (err) {
167
+ throw new CiRunError(`committed ${branch} here, and pushing it to ${remote} failed: ${message(err).split("\n")[0]}`);
168
+ }
169
+ }
170
+ return { branch, commit, pushed, digest: doc.digest };
171
+ } finally {
172
+ tryGit(req.top, ["worktree", "remove", "--force", dir]);
173
+ rmSync(dir, { recursive: true, force: true });
174
+ }
175
+ }
176
+
177
+ export interface RunnerKeySetup {
178
+ key: HostRunnerKey;
179
+ /** The target's head the trust config was read at, or null when there is no such branch. */
180
+ head: string | null;
181
+ /** The principal the key is listed for at the head, or null. */
182
+ listedAs: string | null;
183
+ principal: string;
184
+ command: string;
185
+ proposal?: RunnerTrustProposal;
186
+ }
187
+
188
+ /** `--setup-key [--trust]`: make or read the key, see whether the target's head lists it, and with `trust` propose the entry. */
189
+ export function setupRunnerKey(req: { top: string; stateDir: string; branch: string; principal?: string; trust?: boolean; by?: string }): RunnerKeySetup {
190
+ const key = ensureHostRunnerKey(req.stateDir);
191
+ const head = targetHead(req.top, req.branch);
192
+ const listed = head ? runnerKeyListed(req.top, head.commit, key.pem) : undefined;
193
+ const principal = req.principal ?? defaultRunnerPrincipal();
194
+ const out: RunnerKeySetup = { key, head: head?.commit ?? null, listedAs: listed?.ok ? listed.principal : null, principal, command: trustCommand(principal, key.publicKey) };
195
+ if (req.trust && !out.listedAs) out.proposal = proposeRunnerTrust({ top: req.top, branch: req.branch, principal, publicKey: key.publicKey, ...(req.by ? { by: req.by } : {}) });
196
+ return out;
197
+ }
198
+
199
+ /** What `--setup-key` prints on stderr; the public key alone goes to stdout. */
200
+ export function formatRunnerKeySetup(s: RunnerKeySetup, branch: string): string {
201
+ const lines = [`runner key ${s.key.path} (${s.key.created ? "made now, mode 0600" : "already here"})`];
202
+ if (s.listedAs) {
203
+ lines.push(`.chant/trust.json at ${branch} ${s.head!.slice(0, 12)} lists it for ${s.listedAs}, with the runner role`);
204
+ } else if (s.proposal?.branch) {
205
+ lines.push(
206
+ `committed the runner entry for ${s.principal} on ${s.proposal.branch} (${s.proposal.commit!.slice(0, 12)})${s.proposal.pushed ? " and pushed it" : ""}; open a pull request into ${branch}, and the key counts once it merges`,
207
+ );
208
+ lines.push("changing .chant/trust.json is a protected write: when the trust policy has signers, an admin signs the commit before it merges");
209
+ } else if (s.proposal) {
210
+ lines.push(`the runner entry for ${s.principal} is already at ${branch}'s head`);
211
+ } else {
212
+ lines.push(`.chant/trust.json at ${branch}${s.head ? ` ${s.head.slice(0, 12)}` : ""} does not list it yet. In a checkout of ${branch}, run`, ` ${s.command}`, "commit the change through review, or run this again with --trust to push it on a branch");
213
+ }
214
+ return lines.join("\n");
215
+ }
216
+
217
+ /**
218
+ * The key `ci serve` signs with, checked before its first poll (#4099): the
219
+ * `--key` file, else `CHANT_RUNNER_KEY`, else the host's key in the state
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.
232
+ */
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)" };
246
+ const host = req.keyFile === undefined && !req.envKey ? hostRunnerKey(req.stateDir) : undefined;
247
+ const pem = req.keyFile !== undefined ? readFileSync(req.keyFile, "utf-8") : req.envKey || host?.pem;
248
+ const source = req.keyFile !== undefined ? req.keyFile : req.envKey ? "CHANT_RUNNER_KEY" : (host?.path ?? join(req.stateDir, RUNNER_KEY_FILE));
249
+ if (pem === undefined) {
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`);
252
+ }
253
+ let publicKey: string;
254
+ try {
255
+ publicKey = loadRunnerKey(pem).publicKey;
256
+ } catch (err) {
257
+ throw new CiRunError(`the runner key from ${source} is not an Ed25519 private key in PEM: ${message(err)}`);
258
+ }
259
+ const head = targetHead(req.top, req.branch);
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
+ }
265
+ const listed = runnerKeyListed(req.top, head.commit, pem);
266
+ if (!listed.ok) {
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}` };
271
+ }
272
+ return { pem, fromFlag: req.keyFile !== undefined, source };
273
+ }
274
+
275
+ const branchAt = (branch: string, commit: string) => `${branch} ${commit.slice(0, 12)}`;
@@ -0,0 +1,106 @@
1
+ /**
2
+ * #4096: the chant runner's secrets file, its allowlist, and the redactor
3
+ * built from it. Each file is written to its own scratch directory.
4
+ */
5
+
6
+ import { chmodSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { afterAll, describe, expect, test } from "vitest";
9
+ import { cleanScratch, scratchDir } from "./__fixtures__/contract-repo";
10
+ import { ciRedactor, ciSecretsFor, CiSecretsError, parseCiSecrets, parseDotenv, readCiSecrets } from "./ci-secrets";
11
+ import { ciSecretsOf } from "./ci-run";
12
+ import { redactorOf, redactText } from "../op/redact";
13
+ import { parseArgs } from "../cli/parse-args";
14
+
15
+ afterAll(cleanScratch);
16
+
17
+ function fileWithMode(text: string, mode: number): string {
18
+ const file = join(scratchDir("chant-ci-secrets-"), "secrets.env");
19
+ writeFileSync(file, text);
20
+ chmodSync(file, mode);
21
+ return file;
22
+ }
23
+
24
+ describe("the secrets file (#4096)", () => {
25
+ test.skipIf(process.platform === "win32")("a file its group or other users can read or change is refused, as ssh refuses a loose key", () => {
26
+ for (const mode of [0o644, 0o640, 0o604, 0o620, 0o660]) {
27
+ const file = fileWithMode("A_TOKEN=abcdef\n", mode);
28
+ expect(() => readCiSecrets(file)).toThrow(CiSecretsError);
29
+ expect(() => readCiSecrets(file)).toThrow(new RegExp(`has mode 0${mode.toString(8)}.*chmod 600`));
30
+ }
31
+ for (const mode of [0o600, 0o400]) expect(readCiSecrets(fileWithMode("A_TOKEN=abcdef\n", mode)).values).toEqual({ A_TOKEN: "abcdef" });
32
+ // CHANT_CI_SECRETS names the file when --secrets does not, and the same check applies.
33
+ const loose = fileWithMode("A_TOKEN=abcdef\n", 0o644);
34
+ expect(() => ciSecretsOf(undefined, { CHANT_CI_SECRETS: loose })).toThrow(/chmod 600/);
35
+ expect(ciSecretsOf(undefined, { CHANT_CI_SECRETS: fileWithMode("A_TOKEN=abcdef\n", 0o600) })?.values).toEqual({ A_TOKEN: "abcdef" });
36
+ expect(ciSecretsOf(undefined, {})).toBeUndefined();
37
+ expect(() => readCiSecrets(join(scratchDir("chant-ci-secrets-"), "missing.env"))).toThrow(/can't be read: ENOENT/);
38
+ });
39
+
40
+ test("dotenv: comments, export, quotes and escapes, and errors that name the line and not the value", () => {
41
+ const text = [
42
+ "# cloud",
43
+ "export AWS_ACCESS_KEY_ID=AKIAEXAMPLE",
44
+ "AWS_SECRET_ACCESS_KEY = 'single # kept'",
45
+ 'PEM="line one\\nline two"',
46
+ "PLAIN=value # a comment",
47
+ "",
48
+ 'MULTI="a',
49
+ 'b"',
50
+ ].join("\n");
51
+ expect(parseDotenv(text, "s.env")).toEqual({
52
+ AWS_ACCESS_KEY_ID: "AKIAEXAMPLE",
53
+ AWS_SECRET_ACCESS_KEY: "single # kept",
54
+ PEM: "line one\nline two",
55
+ PLAIN: "value",
56
+ MULTI: "a\nb",
57
+ });
58
+ expect(() => parseDotenv("A=1\nnot a line hunter2\n", "s.env")).toThrow(/^s\.env:2: expected NAME=value$/);
59
+ expect(() => parseDotenv('A="unclosed hunter2', "s.env")).toThrow(/s\.env:1: the value of A has no closing "/);
60
+ expect(() => parseDotenv("A=1\nA=2\n", "s.env")).toThrow(/A is set twice/);
61
+ expect(() => parseCiSecrets("CHANT_RUNNER_KEY=x\n", "s.env")).toThrow(/runner key is never passed to a job/);
62
+ });
63
+
64
+ test("JSON: a flat map, or secrets with an allowlist for each job", () => {
65
+ expect(parseCiSecrets('{ "A_TOKEN": "abcd" }', "s.json")).toEqual({ values: { A_TOKEN: "abcd" }, jobs: {} });
66
+ const s = { file: "s.json", ...parseCiSecrets(JSON.stringify({ secrets: { A: "aaaa", B: "bbbb" }, jobs: { plan: ["A"] } }), "s.json") };
67
+ expect(ciSecretsFor(s, "plan")).toEqual({ A: "aaaa" });
68
+ expect(ciSecretsFor(s, "apply")).toEqual({ A: "aaaa", B: "bbbb" });
69
+ expect(ciSecretsFor(undefined, "plan")).toEqual({});
70
+ expect(ciSecretsFor({ ...s, jobs: { plan: [] } }, "plan")).toEqual({});
71
+ expect(() => parseCiSecrets(JSON.stringify({ secrets: { A: "aaaa" }, jobs: { plan: ["C"] } }), "s.json")).toThrow(/jobs\.plan names C, which the file does not set/);
72
+ expect(() => parseCiSecrets(JSON.stringify({ secrets: { A: "aaaa" }, jobs: { deploy: [] } }), "s.json")).toThrow(/the jobs are plan and apply/);
73
+ expect(() => parseCiSecrets('{ "A": 1 }', "s.json")).toThrow(/must be a string/);
74
+ // A parse error does not quote the text, which may hold a secret.
75
+ expect(() => parseCiSecrets('{ "A": "hunter2', "s.json")).toThrow(/^s\.json starts with \{, and is not valid JSON$/);
76
+ });
77
+
78
+ test("ci run and ci serve take --secrets", () => {
79
+ expect(parseArgs(["ci", "run", "plan", "--secrets", "/etc/chant/ci.env"])).toMatchObject({ ciSecrets: "/etc/chant/ci.env" });
80
+ expect(parseArgs(["ci", "serve", "--secrets", "s.json"])).toMatchObject({ ciSecrets: "s.json" });
81
+ expect(() => parseArgs(["ci", "run", "plan", "--secrets"])).toThrow(/--secrets needs/);
82
+ });
83
+ });
84
+
85
+ describe("redaction (#4096)", () => {
86
+ test("every secret, and each job variable whose name looks secret, is ***; a value under 4 characters is kept", () => {
87
+ const secrets = { file: "s", values: { DB: "hunter22", PIN: "abc" }, jobs: {} };
88
+ const redact = ciRedactor({ GITHUB_TOKEN: "ghp_abcdef", AWS_SECRET_ACCESS_KEY: "aws-secret", MY_PASSWORD: "pw-1234", HOME: "/home/runner", GITHUB_REPOSITORY: "acme/estate" }, secrets)!;
89
+ expect(redact("hunter22 ghp_abcdef aws-secret pw-1234 abc /home/runner acme/estate")).toBe("*** *** *** *** abc /home/runner acme/estate");
90
+ expect(ciRedactor({ HOME: "/h" })).toBeUndefined();
91
+ expect(redactorOf(["abc", ""])).toBeUndefined();
92
+ });
93
+
94
+ test("a JSON document or line stays JSON, a value that spans lines is redacted line by line, and the longest value wins", () => {
95
+ const redact = redactorOf(["true", 'qu"ote', "-----BEGIN KEY-----\nAAAABBBB\n-----END KEY-----", "abcd", "abcdefgh"])!;
96
+ const line = `${JSON.stringify({ ok: true, msg: "it is true", q: 'say qu"ote' })}\n`;
97
+ const out = redactText(line, redact);
98
+ expect(JSON.parse(out)).toEqual({ ok: true, msg: "it is ***", q: "say ***" });
99
+ expect(out.endsWith("\n")).toBe(true);
100
+ expect(redactText("[x] cmd | AAAABBBB", redact)).toBe("[x] cmd | ***");
101
+ expect(redact("abcdefgh abcd")).toBe("*** ***");
102
+ expect(redactText("nothing here", redact)).toBe("nothing here");
103
+ // JSON Lines: each line on its own.
104
+ expect(redactText('{"a":"abcd"}\nplain abcd\n', redact)).toBe('{"a":"***"}\nplain ***\n');
105
+ });
106
+ });