@intentius/chant 0.124.29 → 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/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/build.js +4 -1
- package/dist/cli/handlers/fan-out.d.ts.map +1 -1
- package/dist/cli/handlers/fan-out.js +27 -5
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +64 -0
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +99 -0
- package/dist/cli/registry.d.ts +35 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/pr-pipeline.d.ts +24 -0
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/components/pr-pipeline.js +16 -0
- package/dist/components/pr-run.d.ts +20 -2
- package/dist/components/pr-run.d.ts.map +1 -1
- package/dist/components/pr-run.js +26 -13
- package/dist/components/wave-plan.d.ts +11 -0
- package/dist/components/wave-plan.d.ts.map +1 -1
- package/dist/components/wave-plan.js +26 -0
- package/dist/lifecycle/release-ledger.d.ts +8 -3
- package/dist/lifecycle/release-ledger.d.ts.map +1 -1
- package/dist/lifecycle/release-ledger.js +2 -0
- package/dist/lifecycle/run-ledger.d.ts +1 -1
- package/dist/lifecycle/run-ledger.d.ts.map +1 -1
- package/dist/lifecycle/run-ledger.js +9 -0
- package/dist/op/activities/activity-contracts.d.ts +1 -0
- package/dist/op/activities/activity-contracts.d.ts.map +1 -1
- package/dist/op/activities/activity-contracts.js +1 -0
- package/dist/op/activities/shell.d.ts +6 -0
- package/dist/op/activities/shell.d.ts.map +1 -1
- package/dist/op/activities/shell.js +1 -1
- package/dist/op/local-executor.d.ts +35 -1
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-executor.js +31 -6
- 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/runtime.d.ts +27 -0
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/op/runtime.js +20 -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 +40 -0
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/pr-forge.js +56 -6
- package/dist/pr-loop.d.ts +10 -0
- package/dist/pr-loop.d.ts.map +1 -1
- package/dist/pr-loop.js +7 -0
- 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/run-event-stream.d.ts +2 -2
- package/dist/run-event-stream.d.ts.map +1 -1
- package/dist/run-event-stream.js +2 -2
- package/dist/run-event.d.ts +15 -0
- package/dist/run-event.d.ts.map +1 -1
- package/dist/run-event.js +2 -0
- package/dist/wave-runtime.mjs +171 -171
- package/dist/workspace/ci-green-forge.d.ts +2 -0
- package/dist/workspace/ci-green-forge.d.ts.map +1 -1
- package/dist/workspace/ci-green.d.ts +2 -0
- package/dist/workspace/ci-green.d.ts.map +1 -1
- package/dist/workspace/ci-green.js +4 -2
- 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 +299 -0
- package/dist/workspace/ci-run.d.ts.map +1 -0
- package/dist/workspace/ci-run.js +691 -0
- 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 +227 -0
- package/dist/workspace/ci-serve.d.ts.map +1 -0
- package/dist/workspace/ci-serve.js +468 -0
- package/dist/workspace/pr-report.schema.json +5 -0
- 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/run-event.schema.json +14 -3
- package/dist/workspace/status-stewards.d.ts +2 -0
- package/dist/workspace/status-stewards.d.ts.map +1 -1
- package/dist/workspace/status-stewards.js +1 -0
- package/dist/workspace/status.schema.json +12 -1
- 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/handlers/build.test.ts +15 -0
- package/src/cli/handlers/build.ts +4 -1
- package/src/cli/handlers/fan-out.test.ts +82 -0
- package/src/cli/handlers/fan-out.ts +34 -5
- package/src/cli/handlers/pr.ts +5 -0
- package/src/cli/main.test.ts +1 -0
- package/src/cli/main.ts +64 -0
- package/src/cli/parse-args.ts +71 -0
- package/src/cli/registry.ts +35 -1
- package/src/components/pr-pipeline.ts +32 -0
- package/src/components/pr-run.test.ts +127 -0
- package/src/components/pr-run.ts +32 -12
- package/src/components/wave-plan.ts +30 -0
- package/src/lifecycle/release-ledger.ts +9 -3
- package/src/lifecycle/run-ledger.ts +7 -1
- package/src/op/activities/activity-contracts.ts +1 -0
- package/src/op/activities/shell.ts +7 -1
- package/src/op/local-executor.ts +58 -7
- package/src/op/redact.ts +93 -0
- package/src/op/runtime.ts +42 -0
- package/src/op/step-console.ts +7 -2
- package/src/pr-forge.test.ts +54 -0
- package/src/pr-forge.ts +92 -6
- package/src/pr-loop.test.ts +13 -1
- package/src/pr-loop.ts +16 -0
- package/src/pr-report-url.ts +53 -1
- package/src/run-event-stream.ts +4 -3
- package/src/run-event.ts +17 -0
- package/src/workspace/ci-green-forge.ts +2 -0
- package/src/workspace/ci-green.ts +6 -2
- 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 +716 -0
- package/src/workspace/ci-run.ts +833 -0
- 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 +367 -0
- package/src/workspace/ci-serve.ts +574 -0
- package/src/workspace/pr-report.schema.json +5 -0
- package/src/workspace/reason-codes.test.ts +2 -0
- package/src/workspace/reason-codes.ts +7 -2
- package/src/workspace/run-event.schema.json +14 -3
- package/src/workspace/status-stewards.ts +3 -0
- package/src/workspace/status.schema.json +12 -1
- 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
|
@@ -0,0 +1,833 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant ci run plan|apply` (#4094, epic #4101): run one pull-request job as
|
|
3
|
+
* a pipeline Op, the first slice of the chant runner.
|
|
4
|
+
*
|
|
5
|
+
* The job is the typed value `prLoopJobs` (../components/pr-pipeline.ts)
|
|
6
|
+
* returns, the one the github, gitlab and forgejo generators wrap in a
|
|
7
|
+
* workflow, so the runner and a generated workflow run the same command with
|
|
8
|
+
* the same values. Nothing reads generated YAML.
|
|
9
|
+
*
|
|
10
|
+
* The job runs as an Op on the local executor (../op/local-executor.ts):
|
|
11
|
+
*
|
|
12
|
+
* - a `setup` phase with the setup lines the default image needs,
|
|
13
|
+
* - a `build` phase with the lines given to run before the command
|
|
14
|
+
* (`--before-script`, which a generated workflow runs in the same place),
|
|
15
|
+
* then the job's build and lint lines with `--pr-build` (#4088),
|
|
16
|
+
* - and a phase named for the job, `plan` or `apply`, with the job's own
|
|
17
|
+
* setup lines and its command in one shell.
|
|
18
|
+
*
|
|
19
|
+
* Each line is one shell step with a clean environment: a short list of the
|
|
20
|
+
* runner's variables (PATH, HOME and the like), the forge's connection and
|
|
21
|
+
* token variables the commands read, any `--pass-env` names, and the job's
|
|
22
|
+
* own variables. The forge values a generated job reads from `${{ ... }}`
|
|
23
|
+
* expressions or GitLab's predefined variables come from flags here:
|
|
24
|
+
* `BASE_SHA` from `--base`, `PR_NUMBER` from `--pr`, and the commit from
|
|
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.
|
|
29
|
+
*
|
|
30
|
+
* The steps run in a fresh checkout of the commit: a detached `git worktree`
|
|
31
|
+
* in a temporary directory outside the repository, removed when the run
|
|
32
|
+
* ends. A worktree shares the repository's refs, so what the job records on
|
|
33
|
+
* `chant/lifecycle` (pr-apply's pending gate facts) and the remote it pushes
|
|
34
|
+
* to are the repository's own, as in a forge job's checkout. The report the
|
|
35
|
+
* job writes under `.chant/pr` is copied out of the worktree before it goes,
|
|
36
|
+
* where a forge would keep it as an artifact.
|
|
37
|
+
*
|
|
38
|
+
* The run is recorded as Op `pr-plan` or `pr-apply` in the run ledger
|
|
39
|
+
* (`<env>/runs__<op>.jsonl` on `chant/lifecycle`), and signed when a runner
|
|
40
|
+
* key is given, so `chant ci tick --source runs` (#4044) judges the commit
|
|
41
|
+
* from it. Its events are `kind: "pipeline"` and `runtime: "ci"`, and the
|
|
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.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
import { execFileSync } from "node:child_process";
|
|
50
|
+
import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
51
|
+
import { tmpdir } from "node:os";
|
|
52
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
53
|
+
import { PR_APPLY_RECORD, PR_LOOP_REPORT_DIR, prLoopJobs, prLoopSetup, PR_LOOP_IMAGE, type PrLoopJob, type PrLoopPipelineOptions } from "../components/pr-pipeline";
|
|
54
|
+
import { detectForge, ForgeEnvironmentError, forgeFromEnv, FORGE_KINDS, type CommitStatus, type ForgeKind } from "../pr-forge";
|
|
55
|
+
import { PR_APPLY_GATE } from "../pr-loop";
|
|
56
|
+
import type { CommandContext } from "../cli/registry";
|
|
57
|
+
import { formatError, formatWarning } from "../cli/format";
|
|
58
|
+
import type { RunOrigin } from "../lifecycle/release-ledger";
|
|
59
|
+
import { pushLifecycleReport } from "../lifecycle/git";
|
|
60
|
+
import { ACTIVITY_PROFILES } from "../op/activity-profiles";
|
|
61
|
+
import type { ActivityFn, ActivityProfile } from "../op/activity-registry";
|
|
62
|
+
import { shellCmd } from "../op/activities/shell";
|
|
63
|
+
import { OpRunFailure, runOpLocally, type OpRunResult, type StepRecord } from "../op/local-executor";
|
|
64
|
+
import type { OpRunFailureReason, OpRunRecord } from "../op/runtime";
|
|
65
|
+
import type { ActivityStep, OpConfig, PhaseDefinition } from "../op/types";
|
|
66
|
+
import { redactText, type Redact } from "../op/redact";
|
|
67
|
+
import { findWorkspaceRoot } from "../project-root";
|
|
68
|
+
import { parseReportUrl, reportKey } from "../pr-report-url";
|
|
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";
|
|
72
|
+
import { resolveMemberContext } from "./member-pipeline";
|
|
73
|
+
import { signOpRunRecord } from "./trust/op-run-statement";
|
|
74
|
+
import { gitTop } from "./tree";
|
|
75
|
+
import { hostRunnerKey } from "./ci-runner-key";
|
|
76
|
+
|
|
77
|
+
/** The two jobs a pull-request pipeline has. */
|
|
78
|
+
export type CiJobName = PrLoopJob["jobName"];
|
|
79
|
+
export const CI_JOB_NAMES: readonly CiJobName[] = ["plan", "apply"];
|
|
80
|
+
|
|
81
|
+
/** The Op each job is recorded as, which `ci.green` phases match. */
|
|
82
|
+
export const CI_JOB_OPS: Record<CiJobName, string> = { plan: "pr-plan", apply: "pr-apply" };
|
|
83
|
+
|
|
84
|
+
/** Where a job the chant runner ran says it ran (#4094). */
|
|
85
|
+
export const CHANT_RUNNER_ORIGIN: RunOrigin = { forge: "chant" };
|
|
86
|
+
|
|
87
|
+
/** The runner's variables every job gets, when the runner has them. */
|
|
88
|
+
export const CI_RUN_BASE_ENV = ["PATH", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL", "LC_CTYPE", "TMPDIR", "TZ"] as const;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The forge's connection and token variables the pull-request commands read
|
|
92
|
+
* (`forgeFromEnv` in ../pr-forge.ts), which a forge job gets from the forge
|
|
93
|
+
* and a job here gets from the runner's environment.
|
|
94
|
+
*/
|
|
95
|
+
export const CI_RUN_FORGE_ENV = [
|
|
96
|
+
"CHANT_FORGE_TOKEN",
|
|
97
|
+
"GITHUB_TOKEN",
|
|
98
|
+
"GH_TOKEN",
|
|
99
|
+
"GITHUB_REPOSITORY",
|
|
100
|
+
"GITHUB_SERVER_URL",
|
|
101
|
+
"GITHUB_API_URL",
|
|
102
|
+
"GITLAB_TOKEN",
|
|
103
|
+
"CI_API_V4_URL",
|
|
104
|
+
"CI_PROJECT_ID",
|
|
105
|
+
"CI_SERVER_HOST",
|
|
106
|
+
"CI_SERVER_URL",
|
|
107
|
+
] as const;
|
|
108
|
+
|
|
109
|
+
/** How long one step may run: a forge job's own limit, six hours. */
|
|
110
|
+
const STEP_TIMEOUT = "6h";
|
|
111
|
+
|
|
112
|
+
/** The forge values a job reads, given on the command line in place of a forge's expressions. */
|
|
113
|
+
export interface CiRunValues {
|
|
114
|
+
/** `--base`: the commit the change is measured from, `BASE_SHA`. */
|
|
115
|
+
base?: string;
|
|
116
|
+
/** `--pr`: the pull request, `PR_NUMBER`. The plan needs it. */
|
|
117
|
+
pr?: number;
|
|
118
|
+
/** The full commit the job runs at. */
|
|
119
|
+
commit: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export class CiRunError extends Error {
|
|
123
|
+
constructor(message: string) {
|
|
124
|
+
super(message);
|
|
125
|
+
this.name = "CiRunError";
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The job's environment: the runner's base variables, the forge variables
|
|
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.
|
|
139
|
+
*/
|
|
140
|
+
export function ciJobEnv(
|
|
141
|
+
job: PrLoopJob,
|
|
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
|
+
},
|
|
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`);
|
|
153
|
+
const out: Record<string, string> = {};
|
|
154
|
+
for (const name of [...CI_RUN_BASE_ENV, ...CI_RUN_FORGE_ENV, ...(input.passEnv ?? [])]) {
|
|
155
|
+
const value = input.hostEnv[name];
|
|
156
|
+
if (value !== undefined) out[name] = value;
|
|
157
|
+
}
|
|
158
|
+
for (const [name, value] of Object.entries(input.secrets ?? {})) if (name !== RUNNER_KEY_ENV) out[name] = value;
|
|
159
|
+
out.CHANT_ENV = input.env;
|
|
160
|
+
out[input.forge === "gitlab" ? "CI_COMMIT_SHA" : "GITHUB_SHA"] = input.values.commit;
|
|
161
|
+
const flags: Record<string, { value: string | undefined; flag: string }> = {
|
|
162
|
+
BASE_SHA: { value: input.values.base, flag: "--base <sha>" },
|
|
163
|
+
PR_NUMBER: { value: input.values.pr !== undefined ? String(input.values.pr) : undefined, flag: "--pr <number>" },
|
|
164
|
+
};
|
|
165
|
+
for (const [name, value] of Object.entries(job.env)) {
|
|
166
|
+
const given = flags[name];
|
|
167
|
+
if (given) {
|
|
168
|
+
if (given.value === undefined) throw new CiRunError(`the ${job.jobName} job reads ${name}, which a forge fills from the event; pass ${given.flag}`);
|
|
169
|
+
out[name] = given.value;
|
|
170
|
+
} else if (!value.includes("$")) {
|
|
171
|
+
out[name] = value;
|
|
172
|
+
} else if (input.secrets?.[name] !== undefined) {
|
|
173
|
+
out[name] = input.secrets[name]!;
|
|
174
|
+
} else if (input.hostEnv[name] !== undefined) {
|
|
175
|
+
out[name] = input.hostEnv[name]!;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
delete out[RUNNER_KEY_ENV];
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** What {@link ciJobOp} builds the Op from. */
|
|
183
|
+
export interface CiJobOpInput {
|
|
184
|
+
job: PrLoopJob;
|
|
185
|
+
env: string;
|
|
186
|
+
/** The directory the steps run in, inside the fresh checkout. */
|
|
187
|
+
cwd: string;
|
|
188
|
+
/** The job's environment, from {@link ciJobEnv}. */
|
|
189
|
+
jobEnv: Record<string, string>;
|
|
190
|
+
/** Setup lines, from `prLoopSetup`. */
|
|
191
|
+
setup: readonly string[];
|
|
192
|
+
/** Lines to run before the command, in order: `--before-script`, then the job's build and lint lines (#4088). */
|
|
193
|
+
build: readonly string[];
|
|
194
|
+
/** What runs chant, in place of `chant` at the start of the command. */
|
|
195
|
+
chant?: string;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** `line` with a `chant` at its start replaced by the command the caller names, when it names one. */
|
|
199
|
+
export function withChant(line: string, chant?: string): string {
|
|
200
|
+
return chant && line.startsWith("chant ") ? `${chant}${line.slice("chant".length)}` : line;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The job's own setup lines and its command, as one shell script, with `chant` at its start replaced by the command the caller names. */
|
|
204
|
+
export function ciJobCommand(job: PrLoopJob, chant?: string): string {
|
|
205
|
+
return [...(job.setup ?? []), withChant(job.command, chant)].join("\n");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The job as an Op: `setup`, `build` and the job's own phase, each line one
|
|
210
|
+
* shell step in `cwd` with the job's environment and nothing else. A phase
|
|
211
|
+
* with no lines is left out.
|
|
212
|
+
*/
|
|
213
|
+
export function ciJobOp(input: CiJobOpInput): OpConfig {
|
|
214
|
+
const step = (cmd: string): ActivityStep => ({
|
|
215
|
+
kind: "activity",
|
|
216
|
+
fn: "shellCmd",
|
|
217
|
+
args: { cmd, cwd: input.cwd, env: input.jobEnv, inheritEnv: false },
|
|
218
|
+
profile: "atMostOnce",
|
|
219
|
+
timeout: STEP_TIMEOUT,
|
|
220
|
+
});
|
|
221
|
+
const phases: PhaseDefinition[] = [
|
|
222
|
+
{ name: "setup", steps: input.setup.map(step) },
|
|
223
|
+
{ name: "build", steps: input.build.map((line) => step(withChant(line, input.chant))) },
|
|
224
|
+
{ name: input.job.jobName, steps: [step(ciJobCommand(input.job, input.chant))] },
|
|
225
|
+
].filter((p) => p.steps.length > 0);
|
|
226
|
+
const op = CI_JOB_OPS[input.job.jobName];
|
|
227
|
+
return {
|
|
228
|
+
name: op,
|
|
229
|
+
overview: input.job.jobName === "plan" ? "Plan the members this pull request reaches" : "Apply the plan a reviewer approved",
|
|
230
|
+
labels: { Env: input.env, Pipeline: "pr", Job: input.job.jobName },
|
|
231
|
+
phases,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The pull-request report statuses that mean the job did not do its work, which a failed job's `failure.code` takes. */
|
|
236
|
+
const FAILED_REPORT_STATUSES = new Set(["plan-failed", "failed", "refused"]);
|
|
237
|
+
|
|
238
|
+
/** The exit code a failed shell step's error names (`command exited <n>`), when it names one. */
|
|
239
|
+
function exitCodeOf(error: string | undefined): number | undefined {
|
|
240
|
+
const m = /command exited (\d+)/.exec(error ?? "");
|
|
241
|
+
return m ? Number(m[1]) : undefined;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The first line of a step's error, for a reason. */
|
|
245
|
+
const firstLine = (text: string): string => text.split("\n").find((l) => l.trim() !== "")?.trim() ?? text;
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Why a failed job failed (#4094), from its step records and, when the
|
|
249
|
+
* command wrote one, its pull-request report (`pr-plan.json` or
|
|
250
|
+
* `pr-apply.json` under `reportDir`):
|
|
251
|
+
*
|
|
252
|
+
* - a failed setup or build line: `setup-failed` or `build-failed`, the line
|
|
253
|
+
* and its exit code as the reason, and the phase;
|
|
254
|
+
* - a failed command with a report: the report's `refusal`, else its
|
|
255
|
+
* `status`, with its `message` as the reason, else the members that failed;
|
|
256
|
+
* - a failed command with no report: `command-failed` and the step's error.
|
|
257
|
+
*
|
|
258
|
+
* Undefined when no step failed, such as a run that stopped on an error of
|
|
259
|
+
* its own before any step ran.
|
|
260
|
+
*/
|
|
261
|
+
export function ciJobFailure(input: { job: CiJobName; op: OpConfig; records: readonly StepRecord[]; reportDir: string }): OpRunFailureReason | undefined {
|
|
262
|
+
const failed = input.records.find((r) => r.status === "fail");
|
|
263
|
+
if (!failed) return undefined;
|
|
264
|
+
const exitCode = exitCodeOf(failed.error);
|
|
265
|
+
const exit = exitCode !== undefined ? { exitCode } : {};
|
|
266
|
+
if (failed.phase === "setup" || failed.phase === "build") {
|
|
267
|
+
const at = input.records.filter((r) => r.phase === failed.phase).indexOf(failed);
|
|
268
|
+
const step = input.op.phases.find((p) => p.name === failed.phase)?.steps[at] as ActivityStep | undefined;
|
|
269
|
+
const line = typeof step?.args?.cmd === "string" ? step.args.cmd : undefined;
|
|
270
|
+
const reason = line !== undefined
|
|
271
|
+
? `${line} ${exitCode !== undefined ? `exited ${exitCode}` : `failed: ${firstLine(failed.error ?? "")}`}`
|
|
272
|
+
: firstLine(failed.error ?? "the step failed");
|
|
273
|
+
return { code: `${failed.phase}-failed`, reason, phase: failed.phase, ...exit };
|
|
274
|
+
}
|
|
275
|
+
const file = join(input.reportDir, `${CI_JOB_OPS[input.job]}.json`);
|
|
276
|
+
let report: { status?: unknown; refusal?: unknown; message?: unknown; members?: unknown } | undefined;
|
|
277
|
+
try {
|
|
278
|
+
if (existsSync(file)) report = JSON.parse(readFileSync(file, "utf-8"));
|
|
279
|
+
} catch {
|
|
280
|
+
report = undefined;
|
|
281
|
+
}
|
|
282
|
+
const status = typeof report?.status === "string" ? report.status : undefined;
|
|
283
|
+
if (report && status && (FAILED_REPORT_STATUSES.has(status) || typeof report.refusal === "string")) {
|
|
284
|
+
const code = typeof report.refusal === "string" ? report.refusal : status;
|
|
285
|
+
let reason = typeof report.message === "string" && report.message.trim() !== "" ? firstLine(report.message) : undefined;
|
|
286
|
+
if (reason === undefined) {
|
|
287
|
+
const members = Array.isArray(report.members) ? (report.members as { member?: unknown; status?: unknown; error?: unknown }[]) : [];
|
|
288
|
+
const bad = members.filter((m) => m.status === "failed" || m.status === "plan-failed");
|
|
289
|
+
reason = bad.length > 0
|
|
290
|
+
? `${bad.length === 1 ? "member" : "members"} ${bad.map((m) => `${String(m.member)} ${String(m.status)}${typeof m.error === "string" ? ` (${firstLine(m.error)})` : ""}`).join(", ")}`
|
|
291
|
+
: `the ${CI_JOB_OPS[input.job]} report says ${status}`;
|
|
292
|
+
}
|
|
293
|
+
return { code, reason, phase: failed.phase, ...exit };
|
|
294
|
+
}
|
|
295
|
+
return { code: "command-failed", reason: firstLine(failed.error ?? "the command failed"), phase: failed.phase, ...exit };
|
|
296
|
+
}
|
|
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
|
+
|
|
369
|
+
/** What `chant ci run` runs. */
|
|
370
|
+
export interface CiRunRequest {
|
|
371
|
+
/** Where the command was run: the project, or a workspace member's directory. */
|
|
372
|
+
cwd: string;
|
|
373
|
+
job: CiJobName;
|
|
374
|
+
/** `--env`: the environment the pipeline is for. */
|
|
375
|
+
env: string;
|
|
376
|
+
forge: ForgeKind;
|
|
377
|
+
/** `--base`, `--pr` and `--commit` (default `HEAD`). */
|
|
378
|
+
base?: string;
|
|
379
|
+
pr?: number;
|
|
380
|
+
commit?: string;
|
|
381
|
+
/** The generator's options: `--gate`, `--branch`, `--events-json`, `--report-url`, `--pr-build` and `--pr-build-command`. */
|
|
382
|
+
loop?: PrLoopPipelineOptions;
|
|
383
|
+
/** `--image`: the image a generated pipeline names. Only the default gets setup lines. */
|
|
384
|
+
image?: string;
|
|
385
|
+
/** `--before-script` lines, run in the build phase before the job's build lines. */
|
|
386
|
+
beforeScript?: readonly string[];
|
|
387
|
+
/** `--chant`: what runs chant in the job. Default `chant`. */
|
|
388
|
+
chant?: string;
|
|
389
|
+
/** `--pass-env`: more of the runner's variables the job gets. */
|
|
390
|
+
passEnv?: readonly string[];
|
|
391
|
+
/** The runner's environment. Default `process.env`. */
|
|
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;
|
|
399
|
+
/** `--output`: where the job's report is copied, from `cwd`. Default `.chant/pr`. */
|
|
400
|
+
output?: string;
|
|
401
|
+
/** `--run-index-limit`: how many runs the index at `loop.reportUrl` keeps (#4098). Default 200. */
|
|
402
|
+
runIndexLimit?: number;
|
|
403
|
+
/** A runner key, PEM, that signs the run's record. */
|
|
404
|
+
runnerKey?: string;
|
|
405
|
+
/**
|
|
406
|
+
* The key came from `CHANT_RUNNER_KEY`, not `--key`: one `.chant/trust.json`
|
|
407
|
+
* does not list leaves the run unsigned with a warning, as `chant run` does,
|
|
408
|
+
* where `--key` refuses the run.
|
|
409
|
+
*/
|
|
410
|
+
runnerKeyFromEnv?: boolean;
|
|
411
|
+
/** The run id. Default `chant-<epoch ms>`. */
|
|
412
|
+
runId?: string;
|
|
413
|
+
/** Where the run's events go as they happen (`--events-json`). They are kept on this host either way. */
|
|
414
|
+
runEvents?: (line: string) => void;
|
|
415
|
+
/** Where each line a step prints goes. */
|
|
416
|
+
stepOutput?: (line: string) => void;
|
|
417
|
+
/** Push `chant/lifecycle` once the record is written. Default true. */
|
|
418
|
+
push?: boolean;
|
|
419
|
+
/**
|
|
420
|
+
* The apply's resume record on this host (#4095), in place of the forge's
|
|
421
|
+
* CI cache: copied into the checkout at `PR_APPLY_RECORD` before the job,
|
|
422
|
+
* when it exists, and the job's record copied back to it after, passed or
|
|
423
|
+
* failed. `chant ci serve` names one per `prApplyRecordKey`.
|
|
424
|
+
*/
|
|
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;
|
|
428
|
+
signal?: AbortSignal;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export interface CiRunResult {
|
|
432
|
+
op: string;
|
|
433
|
+
/** The executor's outcome: `ok` or `fail` for a job. */
|
|
434
|
+
status: OpRunResult["status"];
|
|
435
|
+
record: OpRunRecord;
|
|
436
|
+
/** The step that failed and why, for a failed run. */
|
|
437
|
+
failure?: { phase: string; error: string };
|
|
438
|
+
/** Where the job's report was copied, or null when it wrote none. */
|
|
439
|
+
report: string | null;
|
|
440
|
+
/** The runner the record was signed as, or null. */
|
|
441
|
+
signer: string | null;
|
|
442
|
+
pushed: boolean;
|
|
443
|
+
pushWarning?: string;
|
|
444
|
+
/** What went wrong beside the job, such as a record the key could not sign. */
|
|
445
|
+
warnings: string[];
|
|
446
|
+
/** The checkout the job ran in, removed by now. */
|
|
447
|
+
checkout: string;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const message = (err: unknown): string => (err instanceof Error ? err.message : String(err));
|
|
451
|
+
|
|
452
|
+
function git(cwd: string, args: string[]): string {
|
|
453
|
+
return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function tryGit(cwd: string, args: string[]): string | undefined {
|
|
457
|
+
try {
|
|
458
|
+
return git(cwd, args);
|
|
459
|
+
} catch {
|
|
460
|
+
return undefined;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** The full commit `rev` names, fetched from `origin` when the repository lacks it. */
|
|
465
|
+
function resolveJobCommit(top: string, rev: string): string {
|
|
466
|
+
if (rev.startsWith("-")) throw new CiRunError(`--commit takes a commit, not ${JSON.stringify(rev)}`);
|
|
467
|
+
const local = tryGit(top, ["rev-parse", "--verify", "--quiet", `${rev}^{commit}`]);
|
|
468
|
+
if (local) return local;
|
|
469
|
+
if (tryGit(top, ["fetch", "--quiet", "--no-tags", "origin", rev]) !== undefined) {
|
|
470
|
+
const fetched = tryGit(top, ["rev-parse", "--verify", "--quiet", "FETCH_HEAD^{commit}"]);
|
|
471
|
+
if (fetched && (fetched === rev || fetched.startsWith(rev))) return fetched;
|
|
472
|
+
}
|
|
473
|
+
throw new CiRunError(`${rev} names no commit here or on origin`);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** The member a job runs for, and its directory from the repository root, as the generator decides it. */
|
|
477
|
+
export function ciJobPlace(cwd: string, top: string): { member?: string; dir: string } {
|
|
478
|
+
const found = findWorkspaceRoot(cwd);
|
|
479
|
+
const member = found ? resolveMemberContext(cwd, found) : undefined;
|
|
480
|
+
const at = member ? member.memberRoot : cwd;
|
|
481
|
+
const dir = relative(top, realpathSync(at)).split(sep).join("/");
|
|
482
|
+
return { ...(member ? { member: member.member.name } : {}), dir: dir === "" ? "." : dir };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** A detached worktree of `commit` in a new temporary directory, and how to remove it. */
|
|
486
|
+
function freshCheckout(top: string, commit: string): { dir: string; remove(): void } {
|
|
487
|
+
const holder = mkdtempSync(join(realpathSync(tmpdir()), "chant-ci-run-"));
|
|
488
|
+
const dir = join(holder, "checkout");
|
|
489
|
+
try {
|
|
490
|
+
git(top, ["worktree", "add", "--quiet", "--detach", dir, commit]);
|
|
491
|
+
} catch (err) {
|
|
492
|
+
rmSync(holder, { recursive: true, force: true });
|
|
493
|
+
const stderr = (err as { stderr?: Buffer | string }).stderr;
|
|
494
|
+
throw new CiRunError(`could not check out ${commit.slice(0, 12)} in a fresh worktree: ${String(stderr ?? err).trim()}`);
|
|
495
|
+
}
|
|
496
|
+
return {
|
|
497
|
+
dir,
|
|
498
|
+
remove() {
|
|
499
|
+
tryGit(top, ["worktree", "remove", "--force", dir]);
|
|
500
|
+
rmSync(holder, { recursive: true, force: true });
|
|
501
|
+
tryGit(top, ["worktree", "prune"]);
|
|
502
|
+
},
|
|
503
|
+
};
|
|
504
|
+
}
|
|
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
|
+
|
|
555
|
+
const ACTIVITIES = new Map<string, ActivityFn>([["shellCmd", shellCmd as unknown as ActivityFn]]);
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Run one pull-request job as a pipeline Op in a fresh checkout of the
|
|
559
|
+
* commit, record it, and copy its report out. Throws {@link CiRunError}
|
|
560
|
+
* before anything runs when the request can't be run.
|
|
561
|
+
*/
|
|
562
|
+
export async function ciRun(req: CiRunRequest): Promise<CiRunResult> {
|
|
563
|
+
const hostEnv = req.hostEnv ?? process.env;
|
|
564
|
+
const cwd = realpathSync(resolve(req.cwd));
|
|
565
|
+
const top = gitTop(cwd);
|
|
566
|
+
if (!top) throw new CiRunError("a job runs in a fresh checkout of a commit, and this directory is not in a git repository");
|
|
567
|
+
const commit = resolveJobCommit(top, req.commit ?? "HEAD");
|
|
568
|
+
const place = ciJobPlace(cwd, top);
|
|
569
|
+
const jobs = prLoopJobs(req.forge, req.env, req.loop ?? {}, place.member);
|
|
570
|
+
const job = jobs.find((j) => j.jobName === req.job);
|
|
571
|
+
if (!job) throw new CiRunError(`the pull-request pipeline has no ${req.job} job; it has ${jobs.map((j) => j.jobName).join(" and ")}`);
|
|
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;
|
|
597
|
+
|
|
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.
|
|
599
|
+
let signer: ReturnType<typeof ciTagSigner> | undefined;
|
|
600
|
+
const early: string[] = [];
|
|
601
|
+
if (req.runnerKey !== undefined) {
|
|
602
|
+
try {
|
|
603
|
+
signer = ciTagSigner(top, commit, req.runnerKey, "the runs it signed");
|
|
604
|
+
} catch (err) {
|
|
605
|
+
if (!req.runnerKeyFromEnv) throw new CiRunError(`the run would not be signed: ${message(err)}`);
|
|
606
|
+
early.push(`${RUNNER_KEY_ENV} is set, and the run will not be signed: ${message(err)}`);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const checkout = freshCheckout(top, commit);
|
|
611
|
+
try {
|
|
612
|
+
const jobDir = place.dir === "." ? checkout.dir : join(checkout.dir, place.dir);
|
|
613
|
+
const op = ciJobOp({
|
|
614
|
+
job,
|
|
615
|
+
env: req.env,
|
|
616
|
+
cwd: jobDir,
|
|
617
|
+
jobEnv,
|
|
618
|
+
setup: prLoopSetup(req.image ?? PR_LOOP_IMAGE),
|
|
619
|
+
build: [...(req.beforeScript ?? []), ...(job.build ?? [])],
|
|
620
|
+
...(req.chant ? { chant: req.chant } : {}),
|
|
621
|
+
});
|
|
622
|
+
const runId = req.runId ?? `chant-${Date.now()}`;
|
|
623
|
+
const warnings: string[] = [...early];
|
|
624
|
+
const resume = req.job === "apply" && req.resumeRecord ? { host: resolve(req.resumeRecord), job: join(jobDir, PR_APPLY_RECORD) } : undefined;
|
|
625
|
+
if (resume && existsSync(resume.host)) {
|
|
626
|
+
mkdirSync(dirname(resume.job), { recursive: true });
|
|
627
|
+
copyFileSync(resume.host, resume.job);
|
|
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`);
|
|
643
|
+
let ledgerError: string | undefined;
|
|
644
|
+
let result: OpRunResult;
|
|
645
|
+
try {
|
|
646
|
+
result = await runOpLocally(op, ACTIVITIES, ACTIVITY_PROFILES as unknown as Record<string, ActivityProfile>, req.signal, {
|
|
647
|
+
runId,
|
|
648
|
+
env: req.env,
|
|
649
|
+
cwd: jobDir,
|
|
650
|
+
commit,
|
|
651
|
+
ledger: { cwd: jobDir },
|
|
652
|
+
runner: { kind: "pipeline", runtime: "ci", runOrigin: CHANT_RUNNER_ORIGIN },
|
|
653
|
+
describeFailure: (records) => ciJobFailure({ job: req.job, op, records, reportDir: join(jobDir, PR_LOOP_REPORT_DIR) }),
|
|
654
|
+
...(req.runEvents ? { runEvents: { write: req.runEvents } } : {}),
|
|
655
|
+
...(req.stepOutput ? { stepOutput: req.stepOutput } : {}),
|
|
656
|
+
...(signer ? { signRecord: (record: OpRunRecord) => signOpRunRecord(record, signer!) } : {}),
|
|
657
|
+
onSignError: (err) => warnings.push(`the run was not signed: ${message(err)}`),
|
|
658
|
+
...(redact ? { redact } : {}),
|
|
659
|
+
onLedgerError: (err) => {
|
|
660
|
+
ledgerError = `the job ran, and its record could not be written to chant/lifecycle: ${message(err)}`;
|
|
661
|
+
},
|
|
662
|
+
});
|
|
663
|
+
} catch (err) {
|
|
664
|
+
if (!(err instanceof OpRunFailure)) {
|
|
665
|
+
await postStatus("error", `${op.name} stopped: ${firstLine(message(err))}`);
|
|
666
|
+
throw err;
|
|
667
|
+
}
|
|
668
|
+
result = err.result;
|
|
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);
|
|
677
|
+
|
|
678
|
+
if (resume && existsSync(resume.job)) {
|
|
679
|
+
try {
|
|
680
|
+
mkdirSync(dirname(resume.host), { recursive: true });
|
|
681
|
+
copyFileSync(resume.job, resume.host);
|
|
682
|
+
} catch (err) {
|
|
683
|
+
warnings.push(`the resume record could not be kept at ${resume.host}: ${message(err)}`);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
const written = join(jobDir, PR_LOOP_REPORT_DIR);
|
|
687
|
+
let report: string | null = null;
|
|
688
|
+
if (existsSync(written)) {
|
|
689
|
+
report = resolve(cwd, req.output ?? PR_LOOP_REPORT_DIR);
|
|
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) });
|
|
695
|
+
}
|
|
696
|
+
if (ledgerError) throw new CiRunError(ledgerError);
|
|
697
|
+
const pushed: { pushed: boolean; pushWarning?: string } = req.push === false ? { pushed: false, pushWarning: "chant/lifecycle was not pushed" } : await pushLifecycleReport({ cwd: top });
|
|
698
|
+
const failed = result.records.find((r) => r.status === "fail");
|
|
699
|
+
return {
|
|
700
|
+
op: op.name,
|
|
701
|
+
status: result.status,
|
|
702
|
+
record: result.record,
|
|
703
|
+
...(failed ? { failure: { phase: failed.phase, error: failed.error ?? "the step failed" } } : {}),
|
|
704
|
+
report,
|
|
705
|
+
signer: signer?.principal ?? null,
|
|
706
|
+
pushed: pushed.pushed,
|
|
707
|
+
...(pushed.pushWarning ? { pushWarning: pushed.pushWarning } : {}),
|
|
708
|
+
warnings,
|
|
709
|
+
checkout: checkout.dir,
|
|
710
|
+
};
|
|
711
|
+
} finally {
|
|
712
|
+
checkout.remove();
|
|
713
|
+
}
|
|
714
|
+
}
|
|
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
|
+
|
|
737
|
+
/** The text `chant ci run` prints when the run settles. */
|
|
738
|
+
export function formatCiRun(r: CiRunResult): string {
|
|
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}`];
|
|
740
|
+
const why = r.record.failure;
|
|
741
|
+
if (why) lines.push(` failure: ${why.code}${why.phase ? ` in ${why.phase}` : ""}${why.exitCode !== undefined ? `, exit ${why.exitCode}` : ""}: ${why.reason}`);
|
|
742
|
+
else if (r.failure) lines.push(` ${r.failure.phase}: ${r.failure.error}`);
|
|
743
|
+
lines.push(` record: ${r.record.env}/runs__${r.op}.jsonl on chant/lifecycle, ${r.signer ? `signed by ${r.signer}` : "unsigned, so ci tick from runs does not count it"}`);
|
|
744
|
+
if (r.report) lines.push(` report: ${r.report}`);
|
|
745
|
+
if (!r.pushed) lines.push(` ${r.pushWarning ?? "chant/lifecycle was not pushed"}`);
|
|
746
|
+
return lines.join("\n");
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// ── The command ──────────────────────────────────────────────────────────────
|
|
750
|
+
|
|
751
|
+
const USAGE =
|
|
752
|
+
"chant ci run plan|apply --env <env> --base <sha> [--pr <n>] [--commit <sha>] [--forge github|gitlab|forgejo] " +
|
|
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>]";
|
|
756
|
+
|
|
757
|
+
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
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
|
+
|
|
778
|
+
/** `chant ci run plan|apply` (#4094). */
|
|
779
|
+
export async function runCiRun(ctx: CommandContext): Promise<number> {
|
|
780
|
+
const { args } = ctx;
|
|
781
|
+
try {
|
|
782
|
+
if (args.setupKey) return (await import("./ci-serve")).runnerKeySetupCommand(ctx);
|
|
783
|
+
const job = args.extraPositional;
|
|
784
|
+
if (job !== "plan" && job !== "apply") throw new CiRunError(`ci run takes the job to run, plan or apply${job ? `, not ${JSON.stringify(job)}` : ""}`);
|
|
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>");
|
|
786
|
+
const forge = args.forge ?? detectForge(process.env);
|
|
787
|
+
if (!forge) throw new CiRunError("--forge names the forge the job posts to: --forge github|gitlab|forgejo");
|
|
788
|
+
if (!(FORGE_KINDS as readonly string[]).includes(forge)) throw new CiRunError(`--forge takes github, gitlab or forgejo, not ${JSON.stringify(forge)}`);
|
|
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)}`);
|
|
790
|
+
const fromFlag = args.key !== undefined;
|
|
791
|
+
// #4099: without --key or CHANT_RUNNER_KEY, the host's key that --setup-key made, if there is one. Like the variable, an unlisted one only warns.
|
|
792
|
+
const hostKey = fromFlag || process.env[RUNNER_KEY_ENV] ? undefined : hostRunnerKeyAt(process.cwd(), args.statePath);
|
|
793
|
+
const runnerKey = fromFlag ? readFileSync(resolve(args.key!), "utf-8") : process.env[RUNNER_KEY_ENV] || hostKey?.pem;
|
|
794
|
+
const hostEnv = process.env;
|
|
795
|
+
const { status, warning } = ciStatusFromEnv({ forge: forge as ForgeKind, hostEnv, noStatus: args.noStatus, statusUrl: args.statusUrl });
|
|
796
|
+
if (warning) console.error(formatWarning({ message: warning }));
|
|
797
|
+
const secrets = ciSecretsOf(args.ciSecrets);
|
|
798
|
+
const result = await ciRun({
|
|
799
|
+
cwd: process.cwd(),
|
|
800
|
+
...(status ? { status } : {}),
|
|
801
|
+
job,
|
|
802
|
+
env: args.env,
|
|
803
|
+
forge: forge as ForgeKind,
|
|
804
|
+
...(args.base ? { base: args.base } : {}),
|
|
805
|
+
...(args.pr !== undefined ? { pr: args.pr } : {}),
|
|
806
|
+
...(args.commit ? { commit: args.commit } : {}),
|
|
807
|
+
loop: {
|
|
808
|
+
...(args.gate ? { gate: args.gate } : {}),
|
|
809
|
+
...(args.branch ? { branch: args.branch } : {}),
|
|
810
|
+
...(args.eventsJson ? { events: true } : {}),
|
|
811
|
+
...(args.reportUrl ? { reportUrl: args.reportUrl } : {}),
|
|
812
|
+
...(args.prBuild ? { build: true } : {}),
|
|
813
|
+
...(args.prBuildCommand ? { buildCommand: args.prBuildCommand } : {}),
|
|
814
|
+
},
|
|
815
|
+
...(args.runIndexLimit ? { runIndexLimit: args.runIndexLimit } : {}),
|
|
816
|
+
...(args.image ? { image: args.image } : {}),
|
|
817
|
+
...(args.beforeScript?.length ? { beforeScript: args.beforeScript } : {}),
|
|
818
|
+
...(args.ciChant ? { chant: args.ciChant } : {}),
|
|
819
|
+
...(args.passEnv?.length ? { passEnv: args.passEnv } : {}),
|
|
820
|
+
...(secrets ? { secrets } : {}),
|
|
821
|
+
...(args.output ? { output: args.output } : {}),
|
|
822
|
+
...(runnerKey !== undefined ? { runnerKey, runnerKeyFromEnv: !fromFlag } : {}),
|
|
823
|
+
...(args.eventsJson ? { runEvents: (line: string) => void process.stdout.write(line) } : {}),
|
|
824
|
+
stepOutput: (line) => void process.stderr.write(`${line}\n`),
|
|
825
|
+
});
|
|
826
|
+
for (const w of result.warnings) console.error(formatWarning({ message: w }));
|
|
827
|
+
console.error(formatCiRun(result));
|
|
828
|
+
return result.status === "ok" ? 0 : 1;
|
|
829
|
+
} catch (err) {
|
|
830
|
+
console.error(formatError({ message: `chant ci run: ${message(err)}`, hint: USAGE }));
|
|
831
|
+
return 1;
|
|
832
|
+
}
|
|
833
|
+
}
|