@intentius/chant 0.124.32 → 0.124.33

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 (281) hide show
  1. package/dist/ci-jobs.d.ts +4 -0
  2. package/dist/ci-jobs.d.ts.map +1 -1
  3. package/dist/ci-jobs.js +9 -2
  4. package/dist/cli/handlers/build.d.ts.map +1 -1
  5. package/dist/cli/handlers/build.js +18 -3
  6. package/dist/cli/handlers/lifecycle-health.d.ts +16 -0
  7. package/dist/cli/handlers/lifecycle-health.d.ts.map +1 -0
  8. package/dist/cli/handlers/lifecycle-health.js +150 -0
  9. package/dist/cli/handlers/lifecycle.d.ts +26 -0
  10. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  11. package/dist/cli/handlers/lifecycle.js +1 -1
  12. package/dist/cli/handlers/pr.d.ts +2 -0
  13. package/dist/cli/handlers/pr.d.ts.map +1 -1
  14. package/dist/cli/handlers/pr.js +124 -2
  15. package/dist/cli/handlers/promote.d.ts.map +1 -1
  16. package/dist/cli/handlers/promote.js +16 -1
  17. package/dist/cli/handlers/release.d.ts +16 -1
  18. package/dist/cli/handlers/release.d.ts.map +1 -1
  19. package/dist/cli/handlers/release.js +83 -11
  20. package/dist/cli/main.d.ts.map +1 -1
  21. package/dist/cli/main.js +81 -21
  22. package/dist/cli/parse-args.d.ts.map +1 -1
  23. package/dist/cli/parse-args.js +23 -1
  24. package/dist/cli/registry.d.ts +18 -4
  25. package/dist/cli/registry.d.ts.map +1 -1
  26. package/dist/components/deploy-inputs.d.ts +91 -0
  27. package/dist/components/deploy-inputs.d.ts.map +1 -0
  28. package/dist/components/deploy-inputs.js +184 -0
  29. package/dist/components/driver.d.ts.map +1 -1
  30. package/dist/components/driver.js +9 -0
  31. package/dist/components/gate-plan.d.ts +8 -0
  32. package/dist/components/gate-plan.d.ts.map +1 -1
  33. package/dist/components/gate-plan.js +6 -0
  34. package/dist/components/pr-pipeline.d.ts +33 -10
  35. package/dist/components/pr-pipeline.d.ts.map +1 -1
  36. package/dist/components/pr-pipeline.js +42 -11
  37. package/dist/components/pr-run.d.ts +1 -1
  38. package/dist/components/pr-run.d.ts.map +1 -1
  39. package/dist/components/release-compile.d.ts +37 -2
  40. package/dist/components/release-compile.d.ts.map +1 -1
  41. package/dist/components/release-compile.js +64 -8
  42. package/dist/components/release-promote.d.ts +17 -3
  43. package/dist/components/release-promote.d.ts.map +1 -1
  44. package/dist/components/release-promote.js +79 -18
  45. package/dist/components/verbs/bom-writer.d.ts +1 -1
  46. package/dist/components/verbs/bom-writer.d.ts.map +1 -1
  47. package/dist/components/verbs/lockfile-sbom-generator.d.ts +20 -0
  48. package/dist/components/verbs/lockfile-sbom-generator.d.ts.map +1 -1
  49. package/dist/components/verbs/lockfile-sbom-generator.js +41 -4
  50. package/dist/components/wave-plan.d.ts +10 -0
  51. package/dist/components/wave-plan.d.ts.map +1 -1
  52. package/dist/components/wave-plan.js +32 -4
  53. package/dist/graph-detail.d.ts.map +1 -1
  54. package/dist/graph-detail.js +32 -7
  55. package/dist/health.d.ts +110 -0
  56. package/dist/health.d.ts.map +1 -0
  57. package/dist/health.js +105 -0
  58. package/dist/health.schema.json +191 -0
  59. package/dist/lexicon.d.ts +27 -0
  60. package/dist/lexicon.d.ts.map +1 -1
  61. package/dist/lifecycle/merge-record.d.ts +104 -0
  62. package/dist/lifecycle/merge-record.d.ts.map +1 -0
  63. package/dist/lifecycle/merge-record.js +102 -0
  64. package/dist/lifecycle/publish-attachments.d.ts +8 -5
  65. package/dist/lifecycle/publish-attachments.d.ts.map +1 -1
  66. package/dist/lifecycle/publish-attachments.js +27 -13
  67. package/dist/lifecycle/publish-npm.d.ts +3 -2
  68. package/dist/lifecycle/publish-npm.d.ts.map +1 -1
  69. package/dist/lifecycle/publish-npm.js +4 -3
  70. package/dist/lifecycle/release-at.d.ts +40 -0
  71. package/dist/lifecycle/release-at.d.ts.map +1 -0
  72. package/dist/lifecycle/release-at.js +83 -0
  73. package/dist/lifecycle/release-cut.d.ts.map +1 -1
  74. package/dist/lifecycle/release-cut.js +23 -12
  75. package/dist/lifecycle/release-publish.d.ts +28 -12
  76. package/dist/lifecycle/release-publish.d.ts.map +1 -1
  77. package/dist/lifecycle/release-publish.js +29 -17
  78. package/dist/lifecycle/release-store.d.ts +25 -2
  79. package/dist/lifecycle/release-store.d.ts.map +1 -1
  80. package/dist/lifecycle/release-store.js +40 -13
  81. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  82. package/dist/lifecycle/run-ledger.js +1 -0
  83. package/dist/lint/rules/op/index.d.ts +2 -1
  84. package/dist/lint/rules/op/index.d.ts.map +1 -1
  85. package/dist/lint/rules/op/index.js +4 -2
  86. package/dist/lint/rules/op/ops018-shell-image-digest.d.ts +10 -0
  87. package/dist/lint/rules/op/ops018-shell-image-digest.d.ts.map +1 -0
  88. package/dist/lint/rules/op/ops018-shell-image-digest.js +38 -0
  89. package/dist/op/activities/activity-contracts.d.ts +5 -0
  90. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  91. package/dist/op/activities/activity-contracts.js +3 -0
  92. package/dist/op/activities/shell.d.ts +38 -0
  93. package/dist/op/activities/shell.d.ts.map +1 -1
  94. package/dist/op/activities/shell.js +89 -3
  95. package/dist/op/builders.d.ts +7 -0
  96. package/dist/op/builders.d.ts.map +1 -1
  97. package/dist/op/builders.js +7 -0
  98. package/dist/op/runtime.d.ts +2 -0
  99. package/dist/op/runtime.d.ts.map +1 -1
  100. package/dist/pr-loop.d.ts +14 -3
  101. package/dist/pr-loop.d.ts.map +1 -1
  102. package/dist/pr-loop.js +15 -5
  103. package/dist/pr-report-url.d.ts +2 -2
  104. package/dist/pr-report-url.d.ts.map +1 -1
  105. package/dist/runner-start.d.ts +179 -0
  106. package/dist/runner-start.d.ts.map +1 -0
  107. package/dist/runner-start.js +277 -0
  108. package/dist/wave-runtime.mjs +235 -228
  109. package/dist/workspace/check.schema.json +70 -2
  110. package/dist/workspace/checks/security.d.ts +35 -0
  111. package/dist/workspace/checks/security.d.ts.map +1 -0
  112. package/dist/workspace/checks/security.js +305 -0
  113. package/dist/workspace/checks.d.ts +2 -0
  114. package/dist/workspace/checks.d.ts.map +1 -1
  115. package/dist/workspace/checks.js +5 -0
  116. package/dist/workspace/ci-builds.d.ts +163 -0
  117. package/dist/workspace/ci-builds.d.ts.map +1 -0
  118. package/dist/workspace/ci-builds.js +315 -0
  119. package/dist/workspace/ci-green-cli.d.ts +2 -1
  120. package/dist/workspace/ci-green-cli.d.ts.map +1 -1
  121. package/dist/workspace/ci-green-cli.js +25 -5
  122. package/dist/workspace/ci-green-forge.d.ts +12 -0
  123. package/dist/workspace/ci-green-forge.d.ts.map +1 -1
  124. package/dist/workspace/ci-green-forge.js +12 -0
  125. package/dist/workspace/ci-green-workflow.d.ts +55 -1
  126. package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
  127. package/dist/workspace/ci-green-workflow.js +128 -1
  128. package/dist/workspace/ci-green.d.ts +89 -4
  129. package/dist/workspace/ci-green.d.ts.map +1 -1
  130. package/dist/workspace/ci-green.js +310 -23
  131. package/dist/workspace/ci-last-green.schema.json +49 -0
  132. package/dist/workspace/ci-list.schema.json +35 -1
  133. package/dist/workspace/ci-run-index.d.ts +1 -1
  134. package/dist/workspace/ci-run-index.d.ts.map +1 -1
  135. package/dist/workspace/ci-run-index.schema.json +1 -1
  136. package/dist/workspace/ci-run.d.ts +12 -6
  137. package/dist/workspace/ci-run.d.ts.map +1 -1
  138. package/dist/workspace/ci-run.js +72 -16
  139. package/dist/workspace/ci-runner-start.d.ts +77 -0
  140. package/dist/workspace/ci-runner-start.d.ts.map +1 -0
  141. package/dist/workspace/ci-runner-start.js +185 -0
  142. package/dist/workspace/ci-serve.d.ts +76 -7
  143. package/dist/workspace/ci-serve.d.ts.map +1 -1
  144. package/dist/workspace/ci-serve.js +153 -19
  145. package/dist/workspace/declaration.d.ts +17 -0
  146. package/dist/workspace/declaration.d.ts.map +1 -1
  147. package/dist/workspace/declaration.js +3 -0
  148. package/dist/workspace/declaration.schema.json +23 -0
  149. package/dist/workspace/ls.d.ts +2 -0
  150. package/dist/workspace/ls.d.ts.map +1 -1
  151. package/dist/workspace/ls.js +1 -1
  152. package/dist/workspace/ls.schema.json +2 -1
  153. package/dist/workspace/member-pipeline.d.ts +1 -0
  154. package/dist/workspace/member-pipeline.d.ts.map +1 -1
  155. package/dist/workspace/member-pipeline.js +2 -0
  156. package/dist/workspace/merge-record.schema.json +49 -0
  157. package/dist/workspace/pr-report.schema.json +3 -3
  158. package/dist/workspace/reason-codes.d.ts +17 -0
  159. package/dist/workspace/reason-codes.d.ts.map +1 -1
  160. package/dist/workspace/reason-codes.js +18 -0
  161. package/dist/workspace/release-candidates.d.ts +129 -0
  162. package/dist/workspace/release-candidates.d.ts.map +1 -0
  163. package/dist/workspace/release-candidates.js +233 -0
  164. package/dist/workspace/release-candidates.schema.json +353 -0
  165. package/dist/workspace/security.d.ts +40 -0
  166. package/dist/workspace/security.d.ts.map +1 -0
  167. package/dist/workspace/security.js +99 -0
  168. package/package.json +8 -1
  169. package/src/__fixtures__/pr-loop/pr-plan.note.golden.md +1 -1
  170. package/src/ci-jobs.ts +11 -2
  171. package/src/cli/handlers/build.test.ts +36 -0
  172. package/src/cli/handlers/build.ts +18 -3
  173. package/src/cli/handlers/lifecycle-health.test.ts +123 -0
  174. package/src/cli/handlers/lifecycle-health.ts +157 -0
  175. package/src/cli/handlers/lifecycle.ts +1 -1
  176. package/src/cli/handlers/pr-deploy-inputs.test.ts +209 -0
  177. package/src/cli/handlers/pr-record.test.ts +201 -0
  178. package/src/cli/handlers/pr.test.ts +22 -0
  179. package/src/cli/handlers/pr.ts +127 -4
  180. package/src/cli/handlers/promote.test.ts +17 -0
  181. package/src/cli/handlers/promote.ts +16 -1
  182. package/src/cli/handlers/release-cut-at.e2e.test.ts +115 -0
  183. package/src/cli/handlers/release-publish-targets.e2e.test.ts +2 -2
  184. package/src/cli/handlers/release-verify.e2e.test.ts +12 -10
  185. package/src/cli/handlers/release.e2e.test.ts +19 -28
  186. package/src/cli/handlers/release.ts +81 -11
  187. package/src/cli/main.ts +81 -21
  188. package/src/cli/parse-args.ts +17 -1
  189. package/src/cli/registry.ts +18 -4
  190. package/src/components/deploy-inputs.test.ts +108 -0
  191. package/src/components/deploy-inputs.ts +225 -0
  192. package/src/components/driver.ts +11 -0
  193. package/src/components/gate-plan.ts +9 -0
  194. package/src/components/pr-pipeline.ts +56 -15
  195. package/src/components/pr-run.ts +1 -1
  196. package/src/components/release-compile.test.ts +34 -0
  197. package/src/components/release-compile.ts +89 -8
  198. package/src/components/release-promote.test.ts +55 -2
  199. package/src/components/release-promote.ts +93 -23
  200. package/src/components/verbs/bom-writer.ts +1 -1
  201. package/src/components/verbs/lockfile-sbom-generator.test.ts +36 -0
  202. package/src/components/verbs/lockfile-sbom-generator.ts +43 -5
  203. package/src/components/wave-plan.ts +32 -4
  204. package/src/graph-detail.test.ts +57 -0
  205. package/src/graph-detail.ts +33 -7
  206. package/src/health.schema.json +191 -0
  207. package/src/health.test.ts +62 -0
  208. package/src/health.ts +178 -0
  209. package/src/lexicon.ts +41 -1
  210. package/src/lifecycle/merge-record.test.ts +111 -0
  211. package/src/lifecycle/merge-record.ts +154 -0
  212. package/src/lifecycle/publish-attachments.ts +25 -13
  213. package/src/lifecycle/publish-npm.ts +4 -3
  214. package/src/lifecycle/publish-targets.test.ts +34 -4
  215. package/src/lifecycle/release-at.test.ts +48 -0
  216. package/src/lifecycle/release-at.ts +100 -0
  217. package/src/lifecycle/release-cut.test.ts +41 -0
  218. package/src/lifecycle/release-cut.ts +22 -11
  219. package/src/lifecycle/release-publish.test.ts +43 -13
  220. package/src/lifecycle/release-publish.ts +38 -18
  221. package/src/lifecycle/release-store.ts +57 -13
  222. package/src/lifecycle/run-ledger.test.ts +13 -0
  223. package/src/lifecycle/run-ledger.ts +1 -0
  224. package/src/lint/rules/op/index.ts +4 -2
  225. package/src/lint/rules/op/ops018-shell-image-digest.test.ts +60 -0
  226. package/src/lint/rules/op/ops018-shell-image-digest.ts +48 -0
  227. package/src/op/activities/activity-contracts.ts +3 -0
  228. package/src/op/activities/shell-container.e2e.test.ts +67 -0
  229. package/src/op/activities/shell.test.ts +44 -1
  230. package/src/op/activities/shell.ts +110 -3
  231. package/src/op/builders.ts +7 -0
  232. package/src/op/runtime.ts +2 -0
  233. package/src/op/step-console.test.ts +4 -2
  234. package/src/pr-loop.test.ts +29 -0
  235. package/src/pr-loop.ts +26 -7
  236. package/src/pr-report-url.ts +2 -2
  237. package/src/runner-start-bundle.test.ts +29 -0
  238. package/src/runner-start.test.ts +179 -0
  239. package/src/runner-start.ts +329 -0
  240. package/src/workspace/check-contract.test.ts +3 -2
  241. package/src/workspace/check.schema.json +70 -2
  242. package/src/workspace/checks/security.test.ts +138 -0
  243. package/src/workspace/checks/security.ts +305 -0
  244. package/src/workspace/checks.ts +5 -0
  245. package/src/workspace/ci-builds.e2e.test.ts +208 -0
  246. package/src/workspace/ci-builds.test.ts +174 -0
  247. package/src/workspace/ci-builds.ts +385 -0
  248. package/src/workspace/ci-green-builds.test.ts +180 -0
  249. package/src/workspace/ci-green-cli.test.ts +54 -0
  250. package/src/workspace/ci-green-cli.ts +26 -5
  251. package/src/workspace/ci-green-forge.ts +24 -0
  252. package/src/workspace/ci-green-workflow.test.ts +84 -1
  253. package/src/workspace/ci-green-workflow.ts +139 -2
  254. package/src/workspace/ci-green.test.ts +5 -2
  255. package/src/workspace/ci-green.ts +359 -27
  256. package/src/workspace/ci-last-green.schema.json +49 -0
  257. package/src/workspace/ci-list.schema.json +35 -1
  258. package/src/workspace/ci-run-index.schema.json +1 -1
  259. package/src/workspace/ci-run-index.ts +1 -1
  260. package/src/workspace/ci-run.test.ts +24 -7
  261. package/src/workspace/ci-run.ts +78 -16
  262. package/src/workspace/ci-runner-start.test.ts +151 -0
  263. package/src/workspace/ci-runner-start.ts +193 -0
  264. package/src/workspace/ci-serve-builds.test.ts +86 -0
  265. package/src/workspace/ci-serve.test.ts +59 -0
  266. package/src/workspace/ci-serve.ts +177 -22
  267. package/src/workspace/declaration.schema.json +23 -0
  268. package/src/workspace/declaration.test.ts +17 -0
  269. package/src/workspace/declaration.ts +22 -1
  270. package/src/workspace/ls.schema.json +2 -1
  271. package/src/workspace/ls.ts +3 -2
  272. package/src/workspace/member-pipeline.ts +2 -1
  273. package/src/workspace/merge-record.schema.json +49 -0
  274. package/src/workspace/pr-report.schema.json +3 -3
  275. package/src/workspace/reason-codes.test.ts +2 -0
  276. package/src/workspace/reason-codes.ts +18 -0
  277. package/src/workspace/release-candidates.schema.json +353 -0
  278. package/src/workspace/release-candidates.test.ts +106 -0
  279. package/src/workspace/release-candidates.ts +315 -0
  280. package/src/workspace/security.test.ts +70 -0
  281. package/src/workspace/security.ts +118 -0
