@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.
Files changed (145) hide show
  1. package/dist/cli/handlers/build.d.ts.map +1 -1
  2. package/dist/cli/handlers/build.js +4 -1
  3. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  4. package/dist/cli/handlers/fan-out.js +27 -5
  5. package/dist/cli/handlers/pr.d.ts.map +1 -1
  6. package/dist/cli/main.d.ts.map +1 -1
  7. package/dist/cli/main.js +64 -0
  8. package/dist/cli/parse-args.d.ts.map +1 -1
  9. package/dist/cli/parse-args.js +99 -0
  10. package/dist/cli/registry.d.ts +35 -1
  11. package/dist/cli/registry.d.ts.map +1 -1
  12. package/dist/components/pr-pipeline.d.ts +24 -0
  13. package/dist/components/pr-pipeline.d.ts.map +1 -1
  14. package/dist/components/pr-pipeline.js +16 -0
  15. package/dist/components/pr-run.d.ts +20 -2
  16. package/dist/components/pr-run.d.ts.map +1 -1
  17. package/dist/components/pr-run.js +26 -13
  18. package/dist/components/wave-plan.d.ts +11 -0
  19. package/dist/components/wave-plan.d.ts.map +1 -1
  20. package/dist/components/wave-plan.js +26 -0
  21. package/dist/lifecycle/release-ledger.d.ts +8 -3
  22. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  23. package/dist/lifecycle/release-ledger.js +2 -0
  24. package/dist/lifecycle/run-ledger.d.ts +1 -1
  25. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  26. package/dist/lifecycle/run-ledger.js +9 -0
  27. package/dist/op/activities/activity-contracts.d.ts +1 -0
  28. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  29. package/dist/op/activities/activity-contracts.js +1 -0
  30. package/dist/op/activities/shell.d.ts +6 -0
  31. package/dist/op/activities/shell.d.ts.map +1 -1
  32. package/dist/op/activities/shell.js +1 -1
  33. package/dist/op/local-executor.d.ts +35 -1
  34. package/dist/op/local-executor.d.ts.map +1 -1
  35. package/dist/op/local-executor.js +31 -6
  36. package/dist/op/redact.d.ts +34 -0
  37. package/dist/op/redact.d.ts.map +1 -0
  38. package/dist/op/redact.js +94 -0
  39. package/dist/op/runtime.d.ts +27 -0
  40. package/dist/op/runtime.d.ts.map +1 -1
  41. package/dist/op/runtime.js +20 -0
  42. package/dist/op/step-console.d.ts +2 -0
  43. package/dist/op/step-console.d.ts.map +1 -1
  44. package/dist/op/step-console.js +5 -2
  45. package/dist/pr-forge.d.ts +40 -0
  46. package/dist/pr-forge.d.ts.map +1 -1
  47. package/dist/pr-forge.js +56 -6
  48. package/dist/pr-loop.d.ts +10 -0
  49. package/dist/pr-loop.d.ts.map +1 -1
  50. package/dist/pr-loop.js +7 -0
  51. package/dist/pr-report-url.d.ts +8 -0
  52. package/dist/pr-report-url.d.ts.map +1 -1
  53. package/dist/pr-report-url.js +47 -1
  54. package/dist/run-event-stream.d.ts +2 -2
  55. package/dist/run-event-stream.d.ts.map +1 -1
  56. package/dist/run-event-stream.js +2 -2
  57. package/dist/run-event.d.ts +15 -0
  58. package/dist/run-event.d.ts.map +1 -1
  59. package/dist/run-event.js +2 -0
  60. package/dist/wave-runtime.mjs +171 -171
  61. package/dist/workspace/ci-green-forge.d.ts +2 -0
  62. package/dist/workspace/ci-green-forge.d.ts.map +1 -1
  63. package/dist/workspace/ci-green.d.ts +2 -0
  64. package/dist/workspace/ci-green.d.ts.map +1 -1
  65. package/dist/workspace/ci-green.js +4 -2
  66. package/dist/workspace/ci-run-index.d.ts +96 -0
  67. package/dist/workspace/ci-run-index.d.ts.map +1 -0
  68. package/dist/workspace/ci-run-index.js +162 -0
  69. package/dist/workspace/ci-run-index.schema.json +50 -0
  70. package/dist/workspace/ci-run.d.ts +299 -0
  71. package/dist/workspace/ci-run.d.ts.map +1 -0
  72. package/dist/workspace/ci-run.js +691 -0
  73. package/dist/workspace/ci-runner-key.d.ts +120 -0
  74. package/dist/workspace/ci-runner-key.d.ts.map +1 -0
  75. package/dist/workspace/ci-runner-key.js +217 -0
  76. package/dist/workspace/ci-secrets.d.ts +65 -0
  77. package/dist/workspace/ci-secrets.d.ts.map +1 -0
  78. package/dist/workspace/ci-secrets.js +211 -0
  79. package/dist/workspace/ci-serve.d.ts +227 -0
  80. package/dist/workspace/ci-serve.d.ts.map +1 -0
  81. package/dist/workspace/ci-serve.js +468 -0
  82. package/dist/workspace/pr-report.schema.json +5 -0
  83. package/dist/workspace/reason-codes.d.ts +6 -2
  84. package/dist/workspace/reason-codes.d.ts.map +1 -1
  85. package/dist/workspace/reason-codes.js +7 -2
  86. package/dist/workspace/run-event.schema.json +14 -3
  87. package/dist/workspace/status-stewards.d.ts +2 -0
  88. package/dist/workspace/status-stewards.d.ts.map +1 -1
  89. package/dist/workspace/status-stewards.js +1 -0
  90. package/dist/workspace/status.schema.json +12 -1
  91. package/dist/workspace/trust/trust-write.d.ts +109 -0
  92. package/dist/workspace/trust/trust-write.d.ts.map +1 -0
  93. package/dist/workspace/trust/trust-write.js +320 -0
  94. package/dist/workspace/trust-write.schema.json +265 -0
  95. package/package.json +1 -1
  96. package/src/cli/handlers/build.test.ts +15 -0
  97. package/src/cli/handlers/build.ts +4 -1
  98. package/src/cli/handlers/fan-out.test.ts +82 -0
  99. package/src/cli/handlers/fan-out.ts +34 -5
  100. package/src/cli/handlers/pr.ts +5 -0
  101. package/src/cli/main.test.ts +1 -0
  102. package/src/cli/main.ts +64 -0
  103. package/src/cli/parse-args.ts +71 -0
  104. package/src/cli/registry.ts +35 -1
  105. package/src/components/pr-pipeline.ts +32 -0
  106. package/src/components/pr-run.test.ts +127 -0
  107. package/src/components/pr-run.ts +32 -12
  108. package/src/components/wave-plan.ts +30 -0
  109. package/src/lifecycle/release-ledger.ts +9 -3
  110. package/src/lifecycle/run-ledger.ts +7 -1
  111. package/src/op/activities/activity-contracts.ts +1 -0
  112. package/src/op/activities/shell.ts +7 -1
  113. package/src/op/local-executor.ts +58 -7
  114. package/src/op/redact.ts +93 -0
  115. package/src/op/runtime.ts +42 -0
  116. package/src/op/step-console.ts +7 -2
  117. package/src/pr-forge.test.ts +54 -0
  118. package/src/pr-forge.ts +92 -6
  119. package/src/pr-loop.test.ts +13 -1
  120. package/src/pr-loop.ts +16 -0
  121. package/src/pr-report-url.ts +53 -1
  122. package/src/run-event-stream.ts +4 -3
  123. package/src/run-event.ts +17 -0
  124. package/src/workspace/ci-green-forge.ts +2 -0
  125. package/src/workspace/ci-green.ts +6 -2
  126. package/src/workspace/ci-run-index.schema.json +50 -0
  127. package/src/workspace/ci-run-index.test.ts +353 -0
  128. package/src/workspace/ci-run-index.ts +192 -0
  129. package/src/workspace/ci-run.test.ts +716 -0
  130. package/src/workspace/ci-run.ts +833 -0
  131. package/src/workspace/ci-runner-key.test.ts +166 -0
  132. package/src/workspace/ci-runner-key.ts +250 -0
  133. package/src/workspace/ci-secrets.test.ts +106 -0
  134. package/src/workspace/ci-secrets.ts +213 -0
  135. package/src/workspace/ci-serve.test.ts +367 -0
  136. package/src/workspace/ci-serve.ts +574 -0
  137. package/src/workspace/pr-report.schema.json +5 -0
  138. package/src/workspace/reason-codes.test.ts +2 -0
  139. package/src/workspace/reason-codes.ts +7 -2
  140. package/src/workspace/run-event.schema.json +14 -3
  141. package/src/workspace/status-stewards.ts +3 -0
  142. package/src/workspace/status.schema.json +12 -1
  143. package/src/workspace/trust/trust-write.test.ts +179 -0
  144. package/src/workspace/trust/trust-write.ts +377 -0
  145. package/src/workspace/trust-write.schema.json +265 -0
