@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
package/src/cli/main.ts CHANGED
@@ -254,7 +254,9 @@ CI:
254
254
  [--events-json] [--report-url <url>] [--image <image>]
255
255
  [--before-script <line>]... [--pr-build]
256
256
  [--pr-build-command <line>] [--chant <command>]
257
- [--pass-env <NAME>]... [--key <runner key.pem>] [-o <dir>]
257
+ [--pass-env <NAME>]... [--secrets <file>] [--key <runner key.pem>]
258
+ [-o <dir>]
259
+ [--no-status] [--status-url <url template>]
258
260
  Run the pull-request pipeline's plan or apply job as
259
261
  a pipeline Op in a fresh worktree of --commit (default
260
262
  HEAD) with a clean environment. --base and --pr stand
@@ -262,10 +264,23 @@ CI:
262
264
  the job's build and lint lines first. Records the run as
263
265
  pr-plan or pr-apply on chant/lifecycle, signed with
264
266
  --key or CHANT_RUNNER_KEY, so ci tick from runs counts
265
- it, and copies the report to -o (default .chant/pr).
267
+ it, and copies the report to -o (default .chant/pr). With a forge
268
+ token it sets a chant/pr-plan or chant/pr-apply commit
269
+ status (pending, then success or failure); --no-status
270
+ turns that off, and --status-url links it.
271
+ --secrets (or CHANT_CI_SECRETS) names a dotenv or
272
+ JSON file only its owner can read whose values the
273
+ job gets, and which are shown as *** in the events,
274
+ the record, the report and the output.
275
+ With --report-url it also adds the run to
276
+ <url>/runs/index.json, newest first, cut to the
277
+ newest --run-index-limit entries (default 200).
278
+ ci runs --report-url <url> [--json]
279
+ List the runs in the index at the report URL, newest
280
+ first.
266
281
  ci serve --forge forgejo|github --repo <owner/repo> --env <env>
267
282
  [--interval <s>] [--max-jobs <n>] [--state <dir>]
