@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,23 +40,33 @@
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
  import { execFileSync } from "node:child_process";
42
- import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
49
+ import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
43
50
  import { tmpdir } from "node:os";
44
51
  import { dirname, join, relative, resolve, sep } from "node:path";
45
52
  import { PR_APPLY_RECORD, PR_LOOP_REPORT_DIR, prLoopJobs, prLoopSetup, PR_LOOP_IMAGE } from "../components/pr-pipeline.js";
46
- import { detectForge, FORGE_KINDS } from "../pr-forge.js";
53
+ import { detectForge, ForgeEnvironmentError, forgeFromEnv, FORGE_KINDS } from "../pr-forge.js";
54
+ import { PR_APPLY_GATE } from "../pr-loop.js";
47
55
  import { formatError, formatWarning } from "../cli/format.js";
48
56
  import { pushLifecycleReport } from "../lifecycle/git.js";
49
57
  import { ACTIVITY_PROFILES } from "../op/activity-profiles.js";
50
58
  import { shellCmd } from "../op/activities/shell.js";
51
59
  import { OpRunFailure, runOpLocally } from "../op/local-executor.js";
60
+ import { redactText } from "../op/redact.js";
52
61
  import { findWorkspaceRoot } from "../project-root.js";
62
+ import { parseReportUrl, reportKey } from "../pr-report-url.js";
53
63
  import { ciTagSigner, RUNNER_KEY_ENV } from "./ci-green.js";
64
+ import { ciRedactor, ciSecretsFor, CI_SECRETS_ENV, readCiSecrets } from "./ci-secrets.js";
65
+ import { updateCiRunIndex } from "./ci-run-index.js";
54
66
  import { resolveMemberContext } from "./member-pipeline.js";
55
67
  import { signOpRunRecord } from "./trust/op-run-statement.js";
56
68
  import { gitTop } from "./tree.js";
69
+ import { hostRunnerKey } from "./ci-runner-key.js";
57
70
  export const CI_JOB_NAMES = ["plan", "apply"];
58
71
  /** The Op each job is recorded as, which `ci.green` phases match. */
59
72
  export const CI_JOB_OPS = { plan: "pr-plan", apply: "pr-apply" };
@@ -89,19 +102,27 @@ export class CiRunError extends Error {
89
102
  }
90
103
  /**
91
104
  * The job's environment: the runner's base variables, the forge variables
92
- * and `passEnv` names it has, `CHANT_ENV` as a generated workflow sets it,
93
- * the commit as the forge spells it, and the job's own variables. A job
94
- * variable the forge fills from an expression comes from a flag, or from
95
- * the runner's environment for a token such as `GITHUB_TOKEN`; a literal
96
- * one, such as `CHANT_RUN_EVENTS_DIR`, is kept as it is.
105
+ * and `passEnv` names it has, the job's secrets from the host's secrets file
106
+ * (#4096), `CHANT_ENV` as a generated workflow sets it, the commit as the
107
+ * forge spells it, and the job's own variables. A job variable the forge
108
+ * fills from an expression comes from a flag, or from a secret or the
109
+ * runner's environment for a token such as `GITHUB_TOKEN`; a literal one,
110
+ * such as `CHANT_RUN_EVENTS_DIR`, is kept as it is. A secret replaces the
111
+ * runner's variable of the same name, and none replaces a variable the
112
+ * runner sets for the job. `CHANT_RUNNER_KEY` is never in it.
97
113
  */