@@ -23,7 +23,7 @@
23
23
  "if": { "properties": { "status": { "const": "fail" } } },
24
24
  "then": { "required": ["failure"], "properties": { "failure": { "type": "object" } } },
25
25
  "properties": {
26
- "job": { "enum": ["plan", "apply"] },
26
+ "job": { "enum": ["plan", "apply", "record"], "description": "record (#4168, chant 0.124.33 and newer): the merge record job." },
27
27
  "pr": { "type": ["integer", "null"], "minimum": 1, "description": "The pull or merge request number: the one the job's report names, else --pr. null when neither has one." },
28
28
  "commit": { "type": "string", "pattern": "^[0-9a-f]{40,64}$", "description": "The full commit the job ran at." },
29
29
  "status": { "enum": ["ok", "fail"] },
@@ -44,7 +44,7 @@ export const CI_RUN_INDEX_LIMIT = 200;
44
44
 
45
45
  /** One job the runner ran. */
46
46
  export interface CiRunIndexEntry {
47
- job: "plan" | "apply";
47
+ job: "plan" | "apply" | "record";
48
48
  /** The pull request, or null when the job found none. */
49
49
  pr: number | null;
50
50
  commit: string;
@@ -206,7 +206,11 @@ describe("the job as an Op (#4094)", () => {
206
206
  expect(ciLoopOptions(args)).toEqual({ gate: "infra", waveGate: "release" });
207
207
  }
208
208
  const [plan, apply] = prLoopJobs("forgejo", "prod", ciLoopOptions(parseArgs(["ci", "run", "apply", "--env", "prod", "--wave-gate", "release"])));
209
- expect(apply!.command).toContain("--require-review --wave-gate release --resume .chant/pr-resume/pr-apply.json");
209
+ // #4168: no --require-review unless asked for.
210
+ expect(apply!.command).toContain(" --wave-gate release --resume .chant/pr-resume/pr-apply.json");
211
+ expect(apply!.command).not.toContain("--require-review");
212
+ const reviewed = prLoopJobs("forgejo", "prod", ciLoopOptions(parseArgs(["ci", "run", "apply", "--env", "prod", "--wave-gate", "release", "--require-review"])))[1]!;
213
+ expect(reviewed.command).toContain("--require-review --wave-gate release --resume .chant/pr-resume/pr-apply.json");
210
214
  expect(apply!.command).not.toContain("--wave ");
211
215
  expect(plan!.command).toContain("--wave-gate release");
212
216
  expect(plan!.command).not.toContain("--wave ");
@@ -294,7 +298,7 @@ describe("chant ci run, then ci tick from runs (#4094)", () => {
294
298
 
295
299
  expect(apply.status).toBe("ok");
296
300
  const applyArgs = readFileSync(join(reports, "apply", "pr-apply.args"), "utf-8").trimEnd().split("\n");
297
- expect(applyArgs).toEqual(["components", "pr-apply", "--base", base, "--env", "prod", "--gate", "pr-apply", "--output", ".chant/pr", "--forge", "forgejo", "--member", "app", "--require-review", "--resume", ".chant/pr-resume/pr-apply.json"]);
301
+ expect(applyArgs).toEqual(["components", "pr-apply", "--base", base, "--env", "prod", "--gate", "pr-apply", "--output", ".chant/pr", "--forge", "forgejo", "--member", "app", "--resume", ".chant/pr-resume/pr-apply.json"]);
298
302
  expect(formatCiRun(apply)).toContain("signed by chant-runner");
299
303
  });
300
304
 
@@ -310,6 +314,17 @@ describe("chant ci run, then ci tick from runs (#4094)", () => {
310
314
  expect(record.phases.apply![0]).toMatchObject({ name: "pr-apply", opRun: { id: apply.record.id, signer: "chant-runner" } });
311
315
  });
312
316
 
317
+ test("under the isolated-runners security group an apply job requires a runner-start statement, and a plan job does not (#4168)", async () => {
318
+ const { root, app, base } = workspace();
319
+ writeFiles(root, { "chant.workspace.json": declaration([{ name: "app", dir: "app", kind: "other", because: "the app", security: ["isolated-runners"] }], { ci: { green: GREEN } }) });
320
+ git(root, "commit", "-qam", "isolated-runners");
321
+ git(root, "push", "-q", "origin", "main");
322
+ const job = (j: "plan" | "apply") =>
323
+ ciRun({ image: JOB_IMAGE, cwd: app, job: j, env: "prod", forge: "github", base, ...(j === "plan" ? { pr: 7 } : {}), chant: fakeChant(), hostEnv: HOST, output: join(scratchDir("chant-ci-run-out-"), "pr"), push: false });
324
+ await expect(job("apply")).rejects.toThrow(/--require-runner-start needs a forge job to check/);
325
+ expect((await job("plan")).status).toBe("ok");
326
+ });
327
+
313
328
  test("a failing job is recorded failed, and a key from the environment that trust.json does not list leaves it unsigned, quietly", async () => {
314
329
  const { root, app, base } = workspace();
315
330
  const stranger = runnerKey();
@@ -746,12 +761,14 @@ describe("a generated forge job runs ci run --in-place (#4156)", () => {
746
761
  // What a GitHub or Forgejo job has in its environment: the forge's run, job and attempt, and the secret.
747
762
  const FORGE = { GITHUB_RUN_ID: "4242", GITHUB_RUN_ATTEMPT: "2", GITHUB_JOB: "apply", GITHUB_REPOSITORY: "acme/estate", GITHUB_SERVER_URL: "http://forgejo.localhost" };
748
763
 
749
- test("the generated job's line parses to the same job, and --in-place, --no-push, --no-require-review and --wave reach it", () => {
750
- const [plan, apply] = prLoopJobs("forgejo", "prod", { events: true, reportUrl: "s3://b/p", requireReview: false, waveGate: "release", branch: "trunk" });
764
+ test("the generated job's line parses to the same job, and --in-place, --no-push, --require-review and --wave reach it", () => {
765
+ const [plan, apply] = prLoopJobs("forgejo", "prod", { events: true, reportUrl: "s3://b/p", requireReview: true, waveGate: "release", branch: "trunk" });
751
766
  expect(plan!.run.command).toBe('chant ci run plan --in-place --no-push --base "$BASE_SHA" --pr "$PR_NUMBER" --env prod --gate pr-apply --forge forgejo --wave-gate release --events-json --report-url "s3://b/p"');
752
767
  const args = parseArgs(apply!.run.command.replace(/"\$BASE_SHA"/, "b".repeat(40)).replace(/"/g, "").split(" ").slice(1).concat(["--wave", "2"]));
753
- expect(args).toMatchObject({ ciInPlace: true, requireReview: false, wave: 2, waveGate: "release", branch: "trunk", eventsJson: true, reportUrl: "s3://b/p" });
754
- expect(ciLoopOptions(args)).toEqual({ gate: "pr-apply", branch: "trunk", events: true, reportUrl: "s3://b/p", waveGate: "release", requireReview: false });
768
+ expect(args).toMatchObject({ ciInPlace: true, requireReview: true, wave: 2, waveGate: "release", branch: "trunk", eventsJson: true, reportUrl: "s3://b/p" });
769
+ expect(ciLoopOptions(args)).toEqual({ gate: "pr-apply", branch: "trunk", events: true, reportUrl: "s3://b/p", waveGate: "release", requireReview: true });
770
+ // --no-require-review, which pipelines generated before #4168 pass, is the default now.
771
+ expect(ciLoopOptions(parseArgs(["ci", "run", "apply", "--no-require-review"]))).toEqual({});
755
772
  expect(parseArgs(["ci", "run", "plan", "--no-push"]).noPush).toBe(true);
756
773
  // ci run rebuilds pr-apply from those options: the very command the job ran before #4156.
757
774
  expect(prLoopJobs("forgejo", "prod", ciLoopOptions(args))[1]!.command).toBe(apply!.command);
@@ -774,7 +791,7 @@ describe("a generated forge job runs ci run --in-place (#4156)", () => {
774
791
  expect(r.report).toBe(join(realpathSync(app), ".chant/pr"));
775
792
  expect(r.record.phases.map((p) => p.name)).toEqual(["apply"]);
776
793
  const argv = readFileSync(join(app, ".chant/pr/pr-apply.args"), "utf-8").trimEnd().split("\n");
777
- expect(argv).toEqual(["components", "pr-apply", "--base", base, "--env", "prod", "--gate", "pr-apply", "--output", ".chant/pr", "--forge", "forgejo", "--member", "app", "--require-review", "--resume", ".chant/pr-resume/pr-apply.json"]);
794
+ expect(argv).toEqual(["components", "pr-apply", "--base", base, "--env", "prod", "--gate", "pr-apply", "--output", ".chant/pr", "--forge", "forgejo", "--member", "app", "--resume", ".chant/pr-resume/pr-apply.json"]);
778
795
  // The forge job's whole environment reaches the step, but never the runner key.
779
796
  const env = envFile(join(app, ".chant/pr"), "pr-apply");
780
797
  expect(env).toMatchObject({ NOT_FOR_JOBS: "leak", GITHUB_RUN_ID: "4242", BASE_SHA: base, GITHUB_SHA: sha });
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `chant ci run plan|apply` (#4094, epic #4101): run one pull-request job as
2
+ * `chant ci run plan|record|apply` (#4094, epic #4101, record #4168): run one pull-request job as
3
3
  * a pipeline Op, the first slice of the chant runner.
4
4
  *
5
5
  * The job is the typed value `prLoopJobs` (../components/pr-pipeline.ts)
@@ -99,13 +99,22 @@ import { resolveMemberContext } from "./member-pipeline";
99
99
  import { signOpRunRecord } from "./trust/op-run-statement";
100
100
  import { gitTop } from "./tree";
101
101
  import { hostRunnerKey } from "./ci-runner-key";
102
+ import { requireRunnerStart } from "./ci-runner-start";
103
+ import { securityGroupsAt } from "./security";
102
104
 
103
- /** The two jobs a pull-request pipeline has. */
105
+ /** The jobs a pull-request pipeline has: plan, and on merge record (#4168) or, opted in, apply. */
104
106
  export type CiJobName = PrLoopJob["jobName"];
105
- export const CI_JOB_NAMES: readonly CiJobName[] = ["plan", "apply"];
107
+ export const CI_JOB_NAMES: readonly CiJobName[] = ["plan", "apply", "record"];
106
108
 
107
109
  /** The Op each job is recorded as, which `ci.green` phases match. */
108
- export const CI_JOB_OPS: Record<CiJobName, string> = { plan: "pr-plan", apply: "pr-apply" };
110
+ export const CI_JOB_OPS: Record<CiJobName, string> = { plan: "pr-plan", apply: "pr-apply", record: "pr-record" };
111
+
112
+ /** What each job's Op says it does. */
113
+ const CI_JOB_OVERVIEWS: Record<CiJobName, string> = {
114
+ plan: "Plan the members this pull request reaches",
115
+ apply: "Apply the plan a reviewer approved",
116
+ record: "Record the plan on the merged commit; nothing applies",
117
+ };
109
118
 
110
119
  /** Where a job the chant runner ran says it ran (#4094). */
111
120
  export const CHANT_RUNNER_ORIGIN: RunOrigin = { forge: "chant" };
@@ -255,7 +264,7 @@ export function ciJobOp(input: CiJobOpInput): OpConfig {
255
264
  const op = CI_JOB_OPS[input.job.jobName];
256
265
  return {
257
266
  name: op,
258
- overview: input.job.jobName === "plan" ? "Plan the members this pull request reaches" : "Apply the plan a reviewer approved",
267
+ overview: CI_JOB_OVERVIEWS[input.job.jobName],
259
268
  labels: { Env: input.env, Pipeline: "pr", Job: input.job.jobName },
260
269
  phases,
261
270
  };
@@ -353,12 +362,12 @@ const STATUS_DESCRIPTION = 140;
353
362
  const cut = (text: string, max = STATUS_DESCRIPTION): string => (text.length > max ? `${text.slice(0, max - 3)}...` : text);
354
363
 
355
364
  /**
356
- * A job's status context: `chant/pr-plan` for the plan, and for the apply
365
+ * A job's status context: `chant/pr-plan` for the plan, `chant/pr-record` for the record (#4168), and for the apply
357
366
  * `chant/<gate>`, which is `chant/pr-apply` unless `--gate` renamed the gate.
358
367
  * pr-plan's own status is `chant/plan` (../pr-loop.ts), a different context.
359
368
  */
360
369
  export function ciStatusContext(job: CiJobName, gate?: string): string {
361
- return job === "apply" ? `chant/${gate ?? PR_APPLY_GATE}` : `chant/${CI_JOB_OPS.plan}`;
370
+ return job === "apply" ? `chant/${gate ?? PR_APPLY_GATE}` : `chant/${CI_JOB_OPS[job]}`;
362
371
  }
363
372
 
364
373
  /**
@@ -483,6 +492,12 @@ export interface CiRunRequest {
483
492
  * `runOrigin: { forge: "local" }` and `runtime: "local"`.
484
493
  */
485
494
  local?: boolean;
495
+ /**
496
+ * `--require-runner-start` (#4147): refuse the job before its first step
497
+ * unless a runner-start statement, signed by a runner key
498
+ * `.chant/trust.json` at `base` lists, names this forge job.
499
+ */
500
+ requireRunnerStart?: boolean;
486
501
  /**
487
502
  * `--wave <k>` (#4003, #4156): the one wave a generated apply job runs, as
488
503
  * `pr-apply --wave <k>`. Needs `--wave-gate`.
@@ -648,6 +663,10 @@ export async function ciRun(req: CiRunRequest): Promise<CiRunResult> {
648
663
  const found = jobs.find((j) => j.jobName === req.job);
649
664
  if (!found) throw new CiRunError(`the pull-request pipeline has no ${req.job} job; it has ${jobs.map((j) => j.jobName).join(" and ")}`);
650
665
  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>");
666
+ // #4147: before anything runs or any secret reaches a step, the provisioner's word that this runner was started for this job.
667
+ // #4168: under the isolated-runners security group an apply job requires the statement too, unless it runs on this machine.
668
+ const isolated = !req.requireRunnerStart && !req.local && req.job === "apply" && securityGroupsAt(cwd).has("isolated-runners");
669
+ const started = req.requireRunnerStart || isolated ? runnerStartFor(req, { top, commit, hostEnv }) : undefined;
651
670
  // A generated wave job's command, as prLoopWaveJobs writes it. #4161: a local job's command posts no status or note.
652
671
  const command = `${found.command}${req.wave !== undefined ? ` --wave ${req.wave}` : ""}${req.local ? " --no-status" : ""}`;
653
672
  const job = { ...found, command, ...(req.wave !== undefined ? { wave: req.wave } : {}) };
@@ -666,7 +685,8 @@ export async function ciRun(req: CiRunRequest): Promise<CiRunResult> {
666
685
  // 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
686
  const redact = ciRedactor(req.inPlace ? { ...longValues(jobEnv), ...own } : jobEnv, req.secrets);
668
687
  try {
669
- const result = await runCiJob(req, { cwd, top, commit, place, job, jobEnv, ...(redact ? { redact } : {}) });
688
+ const ran = await runCiJob(req, { cwd, top, commit, place, job, jobEnv, ...(redact ? { redact } : {}) });
689
+ const result = started ? { ...ran, notes: [started, ...ran.notes] } : ran;
670
690
  return redact ? { ...result, warnings: result.warnings.map(redact), notes: result.notes.map(redact), ...(result.pushWarning ? { pushWarning: redact(result.pushWarning) } : {}) } : result;
671
691
  } catch (err) {
672
692
  if (redact && err instanceof Error) err.message = redact(err.message);
@@ -674,6 +694,22 @@ export async function ciRun(req: CiRunRequest): Promise<CiRunResult> {
674
694
  }
675
695
  }
676
696
 
697
+ /**
698
+ * `--require-runner-start` (#4147): the runner-start statement for this
699
+ * forge job, checked against `.chant/trust.json` at `--base` (the commit
700
+ * before the change, as a push's `before` is), or at the job's commit
701
+ * without one. Throws {@link CiRunError} unless it verifies.
702
+ */
703
+ function runnerStartFor(req: CiRunRequest, at: { top: string; commit: string; hostEnv: Record<string, string | undefined> }): string {
704
+ if (req.local) throw new CiRunError("--require-runner-start checks a forge job's runner, and --local runs the job on this machine");
705
+ const base = req.base ? resolveJobCommit(at.top, req.base) : at.commit;
706
+ try {
707
+ return requireRunnerStart({ top: at.top, base, commit: at.commit, env: at.hostEnv }).reason;
708
+ } catch (err) {
709
+ throw new CiRunError(message(err));
710
+ }
711
+ }
712
+
677
713
  /** The generator's options for a `--local` job (#4161): the same, with no report URL. */
678
714
  function localLoop(loop: PrLoopPipelineOptions | undefined): PrLoopPipelineOptions {
679
715
  const { reportUrl: _dropped, ...rest } = loop ?? {};
@@ -899,8 +935,8 @@ export function formatCiRun(r: CiRunResult): string {
899
935
  * The generator's options from `ci run`'s and `ci serve`'s flags, as `chant
900
936
  * build --components --generate <lexicon> --pr-loop` takes them: `--gate`,
901
937
  * `--branch`, `--events-json`, `--report-url`, `--pr-build`,
902
- * `--pr-build-command`, `--wave-gate` (#4003) and `--no-require-review`
903
- * (#4156).
938
+ * `--pr-build-command`, `--wave-gate` (#4003) and `--require-review`
939
+ * (#4168; `--no-require-review`, the default, is still accepted).
904
940
  */
905
941
  export function ciLoopOptions(args: CommandContext["args"]): PrLoopPipelineOptions {
906
942
  return {
@@ -911,15 +947,15 @@ export function ciLoopOptions(args: CommandContext["args"]): PrLoopPipelineOptio
911
947
  ...(args.prBuild ? { build: true } : {}),
912
948
  ...(args.prBuildCommand ? { buildCommand: args.prBuildCommand } : {}),
913
949
  ...(args.waveGate ? { waveGate: args.waveGate } : {}),
914
- ...(args.requireReview === false ? { requireReview: false } : {}),
950
+ ...(args.requireReview === true ? { requireReview: true } : {}),
915
951
  };
916
952
  }
917
953
 
918
954
  const USAGE =
919
- "chant ci run plan|apply --env <env> --base <sha> [--pr <n>] [--commit <sha>] [--forge github|gitlab|forgejo] " +
955
+ "chant ci run plan|record|apply --env <env> --base <sha> [--pr <n>] [--commit <sha>] [--forge github|gitlab|forgejo] " +
920
956
  "[--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>]";
957
+ "[--pr-build] [--pr-build-command <line>] [--require-review] [--no-status] [--status-url <url template>] " +
958
+ "[--chant <command>] [--pass-env <NAME>]... [--secrets <file>] [--key <runner key.pem>] [-o <dir>] [--in-place | --local] [--no-push] [--verbose] | chant ci run --at <sha> | chant ci run --setup-key [--trust [--principal <name>]] [--state <dir>] [--branch <name>]";
923
959
 
924
960
  const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
925
961
 
@@ -942,13 +978,38 @@ export function ciSecretsOf(flag: string | undefined, env: Record<string, string
942
978
  return file === undefined ? undefined : readCiSecrets(resolve(file));
943
979
  }
944
980
 
945
- /** `chant ci run plan|apply` (#4094). */
981
+ /**
982
+ * `chant ci run --at <sha>` (#4169): ask for every required phase to be built
983
+ * at a commit of the green branch. On GitHub, with a token in the
984
+ * environment, the workflows holding the phases start at once.
985
+ */
986
+ async function requestBuildCommand(at: string): Promise<number> {
987
+ const { requestBuild } = await import("./ci-green");
988
+ const { ciForgeFromEnv } = await import("./ci-green-forge");
989
+ let forge: Awaited<ReturnType<typeof ciForgeFromEnv>> | undefined;
990
+ try {
991
+ forge = process.env.GITHUB_REPOSITORY ? ciForgeFromEnv("github") : undefined;
992
+ } catch {
993
+ forge = undefined;
994
+ }
995
+ const r = await requestBuild({ cwd: process.cwd(), at, ...(forge ? { forge } : {}) });
996
+ for (const w of r.warnings) console.error(formatWarning({ message: w }));
997
+ const what = `${r.request.phases.join(", ")} at ${r.request.commit.slice(0, 12)}`;
998
+ console.log(
999
+ `${r.existed ? `${r.request.tag} already asks for a build` : `Asked for ${what}: ${r.request.tag}`}. ` +
1000
+ (r.dispatched.length > 0 ? `Started ${r.dispatched.join(", ")}.` : "The next ci tick starts it on GitHub, and ci serve --build-command serves it for chant runs."),
1001
+ );
1002
+ return 0;
1003
+ }
1004
+
1005
+ /** `chant ci run plan|record|apply` (#4094, #4168). */
946
1006
  export async function runCiRun(ctx: CommandContext): Promise<number> {
947
1007
  const { args } = ctx;
948
1008
  try {
949
1009
  if (args.setupKey) return (await import("./ci-serve")).runnerKeySetupCommand(ctx);
1010
+ if (args.at !== undefined && args.extraPositional === undefined) return await requestBuildCommand(args.at);
950
1011
  const job = args.extraPositional;
951
- if (job !== "plan" && job !== "apply") throw new CiRunError(`ci run takes the job to run, plan or apply${job ? `, not ${JSON.stringify(job)}` : ""}`);
1012
+ if (job !== "plan" && job !== "apply" && job !== "record") throw new CiRunError(`ci run takes the job to run, plan, record or apply${job ? `, not ${JSON.stringify(job)}` : ""}`);
952
1013
  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>");
953
1014
  const forge = args.forge ?? detectForge(process.env);
954
1015
  if (!forge) throw new CiRunError("--forge names the forge the job posts to: --forge github|gitlab|forgejo");
@@ -980,6 +1041,7 @@ export async function runCiRun(ctx: CommandContext): Promise<number> {
980
1041
  ...(args.local ? { local: true } : {}),
981
1042
  ...(args.noPush ? { push: false } : {}),
982
1043
  ...(args.wave !== undefined ? { wave: args.wave } : {}),
1044
+ ...(args.requireRunnerStart ? { requireRunnerStart: true } : {}),
983
1045
  ...(args.runIndexLimit ? { runIndexLimit: args.runIndexLimit } : {}),
984
1046
  ...(args.image ? { image: args.image } : {}),
985
1047
  ...(args.beforeScript?.length ? { beforeScript: args.beforeScript } : {}),
@@ -0,0 +1,151 @@
1
+ /**
2
+ * `chant ci runner-start` and `chant ci run --require-runner-start` (#4147):
3
+ * the statement checked against `.chant/trust.json` at base, and the
4
+ * readiness the isolated-runners security group reads (#4168).
5
+ */
6
+
7
+ import { createPublicKey, generateKeyPairSync } from "node:crypto";
8
+ import { readFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { afterAll, describe, expect, test, vi } from "vitest";
11
+ import { parseArgs } from "../cli/parse-args";
12
+ import type { CommandContext } from "../cli/registry";
13
+ import { signRunnerStart, type CiJobIdentity } from "../runner-start";
14
+ import { cleanScratch, git, scratchDir, writeFiles } from "./__fixtures__/contract-repo";
15
+ import { checkRunnerStart, parseJobSpec, requireRunnerStart, runCiRunnerStart, runnerStartReadiness, runnerStartReadinessAt } from "./ci-runner-start";
16
+ import { ciRun, CiRunError } from "./ci-run";
17
+ import { sshFromEd25519 } from "./trust/dsse";
18
+ import { emptyPolicy } from "./trust/policy";
19
+
20
+ afterAll(cleanScratch);
21
+
22
+ function key(principal: string): { principal: string; pem: string; ssh: string; signer: Parameters<typeof signRunnerStart>[1] } {
23
+ const { privateKey } = generateKeyPairSync("ed25519");
24
+ const jwk = createPublicKey(privateKey).export({ format: "jwk" }) as { x: string };
25
+ const ssh = sshFromEd25519(Buffer.from(jwk.x, "base64url"));
26
+ return { principal, pem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(), ssh, signer: { principal, key: privateKey, publicKey: ssh } };
27
+ }
28
+
29
+ const PROVISIONER = key("provisioner");
30
+ const LATER = key("late-provisioner");
31
+
32
+ /** A repository whose first commit lists PROVISIONER as a runner, and whose second adds LATER. */
33
+ function repo(): { root: string; base: string; head: string } {
34
+ const root = scratchDir("chant-runner-start-");
35
+ git(root, "init", "-q", "-b", "main");
36
+ git(root, "config", "user.name", "t");
37
+ git(root, "config", "user.email", "t@example.com");
38
+ git(root, "config", "commit.gpgsign", "false");
39
+ const trust = (runners: Array<{ principal: string; ssh: string }>) =>
40
+ JSON.stringify({ schema: 1, runners: runners.map((r) => ({ principal: r.principal, class: "runner", key: r.ssh })), roles: { runner: runners.map((r) => r.principal) } });
41
+ writeFiles(root, { ".chant/trust.json": trust([PROVISIONER]) });
42
+ git(root, "add", "-A");
43
+ git(root, "commit", "-q", "-m", "trust");
44
+ const base = git(root, "rev-parse", "HEAD");
45
+ writeFiles(root, { ".chant/trust.json": trust([PROVISIONER, LATER]) });
46
+ git(root, "add", "-A");
47
+ git(root, "commit", "-q", "-m", "a pull request adds its own provisioner");
48
+ return { root, base, head: git(root, "rev-parse", "HEAD") };
49
+ }
50
+
51
+ const R = repo();
52
+
53
+ const job = (commit: string): CiJobIdentity => ({ system: "github", repo: "acme/infra", run: "4242", attempt: "1", job: "apply", commit, runner: "jit-1" });
54
+ const env = (commit: string, statement?: unknown): Record<string, string> => ({
55
+ GITHUB_ACTIONS: "true",
56
+ GITHUB_REPOSITORY: "acme/infra",
57
+ GITHUB_RUN_ID: "4242",
58
+ GITHUB_RUN_ATTEMPT: "1",
59
+ GITHUB_JOB: "apply",
60
+ GITHUB_SHA: commit,
61
+ RUNNER_NAME: "jit-1",
62
+ CHANT_RUNNER_START_FILE: "/nonexistent/runner-start.json",
63
+ ...(statement ? { CHANT_RUNNER_START: JSON.stringify(statement) } : {}),
64
+ });
65
+
66
+ describe("checkRunnerStart reads the runner keys at base", () => {
67
+ test("a statement by a provisioner listed at base verifies", () => {
68
+ const v = checkRunnerStart({ top: R.root, base: R.base, env: env(R.head, signRunnerStart(job(R.head), PROVISIONER.signer)) });
69
+ expect(v).toMatchObject({ status: "verified", signer: "provisioner" });
70
+ });
71
+
72
+ test("a provisioner the change itself lists does not count at the base before it", () => {
73
+ const statement = signRunnerStart(job(R.head), LATER.signer);
74
+ expect(checkRunnerStart({ top: R.root, base: R.base, env: env(R.head, statement) }).status).toBe("untrusted");
75
+ expect(checkRunnerStart({ top: R.root, base: R.head, env: env(R.head, statement) }).status).toBe("verified");
76
+ });
77
+
78
+ test("the job's commit stands in for the forge's variable, so a statement for another commit is refused", () => {
79
+ const statement = signRunnerStart(job(R.head), PROVISIONER.signer);
80
+ expect(checkRunnerStart({ top: R.root, base: R.base, env: env(R.head, statement), commit: R.base }).status).toBe("mismatch");
81
+ });
82
+
83
+ test("requireRunnerStart throws with the reason, and a missing statement is named", () => {
84
+ expect(() => requireRunnerStart({ top: R.root, base: R.base, commit: R.head, env: env(R.head) })).toThrow(/--require-runner-start: missing: .*has no runner-start statement/);
85
+ expect(() => requireRunnerStart({ top: R.root, base: R.base, commit: R.head, env: {} })).toThrow(/needs a forge job to check/);
86
+ expect(requireRunnerStart({ top: R.root, base: R.base, commit: R.head, env: env(R.head, signRunnerStart(job(R.head), PROVISIONER.signer)) }).signer).toBe("provisioner");
87
+ });
88
+ });
89
+
90
+ describe("runnerStartReadiness, for the isolated-runners group", () => {
91
+ test("a policy with a runner key whose principal holds the runner role is configured", () => {
92
+ expect(runnerStartReadinessAt(R.root, R.base)).toEqual({ configured: true, gaps: [] });
93
+ });
94
+
95
+ test("no runner key, a key with no runner role, and an unreadable policy each name their gap", () => {
96
+ expect(runnerStartReadiness(emptyPolicy("x")).gaps.map((g) => g.gap)).toEqual(["no-runner-key"]);
97
+ const noRole = { ...emptyPolicy("x"), runners: [{ principal: "p", class: "runner" as const, key: PROVISIONER.ssh }] };
98
+ expect(runnerStartReadiness(noRole)).toMatchObject({ configured: false, gaps: [{ gap: "no-runner-role" }] });
99
+ expect(runnerStartReadiness(emptyPolicy("x", ["bad json"])).gaps.map((g) => g.gap)).toEqual(["trust-unreadable"]);
100
+ });
101
+ });
102
+
103
+ describe("chant ci runner-start sign and verify", () => {
104
+ test("--job takes JSON or @file, and refuses fields chant does not know", () => {
105
+ const dir = scratchDir("chant-runner-start-job-");
106
+ writeFiles(dir, { "job.json": JSON.stringify(job(R.head)) });
107
+ expect(parseJobSpec(`@${join(dir, "job.json")}`)).toEqual(job(R.head));
108
+ expect(parseJobSpec('{"system":"gitlab","repo":"g/s/p","run":9,"job":77,"commit":"x"}')).toEqual({ system: "gitlab", repo: "g/s/p", run: "9", attempt: "1", job: "77", commit: "x" });
109
+ expect(() => parseJobSpec('{"system":"github","pr":7}')).toThrow(/fields chant does not know: pr/);
110
+ expect(parseArgs(["ci", "runner-start", "sign", "--key", "k.pem", "--principal", "provisioner", "--job", "@job.json"])).toMatchObject({ path: "runner-start", extraPositional: "sign", key: "k.pem", ciPrincipal: "provisioner", ciJob: "@job.json" });
111
+ expect(parseArgs(["ci", "run", "apply", "--env", "prod", "--require-runner-start"])).toMatchObject({ requireRunnerStart: true });
112
+ });
113
+
114
+ test("sign writes an envelope the job then verifies, and verify exits 1 for another attempt", async () => {
115
+ const dir = scratchDir("chant-runner-start-cli-");
116
+ writeFiles(dir, { "key.pem": PROVISIONER.pem, "job.json": JSON.stringify(job(R.head)) });
117
+ const out = join(dir, "runner-start.json");
118
+ const ctx = (argv: string[]) => ({ args: parseArgs(argv) }) as unknown as CommandContext;
119
+ const quiet = vi.spyOn(console, "error").mockImplementation(() => {});
120
+ try {
121
+ expect(await runCiRunnerStart(ctx(["ci", "runner-start", "sign", "--key", join(dir, "key.pem"), "--principal", "provisioner", "--job", `@${join(dir, "job.json")}`, "-o", out]))).toBe(0);
122
+ const statement = JSON.parse(readFileSync(out, "utf8"));
123
+ const cwd = process.cwd();
124
+ process.chdir(R.root);
125
+ try {
126
+ vi.stubEnv("GITHUB_ACTIONS", "true");
127
+ for (const [k, v] of Object.entries(env(R.head))) vi.stubEnv(k, v);
128
+ vi.stubEnv("CHANT_RUNNER_START_FILE", out);
129
+ expect(await runCiRunnerStart(ctx(["ci", "runner-start", "verify", "--base", R.base]))).toBe(0);
130
+ vi.stubEnv("GITHUB_RUN_ATTEMPT", "2");
131
+ expect(await runCiRunnerStart(ctx(["ci", "runner-start", "verify", "--base", R.base]))).toBe(1);
132
+ } finally {
133
+ vi.unstubAllEnvs();
134
+ process.chdir(cwd);
135
+ }
136
+ expect(statement.payloadType).toBe("application/vnd.in-toto+json");
137
+ expect(await runCiRunnerStart(ctx(["ci", "runner-start", "nope"]))).toBe(1);
138
+ } finally {
139
+ quiet.mockRestore();
140
+ }
141
+ });
142
+ });
143
+
144
+ describe("ci run --require-runner-start", () => {
145
+ test("a job with no statement is refused before anything runs, and --local is refused with it", async () => {
146
+ const run = (extra: Partial<Parameters<typeof ciRun>[0]>) => ciRun({ cwd: R.root, job: "apply", env: "prod", forge: "github", base: R.base, hostEnv: env(R.head), push: false, requireRunnerStart: true, ...extra });
147
+ await expect(run({})).rejects.toThrow(CiRunError);
148
+ await expect(run({})).rejects.toThrow(/has no runner-start statement/);
149
+ await expect(run({ local: true, hostEnv: {} })).rejects.toThrow(/--local runs the job on this machine/);
150
+ });
151
+ });
@@ -0,0 +1,193 @@
1
+ /**
2
+ * `chant ci runner-start sign|verify` and `chant ci run --require-runner-start`
3
+ * (#4147): the runner-start statement (../runner-start.ts) checked against
4
+ * `.chant/trust.json` at base.
5
+ *
6
+ * - `sign` is for the provisioner that starts a runner for one job: it signs
7
+ * the job's ids with a runner key the job never holds and prints the
8
+ * envelope, which the provisioner puts where the job looks for it.
9
+ * - `verify` is for the job: it names itself from the forge's variables,
10
+ * reads the statement, and fails unless a runner key `.chant/trust.json`
11
+ * at base lists, whose principal holds the `runner` role, signed it for
12
+ * this job.
13
+ * - `ci run --require-runner-start` makes the same check before the job's
14
+ * first step, so an apply refuses before it trades for any credentials.
15
+ *
16
+ * Requiring the statement is opt-in (#4156). With nothing asking for it,
17
+ * nothing reads it, and a runner with none never refuses, warns or hides
18
+ * anything.
19
+ *
20
+ * {@link runnerStartReadiness} says whether a repository could require it
21
+ * at base, and what is missing, for the isolated-runners security group
22
+ * (#4168, section 8), whose WSP165 check reports these gaps.
23
+ */
24
+
25
+ import { execFileSync } from "node:child_process";
26
+ import { readFileSync, writeFileSync } from "node:fs";
27
+ import { resolve } from "node:path";
28
+ import type { CommandContext } from "../cli/registry";
29
+ import { formatError } from "../cli/format";
30
+ import {
31
+ ciJobFromEnv,
32
+ RUNNER_START_SIGNER_ROLE,
33
+ signRunnerStart,
34
+ verifyRunnerStartForJob,
35
+ type CiJobIdentity,
36
+ type CiSystem,
37
+ type RunnerStartVerdict,
38
+ } from "../runner-start";
39
+ import { gitRevisionSource } from "./record-source";
40
+ import { gitTop } from "./tree";
41
+ import { loadRunnerKey } from "./trust/dsse";
42
+ import { emptyPolicy, readTrustPolicy, type TrustPolicy } from "./trust/policy";
43
+
44
+ /** The policy at `base` in the repository around `cwd`. */
45
+ function policyAt(top: string, base: string): TrustPolicy {
46
+ return readTrustPolicy(gitRevisionSource(top, base), base);
47
+ }
48
+
49
+ /**
50
+ * Check this job's runner-start statement against `.chant/trust.json` at
51
+ * `base`. `commit` is the commit the job runs, in place of the forge's
52
+ * variable. Never throws.
53
+ */
54
+ export function checkRunnerStart(input: { top: string; base: string; env?: Record<string, string | undefined>; commit?: string; readFile?: (path: string) => string }): RunnerStartVerdict {
55
+ const policy = policyAt(input.top, input.base);
56
+ if (policy.problems.length > 0) return { status: "untrusted", reason: `.chant/trust.json at ${input.base.slice(0, 12)} cannot be read: ${policy.problems.join("; ")}` };
57
+ return verifyRunnerStartForJob({
58
+ runners: policy.runners,
59
+ roles: policy.roles,
60
+ ...(input.env ? { env: input.env } : {}),
61
+ ...(input.commit ? { commit: input.commit } : {}),
62
+ ...(input.readFile ? { readFile: input.readFile } : {}),
63
+ });
64
+ }
65
+
66
+ /** One thing a repository lacks before it can require runner-start statements. */
67
+ export interface RunnerStartGap {
68
+ /** Not a read-contract reason code: the isolated-runners group (#4168) maps each to its own security code. */
69
+ gap: "no-runner-key" | "no-runner-role" | "trust-unreadable";
70
+ reason: string;
71
+ }
72
+
73
+ /**
74
+ * Whether `.chant/trust.json` in `policy` lets a job require a runner-start
75
+ * statement: a key under `runners` whose principal holds the `runner` role.
76
+ * `configured` is true when there are no gaps. For the isolated-runners
77
+ * security group (#4168); nothing here refuses or warns.
78
+ */
79
+ export function runnerStartReadiness(policy: TrustPolicy): { configured: boolean; gaps: RunnerStartGap[] } {
80
+ const gaps: RunnerStartGap[] = [];
81
+ if (policy.problems.length > 0) {
82
+ gaps.push({ gap: "trust-unreadable", reason: `.chant/trust.json cannot be read: ${policy.problems.join("; ")}` });
83
+ return { configured: false, gaps };
84
+ }
85
+ if (policy.runners.length === 0) {
86
+ gaps.push({ gap: "no-runner-key", reason: ".chant/trust.json lists no key under runners for a provisioner to sign with: chant workspace trust runner add <principal> --key <ssh-ed25519 line>" });
87
+ } else {
88
+ const holders = new Set((policy.roles[RUNNER_START_SIGNER_ROLE] ?? []).map((p) => p.normalize("NFKC").trim().toLowerCase()));
89
+ if (!policy.runners.some((r) => holders.has(r.principal.normalize("NFKC").trim().toLowerCase()))) {
90
+ gaps.push({ gap: "no-runner-role", reason: `no principal under runners holds the ${RUNNER_START_SIGNER_ROLE} role in .chant/trust.json: "roles": { "${RUNNER_START_SIGNER_ROLE}": ["<principal>"] }` });
91
+ }
92
+ }
93
+ return { configured: gaps.length === 0, gaps };
94
+ }
95
+
96
+ /** {@link runnerStartReadiness} for the repository around `cwd` at `base` (default `HEAD`). */
97
+ export function runnerStartReadinessAt(cwd: string, base = "HEAD"): { configured: boolean; gaps: RunnerStartGap[] } {
98
+ const top = gitTop(cwd);
99
+ if (!top) return runnerStartReadiness(emptyPolicy(null, [`${cwd} is not in a git repository`]));
100
+ const commit = revParse(top, base);
101
+ return runnerStartReadiness(commit ? policyAt(top, commit) : emptyPolicy(null, [`${base} is not a commit`]));
102
+ }
103
+
104
+ function revParse(top: string, rev: string): string | undefined {
105
+ try {
106
+ return execFileSync("git", ["rev-parse", "--verify", "-q", `${rev}^{commit}`], { cwd: top, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || undefined;
107
+ } catch {
108
+ return undefined;
109
+ }
110
+ }
111
+
112
+ /** The base `verify` reads trust at: `--base`, else the remote's default branch, else `HEAD`. */
113
+ function verifyBase(top: string, base?: string): string {
114
+ const rev = base ?? (revParse(top, "refs/remotes/origin/HEAD") ? "refs/remotes/origin/HEAD" : "HEAD");
115
+ const commit = revParse(top, rev);
116
+ if (!commit) throw new Error(`--base ${rev} is not a commit in this repository`);
117
+ return commit;
118
+ }
119
+
120
+ /** The job named by `--job`: JSON, or `@<file>` holding it. */
121
+ export function parseJobSpec(spec: string): CiJobIdentity {
122
+ const text = spec.startsWith("@") ? readFileSync(resolve(spec.slice(1)), "utf8") : spec;
123
+ let raw: unknown;
124
+ try {
125
+ raw = JSON.parse(text);
126
+ } catch {
127
+ throw new Error("--job takes the job as JSON, or @<file> holding it: {\"system\":\"github\",\"repo\":\"owner/name\",\"run\":\"<id>\",\"attempt\":\"1\",\"job\":\"apply\",\"commit\":\"<sha>\",\"runner\":\"<name>\"}");
128
+ }
129
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) throw new Error("--job takes a JSON object");
130
+ const o = raw as Record<string, unknown>;
131
+ const str = (k: string): string | undefined => (o[k] === undefined ? undefined : String(o[k]));
132
+ const known = ["system", "repo", "run", "attempt", "job", "commit", "runner"];
133
+ const extra = Object.keys(o).filter((k) => !known.includes(k));
134
+ if (extra.length > 0) throw new Error(`--job has fields chant does not know: ${extra.join(", ")}`);
135
+ return {
136
+ system: (str("system") ?? "") as CiSystem,
137
+ repo: str("repo") ?? "",
138
+ run: str("run") ?? "",
139
+ attempt: str("attempt") ?? "1",
140
+ job: str("job") ?? "",
141
+ commit: str("commit") ?? "",
142
+ ...(str("runner") ? { runner: str("runner")! } : {}),
143
+ };
144
+ }
145
+
146
+ const USAGE = "chant ci runner-start sign --key <runner key.pem> --principal <name> --job <json|@file> [-o <file>]\n chant ci runner-start verify [--base <rev>] [--commit <sha>] [--json]";
147
+
148
+ /** `chant ci runner-start sign|verify`. */
149
+ export async function runCiRunnerStart(ctx: CommandContext): Promise<number> {
150
+ const { args } = ctx;
151
+ const action = args.extraPositional;
152
+ try {
153
+ if (action === "sign") {
154
+ if (!args.key) throw new Error("--key names the provisioner's runner key, an Ed25519 private key in PEM, which the job never holds");
155
+ if (!args.ciPrincipal) throw new Error("--principal names the principal .chant/trust.json lists the key for under runners");
156
+ if (!args.ciJob) throw new Error("--job names the job the runner was started for, as JSON or @<file>");
157
+ const job = parseJobSpec(args.ciJob);
158
+ const pem = readFileSync(resolve(args.key), "utf8");
159
+ const loaded = loadRunnerKey(pem);
160
+ const envelope = signRunnerStart(job, { principal: args.ciPrincipal, key: loaded.key, publicKey: loaded.publicKey });
161
+ const text = `${JSON.stringify(envelope)}\n`;
162
+ if (args.output) writeFileSync(resolve(args.output), text, { mode: 0o644 });
163
+ else process.stdout.write(text);
164
+ return 0;
165
+ }
166
+ if (action === "verify") {
167
+ const top = gitTop(process.cwd());
168
+ if (!top) throw new Error("verify reads .chant/trust.json at a commit, and this directory is not in a git repository");
169
+ const base = verifyBase(top, args.base);
170
+ const verdict = checkRunnerStart({ top, base, ...(args.commit ? { commit: args.commit } : {}) });
171
+ if (args.json) process.stdout.write(`${JSON.stringify({ ...verdict, base })}\n`);
172
+ else console.error(`ci runner-start: ${verdict.status}: ${verdict.reason}`);
173
+ return verdict.status === "verified" ? 0 : 1;
174
+ }
175
+ throw new Error(`ci runner-start takes sign or verify${action ? `, not ${JSON.stringify(action)}` : ""}`);
176
+ } catch (err) {
177
+ console.error(formatError({ message: `chant ci runner-start: ${err instanceof Error ? err.message : String(err)}`, hint: USAGE }));
178
+ return 1;
179
+ }
180
+ }
181
+
182
+ /**
183
+ * The check `ci run --require-runner-start` makes before the job's first
184
+ * step: the statement for this job, verified against `.chant/trust.json` at
185
+ * `base`. Throws with the verdict's reason unless it verified.
186
+ */
187
+ export function requireRunnerStart(input: { top: string; base: string; commit: string; env: Record<string, string | undefined> }): Extract<RunnerStartVerdict, { status: "verified" }> {
188
+ const named = ciJobFromEnv(input.env);
189
+ if (!named.ok) throw new Error(`--require-runner-start needs a forge job to check: ${named.reason}`);
190
+ const verdict = checkRunnerStart({ top: input.top, base: input.base, env: input.env, commit: input.commit });
191
+ if (verdict.status !== "verified") throw new Error(`--require-runner-start: ${verdict.status}: ${verdict.reason}`);
192
+ return verdict;
193
+ }