@@ -0,0 +1,691 @@
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
+ import { execFileSync } from "node:child_process";
49
+ import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
50
+ import { tmpdir } from "node:os";
51
+ import { dirname, join, relative, resolve, sep } from "node:path";
52
+ import { PR_APPLY_RECORD, PR_LOOP_REPORT_DIR, prLoopJobs, prLoopSetup, PR_LOOP_IMAGE } from "../components/pr-pipeline.js";
53
+ import { detectForge, ForgeEnvironmentError, forgeFromEnv, FORGE_KINDS } from "../pr-forge.js";
54
+ import { PR_APPLY_GATE } from "../pr-loop.js";
55
+ import { formatError, formatWarning } from "../cli/format.js";
56
+ import { pushLifecycleReport } from "../lifecycle/git.js";
57
+ import { ACTIVITY_PROFILES } from "../op/activity-profiles.js";
58
+ import { shellCmd } from "../op/activities/shell.js";
59
+ import { OpRunFailure, runOpLocally } from "../op/local-executor.js";
60
+ import { redactText } from "../op/redact.js";
61
+ import { findWorkspaceRoot } from "../project-root.js";
62
+ import { parseReportUrl, reportKey } from "../pr-report-url.js";
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";
66
+ import { resolveMemberContext } from "./member-pipeline.js";
67
+ import { signOpRunRecord } from "./trust/op-run-statement.js";
68
+ import { gitTop } from "./tree.js";
69
+ import { hostRunnerKey } from "./ci-runner-key.js";
70
+ export const CI_JOB_NAMES = ["plan", "apply"];
71
+ /** The Op each job is recorded as, which `ci.green` phases match. */
72
+ export const CI_JOB_OPS = { plan: "pr-plan", apply: "pr-apply" };
73
+ /** Where a job the chant runner ran says it ran (#4094). */
74
+ export const CHANT_RUNNER_ORIGIN = { forge: "chant" };
75
+ /** The runner's variables every job gets, when the runner has them. */
76
+ export const CI_RUN_BASE_ENV = ["PATH", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL", "LC_CTYPE", "TMPDIR", "TZ"];
77
+ /**
78
+ * The forge's connection and token variables the pull-request commands read
79
+ * (`forgeFromEnv` in ../pr-forge.ts), which a forge job gets from the forge
80
+ * and a job here gets from the runner's environment.
81
+ */
82
+ export const CI_RUN_FORGE_ENV = [
83
+ "CHANT_FORGE_TOKEN",
84
+ "GITHUB_TOKEN",
85
+ "GH_TOKEN",
86
+ "GITHUB_REPOSITORY",
87
+ "GITHUB_SERVER_URL",
88
+ "GITHUB_API_URL",
89
+ "GITLAB_TOKEN",
90
+ "CI_API_V4_URL",
91
+ "CI_PROJECT_ID",
92
+ "CI_SERVER_HOST",
93
+ "CI_SERVER_URL",
94
+ ];
95
+ /** How long one step may run: a forge job's own limit, six hours. */
96
+ const STEP_TIMEOUT = "6h";
97
+ export class CiRunError extends Error {
98
+ constructor(message) {
99
+ super(message);
100
+ this.name = "CiRunError";
101
+ }
102
+ }
103
+ /**
104
+ * The job's environment: the runner's base variables, the forge variables
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.
113
+ */
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`);
117
+ const out = {};
118
+ for (const name of [...CI_RUN_BASE_ENV, ...CI_RUN_FORGE_ENV, ...(input.passEnv ?? [])]) {
119
+ const value = input.hostEnv[name];
120
+ if (value !== undefined)
121
+ out[name] = value;
122
+ }
123
+ for (const [name, value] of Object.entries(input.secrets ?? {}))
124
+ if (name !== RUNNER_KEY_ENV)
125
+ out[name] = value;
126
+ out.CHANT_ENV = input.env;
127
+ out[input.forge === "gitlab" ? "CI_COMMIT_SHA" : "GITHUB_SHA"] = input.values.commit;
128
+ const flags = {
129
+ BASE_SHA: { value: input.values.base, flag: "--base <sha>" },
130
+ PR_NUMBER: { value: input.values.pr !== undefined ? String(input.values.pr) : undefined, flag: "--pr <number>" },
131
+ };
132
+ for (const [name, value] of Object.entries(job.env)) {
133
+ const given = flags[name];
134
+ if (given) {
135
+ if (given.value === undefined)
136
+ throw new CiRunError(`the ${job.jobName} job reads ${name}, which a forge fills from the event; pass ${given.flag}`);
137
+ out[name] = given.value;
138
+ }
139
+ else if (!value.includes("$")) {
140
+ out[name] = value;
141
+ }
142
+ else if (input.secrets?.[name] !== undefined) {
143
+ out[name] = input.secrets[name];
144
+ }
145
+ else if (input.hostEnv[name] !== undefined) {
146
+ out[name] = input.hostEnv[name];
147
+ }
148
+ }
149
+ delete out[RUNNER_KEY_ENV];
150
+ return out;
151
+ }
152
+ /** `line` with a `chant` at its start replaced by the command the caller names, when it names one. */
153
+ export function withChant(line, chant) {
154
+ return chant && line.startsWith("chant ") ? `${chant}${line.slice("chant".length)}` : line;
155
+ }
156
+ /** 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. */
157
+ export function ciJobCommand(job, chant) {
158
+ return [...(job.setup ?? []), withChant(job.command, chant)].join("\n");
159
+ }
160
+ /**
161
+ * The job as an Op: `setup`, `build` and the job's own phase, each line one
162
+ * shell step in `cwd` with the job's environment and nothing else. A phase
163
+ * with no lines is left out.
164
+ */
165
+ export function ciJobOp(input) {
166
+ const step = (cmd) => ({
167
+ kind: "activity",
168
+ fn: "shellCmd",
169
+ args: { cmd, cwd: input.cwd, env: input.jobEnv, inheritEnv: false },
170
+ profile: "atMostOnce",
171
+ timeout: STEP_TIMEOUT,
172
+ });
173
+ const phases = [
174
+ { name: "setup", steps: input.setup.map(step) },
175
+ { name: "build", steps: input.build.map((line) => step(withChant(line, input.chant))) },
176
+ { name: input.job.jobName, steps: [step(ciJobCommand(input.job, input.chant))] },
177
+ ].filter((p) => p.steps.length > 0);
178
+ const op = CI_JOB_OPS[input.job.jobName];
179
+ return {
180
+ name: op,
181
+ overview: input.job.jobName === "plan" ? "Plan the members this pull request reaches" : "Apply the plan a reviewer approved",
182
+ labels: { Env: input.env, Pipeline: "pr", Job: input.job.jobName },
183
+ phases,
184
+ };
185
+ }
186
+ /** The pull-request report statuses that mean the job did not do its work, which a failed job's `failure.code` takes. */
187
+ const FAILED_REPORT_STATUSES = new Set(["plan-failed", "failed", "refused"]);
188
+ /** The exit code a failed shell step's error names (`command exited <n>`), when it names one. */
189
+ function exitCodeOf(error) {
190
+ const m = /command exited (\d+)/.exec(error ?? "");
191
+ return m ? Number(m[1]) : undefined;
192
+ }
193
+ /** The first line of a step's error, for a reason. */
194
+ const firstLine = (text) => text.split("\n").find((l) => l.trim() !== "")?.trim() ?? text;
195
+ /**
196
+ * Why a failed job failed (#4094), from its step records and, when the
197
+ * command wrote one, its pull-request report (`pr-plan.json` or
198
+ * `pr-apply.json` under `reportDir`):
199
+ *
200
+ * - a failed setup or build line: `setup-failed` or `build-failed`, the line
201
+ * and its exit code as the reason, and the phase;
202
+ * - a failed command with a report: the report's `refusal`, else its
203
+ * `status`, with its `message` as the reason, else the members that failed;
204
+ * - a failed command with no report: `command-failed` and the step's error.
205
+ *
206
+ * Undefined when no step failed, such as a run that stopped on an error of
207
+ * its own before any step ran.
208
+ */
209
+ export function ciJobFailure(input) {
210
+ const failed = input.records.find((r) => r.status === "fail");
211
+ if (!failed)
212
+ return undefined;
213
+ const exitCode = exitCodeOf(failed.error);
214
+ const exit = exitCode !== undefined ? { exitCode } : {};
215
+ if (failed.phase === "setup" || failed.phase === "build") {
216
+ const at = input.records.filter((r) => r.phase === failed.phase).indexOf(failed);
217
+ const step = input.op.phases.find((p) => p.name === failed.phase)?.steps[at];
218
+ const line = typeof step?.args?.cmd === "string" ? step.args.cmd : undefined;
219
+ const reason = line !== undefined
220
+ ? `${line} ${exitCode !== undefined ? `exited ${exitCode}` : `failed: ${firstLine(failed.error ?? "")}`}`
221
+ : firstLine(failed.error ?? "the step failed");
222
+ return { code: `${failed.phase}-failed`, reason, phase: failed.phase, ...exit };
223
+ }
224
+ const file = join(input.reportDir, `${CI_JOB_OPS[input.job]}.json`);
225
+ let report;
226
+ try {
227
+ if (existsSync(file))
228
+ report = JSON.parse(readFileSync(file, "utf-8"));
229
+ }
230
+ catch {
231
+ report = undefined;
232
+ }
233
+ const status = typeof report?.status === "string" ? report.status : undefined;
234
+ if (report && status && (FAILED_REPORT_STATUSES.has(status) || typeof report.refusal === "string")) {
235
+ const code = typeof report.refusal === "string" ? report.refusal : status;
236
+ let reason = typeof report.message === "string" && report.message.trim() !== "" ? firstLine(report.message) : undefined;
237
+ if (reason === undefined) {
238
+ const members = Array.isArray(report.members) ? report.members : [];
239
+ const bad = members.filter((m) => m.status === "failed" || m.status === "plan-failed");
240
+ reason = bad.length > 0
241
+ ? `${bad.length === 1 ? "member" : "members"} ${bad.map((m) => `${String(m.member)} ${String(m.status)}${typeof m.error === "string" ? ` (${firstLine(m.error)})` : ""}`).join(", ")}`
242
+ : `the ${CI_JOB_OPS[input.job]} report says ${status}`;
243
+ }
244
+ return { code, reason, phase: failed.phase, ...exit };
245
+ }
246
+ return { code: "command-failed", reason: firstLine(failed.error ?? "the command failed"), phase: failed.phase, ...exit };
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
+ }
309
+ const message = (err) => (err instanceof Error ? err.message : String(err));
310
+ function git(cwd, args) {
311
+ return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }).trim();
312
+ }
313
+ function tryGit(cwd, args) {
314
+ try {
315
+ return git(cwd, args);
316
+ }
317
+ catch {
318
+ return undefined;
319
+ }
320
+ }
321
+ /** The full commit `rev` names, fetched from `origin` when the repository lacks it. */
322
+ function resolveJobCommit(top, rev) {
323
+ if (rev.startsWith("-"))
324
+ throw new CiRunError(`--commit takes a commit, not ${JSON.stringify(rev)}`);
325
+ const local = tryGit(top, ["rev-parse", "--verify", "--quiet", `${rev}^{commit}`]);
326
+ if (local)
327
+ return local;
328
+ if (tryGit(top, ["fetch", "--quiet", "--no-tags", "origin", rev]) !== undefined) {
329
+ const fetched = tryGit(top, ["rev-parse", "--verify", "--quiet", "FETCH_HEAD^{commit}"]);
330
+ if (fetched && (fetched === rev || fetched.startsWith(rev)))
331
+ return fetched;
332
+ }
333
+ throw new CiRunError(`${rev} names no commit here or on origin`);
334
+ }
335
+ /** The member a job runs for, and its directory from the repository root, as the generator decides it. */
336
+ export function ciJobPlace(cwd, top) {
337
+ const found = findWorkspaceRoot(cwd);
338
+ const member = found ? resolveMemberContext(cwd, found) : undefined;
339
+ const at = member ? member.memberRoot : cwd;
340
+ const dir = relative(top, realpathSync(at)).split(sep).join("/");
341
+ return { ...(member ? { member: member.member.name } : {}), dir: dir === "" ? "." : dir };
342
+ }
343
+ /** A detached worktree of `commit` in a new temporary directory, and how to remove it. */
344
+ function freshCheckout(top, commit) {
345
+ const holder = mkdtempSync(join(realpathSync(tmpdir()), "chant-ci-run-"));
346
+ const dir = join(holder, "checkout");
347
+ try {
348
+ git(top, ["worktree", "add", "--quiet", "--detach", dir, commit]);
349
+ }
350
+ catch (err) {
351
+ rmSync(holder, { recursive: true, force: true });
352
+ const stderr = err.stderr;
353
+ throw new CiRunError(`could not check out ${commit.slice(0, 12)} in a fresh worktree: ${String(stderr ?? err).trim()}`);
354
+ }
355
+ return {
356
+ dir,
357
+ remove() {
358
+ tryGit(top, ["worktree", "remove", "--force", dir]);
359
+ rmSync(holder, { recursive: true, force: true });
360
+ tryGit(top, ["worktree", "prune"]);
361
+ },
362
+ };
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
+ }
403
+ const ACTIVITIES = new Map([["shellCmd", shellCmd]]);
404
+ /**
405
+ * Run one pull-request job as a pipeline Op in a fresh checkout of the
406
+ * commit, record it, and copy its report out. Throws {@link CiRunError}
407
+ * before anything runs when the request can't be run.
408
+ */
409
+ export async function ciRun(req) {
410
+ const hostEnv = req.hostEnv ?? process.env;
411
+ const cwd = realpathSync(resolve(req.cwd));
412
+ const top = gitTop(cwd);
413
+ if (!top)
414
+ throw new CiRunError("a job runs in a fresh checkout of a commit, and this directory is not in a git repository");
415
+ const commit = resolveJobCommit(top, req.commit ?? "HEAD");
416
+ const place = ciJobPlace(cwd, top);
417
+ const jobs = prLoopJobs(req.forge, req.env, req.loop ?? {}, place.member);
418
+ const job = jobs.find((j) => j.jobName === req.job);
419
+ if (!job)
420
+ throw new CiRunError(`the pull-request pipeline has no ${req.job} job; it has ${jobs.map((j) => j.jobName).join(" and ")}`);
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;
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.
445
+ let signer;
446
+ const early = [];
447
+ if (req.runnerKey !== undefined) {
448
+ try {
449
+ signer = ciTagSigner(top, commit, req.runnerKey, "the runs it signed");
450
+ }
451
+ catch (err) {
452
+ if (!req.runnerKeyFromEnv)
453
+ throw new CiRunError(`the run would not be signed: ${message(err)}`);
454
+ early.push(`${RUNNER_KEY_ENV} is set, and the run will not be signed: ${message(err)}`);
455
+ }
456
+ }
457
+ const checkout = freshCheckout(top, commit);
458
+ try {
459
+ const jobDir = place.dir === "." ? checkout.dir : join(checkout.dir, place.dir);
460
+ const op = ciJobOp({
461
+ job,
462
+ env: req.env,
463
+ cwd: jobDir,
464
+ jobEnv,
465
+ setup: prLoopSetup(req.image ?? PR_LOOP_IMAGE),
466
+ build: [...(req.beforeScript ?? []), ...(job.build ?? [])],
467
+ ...(req.chant ? { chant: req.chant } : {}),
468
+ });
469
+ const runId = req.runId ?? `chant-${Date.now()}`;
470
+ const warnings = [...early];
471
+ const resume = req.job === "apply" && req.resumeRecord ? { host: resolve(req.resumeRecord), job: join(jobDir, PR_APPLY_RECORD) } : undefined;
472
+ if (resume && existsSync(resume.host)) {
473
+ mkdirSync(dirname(resume.job), { recursive: true });
474
+ copyFileSync(resume.host, resume.job);
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`);
492
+ let ledgerError;
493
+ let result;
494
+ try {
495
+ result = await runOpLocally(op, ACTIVITIES, ACTIVITY_PROFILES, req.signal, {
496
+ runId,
497
+ env: req.env,
498
+ cwd: jobDir,
499
+ commit,
500
+ ledger: { cwd: jobDir },
501
+ runner: { kind: "pipeline", runtime: "ci", runOrigin: CHANT_RUNNER_ORIGIN },
502
+ describeFailure: (records) => ciJobFailure({ job: req.job, op, records, reportDir: join(jobDir, PR_LOOP_REPORT_DIR) }),
503
+ ...(req.runEvents ? { runEvents: { write: req.runEvents } } : {}),
504
+ ...(req.stepOutput ? { stepOutput: req.stepOutput } : {}),
505
+ ...(signer ? { signRecord: (record) => signOpRunRecord(record, signer) } : {}),
506
+ onSignError: (err) => warnings.push(`the run was not signed: ${message(err)}`),
507
+ ...(redact ? { redact } : {}),
508
+ onLedgerError: (err) => {
509
+ ledgerError = `the job ran, and its record could not be written to chant/lifecycle: ${message(err)}`;
510
+ },
511
+ });
512
+ }
513
+ catch (err) {
514
+ if (!(err instanceof OpRunFailure)) {
515
+ await postStatus("error", `${op.name} stopped: ${firstLine(message(err))}`);
516
+ throw err;
517
+ }
518
+ result = err.result;
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);
523
+ if (resume && existsSync(resume.job)) {
524
+ try {
525
+ mkdirSync(dirname(resume.host), { recursive: true });
526
+ copyFileSync(resume.job, resume.host);
527
+ }
528
+ catch (err) {
529
+ warnings.push(`the resume record could not be kept at ${resume.host}: ${message(err)}`);
530
+ }
531
+ }
532
+ const written = join(jobDir, PR_LOOP_REPORT_DIR);
533
+ let report = null;
534
+ if (existsSync(written)) {
535
+ report = resolve(cwd, req.output ?? PR_LOOP_REPORT_DIR);
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) });
542
+ }
543
+ if (ledgerError)
544
+ throw new CiRunError(ledgerError);
545
+ const pushed = req.push === false ? { pushed: false, pushWarning: "chant/lifecycle was not pushed" } : await pushLifecycleReport({ cwd: top });
546
+ const failed = result.records.find((r) => r.status === "fail");
547
+ return {
548
+ op: op.name,
549
+ status: result.status,
550
+ record: result.record,
551
+ ...(failed ? { failure: { phase: failed.phase, error: failed.error ?? "the step failed" } } : {}),
552
+ report,
553
+ signer: signer?.principal ?? null,
554
+ pushed: pushed.pushed,
555
+ ...(pushed.pushWarning ? { pushWarning: pushed.pushWarning } : {}),
556
+ warnings,
557
+ checkout: checkout.dir,
558
+ };
559
+ }
560
+ finally {
561
+ checkout.remove();
562
+ }
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
+ }
587
+ /** The text `chant ci run` prints when the run settles. */
588
+ export function formatCiRun(r) {
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}`];
590
+ const why = r.record.failure;
591
+ if (why)
592
+ lines.push(` failure: ${why.code}${why.phase ? ` in ${why.phase}` : ""}${why.exitCode !== undefined ? `, exit ${why.exitCode}` : ""}: ${why.reason}`);
593
+ else if (r.failure)
594
+ lines.push(` ${r.failure.phase}: ${r.failure.error}`);
595
+ 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"}`);
596
+ if (r.report)
597
+ lines.push(` report: ${r.report}`);
598
+ if (!r.pushed)
599
+ lines.push(` ${r.pushWarning ?? "chant/lifecycle was not pushed"}`);
600
+ return lines.join("\n");
601
+ }
602
+ // ── The command ──────────────────────────────────────────────────────────────
603
+ const USAGE = "chant ci run plan|apply --env <env> --base <sha> [--pr <n>] [--commit <sha>] [--forge github|gitlab|forgejo] " +
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>]";
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
+ }
626
+ /** `chant ci run plan|apply` (#4094). */
627
+ export async function runCiRun(ctx) {
628
+ const { args } = ctx;
629
+ try {
630
+ if (args.setupKey)
631
+ return (await import("./ci-serve.js")).runnerKeySetupCommand(ctx);
632
+ const job = args.extraPositional;
633
+ if (job !== "plan" && job !== "apply")
634
+ throw new CiRunError(`ci run takes the job to run, plan or apply${job ? `, not ${JSON.stringify(job)}` : ""}`);
635
+ if (!args.env)
636
+ throw new CiRunError("--env names the environment the pipeline is for, as chant build --components --generate <lexicon> --pr-loop took it: --env <env>");
637
+ const forge = args.forge ?? detectForge(process.env);
638
+ if (!forge)
639
+ throw new CiRunError("--forge names the forge the job posts to: --forge github|gitlab|forgejo");
640
+ if (!FORGE_KINDS.includes(forge))
641
+ throw new CiRunError(`--forge takes github, gitlab or forgejo, not ${JSON.stringify(forge)}`);
642
+ for (const name of args.passEnv ?? [])
643
+ if (!ENV_NAME.test(name))
644
+ throw new CiRunError(`--pass-env takes a variable's name, not ${JSON.stringify(name)}`);
645
+ const fromFlag = args.key !== undefined;
646
+ // #4099: without --key or CHANT_RUNNER_KEY, the host's key that --setup-key made, if there is one. Like the variable, an unlisted one only warns.
647
+ const hostKey = fromFlag || process.env[RUNNER_KEY_ENV] ? undefined : hostRunnerKeyAt(process.cwd(), args.statePath);
648
+ const runnerKey = fromFlag ? readFileSync(resolve(args.key), "utf-8") : process.env[RUNNER_KEY_ENV] || hostKey?.pem;
649
+ const hostEnv = process.env;
650
+ const { status, warning } = ciStatusFromEnv({ forge: forge, hostEnv, noStatus: args.noStatus, statusUrl: args.statusUrl });
651
+ if (warning)
652
+ console.error(formatWarning({ message: warning }));
653
+ const secrets = ciSecretsOf(args.ciSecrets);
654
+ const result = await ciRun({
655
+ cwd: process.cwd(),
656
+ ...(status ? { status } : {}),
657
+ job,
658
+ env: args.env,
659
+ forge: forge,
660
+ ...(args.base ? { base: args.base } : {}),
661
+ ...(args.pr !== undefined ? { pr: args.pr } : {}),
662
+ ...(args.commit ? { commit: args.commit } : {}),
663
+ loop: {
664
+ ...(args.gate ? { gate: args.gate } : {}),
665
+ ...(args.branch ? { branch: args.branch } : {}),
666
+ ...(args.eventsJson ? { events: true } : {}),
667
+ ...(args.reportUrl ? { reportUrl: args.reportUrl } : {}),
668
+ ...(args.prBuild ? { build: true } : {}),
669
+ ...(args.prBuildCommand ? { buildCommand: args.prBuildCommand } : {}),
670
+ },
671
+ ...(args.runIndexLimit ? { runIndexLimit: args.runIndexLimit } : {}),
672
+ ...(args.image ? { image: args.image } : {}),
673
+ ...(args.beforeScript?.length ? { beforeScript: args.beforeScript } : {}),
674
+ ...(args.ciChant ? { chant: args.ciChant } : {}),
675
+ ...(args.passEnv?.length ? { passEnv: args.passEnv } : {}),
676
+ ...(secrets ? { secrets } : {}),
677
+ ...(args.output ? { output: args.output } : {}),
678
+ ...(runnerKey !== undefined ? { runnerKey, runnerKeyFromEnv: !fromFlag } : {}),
679
+ ...(args.eventsJson ? { runEvents: (line) => void process.stdout.write(line) } : {}),
680
+ stepOutput: (line) => void process.stderr.write(`${line}\n`),
681
+ });
682
+ for (const w of result.warnings)
683
+ console.error(formatWarning({ message: w }));
684
+ console.error(formatCiRun(result));
685
+ return result.status === "ok" ? 0 : 1;
686
+ }
687
+ catch (err) {
688
+ console.error(formatError({ message: `chant ci run: ${message(err)}`, hint: USAGE }));
689
+ return 1;
690
+ }
691
+ }