98
114
  export function ciJobEnv(job, input) {
115
+ if (input.passEnv?.includes(RUNNER_KEY_ENV))
116
+ throw new CiRunError(`--pass-env names ${RUNNER_KEY_ENV}, and the runner key is never passed to a job`);
99
117
  const out = {};
100
118
  for (const name of [...CI_RUN_BASE_ENV, ...CI_RUN_FORGE_ENV, ...(input.passEnv ?? [])]) {
101
119
  const value = input.hostEnv[name];
102
120
  if (value !== undefined)
103
121
  out[name] = value;
104
122
  }
123
+ for (const [name, value] of Object.entries(input.secrets ?? {}))
124
+ if (name !== RUNNER_KEY_ENV)
125
+ out[name] = value;
105
126
  out.CHANT_ENV = input.env;
106
127
  out[input.forge === "gitlab" ? "CI_COMMIT_SHA" : "GITHUB_SHA"] = input.values.commit;
107
128
  const flags = {
@@ -118,10 +139,14 @@ export function ciJobEnv(job, input) {
118
139
  else if (!value.includes("$")) {
119
140
  out[name] = value;
120
141
  }
142
+ else if (input.secrets?.[name] !== undefined) {
143
+ out[name] = input.secrets[name];
144
+ }
121
145
  else if (input.hostEnv[name] !== undefined) {
122
146
  out[name] = input.hostEnv[name];
123
147
  }
124
148
  }
149
+ delete out[RUNNER_KEY_ENV];
125
150
  return out;
126
151
  }
127
152
  /** `line` with a `chant` at its start replaced by the command the caller names, when it names one. */
@@ -220,6 +245,67 @@ export function ciJobFailure(input) {
220
245
  }
221
246
  return { code: "command-failed", reason: firstLine(failed.error ?? "the command failed"), phase: failed.phase, ...exit };
222
247
  }
248
+ /** A status description is cut to what GitHub and Forgejo keep. */
249
+ const STATUS_DESCRIPTION = 140;
250
+ const cut = (text, max = STATUS_DESCRIPTION) => (text.length > max ? `${text.slice(0, max - 3)}...` : text);
251
+ /**
252
+ * A job's status context: `chant/pr-plan` for the plan, and for the apply
253
+ * `chant/<gate>`, which is `chant/pr-apply` unless `--gate` renamed the gate.
254
+ * pr-plan's own status is `chant/plan` (../pr-loop.ts), a different context.
255
+ */
256
+ export function ciStatusContext(job, gate) {
257
+ return job === "apply" ? `chant/${gate ?? PR_APPLY_GATE}` : `chant/${CI_JOB_OPS.plan}`;
258
+ }
259
+ /**
260
+ * The link a job's status carries: the `--status-url` template with `{key}`
261
+ * filled, else the https `--report-url` followed by the key and a slash, else
262
+ * none (an `s3://` or `file://` URL is not one a browser opens).
263
+ */
264
+ export function ciStatusUrl(input) {
265
+ const key = reportKey(input.pr ?? null, input.commit, input.member);
266
+ if (input.urlTemplate)
267
+ return input.urlTemplate.split("{key}").join(key);
268
+ const url = input.reportUrl?.trim();
269
+ if (url && /^https:\/\//.test(url))
270
+ return `${url.replace(/\/+$/, "")}/${key}/`;
271
+ return undefined;
272
+ }
273
+ /** What the settled status says: a short summary, or on failure the record's code and reason. */
274
+ export function ciStatusSettled(op, result, seconds) {
275
+ if (result.status === "ok")
276
+ return { state: "success", description: cut(`${op} passed in ${seconds}s`) };
277
+ const why = result.record.failure;
278
+ const text = why ? `${why.code}: ${why.reason}` : result.failure ? `${result.failure.phase}: ${firstLine(result.failure.error)}` : `${op} failed`;
279
+ return { state: "failure", description: cut(text) };
280
+ }
281
+ /**
282
+ * The status client for `chant ci run` and `chant ci serve` (#4097), or none:
283
+ * off with `--no-status`, on GitLab (its client has no commit status call
284
+ * here), and without a forge token. A token with the rest of the forge's
285
+ * variables missing gives a warning instead.
286
+ */
287
+ export function ciStatusFromEnv(input) {
288
+ if (input.noStatus || input.forge === "gitlab")
289
+ return {};
290
+ if (input.statusUrl !== undefined && !/^https?:\/\/\S+$/.test(input.statusUrl)) {
291
+ throw new CiRunError(`--status-url takes an http or https URL template such as https://host/reports/{key}, not ${JSON.stringify(input.statusUrl)}`);
292
+ }
293
+ const env = input.hostEnv;
294
+ const hasToken = Boolean(env.CHANT_FORGE_TOKEN ?? env.GITHUB_TOKEN ?? env.GH_TOKEN);
295
+ if (!hasToken)
296
+ return {};
297
+ try {
298
+ const client = forgeFromEnv(input.forge, env);
299
+ if (!client.setCommitStatus)
300
+ return {};
301
+ return { status: { client: { setCommitStatus: client.setCommitStatus.bind(client) }, ...(input.statusUrl ? { urlTemplate: input.statusUrl } : {}) } };
302
+ }
303
+ catch (err) {
304
+ if (err instanceof ForgeEnvironmentError)
305
+ return { warning: `no commit status will be set: ${err.message}` };
306
+ throw err;
307
+ }
308
+ }
223
309
  const message = (err) => (err instanceof Error ? err.message : String(err));
224
310
  function git(cwd, args) {
225
311
  return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }).trim();