268
- [--author <login>]... [--once] [--rerun-failed]
283
+ [--author <login>]... [--once] [--rerun-failed] [--unsigned]
269
284
  [ci run's job flags]
270
285
  Poll the forge for open pull requests and the target
271
286
  branch's head, and run ci run plan for each new pull
@@ -275,7 +290,15 @@ CI:
275
290
  each apply's resume record there, and runs at most
276
291
  --max-jobs (default 1) jobs at once. --once runs one
277
292
  poll and waits for its jobs. --rerun-failed runs a
278
- failed apply or plan again.
293
+ failed apply or plan again. Refuses to start when its
294
+ runner key is not listed in .chant/trust.json at the
295
+ target's head, unless --unsigned
296
+ ci serve|run --setup-key [--trust [--principal <name>]] [--state <dir>] [--branch <name>]
297
+ Make the host's runner key (Ed25519, mode 0600) in the
298
+ state directory when there is none and print its public
299
+ key. --trust commits its runner entry on a branch of its
300
+ own and pushes it for review; the target branch is never
301
+ written
279
302
  ci workflow [--workflow <name>]... [--output <file>] Write
280
303
  [--chant <command>] [--install <command>]
281
304
  [--token-secret <NAME> | --app-id-var <VAR> --app-key-secret <NAME>]
@@ -701,6 +724,13 @@ Workspace (level 1, #2524):
701
724
  threshold of the old one. A commit an agent run made
702
725
  is attested by the run's signed statement; with
703
726
  attested-runs, only those commits must be
727
+ workspace trust runner add <principal> --key <ssh-ed25519 line or file> [--class runner|service] [--by <principal>] [--expect <digest>] [--dry-run]
728
+ workspace trust runner remove <principal> [--by <principal>] [--expect <digest>] [--dry-run]
729
+ List a runner's key under runners in .chant/trust.json,
730
+ granting a runner the runner role, or take both away,
731
+ editing the file in place. --expect refuses the write
732
+ when the file's digest has changed. Prints the
733
+ trust-write document and the new digest; never commits
704
734
  workspace signers [--json] [rotate [--threshold <n>] | sign --key <file>]
705
735
  Show the signer history at base, or propose the next
706
736
  signer set and sign it with a current signer's key
@@ -1335,6 +1365,8 @@ export const commandRegistry: CommandDef[] = [
1335
1365
  { name: "ci workflow", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/ci-green-cli")).runCiWorkflow(ctx) },
1336
1366
  // #4094: one pull-request job as a pipeline Op in a fresh checkout, the chant runner's first slice.
1337
1367
  { name: "ci run", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/ci-run")).runCiRun(ctx) },
1368
+ // #4098: the run index at the report URL.
1369
+ { name: "ci runs", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/ci-run-index")).runCiRuns(ctx) },
1338
1370
  { name: "ci serve", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/ci-serve")).runCiServe(ctx) },
1339
1371
  // #4026: the release cut from a green commit (ws-104). Imported on first use.
1340
1372
  { name: "release cut", runsNoConfig: true, handler: async (ctx) => (await import("./handlers/release")).runReleaseCut(ctx) },
@@ -1439,6 +1471,8 @@ export const commandRegistry: CommandDef[] = [
1439
1471
  // #2548 — an agent session's member, write scope and spec, for a session that resumes.
1440
1472
  { name: "workspace agent", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/agent-cli")).runWorkspaceAgent(ctx) },
1441
1473
  { name: "workspace verify", handler: async (ctx) => (await import("../workspace/trust/verify-cli")).runWorkspaceVerify(ctx) },
1474
+ // #3510 — a runner entry of .chant/trust.json, written through chant so a box repo never edits it by hand (ws-074).
1475
+ { name: "workspace trust", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/trust/trust-write")).runWorkspaceTrust(ctx) },
1442
1476
  { name: "workspace signers", handler: async (ctx) => (await import("../workspace/trust/signers-cli")).runWorkspaceSigners(ctx) },
1443
1477
  { name: "workspace evidence", handler: async (ctx) => (await import("../workspace/trust/evidence-cli")).runWorkspaceEvidence(ctx) },
1444
1478
 
@@ -66,6 +66,9 @@ const BOOLEAN_FLAGS = new Set([
66
66
  "--confirm-prod",
67
67
  "--once",
68
68
  "--rerun-failed",
69
+ "--setup-key",
70
+ "--trust",
71
+ "--unsigned",
69
72
  "--expire",
70
73
  "--check-live",
71
74
  "--check-snapshot",
@@ -388,6 +391,13 @@ export function parseArgs(args: string[]): ParsedArgs {
388
391
  const url = args[++i];
389
392
  if (!url || url.startsWith("-")) throw new Error("--report-url needs a URL: --report-url s3://<bucket>/<prefix> or file://<directory>");
390
393
  result.reportUrl = url;
394
+ } else if (arg === "--no-status") {
395
+ // `chant ci run|serve --no-status` (#4097): post no commit status for the job.
396
+ result.noStatus = true;
397
+ } else if (arg === "--status-url") {
398
+ // `chant ci run|serve --status-url <template>` (#4097): the link a job's status carries, in place of an https --report-url.
399
+ result.statusUrl = args[++i];
400
+ if (!result.statusUrl || result.statusUrl.startsWith("-")) throw new Error("--status-url needs a URL template: --status-url https://host/reports/{key}");
391
401
  } else if (arg === "--allow-unsigned") {
392
402
  // `chant release publish --allow-unsigned` (#4049).
393
403
  result.allowUnsigned = true;
@@ -536,10 +546,19 @@ export function parseArgs(args: string[]): ParsedArgs {
536
546
  const name = args[++i];
537
547
  if (!name || name.startsWith("-")) throw new Error("--pass-env needs a variable's name, such as AWS_PROFILE: --pass-env <NAME>");
538
548
  (result.passEnv ??= []).push(name);
549
+ } else if (arg === "--secrets") {
550
+ // `chant ci run|serve --secrets <file>` (#4096): the host's secrets file, readable only by its owner.
551
+ result.ciSecrets = args[++i];
552
+ if (!result.ciSecrets || result.ciSecrets.startsWith("-")) throw new Error("--secrets needs the host's secrets file, a dotenv or JSON file only its owner can read: --secrets <file>");
539
553
  } else if (arg === "--repo") {
540
554
  // `chant ci serve --repo <owner/repo>` (#4095): the repository whose pull requests and target branch it polls.
541
555
  result.ciRepo = args[++i];
542
556
  if (!result.ciRepo || !/^[^/\s-][^/\s]*\/[^/\s]+$/.test(result.ciRepo)) throw new Error("--repo needs the repository as owner/name: --repo <owner/repo>");
557
+ } else if (arg === "--run-index-limit") {
558
+ // `chant ci run --run-index-limit <n>` (#4098): how many runs the index at the report URL keeps.
559
+ const n = Number(args[++i]);
560
+ if (!Number.isInteger(n) || n < 1) throw new Error("--run-index-limit needs a whole number of runs, 1 or more: --run-index-limit <n>");
561
+ result.runIndexLimit = n;
543
562
  } else if (arg === "--max-jobs") {
544
563
  // `chant ci serve --max-jobs <n>` (#4095): how many jobs run at once.
545
564
  const n = Number(args[++i]);
@@ -576,6 +595,10 @@ export function parseArgs(args: string[]): ParsedArgs {
576
595
  // `chant workspace records review <id> --verdict agree|dissent|abstain` (#2670)
577
596
  result.verdict = args[++i];
578
597
  if (!result.verdict || result.verdict.startsWith("-")) throw new Error("--verdict needs agree, dissent or abstain");
598
+ } else if (arg === "--class") {
599
+ // `chant workspace trust runner add <principal> --class runner|service` (#3510).
600
+ result.runnerClass = args[++i];
601
+ if (!result.runnerClass || result.runnerClass.startsWith("-")) throw new Error("--class needs runner or service: --class <class>");
579
602
  } else if (arg === "--by") {
580
603
  // `chant workspace records review <id> --by <principal>` (#2670): whoever the caller says.
581
604
  result.by = args[++i];
@@ -748,6 +771,19 @@ export function parseArgs(args: string[]): ParsedArgs {
748
771
  }
749
772
  } else if (arg === "--once") {
750
773
  result.once = true;
774
+ } else if (arg === "--setup-key") {
775
+ // `chant ci serve|run --setup-key` (#4099): make the host's runner key and print its public half.
776
+ result.setupKey = true;
777
+ } else if (arg === "--trust") {
778
+ // `chant ci serve|run --setup-key --trust` (#4099): list the key in .chant/trust.json on a branch.
779
+ result.trustKey = true;
780
+ } else if (arg === "--unsigned") {
781
+ // `chant ci serve --unsigned` (#4099): serve without a runner key listed at the target's head.
782
+ result.unsigned = true;
783
+ } else if (arg === "--principal") {
784
+ // `chant ci serve|run --setup-key --trust --principal <name>` (#4099): the runner's principal.
785
+ result.ciPrincipal = args[++i];
786
+ if (!result.ciPrincipal || result.ciPrincipal.startsWith("-")) throw new Error("--principal needs the runner's principal: --principal <name>");
751
787
  } else if (arg === "--work") {
752
788
  // `chant run <op> --work <id>` (#2748)
753
789
  const value = args[++i];
@@ -462,8 +462,16 @@ export interface ParsedArgs {
462
462
  commit?: string;
463
463
  /** `chant ci run --pass-env <NAME>`, repeatable (#4094): a variable of the runner's environment the job gets, beside the few it always gets. */
464
464
  passEnv?: string[];
465
+ /** `chant ci run|serve --secrets <file>` (#4096): the host's secrets file. Default the file `CHANT_CI_SECRETS` names. */
466
+ ciSecrets?: string;
465
467
  /** `chant ci workflow --chant <command>` (#3573): the command the tick step runs chant with, in place of the npx pin. Also `chant ci run --chant` (#4094): what runs chant in the job. */
466
468
  ciChant?: string;
469
+ /** `chant ci run|serve --no-status` (#4097): post no commit status for the job. */
470
+ noStatus?: boolean;
471
+ /** `chant ci run|serve --status-url <template>` (#4097): the link a job's status carries. `{key}` is `<pr>/<commit>[/<member>]`, as `--report-url` keys it. */
472
+ statusUrl?: string;
473
+ /** `chant ci run --run-index-limit <n>` (#4098): how many runs the index at the report URL keeps. Default 200. */
474
+ runIndexLimit?: number;
467
475
  /** `chant ci serve --repo <owner/repo>` (#4095): the repository it polls. */
468
476
  ciRepo?: string;
469
477
  /** `chant ci serve --max-jobs <n>` (#4095): how many jobs run at once. Default 1. */
@@ -490,6 +498,16 @@ export interface ParsedArgs {
490
498
  * `records-write.ts`.
491
499
  */
492
500
  by?: string;
501
+ /** `chant ci serve|run --setup-key` (#4099): make the host's runner key in the state directory and print its public half. */
502
+ setupKey?: boolean;
503
+ /** `chant ci serve|run --setup-key --trust` (#4099): list the runner key in .chant/trust.json on a branch pushed for review. */
504
+ trustKey?: boolean;
505
+ /** `chant ci serve --unsigned` (#4099): start without a runner key listed in .chant/trust.json at the target's head. */
506
+ unsigned?: boolean;
507
+ /** `chant ci serve|run --setup-key --trust --principal <name>` (#4099): the principal the runner key is listed for. */
508
+ ciPrincipal?: string;
509
+ /** `chant workspace trust runner add <principal> --class runner|service` (#3510): the runner entry's class. */
510
+ runnerClass?: string;
493
511
  /**
494
512
  * `chant workspace records review <id> --sign [<key file>]` (#2687): the key that seals the verdict, or true for git's user.signingkey.
495
513
  * On `records new` and `records amend`, the key that seals the record's author (#2688).
@@ -56,6 +56,7 @@ import { LiveRun, currentLiveRun, withLiveRun } from "./run-live";
56
56
  import { runEnvOf } from "../lifecycle/run-ledger";
57
57
  import { resolveCommitSha } from "../lifecycle/commit-sha";
58
58
  import { resolveCiRun, type RunOrigin } from "../lifecycle/release-ledger";
59
+ import { redactDeep, redactText, type Redact } from "./redact";
59
60
  import { runEventsOfStepRecord, type RunEventKind, type RunEventRuntime, type RunEventSkipReason } from "../run-event";
60
61
  import { openRunEventStore, teeLines } from "../run-event-store";
61
62
  import { openRunEventStream, runEventRunOf, type RunEventSink, type RunEventStream } from "../run-event-stream";
@@ -1377,6 +1378,17 @@ export interface RunOpOptions {
1377
1378
  * needs one. The local runtime passes the configured lexicons' contracts.
1378
1379
  */
1379
1380
  contracts?: ReadonlyMap<string, ActivityContract>;
1381
+ /**
1382
+ * Redacts secret values (#4096) from what the run writes about itself:
1383
+ * each {@link runEvents} line and each line kept on this host, each
1384
+ * {@link stepOutput} line and step label, and the run record, its step
1385
+ * records and its `failure`, before {@link signRecord} signs it and the
1386
+ * ledger stores it. The step records on the result are redacted too. This
1387
+ * is the one place a run's writes pass through, so a writer added here
1388
+ * later is redacted with the rest. `chant ci run` passes one built from
1389
+ * the host's secrets file (../workspace/ci-secrets.ts).
1390
+ */
1391
+ redact?: Redact;
1380
1392
  }
1381
1393
 
1382
1394
  /**
@@ -1437,7 +1449,11 @@ export async function runOpLocally(
1437
1449
  const events: { stream?: RunEventStream } = {};
1438
1450
  try {
1439
1451
  const stepConsole = options.stepOutput
1440
- ? { write: options.stepOutput, ...(options.stepHeartbeatMs !== undefined ? { heartbeatMs: options.stepHeartbeatMs } : {}) }
1452
+ ? {
1453
+ write: options.stepOutput,
1454
+ ...(options.stepHeartbeatMs !== undefined ? { heartbeatMs: options.stepHeartbeatMs } : {}),
1455
+ ...(options.redact ? { redact: options.redact } : {}),
1456
+ }
1441
1457
  : undefined;
1442
1458
  return await withLiveRun(live, () =>
1443
1459
  withRunConsole(stepConsole, () =>
@@ -1478,7 +1494,10 @@ async function runOpInTurn(
1478
1494
  const store = options.ledger
1479
1495
  ? await openRunEventStore(options.ledger.cwd ?? options.cwd ?? process.cwd(), runId)
1480
1496
  : undefined;
1481
- const eventsWrite = teeLines(options.runEvents?.write, store?.write.bind(store));
1497
+ const teed = teeLines(options.runEvents?.write, store?.write.bind(store));
1498
+ // #4096: every event line, to the caller and to this host's store alike, is redacted once, here.
1499
+ const redact = options.redact;
1500
+ const eventsWrite = teed && redact ? (line: string) => teed(redactText(line, redact)) : teed;
1482
1501
  const stream = eventsWrite
1483
1502
  ? openRunEventStream(
1484
1503
  runEventRunOf({
@@ -1620,7 +1639,7 @@ async function runOpInTurn(
1620
1639
  point?: WaitingPoint,
1621
1640
  ): Promise<OpRunRecord> => {
1622
1641
  const ended = options.now ?? new Date().toISOString();
1623
- const input = buildRunRecord(config, settled, {
1642
+ let input = buildRunRecord(config, settled, {
1624
1643
  started: startedAt,
1625
1644
  ended,
1626
1645
  status,
@@ -1638,6 +1657,15 @@ async function runOpInTurn(
1638
1657
  const failure = options.describeFailure(settled);
1639
1658
  if (failure) input.failure = failure;
1640
1659
  }
1660
+ // #4096: the record is redacted before it is signed, so the stored record is the one the statement covers.
1661
+ // The step records on the result are redacted in place, so a caller printing them prints no secret.
1662
+ if (redact) {
1663
+ input = redactDeep(input, redact);
1664
+ for (const r of settled) {
1665
+ if (r.error !== undefined) r.error = redact(r.error);
1666
+ if (r.args) r.args = redactDeep(r.args, redact);
1667
+ }
1668
+ }
1641
1669
  let record: OpRunRecord = { version: 1, ...input, id: runId };
1642
1670
  if (options.signRecord) {
1643
1671
  try {
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Redaction of secret values (#4096): what a run writes about itself, with
3
+ * each secret value replaced by `***`.
4
+ *
5
+ * The executor (./local-executor.ts) takes a {@link Redact} and applies it
6
+ * where a run writes: each run-event line, each line a step prints, and the
7
+ * run record before it is signed and stored. `chant ci run` builds one from
8
+ * the host's secrets file and the job's secret-looking variables
9
+ * (../workspace/ci-secrets.ts).
10
+ *
11
+ * A value shorter than {@link REDACT_MIN_LENGTH} characters is not
12
+ * redacted: a two- or three-character value matches too much ordinary text.
13
+ */
14
+
15
+ /** What every redacted value is replaced with. */
16
+ export const REDACTED = "***";
17
+
18
+ /** The shortest value redacted. Shorter values are left as they are. */
19
+ export const REDACT_MIN_LENGTH = 4;
20
+
21
+ /** A text with every secret value it contains replaced by {@link REDACTED}. */
22
+ export type Redact = (text: string) => string;
23
+
24
+ const escapeRegExp = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
25
+
26
+ /**
27
+ * The redactor for `values`, or undefined when none is long enough to
28
+ * redact. A value that spans lines is also redacted line by line, since
29
+ * step output reaches a reader one line at a time.
30
+ */
31
+ export function redactorOf(values: Iterable<string>): Redact | undefined {
32
+ const all = new Set<string>();
33
+ const add = (v: string): void => {
34
+ if (v.length < REDACT_MIN_LENGTH) return;
35
+ all.add(v);
36
+ // As a JSON string spells it, for a value with a quote, a backslash or a control character in it.
37
+ all.add(JSON.stringify(v).slice(1, -1));
38
+ };
39
+ for (const value of values) {
40
+ add(value);
41
+ if (value.includes("\n")) for (const line of value.split(/\r?\n/)) add(line.trim());
42
+ }
43
+ if (all.size === 0) return undefined;
44
+ // Longest first, so a value that contains another is replaced whole.
45
+ const pattern = new RegExp([...all].sort((a, b) => b.length - a.length).map(escapeRegExp).join("|"), "g");
46
+ return (text) => (typeof text === "string" && text.length >= REDACT_MIN_LENGTH ? text.replace(pattern, REDACTED) : text);
47
+ }
48
+
49
+ /** `value` with `redact` applied to every string in it, keys included. Numbers, booleans and nulls are kept. */
50
+ export function redactDeep<T>(value: T, redact: Redact): T {
51
+ if (typeof value === "string") return redact(value) as T;
52
+ if (Array.isArray(value)) return value.map((v) => redactDeep(v, redact)) as T;
53
+ if (value !== null && typeof value === "object") {
54
+ const out: Record<string, unknown> = {};
55
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) out[redact(k)] = redactDeep(v, redact);
56
+ return out as T;
57
+ }
58
+ return value;
59
+ }
60
+
61
+ /**
62
+ * A JSON value's text, as `JSON.stringify` wrote it, with `redact` applied to
63
+ * its strings: parsed, redacted and written again, so a short value such as
64
+ * `true` that is also a secret doesn't break the document. Undefined when
65
+ * `text` is not JSON.
66
+ */
67
+ function redactJsonText(text: string, redact: Redact): string | undefined {
68
+ let parsed: unknown;
69
+ try {
70
+ parsed = JSON.parse(text);
71
+ } catch {
72
+ return undefined;
73
+ }
74
+ if (parsed === null || typeof parsed !== "object") return undefined;
75
+ const indent = /^[[{]\r?\n(\s+)/.exec(text)?.[1]?.length;
76
+ const trailing = /\s*$/.exec(text)?.[0] ?? "";
77
+ return `${JSON.stringify(redactDeep(parsed, redact), null, indent)}${trailing}`;
78
+ }
79
+
80
+ /**
81
+ * A file's or line's text redacted: a JSON document as JSON, each line of a
82
+ * JSON Lines text as JSON, and any other text, or line, as text. The text
83
+ * comes back unchanged when it holds no secret.
84
+ */
85
+ export function redactText(text: string, redact: Redact): string {
86
+ if (redact(text) === text) return text;
87
+ const whole = redactJsonText(text, redact);
88
+ if (whole !== undefined) return whole;
89
+ return text
90
+ .split("\n")
91
+ .map((line) => (redact(line) === line ? line : (redactJsonText(line, redact) ?? redact(line))))
92
+ .join("\n");
93
+ }
@@ -41,6 +41,8 @@ interface StepConsole {
41
41
  export interface RunConsole {
42
42
  write: (line: string) => void;
43
43
  heartbeatMs?: number;
44
+ /** Applied to each line before it is written, and to a step's call before it is cut to a label (#4096). */
45
+ redact?: (text: string) => string;
44
46
  }
45
47
 
46
48
  const store = new AsyncLocalStorage<StepConsole>();
@@ -60,7 +62,10 @@ function formatArgs(args: Record<string, unknown>): string {
60
62
  /** The name a step's lines carry: its id, else `fn(args)` on one line, cut to {@link MAX_LABEL}. */
61
63
  export function stepLabel(fn: string, args: Record<string, unknown>, id?: string): string {
62
64
  if (id) return id;
63
- const call = `${fn}(${formatArgs(args)})`.replace(/\s+/g, " ");
65
+ // A secret in the call is redacted before the label is cut, so a cut never leaves part of one.
66
+ const redact = runStore.getStore()?.redact;
67
+ const raw = `${fn}(${formatArgs(args)})`;
68
+ const call = (redact ? redact(raw) : raw).replace(/\s+/g, " ");
64
69
  return call.length > MAX_LABEL ? `${call.slice(0, MAX_LABEL - 4)}...)` : call;
65
70
  }
66
71
 
@@ -72,7 +77,7 @@ export function stepLabel(fn: string, args: Record<string, unknown>, id?: string
72
77
  export async function withStepConsole<T>(opts: { phase: string; label: string }, fn: () => Promise<T>): Promise<T> {
73
78
  const run = runStore.getStore();
74
79
  if (!run) return fn();
75
- const { write } = run;
80
+ const write = run.redact ? (line: string) => run.write(run.redact!(line)) : run.write;
76
81
  const prefix = `[${opts.phase}] ${opts.label}`;
77
82
  const started = Date.now();
78
83
  let spoke = false;
@@ -912,3 +912,32 @@ describe("pull requests over REST (#3798)", () => {
912
912
  await expect(forgejoForge({ ...fjOpts, fetch }).openPullRequest({ head: "h", title: "t", body: "b" })).rejects.toThrow(/no default branch.*pass base/);
913
913
  });
914
914
  });
915
+
916
+ describe("setCommitStatus, a runner job's status (#4097)", () => {
917
+ const long = "x".repeat(300);
918
+
919
+ test("GitHub posts state, context, a description cut to 140 and target_url", async () => {
920
+ const { fetch, calls } = fakeFetch({ [`POST /repos/acme/infra/statuses/${SHA}`]: {} });
921
+ const forge = githubForge({ apiBase: "https://api.github.com", repo: "acme/infra", token: "t0k", fetch });
922
+ await forge.setCommitStatus!(SHA, { context: "chant/pr-plan", state: "failure", description: long, targetUrl: "https://host/reports/7/abc/" });
923
+ expect(calls).toHaveLength(1);
924
+ expect(calls[0]).toMatchObject({ method: "POST", url: `https://api.github.com/repos/acme/infra/statuses/${SHA}` });
925
+ expect(calls[0].headers.Authorization).toBe("Bearer t0k");
926
+ expect(calls[0].body).toEqual({ state: "failure", context: "chant/pr-plan", description: "x".repeat(140), target_url: "https://host/reports/7/abc/" });
927
+ });
928
+
929
+ test("Forgejo posts to /repos/{owner}/{repo}/statuses/{sha} under /api/v1, and omits target_url when there is none", async () => {
930
+ const { fetch, calls } = fakeFetch({ [`POST /api/v1/repos/acme/infra/statuses/${SHA}`]: {} });
931
+ const forge = forgejoForge({ apiBase: "https://codeberg.org/api/v1", repo: "acme/infra", token: "t0k", host: "codeberg.org", fetch });
932
+ await forge.setCommitStatus!(SHA, { context: "chant/pr-apply", state: "pending", description: "pr-apply running" });
933
+ expect(calls[0]).toMatchObject({ method: "POST", url: `https://codeberg.org/api/v1/repos/acme/infra/statuses/${SHA}` });
934
+ expect(calls[0].headers.Authorization).toBe("token t0k");
935
+ expect(calls[0].body).toEqual({ state: "pending", context: "chant/pr-apply", description: "pr-apply running" });
936
+ });
937
+
938
+ test("a refusal throws, and GitLab's client has none", async () => {
939
+ const { fetch } = fakeFetch({ [`POST /repos/acme/infra/statuses/${SHA}`]: 403 });
940
+ await expect(githubForge({ apiBase: "https://api.github.com", repo: "acme/infra", token: "t", fetch }).setCommitStatus!(SHA, { context: "c", state: "success", description: "d" })).rejects.toBeInstanceOf(ForgeApiError);
941
+ expect(gitlabForge({ apiBase: "https://gl/api/v4", project: "42", token: "t", fetch }).setCommitStatus).toBeUndefined();
942
+ });
943
+ });
package/src/pr-forge.ts CHANGED
@@ -61,6 +61,16 @@ export interface ForgeStatus {
61
61
  pipelineId?: number;
62
62
  }
63
63
 
64
+ /** A runner job's commit status (#4097): the states GitHub and Forgejo both keep. */
65
+ export interface CommitStatus {
66
+ context: string;
67
+ state: "pending" | "success" | "failure" | "error";
68
+ /** Cut to 140 characters. */
69
+ description: string;
70
+ /** Where the status links to. Omitted from the request when absent. */
71
+ targetUrl?: string;
72
+ }
73
+
64
74
  /** A commit status's state, as a caller names it. */
65
75
  export type ForgeStatusState = "pending" | "running" | "success" | "failed" | "failure" | "canceled" | "error";
66
76
 
@@ -309,6 +319,14 @@ export interface PrForge {
309
319
  */
310
320
  addComment(pr: number, body: string): Promise<AddedComment>;
311
321
  setStatus(sha: string, status: ForgeStatus): Promise<void>;
322
+ /**
323
+ * GitHub and Forgejo (#4097): set a commit status from a runner's job, one
324
+ * of `pending`, `success`, `failure` or `error`. The request is the one
325
+ * {@link setStatus} sends (`POST /repos/{owner}/{repo}/statuses/{sha}`), with
326
+ * the description cut to 140 characters and `target_url` only when given.
327
+ * `chant ci run` posts a job's progress through it.
328
+ */
329
+ setCommitStatus?(sha: string, status: CommitStatus): Promise<void>;
312
330
  /**
313
331
  * Keep one OPEN tracking issue for `marker` in line with `finding`: open
314
332
  * it, update its title and body, or, when `finding` is `null` and
@@ -614,6 +632,14 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
614
632
  await api.call("PATCH", `${repo}/issues/${n}`, { state: "closed" });
615
633
  },
616
634
  };
635
+ const postStatus = async (sha: string, state: ForgeStatusState, context: string, description: string, url?: string): Promise<void> => {
636
+ await api.call("POST", `${repo}/statuses/${sha}`, {
637
+ state: statusStateFor(kind, state),
638
+ context,
639
+ description: description.slice(0, GITHUB_DESCRIPTION),
640
+ ...(url ? { target_url: url } : {}),
641
+ });
642
+ };
617
643
  return {
618
644
  kind,
619
645
  host,
@@ -621,12 +647,10 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
621
647
  principalOf: (login) => forgePrincipalOf(kind, host, login),
622
648
  ...threadCalls(t),
623
649
  async setStatus(sha, status) {
624
- await api.call("POST", `${repo}/statuses/${sha}`, {
625
- state: statusStateFor(kind, status.state),
626
- context: status.context,
627
- description: status.description.slice(0, GITHUB_DESCRIPTION),
628
- ...(status.url ? { target_url: status.url } : {}),
629
- });
650
+ await postStatus(sha, status.state, status.context, status.description, status.url);
651
+ },
652
+ async setCommitStatus(sha, status) {
653
+ await postStatus(sha, status.state, status.context, status.description, status.targetUrl);
630
654
  },
631
655
  async approvers(pr) {
632
656
  // A reviewer's newest deciding review is their standing one. A comment
@@ -35,7 +35,7 @@
35
35
  */
36
36
 
37
37
  import { spawn } from "node:child_process";
38
- import { mkdirSync, renameSync, writeFileSync } from "node:fs";
38
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
39
39
  import { dirname, isAbsolute, join } from "node:path";
40
40
  import { fileURLToPath } from "node:url";
41
41
 
@@ -120,6 +120,58 @@ export function reportPut(target: ReportTarget, aws: AwsCli = realAwsCli): Repor
120
120
  };
121
121
  }
122
122
 
123
+ /** Reads one object: `key` below the URL. Resolves null for an object that is not there, and rejects with a line a person can read for any other failure. */
124
+ export type ReportGet = (key: string) => Promise<string | null>;
125
+
126
+ /** Runs the AWS CLI with `args` and resolves its stdout, rejecting with its error line on a non-zero exit. */
127
+ export type AwsRead = (args: string[]) => Promise<string>;
128
+
129
+ /** The AWS CLI on PATH, read for the body `aws s3 cp <s3 url> -` prints. */
130
+ export const realAwsRead: AwsRead = (args) =>
131
+ new Promise((resolveRead, reject) => {
132
+ const child = spawn(AWS_BINARY, args, { stdio: ["ignore", "pipe", "pipe"] });
133
+ const out: Buffer[] = [];
134
+ let stderr = "";
135
+ child.stdout.on("data", (chunk: Buffer) => out.push(chunk));
136
+ child.stderr.on("data", (chunk: Buffer) => {
137
+ if (stderr.length < 4096) stderr += chunk.toString("utf8");
138
+ });
139
+ child.on("error", (err: NodeJS.ErrnoException) =>
140
+ reject(new Error(err.code === "ENOENT" ? "the AWS CLI (aws) is not on PATH; an s3 report URL is read with it" : err.message)),
141
+ );
142
+ child.on("close", (code) => {
143
+ if (code === 0) return resolveRead(Buffer.concat(out).toString("utf8"));
144
+ const line = stderr.trim().split("\n").filter(Boolean).at(-1) ?? "";
145
+ reject(new Error(`aws s3 cp exited ${code}${line ? `: ${line.slice(0, 300)}` : ""}`));
146
+ });
147
+ });
148
+
149
+ /** What the AWS CLI says about an object that does not exist. */
150
+ const S3_ABSENT = /\(404\)|Not Found|NoSuchKey|does not exist/i;
151
+
152
+ /** A get for `target`. `aws` replaces the AWS CLI in a test. */
153
+ export function reportGet(target: ReportTarget, aws: AwsRead = realAwsRead): ReportGet {
154
+ if (target.scheme === "file") {
155
+ return async (key) => {
156
+ try {
157
+ return readFileSync(join(target.dir, ...key.split("/")), "utf-8");
158
+ } catch (err) {
159
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
160
+ throw err;
161
+ }
162
+ };
163
+ }
164
+ return async (key) => {
165
+ const object = `s3://${target.bucket}/${target.prefix ? `${target.prefix}/` : ""}${key}`;
166
+ try {
167
+ return await aws(["s3", "cp", object, "-", "--only-show-errors"]);
168
+ } catch (err) {
169
+ if (S3_ABSENT.test(messageOf(err))) return null;
170
+ throw err;
171
+ }
172
+ };
173
+ }
174
+
123
175
  /** A stage's upload: its events as they are written, and its files at the end. */
124
176
  export interface PrReportUpload {
125
177
  /** The folder the stage's files go to: `<url>/<key>`. */
@@ -0,0 +1,50 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://intentius.io/chant/schemas/workspace/ci-run-index/v1/ci-run-index.schema.json",
4
+ "title": "chant ci run index",
5
+ "description": "The runs the chant runner has run, kept at <report-url>/runs/index.json (#4098, epic #4101) so a reader outside CI finds them without a forge or a workflow file. `chant ci run` adds an entry to the front after each job settles when --report-url is set, so the list is newest first, and keeps the newest 200 entries unless --run-index-limit says otherwise. `chant ci runs --json` prints the same document. An entry's `key` is the folder the job's report is under, below the report URL. The update is a read, a change and a write of the whole file, through the same s3:// and file:// paths the report upload uses; a file:// write goes through a temporary name and a rename. Two runners updating one index at once can lose an entry. That is out of scope, because `chant ci serve` is one host. Version 1 of the workspace read contract. Readers ignore fields they do not know; a field is only ever added within a version. Written by chant 0.124.31 and newer.",
6
+ "type": "object",
7
+ "required": ["$schema", "contract", "chant", "updated", "runs"],
8
+ "properties": {
9
+ "$schema": { "const": "https://intentius.io/chant/schemas/workspace/ci-run-index/v1/ci-run-index.schema.json" },
10
+ "contract": { "const": 1 },
11
+ "chant": { "type": "string", "description": "The chant that last wrote the index." },
12
+ "updated": { "type": "string", "description": "When the index was last written." },
13
+ "runs": {
14
+ "type": "array",
15
+ "description": "Newest first, at most the limit the writer was given (200 by default).",
16
+ "items": { "$ref": "#/$defs/run" }
17
+ }
18
+ },
19
+ "$defs": {
20
+ "run": {
21
+ "type": "object",
22
+ "required": ["job", "pr", "commit", "status", "started", "ended", "runId", "key", "runOrigin"],
23
+ "if": { "properties": { "status": { "const": "fail" } } },
24
+ "then": { "required": ["failure"], "properties": { "failure": { "type": "object" } } },
25
+ "properties": {
26
+ "job": { "enum": ["plan", "apply"] },
27
+ "pr": { "type": ["integer", "null"], "minimum": 1, "description": "The pull or merge request number: the one the job's report names, else --pr. null when neither has one." },
28
+ "commit": { "type": "string", "pattern": "^[0-9a-f]{40,64}$", "description": "The full commit the job ran at." },
29
+ "status": { "enum": ["ok", "fail"] },
30
+ "failure": {
31
+ "type": "object",
32
+ "required": ["code"],
33
+ "description": "Present when status is fail. The same code the run record's failure carries: setup-failed, build-failed, a report's refusal or status, or command-failed.",
34
+ "properties": { "code": { "type": "string" } }
35
+ },
36
+ "member": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]{0,39}$", "description": "The workspace member whose pipeline the job ran for. Absent outside a member." },
37
+ "started": { "type": "string", "description": "ISO-8601 instant the job started." },
38
+ "ended": { "type": "string", "description": "ISO-8601 instant the job settled." },
39
+ "runId": { "type": "string", "description": "The Op run id, as in the run ledger on chant/lifecycle." },
40
+ "key": { "type": "string", "description": "reportKey(pr, commit): <pr>/<commit>, with /<member> after for a member, and unknown for a missing pr. The job's report is <report-url>/<key>/pr-<job>.json." },
41
+ "runOrigin": {
42
+ "type": "object",
43
+ "required": ["forge"],
44
+ "description": "Where the run says it ran. { forge: \"chant\" } for a job the chant runner ran.",
45
+ "properties": { "forge": { "type": "string" } }
46
+ }
47
+ }
48
+ }
49
+ }
50
+ }