@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
@@ -12,12 +12,13 @@
12
12
 
13
13
  import { execFileSync } from "node:child_process";
14
14
  import { createPublicKey, generateKeyPairSync } from "node:crypto";
15
- import { chmodSync, existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
15
+ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
16
16
  import { join } from "node:path";
17
- import { afterAll, describe, expect, test } from "vitest";
18
- import { cleanScratch, declaration, git, scratchDir, writeFiles } from "./__fixtures__/contract-repo";
17
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
18
+ import { cleanScratch, declaration, git, repoFixture, scratchDir, writeFiles } from "./__fixtures__/contract-repo";
19
19
  import { checkRunEventLines } from "./__fixtures__/run-event-check";
20
- import { ciJobCommand, ciJobEnv, ciJobFailure, ciJobOp, ciRun, CiRunError, formatCiRun } from "./ci-run";
20
+ import { ciJobCommand, ciJobEnv, ciJobFailure, ciJobOp, ciRun, CiRunError, ciStatusContext, ciStatusFromEnv, ciStatusUrl, formatCiRun } from "./ci-run";
21
+ import type { CommitStatus } from "../pr-forge";
21
22
  import { ciTick, evaluateCommit, readOpRunSource, revokedAnnotation } from "./ci-green";
22
23
  import type { StepRecord } from "../op/local-executor";
23
24
  import { sshFromEd25519 } from "./trust/dsse";
@@ -25,6 +26,8 @@ import { verifyOpRunStatement } from "./trust/op-run-statement";
25
26
  import { readRunLedger } from "../lifecycle/run-ledger";
26
27
  import { prLoopJobs, PR_LOOP_SETUP } from "../components/pr-pipeline";
27
28
  import { parseArgs } from "../cli/parse-args";
29
+ import { ciSecretsFor, readCiSecrets } from "./ci-secrets";
30
+ import { runEventFileName, runEventsDir } from "../run-event-store";
28
31
 
29
32
  afterAll(cleanScratch);
30
33
 
@@ -51,11 +54,17 @@ function commitAt(root: string, hours: number, file: string): string {
51
54
  return git(root, "rev-parse", "HEAD");
52
55
  }
53
56
 
54
- /** A checkout on main with a bare origin: member `app`, `ci.green` from runs, and BOT as the runner `chant-runner`. */
55
- function workspace(): { root: string; app: string } {
56
- const origin = scratchDir("chant-ci-run-origin-");
57
+ /**
58
+ * A checkout on main with a bare origin: member `app`, `ci.green` from runs, and BOT as the runner `chant-runner`.
59
+ * The history is two commits, `base` and then `sha`, which changes app/a.txt, and both are pushed to origin.
60
+ */
61
+ const workspaceTemplate = repoFixture(() => {
62
+ const top = scratchDir("chant-ci-run-template-");
63
+ const origin = join(top, "origin");
64
+ const root = join(top, "root");
65
+ mkdirSync(origin);
66
+ mkdirSync(root);
57
67
  git(origin, "init", "-q", "--bare");
58
- const root = scratchDir("chant-ci-run-");
59
68
  git(root, "init", "-q");
60
69
  git(root, "symbolic-ref", "HEAD", "refs/heads/main");
61
70
  git(root, "config", "user.name", "t");
@@ -68,7 +77,17 @@ function workspace(): { root: string; app: string } {
68
77
  });
69
78
  git(root, "remote", "add", "origin", origin);
70
79
  commitAt(root, 23, "app/init.txt");
71
- return { root, app: join(root, "app") };
80
+ commitAt(root, 2, "app/a.txt");
81
+ git(root, "push", "-q", "origin", "main");
82
+ return top;
83
+ }, "chant-ci-run-");
84
+
85
+ /** A copy of the template repository, its origin pointed at the copy of the bare one. */
86
+ function workspace(): { root: string; app: string; base: string; sha: string } {
87
+ const top = workspaceTemplate();
88
+ const root = join(top, "root");
89
+ git(root, "remote", "set-url", "origin", join(top, "origin"));
90
+ return { root, app: join(root, "app"), base: git(root, "rev-parse", "HEAD~1"), sha: git(root, "rev-parse", "HEAD") };
72
91
  }
73
92
 
74
93
  /**
@@ -183,23 +202,29 @@ describe("the job as an Op (#4094)", () => {
183
202
  });
184
203
 
185
204
  describe("chant ci run, then ci tick from runs (#4094)", () => {
186
- test("plan and apply run in a fresh worktree, are recorded signed, and the tick tags the commit green", async () => {
187
- const before = { worktrees: undefined as string | undefined };
188
- const { root, app } = workspace();
189
- const base = git(root, "rev-parse", "HEAD");
190
- const sha = commitAt(root, 2, "app/a.txt");
191
- git(root, "push", "-q", "origin", "main");
192
- before.worktrees = git(root, "worktree", "list", "--porcelain");
193
- const chant = fakeChant();
194
- const reports = scratchDir("chant-ci-run-reports-");
195
-
196
- const events: string[] = [];
197
- const plan = await ciRun({ image: JOB_IMAGE,
198
- cwd: app,
205
+ // The plan and the apply run once, in beforeAll, and the tests below read what they left. A job
206
+ // takes a few hundred milliseconds here and several times that on a loaded runner, so two jobs and
207
+ // a tick in one test came close to the unit budget (#2817). A file's beforeAll is not counted in it.
208
+ let w: ReturnType<typeof workspace>;
209
+ let worktrees: string;
210
+ let chant: string;
211
+ let reports: string;
212
+ let events: string[];
213
+ let plan: Awaited<ReturnType<typeof ciRun>>;
214
+ let apply: Awaited<ReturnType<typeof ciRun>>;
215
+
216
+ beforeAll(async () => {
217
+ w = workspace();
218
+ worktrees = git(w.root, "worktree", "list", "--porcelain");
219
+ chant = fakeChant();
220
+ reports = scratchDir("chant-ci-run-reports-");
221
+ events = [];
222
+ plan = await ciRun({ image: JOB_IMAGE,
223
+ cwd: w.app,
199
224
  job: "plan",
200
225
  env: "prod",
201
226
  forge: "forgejo",
202
- base,
227
+ base: w.base,
203
228
  pr: 7,
204
229
  chant,
205
230
  hostEnv: HOST,
@@ -209,6 +234,11 @@ describe("chant ci run, then ci tick from runs (#4094)", () => {
209
234
  runId: "chant-plan-1",
210
235
  runEvents: (line) => events.push(line),
211
236
  });
237
+ apply = await ciRun({ image: JOB_IMAGE, cwd: w.app, job: "apply", env: "prod", forge: "forgejo", base: w.base, chant, hostEnv: HOST, runnerKey: BOT.pem, output: join(reports, "apply") });
238
+ });
239
+
240
+ test("the plan runs in a fresh worktree, in the member's directory, with a clean environment", () => {
241
+ const { root, base, sha } = w;
212
242
  expect(plan.status).toBe("ok");
213
243
  expect(plan.warnings).toEqual([]);
214
244
  expect(plan.signer).toBe("chant-runner");
@@ -224,14 +254,18 @@ describe("chant ci run, then ci tick from runs (#4094)", () => {
224
254
  expect(pwd).toBe(join(plan.checkout, "app"));
225
255
  expect(pwd.startsWith(realpathSync(root))).toBe(false);
226
256
  expect(existsSync(plan.checkout)).toBe(false);
227
- expect(git(root, "worktree", "list", "--porcelain")).toBe(before.worktrees);
257
+ expect(git(root, "worktree", "list", "--porcelain")).toBe(worktrees);
228
258
  expect(JSON.parse(readFileSync(join(out, "pr-plan.json"), "utf-8"))).toEqual({ stage: "pr-plan" });
229
259
 
230
- // The environment is clean: the job's variables and the few the runner hands on.
260
+ // The environment is clean: the job's variables and the few the runner hands on. The copied
261
+ // report shows the values of the variables whose names look secret as *** (#4096).
231
262
  const env = envFile(out, "pr-plan");
232
- expect(env).toMatchObject({ BASE_SHA: base, PR_NUMBER: "7", GITHUB_SHA: sha, CHANT_ENV: "prod", GITHUB_TOKEN: "forge-token", AWS_ACCESS_KEY_ID: "floci" });
263
+ expect(env).toMatchObject({ BASE_SHA: base, PR_NUMBER: "7", GITHUB_SHA: sha, CHANT_ENV: "prod", GITHUB_TOKEN: "***", AWS_ACCESS_KEY_ID: "***" });
233
264
  for (const name of ["NOT_FOR_JOBS", "CHANT_RUNNER_KEY", "GITHUB_RUN_ID"]) expect(env).not.toHaveProperty(name);
265
+ });
234
266
 
267
+ test("the plan and the apply are recorded signed, with a pipeline run's events", async () => {
268
+ const { app, base, sha } = w;
235
269
  // The record: Op pr-plan at the commit, run by chant, with a statement that verifies.
236
270
  expect(plan.record).toMatchObject({ op: "pr-plan", env: "prod", status: "ok", gitSha: sha, runOrigin: { forge: "chant" }, id: "chant-plan-1" });
237
271
  const stored = (await readRunLedger("prod", "pr-plan", { cwd: app })).records.at(-1)!;
@@ -244,13 +278,14 @@ describe("chant ci run, then ci tick from runs (#4094)", () => {
244
278
  expect(stream[0]).toMatchObject({ kind: "pipeline", runtime: "ci", name: "pr-plan", runId: "chant-plan-1", gitSha: sha, runOrigin: { forge: "chant" } });
245
279
  expect(stream.at(-1)).toMatchObject({ type: "run-settle", status: "ok" });
246
280
 
247
- const apply = await ciRun({ image: JOB_IMAGE, cwd: app, job: "apply", env: "prod", forge: "forgejo", base, chant, hostEnv: HOST, runnerKey: BOT.pem, output: join(reports, "apply") });
248
281
  expect(apply.status).toBe("ok");
249
282
  const applyArgs = readFileSync(join(reports, "apply", "pr-apply.args"), "utf-8").trimEnd().split("\n");
250
283
  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"]);
251
284
  expect(formatCiRun(apply)).toContain("signed by chant-runner");
285
+ });
252
286
 
253
- // The records reached origin, and a tick from runs tags the commit green with no forge.
287
+ test("the records reached origin, and a tick from runs tags the commit green with no forge", async () => {
288
+ const { root, app, sha } = w;
254
289
  expect(git(root, "ls-remote", "origin", "chant/lifecycle")).toContain(git(root, "rev-parse", "chant/lifecycle"));
255
290
  const tick = await ciTick({ cwd: app, now: NOW, runnerKey: BOT.pem });
256
291
  expect(tick.source).toBe("runs");
@@ -262,9 +297,7 @@ describe("chant ci run, then ci tick from runs (#4094)", () => {
262
297
  });
263
298
 
264
299
  test("a failing job is recorded failed, and a key from the environment that trust.json does not list leaves it unsigned", async () => {
265
- const { root, app } = workspace();
266
- const base = git(root, "rev-parse", "HEAD");
267
- commitAt(root, 2, "app/a.txt");
300
+ const { app, base } = workspace();
268
301
  const stranger = runnerKey();
269
302
  const lines: string[] = [];
270
303
  const r = await ciRun({ image: JOB_IMAGE,
@@ -300,9 +333,7 @@ describe("chant ci run, then ci tick from runs (#4094)", () => {
300
333
  });
301
334
 
302
335
  test("--pr-build runs the job's build and lint lines after --before-script and before the command, and a failing build fails the job", async () => {
303
- const { root, app } = workspace();
304
- const base = git(root, "rev-parse", "HEAD");
305
- const sha = commitAt(root, 2, "app/a.txt");
336
+ const { root, app, base, sha } = workspace();
306
337
  const chant = fakeChant();
307
338
  const out = join(scratchDir("chant-ci-run-build-"), "pr");
308
339
  const ok = await ciRun({ image: JOB_IMAGE,
@@ -349,9 +380,7 @@ describe("chant ci run, then ci tick from runs (#4094)", () => {
349
380
  });
350
381
 
351
382
  test("a failed job's record and settle event say why from its pr report, signed, and a revoke note names the code", async () => {
352
- const { root, app } = workspace();
353
- const base = git(root, "rev-parse", "HEAD");
354
- const sha = commitAt(root, 2, "app/a.txt");
383
+ const { root, app, base, sha } = workspace();
355
384
  const refusedReport = { stage: "apply", status: "refused", refusal: "not-a-reviewer", message: "ana approved the plan, and ana has not approved the pull request on the forge" };
356
385
  const extra = { FAKE_EXIT: "3", FAKE_REPORT: JSON.stringify(refusedReport) };
357
386
  const events: string[] = [];
@@ -414,3 +443,274 @@ describe("chant ci run, then ci tick from runs (#4094)", () => {
414
443
  expect(at([ok("build"), fail("apply", 1)])!.code).toBe("command-failed");
415
444
  });
416
445
  });
446
+
447
+ describe("a commit status per job (#4097)", () => {
448
+ /** A fake forge client: it records each status, and fails on the states in `refuse`. */
449
+ function fakeForge(refuse: string[] = []): { client: { setCommitStatus(sha: string, status: CommitStatus): Promise<void> }; posts: { sha: string; status: CommitStatus }[] } {
450
+ const posts: { sha: string; status: CommitStatus }[] = [];
451
+ return {
452
+ posts,
453
+ client: {
454
+ async setCommitStatus(sha, status) {
455
+ if (refuse.includes(status.state)) throw new Error(`POST /statuses/${sha} answered 500`);
456
+ posts.push({ sha, status });
457
+ },
458
+ },
459
+ };
460
+ }
461
+
462
+ async function job(over: Record<string, unknown>) {
463
+ const { root, app, base, sha } = workspace();
464
+ const r = await ciRun({ image: JOB_IMAGE, cwd: app, job: "plan", env: "prod", forge: "forgejo", base, pr: 7, chant: fakeChant(), hostEnv: HOST, push: false, output: join(scratchDir("chant-ci-status-"), "pr"), ...over } as Parameters<typeof ciRun>[0]);
465
+ return { r, sha };
466
+ }
467
+
468
+ test("a good job posts pending, then success, at its commit", async () => {
469
+ const forge = fakeForge();
470
+ const { r, sha } = await job({ status: { client: forge.client } });
471
+ expect(r.status).toBe("ok");
472
+ expect(r.warnings).toEqual([]);
473
+ expect(forge.posts.map((p) => [p.sha, p.status.context, p.status.state])).toEqual([[sha, "chant/pr-plan", "pending"], [sha, "chant/pr-plan", "success"]]);
474
+ expect(forge.posts[0]!.status.description).toBe("pr-plan running");
475
+ expect(forge.posts[1]!.status.description).toMatch(/^pr-plan passed in \d+s$/);
476
+ expect(forge.posts[1]!.status).not.toHaveProperty("targetUrl");
477
+ });
478
+
479
+ test("a failed job posts pending, then failure with the run record's code and reason", async () => {
480
+ const forge = fakeForge();
481
+ const report = JSON.stringify({ stage: "apply", status: "refused", refusal: "not-approved", message: "no reviewer approved this plan" });
482
+ const { r } = await job({ job: "apply", pr: undefined, hostEnv: { ...HOST, FAKE_EXIT: "1", FAKE_REPORT: report }, passEnv: ["FAKE_EXIT", "FAKE_REPORT"], status: { client: forge.client } });
483
+ expect(r.status).toBe("fail");
484
+ expect(forge.posts.map((p) => [p.status.context, p.status.state])).toEqual([["chant/pr-apply", "pending"], ["chant/pr-apply", "failure"]]);
485
+ expect(r.record.failure!.code).toBe("not-approved");
486
+ expect(forge.posts[1]!.status.description).toBe("not-approved: no reviewer approved this plan");
487
+ });
488
+
489
+ test("a long failure reason is cut to the forge's 140 characters", async () => {
490
+ const forge = fakeForge();
491
+ const report = JSON.stringify({ stage: "apply", status: "refused", refusal: "plan-changed", message: "y".repeat(400) });
492
+ await job({ job: "apply", pr: undefined, hostEnv: { ...HOST, FAKE_EXIT: "1", FAKE_REPORT: report }, passEnv: ["FAKE_EXIT", "FAKE_REPORT"], status: { client: forge.client } });
493
+ const d = forge.posts[1]!.status.description;
494
+ expect(d).toHaveLength(140);
495
+ expect(d.startsWith("plan-changed: yyy")).toBe(true);
496
+ expect(d.endsWith("...")).toBe(true);
497
+ });
498
+
499
+ test("a renamed gate names the apply's context", async () => {
500
+ const forge = fakeForge();
501
+ await job({ job: "apply", pr: undefined, loop: { gate: "prod-approval" }, status: { client: forge.client } });
502
+ expect(forge.posts.map((p) => p.status.context)).toEqual(["chant/prod-approval", "chant/prod-approval"]);
503
+ expect(ciStatusContext("plan", "prod-approval")).toBe("chant/pr-plan");
504
+ });
505
+
506
+ // One job per test: each runs a whole Op in a worktree, and three in one test went over the unit budget.
507
+ test("target_url is an https --report-url with the key", async () => {
508
+ const https = fakeForge();
509
+ const { sha } = await job({ loop: { reportUrl: "https://host/reports/" }, status: { client: https.client } });
510
+ const key = `7/${sha}/app`;
511
+ expect(https.posts.map((p) => p.status.targetUrl)).toEqual([`https://host/reports/${key}/`, `https://host/reports/${key}/`]);
512
+ });
513
+
514
+ /**
515
+ * An `aws` stand-in first on PATH for the test. The run index of an s3 --report-url is read and
516
+ * written with the AWS CLI, and the real one reaches for credentials and the network: seconds on a
517
+ * runner that has it. The stub takes a put and answers a get with a missing object.
518
+ */
519
+ function withStubAws(): () => void {
520
+ const dir = scratchDir("chant-ci-run-aws-");
521
+ const file = join(dir, "aws");
522
+ writeFileSync(file, ['#!/bin/sh', 'if [ "$3" = "-" ]; then cat > /dev/null; exit 0; fi', 'echo "An error occurred (404) when calling the HeadObject operation: Key not found" >&2', "exit 1", ""].join("\n"));
523
+ chmodSync(file, 0o755);
524
+ const saved = process.env.PATH;
525
+ process.env.PATH = `${dir}:${saved}`;
526
+ return () => {
527
+ process.env.PATH = saved;
528
+ };
529
+ }
530
+
531
+ test("target_url is the --status-url template for an s3 --report-url", async () => {
532
+ const s3 = fakeForge();
533
+ const restore = withStubAws();
534
+ try {
535
+ const mapped = await job({ loop: { reportUrl: "s3://bucket/prefix" }, status: { client: s3.client, urlTemplate: "https://host/reports/{key}" } });
536
+ expect(s3.posts.map((p) => p.status.targetUrl)).toEqual([`https://host/reports/7/${mapped.sha}/app`, `https://host/reports/7/${mapped.sha}/app`]);
537
+ } finally {
538
+ restore();
539
+ }
540
+ });
541
+
542
+ test("an s3 or file --report-url with no --status-url gives no target_url", async () => {
543
+ const bare = fakeForge();
544
+ const restore = withStubAws();
545
+ try {
546
+ await job({ loop: { reportUrl: "s3://bucket/prefix" }, status: { client: bare.client } });
547
+ } finally {
548
+ restore();
549
+ }
550
+ expect(bare.posts.every((p) => p.status.targetUrl === undefined)).toBe(true);
551
+ expect(ciStatusUrl({ reportUrl: "file:///tmp/r", commit: "abc" })).toBeUndefined();
552
+ });
553
+
554
+ test("--no-status, a missing token and GitLab give no client; a bad --status-url is refused", () => {
555
+ const env = { GITHUB_TOKEN: "t", GITHUB_REPOSITORY: "acme/estate", GITHUB_SERVER_URL: "http://forgejo.localhost", GITHUB_API_URL: "http://forgejo.localhost/api/v1" };
556
+ expect(ciStatusFromEnv({ forge: "forgejo", hostEnv: env, noStatus: true })).toEqual({});
557
+ expect(ciStatusFromEnv({ forge: "forgejo", hostEnv: { GITHUB_REPOSITORY: "acme/estate" } })).toEqual({});
558
+ expect(ciStatusFromEnv({ forge: "gitlab", hostEnv: { ...env, GITLAB_TOKEN: "t" } })).toEqual({});
559
+ expect(ciStatusFromEnv({ forge: "forgejo", hostEnv: env }).status?.client.setCommitStatus).toBeTypeOf("function");
560
+ expect(ciStatusFromEnv({ forge: "forgejo", hostEnv: env, statusUrl: "https://h/{key}" }).status?.urlTemplate).toBe("https://h/{key}");
561
+ expect(ciStatusFromEnv({ forge: "forgejo", hostEnv: { GITHUB_TOKEN: "t" } }).warning).toMatch(/no commit status/);
562
+ expect(() => ciStatusFromEnv({ forge: "forgejo", hostEnv: env, statusUrl: "s3://b/{key}" })).toThrow(/--status-url takes an http or https URL/);
563
+ const flags = parseArgs(["ci", "run", "plan", "--no-status", "--status-url", "https://h/{key}"]);
564
+ expect(flags).toMatchObject({ noStatus: true, statusUrl: "https://h/{key}" });
565
+ });
566
+
567
+ test("a status the forge refuses is a warning, and the job stays ok", async () => {
568
+ const forge = fakeForge(["pending", "success"]);
569
+ const { r } = await job({ status: { client: forge.client } });
570
+ expect(r.status).toBe("ok");
571
+ expect(r.record.status).toBe("ok");
572
+ expect(r.warnings).toHaveLength(2);
573
+ expect(r.warnings[0]).toMatch(/could not set the chant\/pr-plan status: POST \/statuses\/[0-9a-f]+ answered 500/);
574
+ });
575
+ });
576
+
577
+ /** A secrets file only its owner can read, as `--secrets` takes it. */
578
+ function secretsFile(text: string, name = "secrets.env"): string {
579
+ const file = join(scratchDir("chant-ci-run-secrets-"), name);
580
+ writeFileSync(file, text, { mode: 0o600 });
581
+ chmodSync(file, 0o600);
582
+ return file;
583
+ }
584
+
585
+ /** Every file under `dir`, with its text. */
586
+ function filesUnder(dir: string): Record<string, string> {
587
+ const out: Record<string, string> = {};
588
+ for (const entry of readdirSync(dir, { recursive: true, withFileTypes: true })) {
589
+ if (!entry.isFile()) continue;
590
+ const path = join(entry.parentPath, entry.name);
591
+ out[path] = readFileSync(path, "utf-8");
592
+ }
593
+ return out;
594
+ }
595
+
596
+ describe("the job's secrets from the host (#4096)", () => {
597
+ test("a secret reaches the step, and the file's allowlist keeps one from the plan", async () => {
598
+ const { app, base } = workspace();
599
+ const secrets = readCiSecrets(
600
+ secretsFile(JSON.stringify({ secrets: { DB_PASSWORD: "hunter22-db", DEPLOY_KEY: "deploy-key-value" }, jobs: { plan: ["DB_PASSWORD"] } }), "secrets.json"),
601
+ );
602
+ const out = join(scratchDir("chant-ci-run-secret-"), "pr");
603
+ const r = await ciRun({
604
+ image: JOB_IMAGE,
605
+ cwd: app,
606
+ job: "plan",
607
+ env: "prod",
608
+ forge: "forgejo",
609
+ base,
610
+ pr: 5,
611
+ chant: fakeChant(),
612
+ hostEnv: HOST,
613
+ secrets,
614
+ // The step checks the value it got, and that the plan got no DEPLOY_KEY.
615
+ beforeScript: ['test "$DB_PASSWORD" = "hunter22-db" && test -z "${DEPLOY_KEY+set}" && mkdir -p .chant/pr && echo reached > .chant/pr/reached'],
616
+ push: false,
617
+ output: out,
618
+ });
619
+ expect(r.status).toBe("ok");
620
+ expect(readFileSync(join(out, "reached"), "utf-8").trim()).toBe("reached");
621
+ const env = envFile(out, "pr-plan");
622
+ // The value reached the command too, and the copied report shows it as ***.
623
+ expect(env.DB_PASSWORD).toBe("***");
624
+ expect(env).not.toHaveProperty("DEPLOY_KEY");
625
+ expect(env).not.toHaveProperty("CHANT_RUNNER_KEY");
626
+
627
+ // The apply is not in the allowlist, so it gets every secret.
628
+ const [, apply] = prLoopJobs("forgejo", "prod");
629
+ const applyEnv = ciJobEnv(apply!, { forge: "forgejo", env: "prod", values: { base, commit: base }, hostEnv: HOST, secrets: ciSecretsFor(secrets, "apply") });
630
+ expect(applyEnv).toMatchObject({ DB_PASSWORD: "hunter22-db", DEPLOY_KEY: "deploy-key-value" });
631
+ // A secret can replace a forge token, and never the variables the runner sets.
632
+ const over = ciJobEnv(apply!, { forge: "forgejo", env: "prod", values: { base, commit: base }, hostEnv: HOST, secrets: { GITHUB_TOKEN: "from-file", BASE_SHA: "x", CHANT_RUNNER_KEY: "k" } });
633
+ expect(over).toMatchObject({ GITHUB_TOKEN: "from-file", BASE_SHA: base });
634
+ expect(over).not.toHaveProperty("CHANT_RUNNER_KEY");
635
+ expect(() => ciJobEnv(apply!, { forge: "forgejo", env: "prod", values: { base, commit: base }, hostEnv: HOST, passEnv: ["CHANT_RUNNER_KEY"] })).toThrow(/never passed to a job/);
636
+ });
637
+
638
+ test("a secret the job prints, writes in its report or fails with is *** in the events, the signed record, the copied report and the output", async () => {
639
+ const { app, base } = workspace();
640
+ const SECRET = "s3cret pass#1";
641
+ const TOKEN = "tok-abc123";
642
+ const secrets = readCiSecrets(secretsFile(`# the host's secrets\nDB_PASSWORD="${SECRET}"\nexport PIN=abc\n`));
643
+ expect(secrets.values).toEqual({ DB_PASSWORD: SECRET, PIN: "abc" });
644
+
645
+ const leaky = join(scratchDir("chant-ci-run-bin-"), "chant");
646
+ writeFileSync(
647
+ leaky,
648
+ [
649
+ "#!/bin/sh",
650
+ "mkdir -p .chant/pr",
651
+ 'env | sort > .chant/pr/pr-apply.env',
652
+ 'printf \'{"stage":"apply","status":"refused","refusal":"not-approved","message":"the deploy failed with %s"}\' "$DB_PASSWORD" > .chant/pr/pr-apply.json',
653
+ 'printf \'{\\n "entries": [\\n { "password": "%s" }\\n ]\\n}\\n\' "$DB_PASSWORD" > .chant/pr/change-set.json',
654
+ 'echo "connecting with $DB_PASSWORD and $MY_API_TOKEN, pin $PIN"',
655
+ 'echo "login refused for $DB_PASSWORD" >&2',
656
+ "exit 3",
657
+ "",
658
+ ].join("\n"),
659
+ );
660
+ chmodSync(leaky, 0o755);
661
+
662
+ const events: string[] = [];
663
+ const lines: string[] = [];
664
+ const out = join(scratchDir("chant-ci-run-leak-"), "pr");
665
+ const r = await ciRun({
666
+ image: JOB_IMAGE,
667
+ cwd: app,
668
+ job: "apply",
669
+ env: "prod",
670
+ forge: "forgejo",
671
+ base,
672
+ chant: leaky,
673
+ hostEnv: { ...HOST, MY_API_TOKEN: TOKEN },
674
+ passEnv: ["MY_API_TOKEN"],
675
+ secrets,
676
+ runnerKey: BOT.pem,
677
+ push: false,
678
+ output: out,
679
+ runId: "chant-leak-1",
680
+ runEvents: (line) => events.push(line),
681
+ stepOutput: (line) => lines.push(line),
682
+ });
683
+ expect(r.status).toBe("fail");
684
+ const leaks = (text: string): boolean => text.includes(SECRET) || text.includes(TOKEN);
685
+
686
+ // The events, as written and as kept on this host, still valid lines.
687
+ expect(events.some(leaks)).toBe(false);
688
+ const stream = checkRunEventLines(events);
689
+ expect(stream.at(-1)).toMatchObject({ type: "run-settle", status: "failed", failure: { code: "not-approved", reason: "the deploy failed with ***" } });
690
+ const kept = readFileSync(join((await runEventsDir(app))!, runEventFileName("chant-leak-1")), "utf-8");
691
+ expect(leaks(kept)).toBe(false);
692
+ expect(kept).toContain("***");
693
+
694
+ // The record: the step's error and failure.reason redacted, and the statement still verifies.
695
+ expect(leaks(JSON.stringify(r.record))).toBe(false);
696
+ expect(r.record.failure).toMatchObject({ code: "not-approved", reason: "the deploy failed with ***", exitCode: 3 });
697
+ const stored = (await readRunLedger("prod", "pr-apply", { cwd: app })).records.at(-1)!;
698
+ expect(leaks(JSON.stringify(stored))).toBe(false);
699
+ expect(stored.phases.at(-1)!.steps[0]!.error).toContain("login refused for ***");
700
+ expect(verifyOpRunStatement(stored, RUNNERS, { runner: ["chant-runner"] })).toMatchObject({ status: "signed", signer: "chant-runner" });
701
+
702
+ // The copied report and change set: redacted, and still JSON in the layout the job wrote.
703
+ const files = filesUnder(out);
704
+ expect(Object.values(files).some(leaks)).toBe(false);
705
+ expect(JSON.parse(files[join(out, "pr-apply.json")]!)).toMatchObject({ message: "the deploy failed with ***" });
706
+ expect(files[join(out, "change-set.json")]).toBe('{\n "entries": [\n {\n "password": "***"\n }\n ]\n}\n');
707
+ expect(envFile(out, "pr-apply")).toMatchObject({ DB_PASSWORD: "***", MY_API_TOKEN: "***" });
708
+
709
+ // What ci run prints: the step's lines, the result's failure and the summary. A value under 4 characters is kept.
710
+ expect(lines.some(leaks)).toBe(false);
711
+ expect(lines.some((l) => l.endsWith("| connecting with *** and ***, pin abc"))).toBe(true);
712
+ expect(leaks(r.failure!.error)).toBe(false);
713
+ expect(leaks(formatCiRun(r))).toBe(false);
714
+ expect(formatCiRun(r)).toContain("not-approved in apply, exit 3: the deploy failed with ***");
715
+ });
716
+ });