@@ -275,6 +361,45 @@ function freshCheckout(top, commit) {
275
361
  },
276
362
  };
277
363
  }
364
+ /**
365
+ * Add the settled job to the run index at its report URL (#4098). The pull
366
+ * request is the one the job's own report names, since a pr-apply finds it
367
+ * itself, else `--pr`. A warning, never a failure, when the index can't be
368
+ * updated.
369
+ */
370
+ async function recordInIndex(input) {
371
+ const target = parseReportUrl(input.url);
372
+ if ("error" in target)
373
+ return input.warn(`the run index is not updated: ${target.error}`);
374
+ let pr = input.pr ?? null;
375
+ try {
376
+ const file = join(input.reportDir, `${CI_JOB_OPS[input.job]}.json`);
377
+ if (existsSync(file)) {
378
+ const said = JSON.parse(readFileSync(file, "utf-8")).pr;
379
+ if (Number.isInteger(said) && said > 0)
380
+ pr = said;
381
+ }
382
+ }
383
+ catch {
384
+ // The report is not readable; the flag stands.
385
+ }
386
+ const failure = input.record.failure;
387
+ const clean = (text) => (input.redact ? input.redact(text) : text);
388
+ const entry = {
389
+ job: input.job,
390
+ pr,
391
+ commit: input.commit,
392
+ status: input.record.status === "ok" ? "ok" : "fail",
393
+ ...(input.record.status !== "ok" ? { failure: { code: clean(failure?.code ?? "command-failed") } } : {}),
394
+ ...(input.member ? { member: clean(input.member) } : {}),
395
+ started: input.record.started,
396
+ ended: input.record.ended,
397
+ runId: clean(input.record.id),
398
+ key: clean(reportKey(pr, input.commit, input.member)),
399
+ runOrigin: CHANT_RUNNER_ORIGIN,
400
+ };
401
+ await updateCiRunIndex({ target, entry, ...(input.limit ? { limit: input.limit } : {}), warn: input.warn });
402
+ }
278
403
  const ACTIVITIES = new Map([["shellCmd", shellCmd]]);
