@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/dist/workspace/ci-run.js
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,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,
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
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, {
|
|
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
|
-
|
|
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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"}
|