@intentius/chant 0.124.30 → 0.124.31

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 (72) hide show
  1. package/dist/cli/main.d.ts.map +1 -1
  2. package/dist/cli/main.js +38 -4
  3. package/dist/cli/parse-args.d.ts.map +1 -1
  4. package/dist/cli/parse-args.js +50 -0
  5. package/dist/cli/registry.d.ts +18 -0
  6. package/dist/cli/registry.d.ts.map +1 -1
  7. package/dist/op/local-executor.d.ts +12 -0
  8. package/dist/op/local-executor.d.ts.map +1 -1
  9. package/dist/op/local-executor.js +22 -3
  10. package/dist/op/redact.d.ts +34 -0
  11. package/dist/op/redact.d.ts.map +1 -0
  12. package/dist/op/redact.js +94 -0
  13. package/dist/op/step-console.d.ts +2 -0
  14. package/dist/op/step-console.d.ts.map +1 -1
  15. package/dist/op/step-console.js +5 -2
  16. package/dist/pr-forge.d.ts +17 -0
  17. package/dist/pr-forge.d.ts.map +1 -1
  18. package/dist/pr-forge.js +12 -6
  19. package/dist/pr-report-url.d.ts +8 -0
  20. package/dist/pr-report-url.d.ts.map +1 -1
  21. package/dist/pr-report-url.js +47 -1
  22. package/dist/wave-runtime.mjs +159 -159
  23. package/dist/workspace/ci-run-index.d.ts +96 -0
  24. package/dist/workspace/ci-run-index.d.ts.map +1 -0
  25. package/dist/workspace/ci-run-index.js +162 -0
  26. package/dist/workspace/ci-run-index.schema.json +50 -0
  27. package/dist/workspace/ci-run.d.ts +84 -7
  28. package/dist/workspace/ci-run.d.ts.map +1 -1
  29. package/dist/workspace/ci-run.js +241 -14
  30. package/dist/workspace/ci-runner-key.d.ts +120 -0
  31. package/dist/workspace/ci-runner-key.d.ts.map +1 -0
  32. package/dist/workspace/ci-runner-key.js +217 -0
  33. package/dist/workspace/ci-secrets.d.ts +65 -0
  34. package/dist/workspace/ci-secrets.d.ts.map +1 -0
  35. package/dist/workspace/ci-secrets.js +211 -0
  36. package/dist/workspace/ci-serve.d.ts +13 -0
  37. package/dist/workspace/ci-serve.d.ts.map +1 -1
  38. package/dist/workspace/ci-serve.js +69 -13
  39. package/dist/workspace/reason-codes.d.ts +6 -2
  40. package/dist/workspace/reason-codes.d.ts.map +1 -1
  41. package/dist/workspace/reason-codes.js +7 -2
  42. package/dist/workspace/trust/trust-write.d.ts +109 -0
  43. package/dist/workspace/trust/trust-write.d.ts.map +1 -0
  44. package/dist/workspace/trust/trust-write.js +320 -0
  45. package/dist/workspace/trust-write.schema.json +265 -0
  46. package/package.json +1 -1
  47. package/src/cli/main.test.ts +1 -0
  48. package/src/cli/main.ts +38 -4
  49. package/src/cli/parse-args.ts +36 -0
  50. package/src/cli/registry.ts +18 -0
  51. package/src/op/local-executor.ts +31 -3
  52. package/src/op/redact.ts +93 -0
  53. package/src/op/step-console.ts +7 -2
  54. package/src/pr-forge.test.ts +29 -0
  55. package/src/pr-forge.ts +30 -6
  56. package/src/pr-report-url.ts +53 -1
  57. package/src/workspace/ci-run-index.schema.json +50 -0
  58. package/src/workspace/ci-run-index.test.ts +353 -0
  59. package/src/workspace/ci-run-index.ts +192 -0
  60. package/src/workspace/ci-run.test.ts +337 -37
  61. package/src/workspace/ci-run.ts +277 -15
  62. package/src/workspace/ci-runner-key.test.ts +166 -0
  63. package/src/workspace/ci-runner-key.ts +250 -0
  64. package/src/workspace/ci-secrets.test.ts +106 -0
  65. package/src/workspace/ci-secrets.ts +213 -0
  66. package/src/workspace/ci-serve.test.ts +136 -37
  67. package/src/workspace/ci-serve.ts +66 -13
  68. package/src/workspace/reason-codes.test.ts +2 -0
  69. package/src/workspace/reason-codes.ts +7 -2
  70. package/src/workspace/trust/trust-write.test.ts +179 -0
  71. package/src/workspace/trust/trust-write.ts +377 -0
  72. package/src/workspace/trust-write.schema.json +265 -0
@@ -10,15 +10,16 @@
10
10
  */
11
11
 
12
12
  import { execFileSync } from "node:child_process";
13
- import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
13
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
14
14
  import { join } from "node:path";
15
- import { afterAll, describe, expect, test } from "vitest";
16
- import { cleanScratch, declaration, git, scratchDir, writeFiles } from "./__fixtures__/contract-repo";
15
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
16
+ import { cleanScratch, declaration, git, repoFixture, scratchDir, writeFiles } from "./__fixtures__/contract-repo";
17
17
  import { ciServeInterval, ciServeKey, ciServePass, formatCiServeEvent, readCiServeState, type CiServeEvent, type CiServeForge, type CiServeOptions } from "./ci-serve";
18
+ import { ciRun } from "./ci-run";
18
19
  import { acquireLease, releaseLease } from "../lifecycle/lease";
19
20
  import { prApplyGroup, prApplyRecordKey } from "../components/pr-pipeline";
20
21
  import { parseArgs } from "../cli/parse-args";
21
- import type { OpenPullRequest } from "../pr-forge";
22
+ import { forgejoForge, githubForge, type ForgeFetch, type OpenPullRequest } from "../pr-forge";
22
23
 
23
24
  afterAll(cleanScratch);
24
25
 
@@ -30,11 +31,14 @@ function commit(root: string, file: string): string {
30
31
  return git(root, "rev-parse", "HEAD");
31
32
  }
32
33
 