279
404
  /**
280
405
  * Run one pull-request job as a pipeline Op in a fresh checkout of the
@@ -293,7 +418,29 @@ export async function ciRun(req) {
293
418
  const job = jobs.find((j) => j.jobName === req.job);
294
419
  if (!job)
295
420
  throw new CiRunError(`the pull-request pipeline has no ${req.job} job; it has ${jobs.map((j) => j.jobName).join(" and ")}`);
296
- const jobEnv = ciJobEnv(job, { forge: req.forge, env: req.env, values: { base: req.base, pr: req.pr, commit }, hostEnv, passEnv: req.passEnv });
421
+ const jobEnv = ciJobEnv(job, {
422
+ forge: req.forge,
423
+ env: req.env,
424
+ values: { base: req.base, pr: req.pr, commit },
425
+ hostEnv,
426
+ passEnv: req.passEnv,
427
+ secrets: ciSecretsFor(req.secrets, req.job),
428
+ });
429
+ // #4096: the secrets and the job's secret-looking variables, redacted from everything the run writes and returns.
430
+ const redact = ciRedactor(jobEnv, req.secrets);
431
+ try {
432
+ const result = await runCiJob(req, { cwd, top, commit, place, job, jobEnv, ...(redact ? { redact } : {}) });
433
+ return redact ? { ...result, warnings: result.warnings.map(redact), ...(result.pushWarning ? { pushWarning: redact(result.pushWarning) } : {}) } : result;
434
+ }
435
+ catch (err) {
436
+ if (redact && err instanceof Error)
437
+ err.message = redact(err.message);
438
+ throw err;
439
+ }
440
+ }
441
+ /** {@link ciRun} once the job and its environment are known. */
442
+ async function runCiJob(req, at) {
443
+ const { cwd, top, commit, place, job, jobEnv, redact } = at;
297
444
  // 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.
298
445
  let signer;
299
446
  const early = [];
@@ -326,6 +473,22 @@ export async function ciRun(req) {
326
473
  mkdirSync(dirname(resume.job), { recursive: true });
327
474
  copyFileSync(resume.host, resume.job);
328
475
  }
476
+ const statusContext = ciStatusContext(req.job, req.loop?.gate);
477
+ const statusUrl = req.status
478
+ ? 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 } : {}) })
479
+ : undefined;
480
+ const postStatus = async (state, description) => {
481
+ if (!req.status)
482
+ return;
483
+ try {
484
+ await req.status.client.setCommitStatus(commit, { context: statusContext, state, description: cut(redact ? redact(description) : description), ...(statusUrl ? { targetUrl: statusUrl } : {}) });
485
+ }
486
+ catch (err) {
487
+ warnings.push(`could not set the ${statusContext} status: ${message(err)}`);
488
+ }
489
+ };
490
+ const startedAt = Date.now();
491
+ await postStatus("pending", `${op.name} running`);
329
492
  let ledgerError;
330
493
  let result;
331
494
  try {
@@ -341,16 +504,22 @@ export async function ciRun(req) {
341
504
  ...(req.stepOutput ? { stepOutput: req.stepOutput } : {}),
342
505
  ...(signer ? { signRecord: (record) => signOpRunRecord(record, signer) } : {}),
343
506
  onSignError: (err) => warnings.push(`the run was not signed: ${message(err)}`),
507
+ ...(redact ? { redact } : {}),
344
508
  onLedgerError: (err) => {
345
509
  ledgerError = `the job ran, and its record could not be written to chant/lifecycle: ${message(err)}`;
346
510
  },
347
511
  });
348
512
  }
349
513
  catch (err) {
350
- if (!(err instanceof OpRunFailure))
514
+ if (!(err instanceof OpRunFailure)) {
515
+ await postStatus("error", `${op.name} stopped: ${firstLine(message(err))}`);
351
516
  throw err;
517
+ }
352
518
  result = err.result;
353
519
  }
520
+ const failedStep = result.records.find((r) => r.status === "fail");
521
+ const settled = ciStatusSettled(op.name, { status: result.status, record: result.record, ...(failedStep ? { failure: { phase: failedStep.phase, error: failedStep.error ?? "the step failed" } } : {}) }, Math.round((Date.now() - startedAt) / 1000));
522
+ await postStatus(settled.state, settled.description);
354
523
  if (resume && existsSync(resume.job)) {
355
524
  try {
356
525
  mkdirSync(dirname(resume.host), { recursive: true });
@@ -365,6 +534,11 @@ export async function ciRun(req) {
365
534
  if (existsSync(written)) {
366
535
  report = resolve(cwd, req.output ?? PR_LOOP_REPORT_DIR);
367
536
  cpSync(written, report, { recursive: true });
537
+ if (redact)
538
+ redactTree(report, redact);
539
+ }
540
+ if (req.loop?.reportUrl) {
541
+ 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) });
368
542
  }
369
543
  if (ledgerError)
370
544
  throw new CiRunError(ledgerError);
