@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.
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +38 -4
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +50 -0
- package/dist/cli/registry.d.ts +18 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +12 -0
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-executor.js +22 -3
- package/dist/op/redact.d.ts +34 -0
- package/dist/op/redact.d.ts.map +1 -0
- package/dist/op/redact.js +94 -0
- package/dist/op/step-console.d.ts +2 -0
- package/dist/op/step-console.d.ts.map +1 -1
- package/dist/op/step-console.js +5 -2
- package/dist/pr-forge.d.ts +17 -0
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/pr-forge.js +12 -6
- package/dist/pr-report-url.d.ts +8 -0
- package/dist/pr-report-url.d.ts.map +1 -1
- package/dist/pr-report-url.js +47 -1
- package/dist/wave-runtime.mjs +159 -159
- package/dist/workspace/ci-run-index.d.ts +96 -0
- package/dist/workspace/ci-run-index.d.ts.map +1 -0
- package/dist/workspace/ci-run-index.js +162 -0
- package/dist/workspace/ci-run-index.schema.json +50 -0
- package/dist/workspace/ci-run.d.ts +84 -7
- package/dist/workspace/ci-run.d.ts.map +1 -1
- package/dist/workspace/ci-run.js +241 -14
- package/dist/workspace/ci-runner-key.d.ts +120 -0
- package/dist/workspace/ci-runner-key.d.ts.map +1 -0
- package/dist/workspace/ci-runner-key.js +217 -0
- package/dist/workspace/ci-secrets.d.ts +65 -0
- package/dist/workspace/ci-secrets.d.ts.map +1 -0
- package/dist/workspace/ci-secrets.js +211 -0
- package/dist/workspace/ci-serve.d.ts +13 -0
- package/dist/workspace/ci-serve.d.ts.map +1 -1
- package/dist/workspace/ci-serve.js +69 -13
- package/dist/workspace/reason-codes.d.ts +6 -2
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/reason-codes.js +7 -2
- package/dist/workspace/trust/trust-write.d.ts +109 -0
- package/dist/workspace/trust/trust-write.d.ts.map +1 -0
- package/dist/workspace/trust/trust-write.js +320 -0
- package/dist/workspace/trust-write.schema.json +265 -0
- package/package.json +1 -1
- package/src/cli/main.test.ts +1 -0
- package/src/cli/main.ts +38 -4
- package/src/cli/parse-args.ts +36 -0
- package/src/cli/registry.ts +18 -0
- package/src/op/local-executor.ts +31 -3
- package/src/op/redact.ts +93 -0
- package/src/op/step-console.ts +7 -2
- package/src/pr-forge.test.ts +29 -0
- package/src/pr-forge.ts +30 -6
- package/src/pr-report-url.ts +53 -1
- package/src/workspace/ci-run-index.schema.json +50 -0
- package/src/workspace/ci-run-index.test.ts +353 -0
- package/src/workspace/ci-run-index.ts +192 -0
- package/src/workspace/ci-run.test.ts +337 -37
- package/src/workspace/ci-run.ts +277 -15
- package/src/workspace/ci-runner-key.test.ts +166 -0
- package/src/workspace/ci-runner-key.ts +250 -0
- package/src/workspace/ci-secrets.test.ts +106 -0
- package/src/workspace/ci-secrets.ts +213 -0
- package/src/workspace/ci-serve.test.ts +136 -37
- package/src/workspace/ci-serve.ts +66 -13
- package/src/workspace/reason-codes.test.ts +2 -0
- package/src/workspace/reason-codes.ts +7 -2
- package/src/workspace/trust/trust-write.test.ts +179 -0
- package/src/workspace/trust/trust-write.ts +377 -0
- package/src/workspace/trust-write.schema.json +265 -0
package/src/workspace/ci-run.ts
CHANGED
|
@@ -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,
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
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: {
|
|
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, {
|
|
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))
|
|
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
|
-
|
|
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
|
+
});
|