33
- /** A clone on main with a bare origin and member `app`. */
34
- function workspace(): { root: string; app: string; main: string } {
35
- const origin = scratchDir("chant-ci-serve-origin-");
34
+ /** The repository every test starts from, built once: a checkout on main with a bare origin and member `app`. */
35
+ const workspaceTemplate = repoFixture(() => {
36
+ const top = scratchDir("chant-ci-serve-template-");
37
+ const origin = join(top, "origin");
38
+ const root = join(top, "root");
39
+ mkdirSync(origin);
40
+ mkdirSync(root);
36
41
  git(origin, "init", "-q", "--bare");
37
- const root = scratchDir("chant-ci-serve-");
38
42
  git(root, "init", "-q");
39
43
  git(root, "symbolic-ref", "HEAD", "refs/heads/main");
40
44
  writeFiles(root, {
@@ -43,9 +47,17 @@ function workspace(): { root: string; app: string; main: string } {
43
47
  "app/a.txt": "a\n",
44
48
  });
45
49
  git(root, "remote", "add", "origin", origin);
46
- const main = commit(root, "app/init.txt");
50
+ commit(root, "app/init.txt");
47
51
  git(root, "push", "-q", "origin", "main");
48
- return { root, app: join(root, "app"), main };
52
+ return top;
53
+ }, "chant-ci-serve-");
54
+
55
+ /** A copy of the template, its origin pointed at the copy of the bare one. */
56
+ function workspace(): { root: string; app: string; main: string } {
57
+ const top = workspaceTemplate();
58
+ const root = join(top, "root");
59
+ git(root, "remote", "set-url", "origin", join(top, "origin"));
60
+ return { root, app: join(root, "app"), main: git(root, "rev-parse", "HEAD") };
49
61
  }
50
62
 
51
63
  /** A pull request's head commit, on a branch off main, pushed to the forge's refs/pull/<n>/head. */
@@ -86,9 +98,17 @@ function fakeChant(): string {
86
98
  }
87
99
 
88
100
  /** A forge whose open pull requests and branch head the test sets. */
89
- function fakeForge(head: string): CiServeForge & { head: string; prs: OpenPullRequest[]; polls: number } {
101
+ function fakeForge(head: string): CiServeForge & { head: string; prs: OpenPullRequest[]; polls: number; merges: Map<string, number | "throw">; asked: string[] } {
90
102
  const f = {
91
103
  head,
104
+ merges: new Map<string, number | "throw">(),
105
+ asked: [] as string[],
106
+ async pullRequestFor(sha: string) {
107
+ f.asked.push(sha);
108
+ const n = f.merges.get(sha);
109
+ if (n === "throw") throw new Error("forge unavailable");
110
+ return n ?? null;
111
+ },
92
112
  prs: [] as OpenPullRequest[],
93
113
  polls: 0,
94
114
  async openPullRequests(base: string) {
@@ -185,6 +205,70 @@ describe("chant ci serve (#4095)", () => {
185
205
  expect((await ciServePass(opts)).jobs).toEqual([]);
186
206
  });
187
207
 
208
+ test("an apply gets the number of the pull request whose merge commit it is (#4112)", async () => {
209
+ const { root, app, main } = workspace();
210
+ const forge = fakeForge(main);
211
+ const seen: Array<number | undefined> = [];
212
+ const { opts } = serve(app, forge, { run: (req) => (seen.push(req.pr), ciRun(req)) });
213
+ await ciServePass(opts);
214
+ const merged = commit(root, "app/merged.txt");
215
+ git(root, "push", "-q", "origin", "main");
216
+ forge.head = merged;
217
+ forge.merges.set(merged, 17);
218
+
219
+ const pass = await ciServePass(opts);
220
+ expect(forge.asked).toEqual([merged]);
221
+ expect(pass.jobs).toEqual([expect.objectContaining({ job: "apply", commit: merged, pr: 17, status: "ok" })]);
222
+ expect(seen).toEqual([17]);
223
+ });
224
+
225
+ /** A serve over a repository whose baseline pass is done, with the `pr` each run was asked for in `seen`. */
226
+ async function mergeWithoutPr() {
227
+ const { root, app, main } = workspace();
228
+ const forge = fakeForge(main);
229
+ const seen: Array<number | undefined> = [];
230
+ const { opts } = serve(app, forge, { run: (req) => (seen.push(req.pr), ciRun(req)) });
231
+ await ciServePass(opts);
232
+ return { root, forge, seen, opts };
233
+ }
234
+
235
+ test("an apply with no pull request found runs without --pr (#4112)", async () => {
236
+ const { root, forge, seen, opts } = await mergeWithoutPr();
237
+ const direct = commit(root, "app/direct.txt");
238
+ git(root, "push", "-q", "origin", "main");
239
+ forge.head = direct;
240
+ const pass = await ciServePass(opts);
241
+ expect(pass.jobs).toEqual([expect.objectContaining({ job: "apply", commit: direct, status: "ok" })]);
242
+ expect(pass.jobs[0]!.pr).toBeUndefined();
243
+ expect(seen).toEqual([undefined]);
244
+ });
245
+
246
+ test("an apply whose pull request lookup fails runs without --pr (#4112)", async () => {
247
+ const { root, forge, seen, opts } = await mergeWithoutPr();
248
+ const broken = commit(root, "app/broken.txt");
249
+ git(root, "push", "-q", "origin", "main");
250
+ forge.head = broken;
251
+ forge.merges.set(broken, "throw");
252
+ const pass = await ciServePass(opts);
253
+ expect(pass.jobs).toEqual([expect.objectContaining({ job: "apply", commit: broken, status: "ok" })]);
254
+ expect(pass.jobs[0]!.pr).toBeUndefined();
255
+ expect(seen).toEqual([undefined]);
256
+ });
257
+
258
+ test("the forge clients look a merge commit up with one GET each (#4112)", async () => {
259
+ const sha = "c".repeat(40);
260
+ const calls: string[] = [];
261
+ const fetch = (body: unknown): ForgeFetch => async (url, init) => {
262
+ calls.push(`${init.method} ${new URL(url).pathname}`);
263
+ return { ok: true, status: 200, text: async () => JSON.stringify(body) };
264
+ };
265
+ const gh: CiServeForge = githubForge({ apiBase: "https://api.github.com", repo: "demo/estate", token: "t", fetch: fetch([{ number: 5, merged_at: "2026-10-01T00:00:00Z", merge_commit_sha: sha }]) }) as unknown as CiServeForge;
266
+ expect(await gh.pullRequestFor!(sha)).toBe(5);
267
+ const fj: CiServeForge = forgejoForge({ apiBase: "http://forgejo.localhost/api/v1", host: "forgejo.localhost", repo: "demo/estate", token: "t", fetch: fetch({ number: 6 }) }) as unknown as CiServeForge;
268
+ expect(await fj.pullRequestFor!(sha)).toBe(6);
269
+ expect(calls).toEqual([`GET /repos/demo/estate/commits/${sha}/pulls`, `GET /api/v1/repos/demo/estate/commits/${sha}/pull`]);
270
+ });
271
+
188
272
  test("the group's lease held elsewhere holds the apply until it is released", async () => {
189
273
  const { root, app, main } = workspace();
190
274
  const forge = fakeForge(main);
@@ -209,33 +293,48 @@ describe("chant ci serve (#4095)", () => {
209
293
  expect((await acquireLease(group, "after", { cwd: app, mode: "claim" })).acquired).toBe(true);
210
294
  });
211
295
 
212
- test("a failed apply stays failed, and --rerun-failed runs it again with its resume record", async () => {
213
- const { root, app, main } = workspace();
214
- const forge = fakeForge(main);
215
- const failing = serve(app, forge, { hostEnv: { FAKE_EXIT: "3", FAKE_ATTEMPT: "1" } });
216
- await ciServePass(failing.opts);
217
- const merged = commit(root, "app/merged.txt");
218
- git(root, "push", "-q", "origin", "main");
219
- forge.head = merged;
296
+ describe("a failed apply", () => {
297
+ // The failing apply runs once in beforeAll, and the two tests read what it left, so no test
298
+ // runs two jobs. A file's beforeAll is not counted in the unit budget (#2817).
299
+ let w: ReturnType<typeof workspace>;
300
+ let forge: ReturnType<typeof fakeForge>;
301
+ let failing: ReturnType<typeof serve>;
302
+ let merged: string;
303
+ let failed: Awaited<ReturnType<typeof ciServePass>>;
304
+ let record: string;
305
+
306
+ beforeAll(async () => {
307
+ w = workspace();
308
+ forge = fakeForge(w.main);
309
+ failing = serve(w.app, forge, { hostEnv: { FAKE_EXIT: "3", FAKE_ATTEMPT: "1" } });
310
+ await ciServePass(failing.opts);
311
+ merged = commit(w.root, "app/merged.txt");
312
+ git(w.root, "push", "-q", "origin", "main");
313
+ forge.head = merged;
314
+ failed = await ciServePass(failing.opts);
315
+ record = join(failing.state, "resume", `${prApplyRecordKey("prod", merged, "app")}.json`);
316
+ });
317
+
318
+ test("stays failed, and keeps its resume record", async () => {
319
+ expect(failed.jobs).toEqual([expect.objectContaining({ job: "apply", status: "fail", attempts: 1, failure: expect.stringContaining("command-failed") })]);
320
+ expect(JSON.parse(readFileSync(record, "utf-8"))).toEqual({ attempt: "1" });
321
+ expect(existsSync(join(failed.jobs[0]!.report!, "resume-in.json"))).toBe(false);
322
+
323
+ // Without --rerun-failed the failed apply is not run again.
324
+ expect((await ciServePass(failing.opts)).jobs).toEqual([]);
325
+ });
220
326
 
221
- const failed = await ciServePass(failing.opts);
222
- expect(failed.jobs).toEqual([expect.objectContaining({ job: "apply", status: "fail", attempts: 1, failure: expect.stringContaining("command-failed") })]);
223
- const record = join(failing.state, "resume", `${prApplyRecordKey("prod", merged, "app")}.json`);
224
- expect(JSON.parse(readFileSync(record, "utf-8"))).toEqual({ attempt: "1" });
225
- expect(existsSync(join(failed.jobs[0]!.report!, "resume-in.json"))).toBe(false);
226
-
227
- // Without --rerun-failed the failed apply is not run again.
228
- expect((await ciServePass(failing.opts)).jobs).toEqual([]);
229
-
230
- const retry = serve(app, forge, { stateDir: failing.state, rerunFailed: true, hostEnv: { FAKE_EXIT: "0", FAKE_ATTEMPT: "2" } });
231
- const again = await ciServePass(retry.opts);
232
- expect(again.jobs).toEqual([expect.objectContaining({ job: "apply", commit: merged, base: main, status: "ok", attempts: 2 })]);
233
- expect(ofType(retry.events, "started")[0]).toMatchObject({ attempt: 2, resume: record });
234
- // The job read the first attempt's record, and left its own.
235
- expect(JSON.parse(readFileSync(join(again.jobs[0]!.report!, "resume-in.json"), "utf-8"))).toEqual({ attempt: "1" });
236
- expect(JSON.parse(readFileSync(record, "utf-8"))).toEqual({ attempt: "2" });
237
- // A passed apply is not run again, --rerun-failed or not.
238
- expect((await ciServePass(retry.opts)).jobs).toEqual([]);
327
+ test("--rerun-failed runs it again with its resume record", async () => {
328
+ const retry = serve(w.app, forge, { stateDir: failing.state, rerunFailed: true, hostEnv: { FAKE_EXIT: "0", FAKE_ATTEMPT: "2" } });
329
+ const again = await ciServePass(retry.opts);
330
+ expect(again.jobs).toEqual([expect.objectContaining({ job: "apply", commit: merged, base: w.main, status: "ok", attempts: 2 })]);
331
+ expect(ofType(retry.events, "started")[0]).toMatchObject({ attempt: 2, resume: record });
332
+ // The job read the first attempt's record, and left its own.
333
+ expect(JSON.parse(readFileSync(join(again.jobs[0]!.report!, "resume-in.json"), "utf-8"))).toEqual({ attempt: "1" });
334
+ expect(JSON.parse(readFileSync(record, "utf-8"))).toEqual({ attempt: "2" });
335
+ // A passed apply is not run again, --rerun-failed or not.
336
+ expect((await ciServePass(retry.opts)).jobs).toEqual([]);
337
+ });
239
338
  });
240
339
 
241
340
  test("--max-jobs holds the jobs over the limit for the next poll", async () => {
@@ -15,6 +15,10 @@
15
15
  * the one `chant operator` holds) named for `prApplyGroup`, renews it while
16
16
  * it runs and releases it after. An apply that finds it held waits for a
17
17
  * later poll, as a forge's concurrency group queues it.
18
+ * - Merged pull request. An apply asks the forge for the pull request whose
19
+ * merge commit the new head is (`pullRequestFor`, as pr-apply does) and
20
+ * passes it as `--pr`, so its commit status link and run index use
21
+ * `<pr>/<commit>`. With none found it runs without `--pr`.
18
22
  * - Resume records. Each apply gets `<state>/resume/<prApplyRecordKey>.json`
19
23
  * as its `--resume` record, so a re-run of a failed apply at the same
20
24
  * commit finishes under the approval it has. This replaces the forge's CI
@@ -34,16 +38,17 @@
34
38
 
35
39
  import { execFileSync } from "node:child_process";
36
40
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
37
- import { isAbsolute, join, resolve } from "node:path";
41
+ import { join, resolve } from "node:path";
38
42
  import { prApplyGroup, prApplyRecordKey } from "../components/pr-pipeline";
39
43
  import { forgeFromEnv, type OpenPullRequest } from "../pr-forge";
40
44
  import type { CommandContext } from "../cli/registry";
41
45
  import { formatError, formatWarning } from "../cli/format";
42
46
  import { acquireLease, currentHolderId, releaseLease, DEFAULT_LEASE_TTL_MS, type LeaseRecord } from "../lifecycle/lease";
43
47
  import { parseDuration } from "../op/duration";
44
- import { CI_JOB_OPS, ciJobPlace, ciRun, CiRunError, type CiJobName, type CiRunRequest, type CiRunResult } from "./ci-run";
48
+ import { CI_JOB_OPS, ciJobPlace, ciSecretsOf, ciRun, ciStatusFromEnv, CiRunError, type CiJobName, type CiRunRequest, type CiRunResult } from "./ci-run";
45
49
  import { RUNNER_KEY_ENV } from "./ci-green";
46
50
  import { gitTop } from "./tree";
51
+ import { ciServeRunnerKey, formatRunnerKeySetup, setupRunnerKey } from "./ci-runner-key";
47
52
 
48
53
  /** The forges `ci serve` polls. GitLab is later. */
49
54
  export type CiServeForgeKind = "github" | "forgejo";
@@ -52,6 +57,8 @@ export type CiServeForgeKind = "github" | "forgejo";
52
57
  export interface CiServeForge {
53
58
  openPullRequests(base: string): Promise<OpenPullRequest[]>;
54
59
  branchHead(branch: string): Promise<string | undefined>;
60
+ /** The merged pull request that produced a commit, as pr-apply finds it (`pullRequestFor`). Without it an apply gets no `--pr`. */
61
+ pullRequestFor?(sha: string): Promise<number | null>;
55
62
  }
56
63
 
57
64
  /** A job the forge's state calls for: the plan of a pull request's head, or the apply of a target-branch commit. */
@@ -347,17 +354,20 @@ async function runJob(
347
354
  t: CiServeTrigger,
348
355
  paths: { runId: string; report: string; resume?: string },
349
356
  held: { lease: LeaseRecord; holder: string; ttlMs: number; now: () => Date } | undefined,
350
- ): Promise<Pick<CiServeJobState, "status" | "failure" | "report">> {
357
+ ): Promise<Pick<CiServeJobState, "status" | "failure" | "report" | "pr">> {
351
358
  const renew = held
352
359
  ? setInterval(() => {
353
360
  void acquireLease(place.group, held.holder, { cwd: opts.cwd, ttlMs: held.ttlMs, mode: "renew", token: held.lease.token, now: held.now }).catch(() => undefined);
354
361
  }, Math.max(1000, Math.floor(held.ttlMs / 3)))
355
362
  : undefined;
356
363
  renew?.unref?.();
364
+ let merged: number | undefined;
357
365
  try {
358
366
  // The pull request's head is on the forge's refs/pull/<n>/head, the target's on the branch.
359
367
  tryGit(place.top, ["fetch", "--quiet", "--no-tags", "origin", t.job === "plan" ? `refs/pull/${t.pr}/head` : place.branch]);
360
368
  const hostEnv = { ...(opts.job?.hostEnv ?? process.env), GITHUB_REPOSITORY: opts.repo };
369
+ // An apply is for a merge: pass the pull request whose merge commit it is, so the commit status link and the run index use `<pr>/<commit>`. None found, or a forge that errs, is today's behaviour.
370
+ if (t.job === "apply" && opts.client.pullRequestFor) merged = (await opts.client.pullRequestFor(t.commit).catch(() => null)) ?? undefined;
361
371
  const run = opts.run ?? ciRun;
362
372
  const r = await run({
363
373
  ...opts.job,
@@ -367,16 +377,16 @@ async function runJob(
367
377
  env: opts.env,
368
378
  forge: opts.forge,
369
379
  base: t.base,
370
- ...(t.job === "plan" ? { pr: t.pr } : {}),
380
+ ...(t.job === "plan" ? { pr: t.pr } : merged !== undefined ? { pr: merged } : {}),
371
381
  commit: t.commit,
372
382
  output: paths.report,
373
383
  runId: paths.runId,
374
384
  ...(paths.resume ? { resumeRecord: paths.resume } : {}),
375
385
  });
376
386
  const why = r.record.failure ? `${r.record.failure.code}: ${r.record.failure.reason}` : r.failure ? `${r.failure.phase}: ${r.failure.error.split("\n")[0]}` : undefined;
377
- return { status: r.status === "ok" ? "ok" : "fail", ...(why && r.status !== "ok" ? { failure: why } : {}), ...(r.report ? { report: r.report } : {}) };
387
+ return { status: r.status === "ok" ? "ok" : "fail", ...(merged !== undefined ? { pr: merged } : {}), ...(why && r.status !== "ok" ? { failure: why } : {}), ...(r.report ? { report: r.report } : {}) };
378
388
  } catch (err) {
379
- return { status: "fail", failure: message(err) };
389
+ return { status: "fail", ...(merged !== undefined ? { pr: merged } : {}), failure: message(err) };
380
390
  } finally {
381
391
  if (renew) clearInterval(renew);
382
392
  if (held) await releaseLease(place.group, held.holder, held.lease.token, { cwd: opts.cwd }).catch(() => false);
@@ -439,10 +449,37 @@ export function formatCiServeEvent(e: CiServeEvent): string {
439
449
 
440
450
  // ── The command ──────────────────────────────────────────────────────────────
441
451
 
452
+ /**
453
+ * `chant ci serve|run --setup-key [--trust [--principal <name>]] [--state <dir>] [--branch <name>]` (#4099):
454
+ * make the host's runner key when there is none, print its public half on
455
+ * stdout, and say whether the target's head lists it. With --trust, commit
456
+ * the runner entry on a branch of its own and push it for review.
457
+ */
458
+ export function runnerKeySetupCommand(ctx: CommandContext): number {
459
+ const { args } = ctx;
460
+ const cwd = process.cwd();
461
+ const top = gitTop(cwd);
462
+ if (!top) throw new CiRunError("the runner key is kept in the repository's git directory, and this directory is not in a git repository");
463
+ if (args.ciPrincipal !== undefined && !args.trustKey) throw new CiRunError("--principal names who --trust lists the key for; give --trust with it");
464
+ const branch = args.branch ?? "main";
465
+ const setup = setupRunnerKey({
466
+ top,
467
+ stateDir: args.statePath ? resolve(args.statePath) : defaultCiServeStateDir(cwd),
468
+ branch,
469
+ ...(args.ciPrincipal ? { principal: args.ciPrincipal } : {}),
470
+ ...(args.trustKey ? { trust: true } : {}),
471
+ ...(args.by ? { by: args.by } : {}),
472
+ });
473
+ console.log(setup.key.publicKey);
474
+ console.error(formatRunnerKeySetup(setup, branch));
475
+ return 0;
476
+ }
477
+
442
478
  const USAGE =
479
+ "chant ci serve --setup-key [--trust [--principal <name>]] [--state <dir>] [--branch <name>] | " +
443
480
  "chant ci serve --forge forgejo|github --repo <owner/repo> --env <env> [--interval <s>] [--max-jobs <n>] [--state <dir>] " +
444
- "[--author <login>]... [--once] [--rerun-failed] [--gate <name>] [--branch <name>] [--events-json] [--report-url <url>] " +
445
- "[--image <image>] [--before-script <line>]... [--pr-build] [--pr-build-command <line>] [--chant <command>] [--pass-env <NAME>]... [--key <runner key.pem>]";
481
+ "[--author <login>]... [--once] [--rerun-failed] [--gate <name>] [--branch <name>] [--events-json] [--report-url <url>] [--no-status] [--status-url <url template>] " +
482
+ "[--image <image>] [--before-script <line>]... [--pr-build] [--pr-build-command <line>] [--chant <command>] [--pass-env <NAME>]... [--secrets <file>] [--key <runner key.pem>] [--unsigned]";
446
483
 
447
484
  const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
448
485
 
@@ -458,24 +495,37 @@ export function ciServeInterval(value: string | undefined): number {
458
495
  export async function runCiServe(ctx: CommandContext): Promise<number> {
459
496
  const { args } = ctx;
460
497
  try {
498
+ if (args.setupKey) return runnerKeySetupCommand(ctx);
461
499
  const forge = args.forge;
462
500
  if (forge !== "forgejo" && forge !== "github") throw new CiRunError(`--forge names the forge ci serve polls: --forge forgejo|github${forge ? `, not ${JSON.stringify(forge)}` : ""}`);
463
501
  if (!args.ciRepo) throw new CiRunError("--repo names the repository whose pull requests ci serve polls: --repo <owner/repo>");
464
502
  if (!args.env) throw new CiRunError("--env names the environment the pipeline is for: --env <env>");
465
503
  for (const name of args.passEnv ?? []) if (!ENV_NAME.test(name)) throw new CiRunError(`--pass-env takes a variable's name, not ${JSON.stringify(name)}`);
466
504
  const intervalMs = ciServeInterval(args.interval);
467
- const fromFlag = args.key !== undefined;
468
- const runnerKey = fromFlag ? readFileSync(resolve(args.key!), "utf-8") : process.env[RUNNER_KEY_ENV] || undefined;
505
+ const cwd = process.cwd();
506
+ const top = gitTop(cwd);
507
+ if (!top) throw new CiRunError("ci serve runs in a clone of the repository it serves, and this directory is not in a git repository");
508
+ const stateDir = args.statePath ? resolve(args.statePath) : defaultCiServeStateDir(cwd);
509
+ const branch = args.branch ?? "main";
510
+ // #4099: the host's key, checked against .chant/trust.json at the target's head before the first poll.
511
+ const keyed = ciServeRunnerKey({ top, stateDir, branch, ...(args.key !== undefined ? { keyFile: resolve(args.key) } : {}), envKey: process.env[RUNNER_KEY_ENV] || undefined, unsigned: args.unsigned });
512
+ const fromFlag = keyed.fromFlag;
513
+ const runnerKey = keyed.pem;
514
+ console.error(`ci serve: ${runnerKey === undefined ? "runs are not signed (--unsigned)" : `signing runs with the runner key from ${keyed.source}`}`);
515
+ // #4096: read once, so a loose or broken secrets file stops the serve before its first poll.
516
+ const secrets = ciSecretsOf(args.ciSecrets);
469
517
  const hostEnv = { ...process.env, GITHUB_REPOSITORY: args.ciRepo };
470
518
  const client = forgeFromEnv(forge, hostEnv);
519
+ const { status, warning } = ciStatusFromEnv({ forge, hostEnv, noStatus: args.noStatus, statusUrl: args.statusUrl });
520
+ if (warning) console.error(formatWarning({ message: warning }));
471
521
  if (!client.openPullRequests || !client.branchHead) throw new CiRunError(`the ${forge} client cannot list pull requests`);
472
522
  const opts: CiServeOptions = {
473
- cwd: process.cwd(),
523
+ cwd,
474
524
  forge,
475
525
  repo: args.ciRepo,
476
526
  env: args.env,
477
- client: { openPullRequests: client.openPullRequests.bind(client), branchHead: client.branchHead.bind(client) },
478
- ...(args.statePath ? { stateDir: isAbsolute(args.statePath) ? args.statePath : resolve(args.statePath) } : {}),
527
+ client: { openPullRequests: client.openPullRequests.bind(client), branchHead: client.branchHead.bind(client), pullRequestFor: client.pullRequestFor.bind(client) },
528
+ stateDir,
479
529
  ...(args.maxJobs ? { maxJobs: args.maxJobs } : {}),
480
530
  ...(args.ciAuthors?.length ? { authors: args.ciAuthors } : {}),
481
531
  ...(args.rerunFailed ? { rerunFailed: true } : {}),
@@ -488,10 +538,13 @@ export async function runCiServe(ctx: CommandContext): Promise<number> {
488
538
  ...(args.prBuild ? { build: true } : {}),
489
539
  ...(args.prBuildCommand ? { buildCommand: args.prBuildCommand } : {}),
490
540
  },
541
+ ...(args.runIndexLimit ? { runIndexLimit: args.runIndexLimit } : {}),
491
542
  ...(args.image ? { image: args.image } : {}),
492
543
  ...(args.beforeScript?.length ? { beforeScript: args.beforeScript } : {}),
493
544
  ...(args.ciChant ? { chant: args.ciChant } : {}),
494
545
  ...(args.passEnv?.length ? { passEnv: args.passEnv } : {}),
546
+ ...(status ? { status } : {}),
547
+ ...(secrets ? { secrets } : {}),
495
548
  ...(runnerKey !== undefined ? { runnerKey, runnerKeyFromEnv: !fromFlag } : {}),
496
549
  ...(args.eventsJson ? { runEvents: (line: string) => void process.stdout.write(line) } : {}),
497
550
  hostEnv,
@@ -44,6 +44,7 @@ import { PLANTABLE_REASON_CODES } from "./box-factory";
44
44
  import { BOX_FACTORY_ERROR_CODES } from "./box-factory-write";
45
45
  import { BOX_LISTING_ERROR_CODES } from "./box-listing";
46
46
  import { DECLARATION_WRITE_ERROR_CODES } from "./declaration-write";
47
+ import { TRUST_WRITE_ERROR_CODES } from "./trust/trust-write";
47
48
  import { BOX_PUBLISH_ERROR_CODES } from "./box-publish";
48
49
  import { WRITE_LOCK_ERROR_CODES } from "./write-lock-cli";
49
50
  import { WIP_ERROR_CODES, WIP_WRITE_ERROR_CODES } from "./wip";
@@ -115,6 +116,7 @@ const PER_COMMAND: Record<string, readonly string[]> = {
115
116
  PLANTABLE_REASON_CODES,
116
117
  BOX_LISTING_ERROR_CODES,
117
118
  DECLARATION_WRITE_ERROR_CODES,
119
+ TRUST_WRITE_ERROR_CODES,
118
120
  BOX_FACTORY_ERROR_CODES,
119
121
  BOX_PUBLISH_ERROR_CODES,
120
122
  WRITE_LOCK_ERROR_CODES,
@@ -271,6 +271,11 @@ export const REASONS = {
271
271
  // A declaration's members and hosts written through chant (member add|remove, host set, #3596): why the write was refused. Nothing is written.
272
272
  "member-exists": "member add names a member the declaration already has, with an entry other than the one given.",
273
273
  "member-unknown": "member remove names a member the declaration does not declare.",
274
+ // A runner entry of .chant/trust.json written through chant (trust runner add|remove, #3510): why the write was refused. Nothing is written.
275
+ "trust-config-invalid": ".chant/trust.json in the working tree is not JSON or does not match its schema, or the write would make it so.",
276
+ "trust-conflict": "--expect named a digest .chant/trust.json no longer has: another write changed it after the caller read it. The refusal names the digest it has now.",
277
+ "runner-exists": "trust runner add names a principal .chant/trust.json already lists with another key or class, or gives a key it lists for another principal.",
278
+ "runner-unknown": "trust runner remove names a principal .chant/trust.json lists no runner for.",
274
279
  // A box's factory written through chant (box factory set, #3600): why the write was refused. Nothing is written.
275
280
  "factory-member-unknown": "box factory set names a member the declaration does not declare.",
276
281
  "factory-box-missing": "box factory set names a member whose entry declares no box block, so it has no factory.",
@@ -314,8 +319,8 @@ export const REASONS = {
314
319
  "evidence-payload-type": "The envelope's payload type is not application/vnd.in-toto+json.",
315
320
  "evidence-statement-invalid": "The payload is not an in-toto Statement v1 with chant's runner-evidence predicate, or has a field the predicate does not define.",
316
321
  "evidence-runner-mismatch": "The statement names a runner other than the one whose key signed it.",
317
- "runner-key-invalid": "The key given to evidence sign or runs sign is not an Ed25519 private key in PEM.",
318
- "runner-key-is-signer": "The key given to evidence sign or runs sign is a person's key in the signers file. Evidence and run statements are signed by a service or CI identity.",
322
+ "runner-key-invalid": "The key given to evidence sign or runs sign is not an Ed25519 private key in PEM, or the key given to trust runner add is not an ssh-ed25519 public key or an Ed25519 key in PEM.",
323
+ "runner-key-is-signer": "The key given to evidence sign, runs sign or trust runner add is a person's key in the signers file, or trust runner add names a signer. Evidence and run statements are signed by a service or CI identity.",
319
324
  "runner-key-unlisted": "The policy at base lists no runner with the key given to evidence sign or runs sign.",
320
325
  // The lineage lock (check).
321
326
  "lock-invalid": "The lineage lock can't be read.",
@@ -0,0 +1,179 @@
1
+ /**
2
+ * #3510: a runner entry of `.chant/trust.json` written through chant, with
3
+ * `--by` and `--expect` as the other working-tree writes take them, and read
4
+ * back by the readers that use runners: `readTrustPolicy`, `ciTagSigner` and
5
+ * `ci tick --source runs`.
6
+ *
7
+ * Each test works in its own temp repository (a bare origin where it pushes),
8
+ * and every key is a throwaway one. Nothing is written in process.cwd().
9
+ */
10
+
11
+ import { execFileSync } from "node:child_process";
12
+ import { createPublicKey, generateKeyPairSync } from "node:crypto";
13
+ import { readFileSync, writeFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { afterAll, describe, expect, test } from "vitest";
16
+ import { parseArgs } from "../../cli/parse-args";
17
+ import { cleanScratch, contract, declaration, git, scratchDir, writeFiles } from "../__fixtures__/contract-repo";
18
+ import { ciTagSigner, ciTick, opRunSigner } from "../ci-green";
19
+ import { workingTreeSource } from "../record-source";
20
+ import schema from "../trust-write.schema.json";
21
+ import { runOpLocally } from "../../op/local-executor";
22
+ import type { ActivityFn } from "../../op/activity-registry";
23
+ import { sshFromEd25519 } from "./dsse";
24
+ import { readTrustPolicy } from "./policy";
25
+ import { trustDigest, trustWrite, type TrustWriteDocument, type TrustWriteRequest } from "./trust-write";
26
+
27
+ afterAll(cleanScratch);
28
+
29
+ const { expectValid } = contract(schema);
30
+
31
+ function runnerKey(): { pem: string; ssh: string } {
32
+ const { privateKey } = generateKeyPairSync("ed25519");
33
+ const jwk = createPublicKey(privateKey).export({ format: "jwk" }) as { x: string };
34
+ return { pem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(), ssh: sshFromEd25519(Buffer.from(jwk.x, "base64url")) };
35
+ }
36
+
37
+ function emptyRepo(files: Record<string, string> = {}): string {
38
+ const root = scratchDir("chant-trust-write-");
39
+ git(root, "init", "-q");
40
+ writeFiles(root, files);
41
+ return root;
42
+ }
43
+
44
+ function run(root: string, req: Omit<TrustWriteRequest, "cwd">): TrustWriteDocument {
45
+ const doc = trustWrite({ ...req, cwd: root });
46
+ expectValid(doc);
47
+ return doc;
48
+ }
49
+
50
+ const code = (doc: TrustWriteDocument) => ("error" in doc ? doc.error.code : null);
51
+ const trustOf = (root: string) => JSON.parse(readFileSync(join(root, ".chant/trust.json"), "utf-8")) as Record<string, unknown>;
52
+ const ok = (doc: TrustWriteDocument) => {
53
+ if ("error" in doc) throw new Error(`${doc.error.code}: ${doc.error.message}`);
54
+ return doc;
55
+ };
56
+
57
+ describe("trust runner add and remove (#3510)", () => {
58
+ test("add writes the entry and the runner role, remove takes both away, and a stale --expect is refused", () => {
59
+ const root = emptyRepo();
60
+ const bot = runnerKey();
61
+ writeFiles(root, { "keys/bot.pub": `${bot.ssh} bot@host\n` });
62
+
63
+ const added = ok(run(root, { action: "runner add", name: "ci:acme/app", key: "keys/bot.pub", by: "github:alex" }));
64
+ expect(added).toMatchObject({ action: "runner add", changed: true, previous: null, entry: { principal: "ci:acme/app", class: "runner", key: bot.ssh }, runnerRole: true, paths: [".chant/trust.json"] });
65
+ expect(added.previousDigest).toBe(trustDigest(null));
66
+ expect(added.digest).toBe(trustDigest(readFileSync(join(root, ".chant/trust.json"), "utf-8")));
67
+ expect(trustOf(root)).toEqual({ schema: 1, runners: [{ principal: "ci:acme/app", class: "runner", key: bot.ssh }], roles: { runner: ["ci:acme/app"] } });
68
+
69
+ // The same entry again changes nothing, and says so.
70
+ const again = ok(run(root, { action: "runner add", name: "ci:acme/app", key: bot.ssh, expect: added.digest }));
71
+ expect(again).toMatchObject({ changed: false, paths: [], digest: added.digest });
72
+
73
+ // A second runner, from a PEM private key, as a lobby holds it.
74
+ const lobby = runnerKey();
75
+ writeFileSync(join(root, "lobby.pem"), lobby.pem);
76
+ const second = ok(run(root, { action: "runner add", name: "studio:lobby", key: "lobby.pem", runnerClass: "service", expect: added.digest }));
77
+ expect(second).toMatchObject({ entry: { principal: "studio:lobby", class: "service", key: lobby.ssh }, runnerRole: false });
78
+
79
+ // The policy reads both, with the role for the runner only.
80
+ const policy = readTrustPolicy(workingTreeSource(root), null);
81
+ expect(policy.problems).toEqual([]);
82
+ expect(policy.runners.map((r) => r.principal)).toEqual(["ci:acme/app", "studio:lobby"]);
83
+ expect(policy.roles.runner).toEqual(["ci:acme/app"]);
84
+
85
+ const removed = ok(run(root, { action: "runner remove", name: "ci:acme/app", by: "github:alex", expect: second.digest }));
86
+ expect(removed).toMatchObject({ changed: true, previous: { principal: "ci:acme/app" }, entry: null, runnerRole: false });
87
+ expect(trustOf(root)).toEqual({ schema: 1, runners: [{ principal: "studio:lobby", class: "service", key: lobby.ssh }], roles: { runner: [] } });
88
+
89
+ // The digest the caller read is the one before the remove: refused, naming the last write.
90
+ const stale = run(root, { action: "runner remove", name: "studio:lobby", expect: second.digest });
91
+ expect(code(stale)).toBe("trust-conflict");
92
+ expect("conflict" in stale && stale.conflict).toMatchObject({ expected: second.digest, digest: removed.digest, lastWrite: { verb: "trust runner remove", by: "github:alex" } });
93
+ expect(trustOf(root).runners).toHaveLength(1);
94
+
95
+ expect(code(run(root, { action: "runner remove", name: "ci:acme/app" }))).toBe("runner-unknown");
96
+ });
97
+
98
+ test("an existing file is edited in place, keeping its other keys and layout", () => {
99
+ const text = `{\n "schema": 1,\n "roles": {\n "admin": ["alex"]\n },\n "adopted": []\n}\n`;
100
+ const root = emptyRepo({ ".chant/trust.json": text });
101
+ const bot = runnerKey();
102
+ const doc = ok(run(root, { action: "runner add", name: "ci-bot", key: bot.ssh, expect: trustDigest(text) }));
103
+ const after = readFileSync(join(root, ".chant/trust.json"), "utf-8");
104
+ expect(after.startsWith(`{\n "schema": 1,\n "roles": {\n "admin": ["alex"],`)).toBe(true);
105
+ expect(JSON.parse(after)).toEqual({ schema: 1, roles: { admin: ["alex"], runner: ["ci-bot"] }, adopted: [], runners: [{ principal: "ci-bot", class: "runner", key: bot.ssh }] });
106
+ expect(doc.digest).toBe(trustDigest(after));
107
+ // --dry-run reports the same and writes nothing.
108
+ const dry = ok(run(root, { action: "runner remove", name: "ci-bot", dryRun: true }));
109
+ expect(dry.changed).toBe(true);
110
+ expect(readFileSync(join(root, ".chant/trust.json"), "utf-8")).toBe(after);
111
+ });
112
+
113
+ test("refusals: a key that is not ssh-ed25519, a person's key, a taken key or principal, a broken file", () => {
114
+ const bot = runnerKey();
115
+ const person = runnerKey();
116
+ const root = emptyRepo({ ".chant/allowed_signers": `alex ${person.ssh}\n` });
117
+ expect(code(run(root, { action: "runner add", name: "x", key: "ssh-rsa AAAAB3NzaC1yc2E=" }))).toBe("runner-key-invalid");
118
+ expect(code(run(root, { action: "runner add", name: "x", key: "ssh-ed25519 AAAA" }))).toBe("runner-key-invalid");
119
+ expect(code(run(root, { action: "runner add", name: "x", key: "no-such-file.pub" }))).toBe("runner-key-invalid");
120
+ expect(code(run(root, { action: "runner add", name: "x", key: person.ssh }))).toBe("runner-key-is-signer");
121
+ expect(code(run(root, { action: "runner add", name: "alex", key: bot.ssh }))).toBe("runner-key-is-signer");
122
+ expect(code(run(root, { action: "runner add", name: "x" }))).toBe("write-usage-invalid");
123
+ expect(code(run(root, { action: "runner add", name: "x", key: bot.ssh, runnerClass: "person" }))).toBe("write-usage-invalid");
124
+ expect(code(run(root, { action: "runner add", name: "x", key: bot.ssh, expect: "abc" }))).toBe("write-usage-invalid");
125
+ expect(code(run(root, { action: "runner remove", name: "x", key: bot.ssh }))).toBe("write-usage-invalid");
126
+
127
+ ok(run(root, { action: "runner add", name: "ci-bot", key: bot.ssh }));
128
+ expect(code(run(root, { action: "runner add", name: "other", key: bot.ssh }))).toBe("runner-exists");
129
+ expect(code(run(root, { action: "runner add", name: "ci-bot", key: runnerKey().ssh }))).toBe("runner-exists");
130
+ expect(code(run(root, { action: "runner add", name: "ci-bot", key: bot.ssh, runnerClass: "service" }))).toBe("runner-exists");
131
+
132
+ writeFileSync(join(root, ".chant/trust.json"), `{ "schema": 2 }`);
133
+ expect(code(run(root, { action: "runner add", name: "y", key: runnerKey().ssh }))).toBe("trust-config-invalid");
134
+ expect(code(trustWrite({ cwd: scratchDir("chant-trust-nogit-"), action: "runner add", name: "y", key: bot.ssh }))).toBe("not-a-git-repository");
135
+ });
136
+
137
+ test("the command line takes --class, --key, --by and --expect", () => {
138
+ const args = parseArgs(["workspace", "trust", "runner", "add", "ci-bot", "--key", "k.pub", "--class", "service", "--by", "github:alex", "--expect", "a".repeat(64)]);
139
+ expect(args).toMatchObject({ key: "k.pub", runnerClass: "service", by: "github:alex", expect: "a".repeat(64) });
140
+ });
141
+ });
142
+
143
+ describe("a runner added through the write is trusted by the readers (#3510)", () => {
144
+ test("ci tick --source runs tags a commit from a run signed by a runner the write listed", async () => {
145
+ const bot = runnerKey();
146
+ const origin = scratchDir("chant-trust-write-origin-");
147
+ git(origin, "init", "-q", "--bare");
148
+ const root = scratchDir("chant-trust-write-ci-");
149
+ git(root, "init", "-q");
150
+ git(root, "symbolic-ref", "HEAD", "refs/heads/main");
151
+ git(root, "remote", "add", "origin", origin);
152
+ const green = { branch: "main", phases: { test: ["test"] }, require: ["test"], source: "runs" };
153
+ writeFiles(root, { "chant.workspace.json": declaration([{ name: "app", dir: "app", kind: "other", because: "the app" }], { ci: { green } }), "app/a.txt": "a\n", "runner.pem": bot.pem });
154
+ writeFiles(root, { ".gitignore": "runner.pem\n" });
155
+
156
+ // Before the write, the key is refused.
157
+ git(root, "add", "-A");
158
+ git(root, "commit", "-q", "-m", "init");
159
+ expect(() => ciTagSigner(root, git(root, "rev-parse", "HEAD"), bot.pem)).toThrow(/lists no runner with this key/);
160
+
161
+ ok(run(root, { action: "runner add", name: "ci-bot", key: "runner.pem", by: "t" }));
162
+ git(root, "add", "-A");
163
+ git(root, "commit", "-q", "-m", "trust the ci-bot runner");
164
+ const head = git(root, "rev-parse", "HEAD");
165
+ expect(ciTagSigner(root, head, bot.pem).principal).toBe("ci-bot");
166
+
167
+ const pass = new Map<string, ActivityFn>([["step", async () => ({})]]);
168
+ const op = { name: "test", overview: "run test", labels: { Env: "ci" }, phases: [{ name: "Run", steps: [{ kind: "activity" as const, fn: "step", args: {} }] }] };
169
+ const { record } = await runOpLocally(op, pass, {}, undefined, { cwd: root, ledger: { cwd: root }, signRecord: opRunSigner(root, bot.pem) });
170
+ expect(record.gitSha).toBe(head);
171
+ git(root, "push", "-q", "origin", "main");
172
+ git(root, "push", "-q", "origin", "chant/lifecycle");
173
+
174
+ const tick = await ciTick({ cwd: root, runnerKey: bot.pem, now: new Date() });
175
+ expect(tick.made).toEqual([`ci/green/${head}`]);
176
+ expect(tick.signer).toBe("ci-bot");
177
+ expect(execFileSync("git", ["ls-remote", "--tags", origin], { encoding: "utf-8" })).toContain(`refs/tags/ci/green/${head}`);
178
+ });
179
+ });