@@ -387,6 +561,29 @@ export async function ciRun(req) {
387
561
  checkout.remove();
388
562
  }
389
563
  }
564
+ /**
565
+ * Redact every text file under `dir` in place (#4096): the job's report, its
566
+ * change set and its component events, as `ci run` copied them out. A
567
+ * JSON file stays JSON. A file with a NUL byte is taken as binary and left.
568
+ */
569
+ export function redactTree(dir, redact) {
570
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
571
+ const path = join(dir, entry.name);
572
+ if (entry.isDirectory()) {
573
+ redactTree(path, redact);
574
+ continue;
575
+ }
576
+ if (!entry.isFile())
577
+ continue;
578
+ const bytes = readFileSync(path);
579
+ if (bytes.includes(0))
580
+ continue;
581
+ const text = bytes.toString("utf-8");
582
+ const clean = redactText(text, redact);
583
+ if (clean !== text)
584
+ writeFileSync(path, clean);
585
+ }
586
+ }
390
587
  /** The text `chant ci run` prints when the run settles. */
391
588
  export function formatCiRun(r) {
392
589
  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}`];
@@ -404,14 +601,34 @@ export function formatCiRun(r) {
404
601
  }
405
602
  // ── The command ──────────────────────────────────────────────────────────────
406
603
  const USAGE = "chant ci run plan|apply --env <env> --base <sha> [--pr <n>] [--commit <sha>] [--forge github|gitlab|forgejo] " +
407
- "[--gate <name>] [--branch <name>] [--events-json] [--report-url <url>] [--image <image>] [--before-script <line>]... " +
408
- "[--pr-build] [--pr-build-command <line>] " +
409
- "[--chant <command>] [--pass-env <NAME>]... [--key <runner key.pem>] [-o <dir>]";
604
+ "[--gate <name>] [--branch <name>] [--events-json] [--report-url <url>] [--run-index-limit <n>] [--image <image>] [--before-script <line>]... " +
605
+ "[--pr-build] [--pr-build-command <line>] [--no-status] [--status-url <url template>] " +
606
+ "[--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>]";
410
607
  const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
608
+ /** The host's runner key in `--state`, or in ci serve's default state directory, when `--setup-key` made one there (#4099). */
609
+ function hostRunnerKeyAt(cwd, statePath) {
610
+ const common = statePath ? null : (() => {
611
+ try {
612
+ return execFileSync("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }).trim();
613
+ }
614
+ catch {
615
+ return null;
616
+ }
617
+ })();
618
+ const dir = statePath ? resolve(statePath) : common ? join(common, "chant", "ci-serve") : null;
619
+ return dir ? hostRunnerKey(dir) : undefined;
620
+ }
621
+ /** The secrets file `--secrets` names, else the one `CHANT_CI_SECRETS` names, read; undefined when neither names one (#4096). */
622
+ export function ciSecretsOf(flag, env = process.env) {
623
+ const file = flag ?? (env[CI_SECRETS_ENV] || undefined);
624
+ return file === undefined ? undefined : readCiSecrets(resolve(file));
625
+ }
411
626
  /** `chant ci run plan|apply` (#4094). */
412
627
  export async function runCiRun(ctx) {
413
628
  const { args } = ctx;
414
629
  try {
630
+ if (args.setupKey)
631
+ return (await import("./ci-serve.js")).runnerKeySetupCommand(ctx);
415
632
  const job = args.extraPositional;
416
633
  if (job !== "plan" && job !== "apply")
417
634
  throw new CiRunError(`ci run takes the job to run, plan or apply${job ? `, not ${JSON.stringify(job)}` : ""}`);
@@ -426,9 +643,17 @@ export async function runCiRun(ctx) {
426
643
  if (!ENV_NAME.test(name))
427
644
  throw new CiRunError(`--pass-env takes a variable's name, not ${JSON.stringify(name)}`);
428
645
  const fromFlag = args.key !== undefined;
