@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
@@ -22,7 +22,10 @@
22
22
  * own variables. The forge values a generated job reads from `${{ ... }}`
23
23
  * expressions or GitLab's predefined variables come from flags here:
24
24
  * `BASE_SHA` from `--base`, `PR_NUMBER` from `--pr`, and the commit from
25
- * `--commit`.
25
+ * `--commit`. The values a forge job gets from repository secrets come from
26
+ * the host's secrets file (`--secrets`, ./ci-secrets.ts, #4096), and every
27
+ * secret value is redacted from the run's events, record, copied report and
28
+ * output by the executor's `redact` option.
26
29
  *
27
30
  * The steps run in a fresh checkout of the commit: a detached `git worktree`
28
31
  * in a temporary directory outside the repository, removed when the run
@@ -37,14 +40,19 @@
37
40
  * key is given, so `chant ci tick --source runs` (#4044) judges the commit
38
41
  * from it. Its events are `kind: "pipeline"` and `runtime: "ci"`, and the
39
42
  * record and the events carry `runOrigin: { forge: "chant" }`.
43
+ *
44
+ * With a forge client (#4097) the job also sets a commit status at its
45
+ * commit: `pending` when it starts, `success` or `failure` when it settles.
46
+ * A status the forge refuses is a warning and never fails the job.
40
47
  */
41
48
 
42
49
  import { execFileSync } from "node:child_process";
43
- import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
50
+ import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
44
51
  import { tmpdir } from "node:os";
45
52
  import { dirname, join, relative, resolve, sep } from "node:path";
46
53
  import { PR_APPLY_RECORD, PR_LOOP_REPORT_DIR, prLoopJobs, prLoopSetup, PR_LOOP_IMAGE, type PrLoopJob, type PrLoopPipelineOptions } from "../components/pr-pipeline";
47
- import { detectForge, FORGE_KINDS, type ForgeKind } from "../pr-forge";
54
+ import { detectForge, ForgeEnvironmentError, forgeFromEnv, FORGE_KINDS, type CommitStatus, type ForgeKind } from "../pr-forge";
55
+ import { PR_APPLY_GATE } from "../pr-loop";
48
56
  import type { CommandContext } from "../cli/registry";
49
57
  import { formatError, formatWarning } from "../cli/format";
50
58
  import type { RunOrigin } from "../lifecycle/release-ledger";
@@ -55,11 +63,16 @@ import { shellCmd } from "../op/activities/shell";
55
63
  import { OpRunFailure, runOpLocally, type OpRunResult, type StepRecord } from "../op/local-executor";
56
64
  import type { OpRunFailureReason, OpRunRecord } from "../op/runtime";
57
65
  import type { ActivityStep, OpConfig, PhaseDefinition } from "../op/types";
66
+ import { redactText, type Redact } from "../op/redact";
58
67
  import { findWorkspaceRoot } from "../project-root";
68
+ import { parseReportUrl, reportKey } from "../pr-report-url";
59
69
  import { ciTagSigner, RUNNER_KEY_ENV } from "./ci-green";
70
+ import { ciRedactor, ciSecretsFor, CI_SECRETS_ENV, readCiSecrets, type CiSecrets } from "./ci-secrets";
71
+ import { updateCiRunIndex, type CiRunIndexEntry } from "./ci-run-index";
60
72
  import { resolveMemberContext } from "./member-pipeline";
61
73
  import { signOpRunRecord } from "./trust/op-run-statement";
62
74
  import { gitTop } from "./tree";
75
+ import { hostRunnerKey } from "./ci-runner-key";
63
76
 
64
77
  /** The two jobs a pull-request pipeline has. */
65
78
  export type CiJobName = PrLoopJob["jobName"];
@@ -115,21 +128,34 @@ export class CiRunError extends Error {
115
128
 
116
129
  /**
117
130
  * The job's environment: the runner's base variables, the forge variables
118
- * and `passEnv` names it has, `CHANT_ENV` as a generated workflow sets it,
119
- * the commit as the forge spells it, and the job's own variables. A job
120
- * variable the forge fills from an expression comes from a flag, or from
121
- * the runner's environment for a token such as `GITHUB_TOKEN`; a literal
122
- * one, such as `CHANT_RUN_EVENTS_DIR`, is kept as it is.
131
+ * and `passEnv` names it has, the job's secrets from the host's secrets file
132
+ * (#4096), `CHANT_ENV` as a generated workflow sets it, the commit as the
133
+ * forge spells it, and the job's own variables. A job variable the forge
134
+ * fills from an expression comes from a flag, or from a secret or the
135
+ * runner's environment for a token such as `GITHUB_TOKEN`; a literal one,
136
+ * such as `CHANT_RUN_EVENTS_DIR`, is kept as it is. A secret replaces the
137
+ * runner's variable of the same name, and none replaces a variable the
138
+ * runner sets for the job. `CHANT_RUNNER_KEY` is never in it.
123
139
  */
124
140
  export function ciJobEnv(
125
141
  job: PrLoopJob,
126
- input: { forge: ForgeKind; env: string; values: CiRunValues; hostEnv: Record<string, string | undefined>; passEnv?: readonly string[] },
142
+ input: {
143
+ forge: ForgeKind;
144
+ env: string;
145
+ values: CiRunValues;
146
+ hostEnv: Record<string, string | undefined>;
147
+ passEnv?: readonly string[];
148
+ /** The secrets this job gets, from {@link ciSecretsFor}. */
149
+ secrets?: Record<string, string>;
150
+ },
127
151
  ): Record<string, string> {
152
+ if (input.passEnv?.includes(RUNNER_KEY_ENV)) throw new CiRunError(`--pass-env names ${RUNNER_KEY_ENV}, and the runner key is never passed to a job`);
128
153
  const out: Record<string, string> = {};
129
154
  for (const name of [...CI_RUN_BASE_ENV, ...CI_RUN_FORGE_ENV, ...(input.passEnv ?? [])]) {
130
155
  const value = input.hostEnv[name];
131
156
  if (value !== undefined) out[name] = value;
132
157
  }
158
+ for (const [name, value] of Object.entries(input.secrets ?? {})) if (name !== RUNNER_KEY_ENV) out[name] = value;
133
159
  out.CHANT_ENV = input.env;
134
160
  out[input.forge === "gitlab" ? "CI_COMMIT_SHA" : "GITHUB_SHA"] = input.values.commit;
135
161
  const flags: Record<string, { value: string | undefined; flag: string }> = {
@@ -143,10 +169,13 @@ export function ciJobEnv(
143
169
  out[name] = given.value;
144
170
  } else if (!value.includes("$")) {
145
171
  out[name] = value;
172
+ } else if (input.secrets?.[name] !== undefined) {
173
+ out[name] = input.secrets[name]!;
146
174
  } else if (input.hostEnv[name] !== undefined) {
147
175
  out[name] = input.hostEnv[name]!;
148
176
  }
149
177
  }
178
+ delete out[RUNNER_KEY_ENV];
150
179
  return out;
151
180
  }
152
181
 
@@ -266,6 +295,77 @@ export function ciJobFailure(input: { job: CiJobName; op: OpConfig; records: rea
266
295
  return { code: "command-failed", reason: firstLine(failed.error ?? "the command failed"), phase: failed.phase, ...exit };
267
296
  }
268
297
 
298
+ /** What posts a job's commit status (#4097): the `setCommitStatus` of a GitHub or Forgejo client. */
299
+ export interface CiStatusClient {
300
+ setCommitStatus(sha: string, status: CommitStatus): Promise<void>;
301
+ }
302
+
303
+ /** Where a job's status goes (#4097). */
304
+ export interface CiStatusConfig {
305
+ client: CiStatusClient;
306
+ /** `--status-url`: the link the status carries, `{key}` standing for `<pr>/<commit>[/<member>]`. Otherwise an https `--report-url` is used. */
307
+ urlTemplate?: string;
308
+ }
309
+
310
+ /** A status description is cut to what GitHub and Forgejo keep. */
311
+ const STATUS_DESCRIPTION = 140;
312
+
313
+ const cut = (text: string, max = STATUS_DESCRIPTION): string => (text.length > max ? `${text.slice(0, max - 3)}...` : text);
314
+
315
+ /**
316
+ * A job's status context: `chant/pr-plan` for the plan, and for the apply
317
+ * `chant/<gate>`, which is `chant/pr-apply` unless `--gate` renamed the gate.
318
+ * pr-plan's own status is `chant/plan` (../pr-loop.ts), a different context.
319
+ */
320
+ export function ciStatusContext(job: CiJobName, gate?: string): string {
321
+ return job === "apply" ? `chant/${gate ?? PR_APPLY_GATE}` : `chant/${CI_JOB_OPS.plan}`;
322
+ }
323
+
324
+ /**
325
+ * The link a job's status carries: the `--status-url` template with `{key}`
326
+ * filled, else the https `--report-url` followed by the key and a slash, else
327
+ * none (an `s3://` or `file://` URL is not one a browser opens).
328
+ */
329
+ export function ciStatusUrl(input: { urlTemplate?: string; reportUrl?: string; pr?: number; commit: string; member?: string }): string | undefined {
330
+ const key = reportKey(input.pr ?? null, input.commit, input.member);
331
+ if (input.urlTemplate) return input.urlTemplate.split("{key}").join(key);
332
+ const url = input.reportUrl?.trim();
333
+ if (url && /^https:\/\//.test(url)) return `${url.replace(/\/+$/, "")}/${key}/`;
334
+ return undefined;
335
+ }
336
+
337
+ /** What the settled status says: a short summary, or on failure the record's code and reason. */
338
+ export function ciStatusSettled(op: string, result: Pick<CiRunResult, "status" | "record" | "failure">, seconds: number): Pick<CommitStatus, "state" | "description"> {
339
+ if (result.status === "ok") return { state: "success", description: cut(`${op} passed in ${seconds}s`) };
340
+ const why = result.record.failure;
341
+ const text = why ? `${why.code}: ${why.reason}` : result.failure ? `${result.failure.phase}: ${firstLine(result.failure.error)}` : `${op} failed`;
342
+ return { state: "failure", description: cut(text) };
343
+ }
344
+
345
+ /**
346
+ * The status client for `chant ci run` and `chant ci serve` (#4097), or none:
347
+ * off with `--no-status`, on GitLab (its client has no commit status call
348
+ * here), and without a forge token. A token with the rest of the forge's
349
+ * variables missing gives a warning instead.
350
+ */
351
+ export function ciStatusFromEnv(input: { forge: ForgeKind; hostEnv: Record<string, string | undefined>; noStatus?: boolean; statusUrl?: string }): { status?: CiStatusConfig; warning?: string } {
352
+ if (input.noStatus || input.forge === "gitlab") return {};
353
+ if (input.statusUrl !== undefined && !/^https?:\/\/\S+$/.test(input.statusUrl)) {
354
+ throw new CiRunError(`--status-url takes an http or https URL template such as https://host/reports/{key}, not ${JSON.stringify(input.statusUrl)}`);
355
+ }
356
+ const env = input.hostEnv;
357
+ const hasToken = Boolean(env.CHANT_FORGE_TOKEN ?? env.GITHUB_TOKEN ?? env.GH_TOKEN);
358
+ if (!hasToken) return {};
359
+ try {
360
+ const client = forgeFromEnv(input.forge, env);
361
+ if (!client.setCommitStatus) return {};
362
+ return { status: { client: { setCommitStatus: client.setCommitStatus.bind(client) }, ...(input.statusUrl ? { urlTemplate: input.statusUrl } : {}) } };
363
+ } catch (err) {
364
+ if (err instanceof ForgeEnvironmentError) return { warning: `no commit status will be set: ${err.message}` };
365
+ throw err;
366
+ }
367
+ }
368
+
269
369
  /** What `chant ci run` runs. */
270
370
  export interface CiRunRequest {
271
371
  /** Where the command was run: the project, or a workspace member's directory. */
@@ -290,8 +390,16 @@ export interface CiRunRequest {
290
390
  passEnv?: readonly string[];
291
391
  /** The runner's environment. Default `process.env`. */
292
392
  hostEnv?: Record<string, string | undefined>;
393
+ /**
394
+ * The host's secrets file, read (#4096, `--secrets`): the job gets the
395
+ * secrets its allowlist names, and every value in it is redacted from the
396
+ * run's events, record, copied report and output.
397
+ */
398
+ secrets?: CiSecrets;
293
399
  /** `--output`: where the job's report is copied, from `cwd`. Default `.chant/pr`. */
294
400
  output?: string;
401
+ /** `--run-index-limit`: how many runs the index at `loop.reportUrl` keeps (#4098). Default 200. */
402
+ runIndexLimit?: number;
295
403
  /** A runner key, PEM, that signs the run's record. */
296
404
  runnerKey?: string;
297
405
  /**
@@ -315,6 +423,8 @@ export interface CiRunRequest {
315
423
  * failed. `chant ci serve` names one per `prApplyRecordKey`.
316
424
  */
317
425
  resumeRecord?: string;
426
+ /** Set a commit status at the job's commit (#4097). Absent: none is set. `chant ci run` builds it from the environment. */
427
+ status?: CiStatusConfig;
318
428
  signal?: AbortSignal;
319
429
  }
320
430
 
@@ -393,6 +503,55 @@ function freshCheckout(top: string, commit: string): { dir: string; remove(): vo
393
503
  };
394
504
  }
395
505
 
506
+ /**
507
+ * Add the settled job to the run index at its report URL (#4098). The pull
508
+ * request is the one the job's own report names, since a pr-apply finds it
509
+ * itself, else `--pr`. A warning, never a failure, when the index can't be
510
+ * updated.
511
+ */
512
+ async function recordInIndex(input: {
513
+ url: string;
514
+ job: CiJobName;
515
+ commit: string;
516
+ member?: string;
517
+ pr?: number;
518
+ record: OpRunRecord;
519
+ reportDir: string;
520
+ limit?: number;
521
+ /** The run's redactor (#4096): every string written to the index goes through it, as the copied report does. */
522
+ redact?: Redact;
523
+ warn: (message: string) => void;
524
+ }): Promise<void> {
525
+ const target = parseReportUrl(input.url);
526
+ if ("error" in target) return input.warn(`the run index is not updated: ${target.error}`);
527
+ let pr: number | null = input.pr ?? null;
528
+ try {
529
+ const file = join(input.reportDir, `${CI_JOB_OPS[input.job]}.json`);
530
+ if (existsSync(file)) {
531
+ const said = (JSON.parse(readFileSync(file, "utf-8")) as { pr?: unknown }).pr;
532
+ if (Number.isInteger(said) && (said as number) > 0) pr = said as number;
533
+ }
534
+ } catch {
535
+ // The report is not readable; the flag stands.
536
+ }
537
+ const failure = input.record.failure;
538
+ const clean = (text: string): string => (input.redact ? input.redact(text) : text);
539
+ const entry: CiRunIndexEntry = {
540
+ job: input.job,
541
+ pr,
542
+ commit: input.commit,
543
+ status: input.record.status === "ok" ? "ok" : "fail",
544
+ ...(input.record.status !== "ok" ? { failure: { code: clean(failure?.code ?? "command-failed") } } : {}),
545
+ ...(input.member ? { member: clean(input.member) } : {}),
546
+ started: input.record.started,
547
+ ended: input.record.ended,
548
+ runId: clean(input.record.id),
549
+ key: clean(reportKey(pr, input.commit, input.member)),
550
+ runOrigin: CHANT_RUNNER_ORIGIN,
551
+ };
552
+ await updateCiRunIndex({ target, entry, ...(input.limit ? { limit: input.limit } : {}), warn: input.warn });
553
+ }
554
+
396
555
  const ACTIVITIES = new Map<string, ActivityFn>([["shellCmd", shellCmd as unknown as ActivityFn]]);
397
556
 
398
557
  /**
@@ -410,7 +569,31 @@ export async function ciRun(req: CiRunRequest): Promise<CiRunResult> {
410
569
  const jobs = prLoopJobs(req.forge, req.env, req.loop ?? {}, place.member);
411
570
  const job = jobs.find((j) => j.jobName === req.job);
412
571
  if (!job) throw new CiRunError(`the pull-request pipeline has no ${req.job} job; it has ${jobs.map((j) => j.jobName).join(" and ")}`);
413
- const jobEnv = ciJobEnv(job, { forge: req.forge, env: req.env, values: { base: req.base, pr: req.pr, commit }, hostEnv, passEnv: req.passEnv });
572
+ const jobEnv = ciJobEnv(job, {
573
+ forge: req.forge,
574
+ env: req.env,
575
+ values: { base: req.base, pr: req.pr, commit },
576
+ hostEnv,
577
+ passEnv: req.passEnv,
578
+ secrets: ciSecretsFor(req.secrets, req.job),
579
+ });
580
+ // #4096: the secrets and the job's secret-looking variables, redacted from everything the run writes and returns.
581
+ const redact = ciRedactor(jobEnv, req.secrets);
582
+ try {
583
+ const result = await runCiJob(req, { cwd, top, commit, place, job, jobEnv, ...(redact ? { redact } : {}) });
584
+ return redact ? { ...result, warnings: result.warnings.map(redact), ...(result.pushWarning ? { pushWarning: redact(result.pushWarning) } : {}) } : result;
585
+ } catch (err) {
586
+ if (redact && err instanceof Error) err.message = redact(err.message);
587
+ throw err;
588
+ }
589
+ }
590
+
591
+ /** {@link ciRun} once the job and its environment are known. */
592
+ async function runCiJob(
593
+ req: CiRunRequest,
594
+ at: { cwd: string; top: string; commit: string; place: { member?: string; dir: string }; job: PrLoopJob; jobEnv: Record<string, string>; redact?: Redact },
595
+ ): Promise<CiRunResult> {
596
+ const { cwd, top, commit, place, job, jobEnv, redact } = at;
414
597
 
415
598
  // The record is signed against .chant/trust.json at the commit the job runs, so a key that commit does not list is refused before anything runs.
416
599
  let signer: ReturnType<typeof ciTagSigner> | undefined;
@@ -443,6 +626,20 @@ export async function ciRun(req: CiRunRequest): Promise<CiRunResult> {
443
626
  mkdirSync(dirname(resume.job), { recursive: true });
444
627
  copyFileSync(resume.host, resume.job);
445
628
  }
629
+ const statusContext = ciStatusContext(req.job, req.loop?.gate);
630
+ const statusUrl = req.status
631
+ ? ciStatusUrl({ ...(req.status.urlTemplate ? { urlTemplate: req.status.urlTemplate } : {}), ...(req.loop?.reportUrl ? { reportUrl: req.loop.reportUrl } : {}), ...(req.pr !== undefined ? { pr: req.pr } : {}), commit, ...(place.member ? { member: place.member } : {}) })
632
+ : undefined;
633
+ const postStatus = async (state: CommitStatus["state"], description: string): Promise<void> => {
634
+ if (!req.status) return;
635
+ try {
636
+ await req.status.client.setCommitStatus(commit, { context: statusContext, state, description: cut(redact ? redact(description) : description), ...(statusUrl ? { targetUrl: statusUrl } : {}) });
637
+ } catch (err) {
638
+ warnings.push(`could not set the ${statusContext} status: ${message(err)}`);
639
+ }
640
+ };
641
+ const startedAt = Date.now();
642
+ await postStatus("pending", `${op.name} running`);
446
643
  let ledgerError: string | undefined;
447
644
  let result: OpRunResult;
448
645
  try {
@@ -458,14 +655,25 @@ export async function ciRun(req: CiRunRequest): Promise<CiRunResult> {
458
655
  ...(req.stepOutput ? { stepOutput: req.stepOutput } : {}),
459
656
  ...(signer ? { signRecord: (record: OpRunRecord) => signOpRunRecord(record, signer!) } : {}),
460
657
  onSignError: (err) => warnings.push(`the run was not signed: ${message(err)}`),
658
+ ...(redact ? { redact } : {}),
461
659
  onLedgerError: (err) => {
462
660
  ledgerError = `the job ran, and its record could not be written to chant/lifecycle: ${message(err)}`;
463
661
  },
464
662
  });
465
663
  } catch (err) {
466
- if (!(err instanceof OpRunFailure)) throw err;
664
+ if (!(err instanceof OpRunFailure)) {
665
+ await postStatus("error", `${op.name} stopped: ${firstLine(message(err))}`);
666
+ throw err;
667
+ }
467
668
  result = err.result;
468
669
  }
670
+ const failedStep = result.records.find((r) => r.status === "fail");
671
+ const settled = ciStatusSettled(
672
+ op.name,
673
+ { status: result.status, record: result.record, ...(failedStep ? { failure: { phase: failedStep.phase, error: failedStep.error ?? "the step failed" } } : {}) },
674
+ Math.round((Date.now() - startedAt) / 1000),
675
+ );
676
+ await postStatus(settled.state, settled.description);
469
677
 
470
678
  if (resume && existsSync(resume.job)) {
471
679
  try {
@@ -480,6 +688,10 @@ export async function ciRun(req: CiRunRequest): Promise<CiRunResult> {
480
688
  if (existsSync(written)) {
481
689
  report = resolve(cwd, req.output ?? PR_LOOP_REPORT_DIR);
482
690
  cpSync(written, report, { recursive: true });
691
+ if (redact) redactTree(report, redact);
692
+ }
693
+ if (req.loop?.reportUrl) {
694
+ await recordInIndex({ url: req.loop.reportUrl, job: req.job, commit, member: place.member, pr: req.pr, record: result.record, reportDir: written, limit: req.runIndexLimit, ...(redact ? { redact } : {}), warn: (w) => warnings.push(w) });
483
695
  }
484
696
  if (ledgerError) throw new CiRunError(ledgerError);
485
697
  const pushed: { pushed: boolean; pushWarning?: string } = req.push === false ? { pushed: false, pushWarning: "chant/lifecycle was not pushed" } : await pushLifecycleReport({ cwd: top });
@@ -501,6 +713,27 @@ export async function ciRun(req: CiRunRequest): Promise<CiRunResult> {
501
713
  }
502
714
  }
503
715
 
716
+ /**
717
+ * Redact every text file under `dir` in place (#4096): the job's report, its
718
+ * change set and its component events, as `ci run` copied them out. A
719
+ * JSON file stays JSON. A file with a NUL byte is taken as binary and left.
720
+ */
721
+ export function redactTree(dir: string, redact: Redact): void {
722
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
723
+ const path = join(dir, entry.name);
724
+ if (entry.isDirectory()) {
725
+ redactTree(path, redact);
726
+ continue;
727
+ }
728
+ if (!entry.isFile()) continue;
729
+ const bytes = readFileSync(path);
730
+ if (bytes.includes(0)) continue;
731
+ const text = bytes.toString("utf-8");
732
+ const clean = redactText(text, redact);
733
+ if (clean !== text) writeFileSync(path, clean);
734
+ }
735
+ }
736
+
504
737
  /** The text `chant ci run` prints when the run settles. */
505
738
  export function formatCiRun(r: CiRunResult): string {
506
739
  const lines = [`${r.op} ${r.status === "ok" ? "passed" : r.status === "fail" ? "failed" : r.status} at ${r.record.gitSha?.slice(0, 12) ?? "an unknown commit"}, run ${r.record.id} in ${r.record.env}`];
@@ -517,16 +750,36 @@ export function formatCiRun(r: CiRunResult): string {
517
750
 
518
751
  const USAGE =
519
752
  "chant ci run plan|apply --env <env> --base <sha> [--pr <n>] [--commit <sha>] [--forge github|gitlab|forgejo] " +
520
- "[--gate <name>] [--branch <name>] [--events-json] [--report-url <url>] [--image <image>] [--before-script <line>]... " +
521
- "[--pr-build] [--pr-build-command <line>] " +
522
- "[--chant <command>] [--pass-env <NAME>]... [--key <runner key.pem>] [-o <dir>]";
753
+ "[--gate <name>] [--branch <name>] [--events-json] [--report-url <url>] [--run-index-limit <n>] [--image <image>] [--before-script <line>]... " +
754
+ "[--pr-build] [--pr-build-command <line>] [--no-status] [--status-url <url template>] " +
755
+ "[--chant <command>] [--pass-env <NAME>]... [--secrets <file>] [--key <runner key.pem>] [-o <dir>] | chant ci run --setup-key [--trust [--principal <name>]] [--state <dir>] [--branch <name>]";
523
756
 
524
757
  const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
525
758
 
759
+ /** The host's runner key in `--state`, or in ci serve's default state directory, when `--setup-key` made one there (#4099). */
760
+ function hostRunnerKeyAt(cwd: string, statePath: string | undefined): { pem: string } | undefined {
761
+ const common = statePath ? null : (() => {
762
+ try {
763
+ return execFileSync("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }).trim();
764
+ } catch {
765
+ return null;
766
+ }
767
+ })();
768
+ const dir = statePath ? resolve(statePath) : common ? join(common, "chant", "ci-serve") : null;
769
+ return dir ? hostRunnerKey(dir) : undefined;
770
+ }
771
+
772
+ /** The secrets file `--secrets` names, else the one `CHANT_CI_SECRETS` names, read; undefined when neither names one (#4096). */
773
+ export function ciSecretsOf(flag: string | undefined, env: Record<string, string | undefined> = process.env): CiSecrets | undefined {
774
+ const file = flag ?? (env[CI_SECRETS_ENV] || undefined);
775
+ return file === undefined ? undefined : readCiSecrets(resolve(file));
776
+ }
777
+
526
778
  /** `chant ci run plan|apply` (#4094). */
527
779
  export async function runCiRun(ctx: CommandContext): Promise<number> {
528
780
  const { args } = ctx;
529
781
  try {
782
+ if (args.setupKey) return (await import("./ci-serve")).runnerKeySetupCommand(ctx);
530
783
  const job = args.extraPositional;
531
784
  if (job !== "plan" && job !== "apply") throw new CiRunError(`ci run takes the job to run, plan or apply${job ? `, not ${JSON.stringify(job)}` : ""}`);
532
785
  if (!args.env) throw new CiRunError("--env names the environment the pipeline is for, as chant build --components --generate <lexicon> --pr-loop took it: --env <env>");
@@ -535,9 +788,16 @@ export async function runCiRun(ctx: CommandContext): Promise<number> {
535
788
  if (!(FORGE_KINDS as readonly string[]).includes(forge)) throw new CiRunError(`--forge takes github, gitlab or forgejo, not ${JSON.stringify(forge)}`);
536
789
  for (const name of args.passEnv ?? []) if (!ENV_NAME.test(name)) throw new CiRunError(`--pass-env takes a variable's name, not ${JSON.stringify(name)}`);
537
790
  const fromFlag = args.key !== undefined;
538
- const runnerKey = fromFlag ? readFileSync(resolve(args.key!), "utf-8") : process.env[RUNNER_KEY_ENV] || undefined;
791
+ // #4099: without --key or CHANT_RUNNER_KEY, the host's key that --setup-key made, if there is one. Like the variable, an unlisted one only warns.
792
+ const hostKey = fromFlag || process.env[RUNNER_KEY_ENV] ? undefined : hostRunnerKeyAt(process.cwd(), args.statePath);
793
+ const runnerKey = fromFlag ? readFileSync(resolve(args.key!), "utf-8") : process.env[RUNNER_KEY_ENV] || hostKey?.pem;
794
+ const hostEnv = process.env;
795
+ const { status, warning } = ciStatusFromEnv({ forge: forge as ForgeKind, hostEnv, noStatus: args.noStatus, statusUrl: args.statusUrl });
796
+ if (warning) console.error(formatWarning({ message: warning }));
797
+ const secrets = ciSecretsOf(args.ciSecrets);
539
798
  const result = await ciRun({
540
799
  cwd: process.cwd(),
800
+ ...(status ? { status } : {}),
541
801
  job,
542
802
  env: args.env,
543
803
  forge: forge as ForgeKind,
@@ -552,10 +812,12 @@ export async function runCiRun(ctx: CommandContext): Promise<number> {
552
812
  ...(args.prBuild ? { build: true } : {}),
553
813
  ...(args.prBuildCommand ? { buildCommand: args.prBuildCommand } : {}),
554
814
  },
815
+ ...(args.runIndexLimit ? { runIndexLimit: args.runIndexLimit } : {}),
555
816
  ...(args.image ? { image: args.image } : {}),
556
817
  ...(args.beforeScript?.length ? { beforeScript: args.beforeScript } : {}),
557
818
  ...(args.ciChant ? { chant: args.ciChant } : {}),
558
819
  ...(args.passEnv?.length ? { passEnv: args.passEnv } : {}),
820
+ ...(secrets ? { secrets } : {}),
559
821
  ...(args.output ? { output: args.output } : {}),
560
822
  ...(runnerKey !== undefined ? { runnerKey, runnerKeyFromEnv: !fromFlag } : {}),
561
823
  ...(args.eventsJson ? { runEvents: (line: string) => void process.stdout.write(line) } : {}),
@@ -0,0 +1,166 @@
1
+ /**
2
+ * #4099: the runner key on a `chant ci serve` or `chant ci run` host. The
3
+ * key is made in the state directory with mode 0600, `--setup-key --trust`
4
+ * proposes its runner entry on a branch through `trust runner add` (#3510)
5
+ * and never writes the target branch, and `ci serve` refuses to start when
6
+ * `.chant/trust.json` at the target's head does not list its key, unless
7
+ * `--unsigned`.
8
+ *
9
+ * Every test works in its own temp repository with a bare origin and its own
10
+ * state directory. The jobs' `chant` is a stub script, and no job runs: the
11
+ * refusals come before the first poll.
12
+ */
13
+
14
+ import { chmodSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
15
+ import { generateKeyPairSync } from "node:crypto";
16
+ import { join } from "node:path";
17
+ import { afterEach, describe, expect, test, vi } from "vitest";
18
+ import { cleanScratch, declaration, git, repoFixture, scratchDir, writeFiles } from "./__fixtures__/contract-repo";
19
+ import { parseArgs } from "../cli/parse-args";
20
+ import type { CommandContext } from "../cli/registry";
21
+ import { ciServeRunnerKey, ensureHostRunnerKey, hostRunnerKey, RUNNER_KEY_FILE } from "./ci-runner-key";
22
+ import { runCiServe } from "./ci-serve";
23
+ import { runCiRun } from "./ci-run";
24
+ import { ciTagSigner } from "./ci-green";
25
+ import { trustWrite } from "./trust/trust-write";
26
+
27
+ const home = process.cwd();
28
+ afterEach(() => {
29
+ process.chdir(home);
30
+ vi.restoreAllMocks();
31
+ cleanScratch();
32
+ });
33
+
34
+ /** The repository every test starts from, built once: a checkout on main with a bare origin, with commits made by a fixed identity and never signed. */
35
+ const workspaceTemplate = repoFixture(() => {
36
+ const top = scratchDir("chant-runner-key-template-");
37
+ const origin = join(top, "origin");
38
+ const root = join(top, "root");
39
+ mkdirSync(origin);
40
+ mkdirSync(root);
41
+ git(origin, "init", "-q", "--bare");
42
+ git(root, "init", "-q");
43
+ git(root, "symbolic-ref", "HEAD", "refs/heads/main");
44
+ for (const [k, v] of [["user.name", "t"], ["user.email", "t@example.com"], ["commit.gpgsign", "false"]]) git(root, "config", k!, v!);
45
+ writeFiles(root, { "chant.workspace.json": declaration([{ name: "app", dir: "app", kind: "other", because: "the app" }]), "app/a.txt": "a\n" });
46
+ git(root, "remote", "add", "origin", origin);
47
+ git(root, "add", "-A");
48
+ git(root, "commit", "-q", "-m", "init");
49
+ git(root, "push", "-q", "origin", "main");
50
+ return top;
51
+ }, "chant-runner-key-");
52
+
53
+ /** A copy of the template, its origin pointed at the copy of the bare one. */
54
+ function workspace(): { root: string; origin: string; main: string } {
55
+ const top = workspaceTemplate();
56
+ const root = join(top, "root");
57
+ const origin = join(top, "origin");
58
+ git(root, "remote", "set-url", "origin", origin);
59
+ return { root, origin, main: git(root, "rev-parse", "HEAD") };
60
+ }
61
+
62
+ /** Run a ci command line in `cwd`, with what it printed. */
63
+ async function cli(cwd: string, argv: string[], run: (ctx: CommandContext) => Promise<number> = runCiServe) {
64
+ process.chdir(cwd);
65
+ const out: string[] = [];
66
+ const err: string[] = [];
67
+ vi.spyOn(console, "log").mockImplementation((s: string) => void out.push(String(s)));
68
+ vi.spyOn(console, "error").mockImplementation((s: string) => void err.push(String(s)));
69
+ const code = await run({ args: parseArgs(argv) } as unknown as CommandContext);
70
+ return { code, out: out.join("\n"), err: err.join("\n") };
71
+ }
72
+
73
+ /** A stand-in for `chant` the jobs would run; no test here reaches a job. */
74
+ function stubChant(): string {
75
+ const file = join(scratchDir("chant-runner-key-bin-"), "chant");
76
+ writeFileSync(file, "#!/bin/sh\nexit 0\n");
77
+ chmodSync(file, 0o755);
78
+ return file;
79
+ }
80
+
81
+ const SERVE = (state: string) => ["ci", "serve", "--forge", "forgejo", "--repo", "demo/estate", "--env", "local", "--once", "--state", state, "--chant", stubChant(), "--image", "node:22-bookworm"];
82
+
83
+ describe("the host's runner key (#4099)", () => {
84
+ test("--setup-key makes an Ed25519 key with mode 0600, prints its public half, and reuses it", async () => {
85
+ const { root } = workspace();
86
+ const state = join(scratchDir("chant-runner-key-state-"), "ci-serve");
87
+ const first = await cli(root, ["ci", "serve", "--setup-key", "--state", state]);
88
+ expect(first.code).toBe(0);
89
+ const path = join(state, RUNNER_KEY_FILE);
90
+ expect(statSync(path).mode & 0o777).toBe(0o600);
91
+ expect(statSync(state).mode & 0o777).toBe(0o700);
92
+ expect(readFileSync(path, "utf-8")).toContain("BEGIN PRIVATE KEY");
93
+ expect(first.out).toMatch(/^ssh-ed25519 [A-Za-z0-9+/]+=*$/);
94
+ expect(first.err).toContain("made now, mode 0600");
95
+ // It is not listed yet, so the exact command is printed.
96
+ expect(first.err).toContain(`chant workspace trust runner add chant-runner:`);
97
+ expect(first.err).toContain(`--key "${first.out}"`);
98
+
99
+ // ci run --setup-key finds the same key and makes no other.
100
+ const again = await cli(root, ["ci", "run", "--setup-key", "--state", state], runCiRun);
101
+ expect(again.code).toBe(0);
102
+ expect(again.out).toBe(first.out);
103
+ expect(again.err).toContain("already here");
104
+
105
+ // A key other users can read is refused.
106
+ chmodSync(path, 0o644);
107
+ expect(() => hostRunnerKey(state)).toThrow(/can be read by other users/);
108
+ chmodSync(path, 0o600);
109
+ expect(ensureHostRunnerKey(state).created).toBe(false);
110
+ });
111
+
112
+ test("--setup-key --trust pushes the runner entry on a branch of its own and leaves main as it was", async () => {
113
+ const { root, origin, main } = workspace();
114
+ const state = scratchDir("chant-runner-key-state-");
115
+ const r = await cli(root, ["ci", "serve", "--setup-key", "--trust", "--principal", "chant-runner:demo", "--by", "github:alex", "--state", state]);
116
+ expect(r.code, r.err).toBe(0);
117
+ expect(r.err).toContain("committed the runner entry for chant-runner:demo on chant/trust-runner/chant-runner-demo");
118
+ const branch = "chant/trust-runner/chant-runner-demo";
119
+
120
+ // main is untouched, here and on the remote; the branch holds the entry and the role.
121
+ expect(git(origin, "rev-parse", "refs/heads/main")).toBe(main);
122
+ expect(git(root, "rev-parse", "HEAD")).toBe(main);
123
+ expect(git(root, "status", "--short")).toBe("");
124
+ const trust = JSON.parse(git(origin, "show", `${branch}:.chant/trust.json`)) as { runners: { principal: string; key: string }[]; roles: { runner: string[] } };
125
+ expect(trust.runners).toEqual([{ principal: "chant-runner:demo", class: "runner", key: r.out }]);
126
+ expect(trust.roles.runner).toEqual(["chant-runner:demo"]);
127
+ expect(git(origin, "rev-parse", `${branch}^`)).toBe(main);
128
+
129
+ // Until it merges, ci serve refuses the key; once it has, the key signs as the principal.
130
+ const key = hostRunnerKey(state)!;
131
+ expect(() => ciServeRunnerKey({ top: root, stateDir: state, branch: "main" })).toThrow(/not trusted at main/);
132
+ git(root, "push", "-q", "origin", `refs/remotes/origin/${branch}:refs/heads/main`);
133
+ expect(ciServeRunnerKey({ top: root, stateDir: state, branch: "main" })).toMatchObject({ pem: key.pem, source: join(state, RUNNER_KEY_FILE) });
134
+ expect(ciTagSigner(root, git(root, "rev-parse", "refs/remotes/origin/main"), key.pem).principal).toBe("chant-runner:demo");
135
+
136
+ // Run again, it says the key is listed and proposes nothing.
137
+ const listed = await cli(root, ["ci", "serve", "--setup-key", "--trust", "--state", state]);
138
+ expect(listed.err).toContain("lists it for chant-runner:demo, with the runner role");
139
+ });
140
+
141
+ test("ci serve refuses to start with a key trust.json does not list at the target's head, and --unsigned starts without one", async () => {
142
+ const { root } = workspace();
143
+ const state = scratchDir("chant-runner-key-state-");
144
+
145
+ const none = await cli(root, SERVE(state));
146
+ expect(none.code).toBe(1);
147
+ expect(none.err).toContain("there is no runner key");
148
+
149
+ ensureHostRunnerKey(state);
150
+ const unlisted = await cli(root, SERVE(state));
151
+ expect(unlisted.code).toBe(1);
152
+ expect(unlisted.err).toMatch(/not trusted at main [0-9a-f]{12}/);
153
+ expect(unlisted.err).toContain("chant ci serve --setup-key --trust");
154
+ expect(unlisted.err).toContain("chant workspace trust runner add");
155
+
156
+ // A key listed only in the working tree, not at the head, is still refused.
157
+ const other = generateKeyPairSync("ed25519").privateKey.export({ type: "pkcs8", format: "pem" }).toString();
158
+ writeFileSync(join(state, "other.pem"), other, { mode: 0o600 });
159
+ expect(trustWrite({ cwd: root, action: "runner add", name: "ci-bot", key: join(state, "other.pem") })).toMatchObject({ changed: true });
160
+ const flagged = await cli(root, [...SERVE(state), "--key", join(state, "other.pem")]);
161
+ expect(flagged.code).toBe(1);
162
+ expect(flagged.err).toContain("lists no runner with this key");
163
+
164
+ expect(ciServeRunnerKey({ top: root, stateDir: state, branch: "main", unsigned: true })).toEqual({ fromFlag: false, source: "none (--unsigned)" });
165
+ });
166
+ });