429
- const runnerKey = fromFlag ? readFileSync(resolve(args.key), "utf-8") : process.env[RUNNER_KEY_ENV] || undefined;
646
+ // #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.
647
+ const hostKey = fromFlag || process.env[RUNNER_KEY_ENV] ? undefined : hostRunnerKeyAt(process.cwd(), args.statePath);
648
+ const runnerKey = fromFlag ? readFileSync(resolve(args.key), "utf-8") : process.env[RUNNER_KEY_ENV] || hostKey?.pem;
649
+ const hostEnv = process.env;
650
+ const { status, warning } = ciStatusFromEnv({ forge: forge, hostEnv, noStatus: args.noStatus, statusUrl: args.statusUrl });
651
+ if (warning)
652
+ console.error(formatWarning({ message: warning }));
653
+ const secrets = ciSecretsOf(args.ciSecrets);
430
654
  const result = await ciRun({
431
655
  cwd: process.cwd(),
656
+ ...(status ? { status } : {}),
432
657
  job,
433
658
  env: args.env,
434
659
  forge: forge,
@@ -443,10 +668,12 @@ export async function runCiRun(ctx) {
443
668
  ...(args.prBuild ? { build: true } : {}),
444
669
  ...(args.prBuildCommand ? { buildCommand: args.prBuildCommand } : {}),
445
670
  },
671
+ ...(args.runIndexLimit ? { runIndexLimit: args.runIndexLimit } : {}),
446
672
  ...(args.image ? { image: args.image } : {}),
447
673
  ...(args.beforeScript?.length ? { beforeScript: args.beforeScript } : {}),
448
674
  ...(args.ciChant ? { chant: args.ciChant } : {}),
449
675
  ...(args.passEnv?.length ? { passEnv: args.passEnv } : {}),
676
+ ...(secrets ? { secrets } : {}),
450
677
  ...(args.output ? { output: args.output } : {}),
451
678
  ...(runnerKey !== undefined ? { runnerKey, runnerKeyFromEnv: !fromFlag } : {}),
452
679
  ...(args.eventsJson ? { runEvents: (line) => void process.stdout.write(line) } : {}),
@@ -0,0 +1,120 @@
1
+ /**
2
+ * The runner key on a `chant ci serve` or `chant ci run` host (#4099, epic
3
+ * #4101).
4
+ *
5
+ * The host keeps its runner key in the serve's state directory, by default
6
+ * `<git common dir>/chant/ci-serve/runner-key.pem`: an Ed25519 private key in
7
+ * PEM, mode 0600, made by `--setup-key` when there is none. Its public half
8
+ * is the `ssh-ed25519` line `.chant/trust.json` lists under `runners`, and
9
+ * `--setup-key --trust` proposes that entry through `chant workspace trust
10
+ * runner add` (#3510) on a branch of its own, pushed for review. It never
11
+ * commits to the target branch: changing the trust config is a protected
12
+ * write, and the change is reviewed and merged like any other.
13
+ *
14
+ * `ci serve` refuses to start when its key is not listed, with the runner
15
+ * role, in `.chant/trust.json` at the target branch's head, since every run
16
+ * it signed would then be ignored by `ci tick --source runs`. `--unsigned`
17
+ * starts it without a key.
18
+ */
19
+ /** The runner key's file in the state directory. */
20
+ export declare const RUNNER_KEY_FILE = "runner-key.pem";
21
+ /** The principal `--setup-key --trust` lists the key for when `--principal` names none. */
22
+ export declare function defaultRunnerPrincipal(): string;
23
+ export interface HostRunnerKey {
24
+ path: string;
25
+ pem: string;
26
+ /** `ssh-ed25519 <base64>`. */
27
+ publicKey: string;
28
+ /** Whether this call made it. */
29
+ created: boolean;
30
+ }
31
+ /** The host's runner key in `stateDir`, or undefined when there is none. */
32
+ export declare function hostRunnerKey(stateDir: string): HostRunnerKey | undefined;
33
+ /** The host's runner key in `stateDir`, made when there is none: Ed25519, PEM, mode 0600 in a directory of mode 0700. */
34
+ export declare function ensureHostRunnerKey(stateDir: string): HostRunnerKey;
35
+ /**
36
+ * The target branch's head, as the readers of runners take it: the remote's
37
+ * branch after a fetch (best effort), else the local branch. Undefined when
38
+ * neither exists.
39
+ */
40
+ export declare function targetHead(top: string, branch: string, remote?: string): {
41
+ commit: string;
42
+ ref: string;
43
+ } | undefined;
44
+ /** The exact command that lists `publicKey` for `principal`. */
45
+ export declare function trustCommand(principal: string, publicKey: string): string;
46
+ /** Whether `.chant/trust.json` at `commit` lists the key as a runner holding the runner role: its principal, or why not. */
47
+ export declare function runnerKeyListed(top: string, commit: string, pem: string): {
48
+ ok: true;
49
+ principal: string;
50
+ } | {
51
+ ok: false;
52
+ reason: string;
53
+ };
54
+ export interface RunnerTrustProposal {
55
+ /** The branch holding the commit, or null when the key was already listed at the target's head. */
56
+ branch: string | null;
57
+ commit: string | null;
58
+ /** Whether the branch was pushed to the remote. */
59
+ pushed: boolean;
60
+ /** The trust config's digest on the branch. */
61
+ digest: string | null;
62
+ }
63
+ /**
64
+ * Propose the runner entry for review (#4099): in a temporary worktree at
65
+ * the target's head, run `trust runner add` (#3510), commit the change on
66
+ * `chant/trust-runner/<principal>`, and push that branch to `remote` when
67
+ * the clone has it. The target branch is never written. The commit is made
68
+ * with the host's git identity and signing settings; when the policy at base
69
+ * has signers, the change needs a signature by a policy writer before it
70
+ * merges.
71
+ */
72
+ export declare function proposeRunnerTrust(req: {
73
+ top: string;
74
+ branch: string;
75
+ principal: string;
76
+ publicKey: string;
77
+ by?: string;
78
+ remote?: string;
79
+ }): RunnerTrustProposal;
80
+ export interface RunnerKeySetup {
81
+ key: HostRunnerKey;
82
+ /** The target's head the trust config was read at, or null when there is no such branch. */
83
+ head: string | null;
84
+ /** The principal the key is listed for at the head, or null. */
85
+ listedAs: string | null;
86
+ principal: string;
87
+ command: string;
88
+ proposal?: RunnerTrustProposal;
89
+ }
90
+ /** `--setup-key [--trust]`: make or read the key, see whether the target's head lists it, and with `trust` propose the entry. */
91
+ export declare function setupRunnerKey(req: {
92
+ top: string;
93
+ stateDir: string;
94
+ branch: string;
95
+ principal?: string;
96
+ trust?: boolean;
97
+ by?: string;
98
+ }): RunnerKeySetup;
99
+ /** What `--setup-key` prints on stderr; the public key alone goes to stdout. */
100
+ export declare function formatRunnerKeySetup(s: RunnerKeySetup, branch: string): string;
101
+ /**
102
+ * The key `ci serve` signs with, checked before its first poll (#4099): the
103
+ * `--key` file, else `CHANT_RUNNER_KEY`, else the host's key in the state
104
+ * directory. Throws, with the command that lists it, when there is none or
105
+ * `.chant/trust.json` at the target's head does not list it with the runner
106
+ * role. With `unsigned` there is no key and nothing is checked.
107
+ */
108
+ export declare function ciServeRunnerKey(req: {
109
+ top: string;
110
+ stateDir: string;
111
+ branch: string;
112
+ keyFile?: string;
113
+ envKey?: string;
114
+ unsigned?: boolean;
115
+ }): {
116
+ pem?: string;
117
+ fromFlag: boolean;
118
+ source: string;
119
+ };
120
+ //# sourceMappingURL=ci-runner-key.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ci-runner-key.d.ts","sourceRoot":"","sources":["../../src/workspace/ci-runner-key.ts"],"names":[],"mappings":"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"}