@intentius/chant 0.124.30 → 0.124.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +38 -4
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +50 -0
- package/dist/cli/registry.d.ts +18 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +12 -0
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-executor.js +22 -3
- package/dist/op/redact.d.ts +34 -0
- package/dist/op/redact.d.ts.map +1 -0
- package/dist/op/redact.js +94 -0
- package/dist/op/step-console.d.ts +2 -0
- package/dist/op/step-console.d.ts.map +1 -1
- package/dist/op/step-console.js +5 -2
- package/dist/pr-forge.d.ts +17 -0
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/pr-forge.js +12 -6
- package/dist/pr-report-url.d.ts +8 -0
- package/dist/pr-report-url.d.ts.map +1 -1
- package/dist/pr-report-url.js +47 -1
- package/dist/wave-runtime.mjs +159 -159
- package/dist/workspace/ci-run-index.d.ts +96 -0
- package/dist/workspace/ci-run-index.d.ts.map +1 -0
- package/dist/workspace/ci-run-index.js +162 -0
- package/dist/workspace/ci-run-index.schema.json +50 -0
- package/dist/workspace/ci-run.d.ts +84 -7
- package/dist/workspace/ci-run.d.ts.map +1 -1
- package/dist/workspace/ci-run.js +241 -14
- package/dist/workspace/ci-runner-key.d.ts +120 -0
- package/dist/workspace/ci-runner-key.d.ts.map +1 -0
- package/dist/workspace/ci-runner-key.js +217 -0
- package/dist/workspace/ci-secrets.d.ts +65 -0
- package/dist/workspace/ci-secrets.d.ts.map +1 -0
- package/dist/workspace/ci-secrets.js +211 -0
- package/dist/workspace/ci-serve.d.ts +13 -0
- package/dist/workspace/ci-serve.d.ts.map +1 -1
- package/dist/workspace/ci-serve.js +69 -13
- package/dist/workspace/reason-codes.d.ts +6 -2
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/reason-codes.js +7 -2
- package/dist/workspace/trust/trust-write.d.ts +109 -0
- package/dist/workspace/trust/trust-write.d.ts.map +1 -0
- package/dist/workspace/trust/trust-write.js +320 -0
- package/dist/workspace/trust-write.schema.json +265 -0
- package/package.json +1 -1
- package/src/cli/main.test.ts +1 -0
- package/src/cli/main.ts +38 -4
- package/src/cli/parse-args.ts +36 -0
- package/src/cli/registry.ts +18 -0
- package/src/op/local-executor.ts +31 -3
- package/src/op/redact.ts +93 -0
- package/src/op/step-console.ts +7 -2
- package/src/pr-forge.test.ts +29 -0
- package/src/pr-forge.ts +30 -6
- package/src/pr-report-url.ts +53 -1
- package/src/workspace/ci-run-index.schema.json +50 -0
- package/src/workspace/ci-run-index.test.ts +353 -0
- package/src/workspace/ci-run-index.ts +192 -0
- package/src/workspace/ci-run.test.ts +337 -37
- package/src/workspace/ci-run.ts +277 -15
- package/src/workspace/ci-runner-key.test.ts +166 -0
- package/src/workspace/ci-runner-key.ts +250 -0
- package/src/workspace/ci-secrets.test.ts +106 -0
- package/src/workspace/ci-secrets.ts +213 -0
- package/src/workspace/ci-serve.test.ts +136 -37
- package/src/workspace/ci-serve.ts +66 -13
- package/src/workspace/reason-codes.test.ts +2 -0
- package/src/workspace/reason-codes.ts +7 -2
- package/src/workspace/trust/trust-write.test.ts +179 -0
- package/src/workspace/trust/trust-write.ts +377 -0
- package/src/workspace/trust-write.schema.json +265 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #4098: the run index `chant ci run --report-url` keeps at the report URL.
|
|
3
|
+
*
|
|
4
|
+
* The runs are real `ci run` jobs in a temp repository with a stub chant and
|
|
5
|
+
* a file:// report URL in another temp directory. The jobs do not push, so
|
|
6
|
+
* nothing reaches chant/lifecycle or a remote. No test makes a network call.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { pathToFileURL } from "node:url";
|
|
13
|
+
import { afterAll, describe, expect, test } from "vitest";
|
|
14
|
+
import { cleanScratch, contract, declaration, git, repoFixture, scratchDir, validSchema, writeFiles } from "./__fixtures__/contract-repo";
|
|
15
|
+
import { ciRun } from "./ci-run";
|
|
16
|
+
import { readCiSecrets } from "./ci-secrets";
|
|
17
|
+
import {
|
|
18
|
+
addRunEntry,
|
|
19
|
+
CI_RUN_INDEX_KEY,
|
|
20
|
+
CI_RUN_INDEX_LIMIT,
|
|
21
|
+
CI_RUN_INDEX_SCHEMA_ID,
|
|
22
|
+
formatCiRunIndex,
|
|
23
|
+
readCiRunIndex,
|
|
24
|
+
updateCiRunIndex,
|
|
25
|
+
type CiRunIndex,
|
|
26
|
+
type CiRunIndexEntry,
|
|
27
|
+
} from "./ci-run-index";
|
|
28
|
+
import { parseReportUrl, reportGet, reportKey, type ReportTarget } from "../pr-report-url";
|
|
29
|
+
import schema from "./ci-run-index.schema.json";
|
|
30
|
+
|
|
31
|
+
afterAll(cleanScratch);
|
|
32
|
+
|
|
33
|
+
const JOB_IMAGE = "chant-ci-run-index-test:local";
|
|
34
|
+
const HOST = { PATH: process.env.PATH, HOME: process.env.HOME, GITHUB_TOKEN: "t", GITHUB_REPOSITORY: "acme/estate" };
|
|
35
|
+
|
|
36
|
+
/** The repository every test starts from, built once: one member, and two commits. */
|
|
37
|
+
const repoTemplate = repoFixture(() => {
|
|
38
|
+
const root = scratchDir("chant-ci-run-index-template-");
|
|
39
|
+
git(root, "init", "-q");
|
|
40
|
+
git(root, "symbolic-ref", "HEAD", "refs/heads/main");
|
|
41
|
+
git(root, "config", "user.name", "t");
|
|
42
|
+
git(root, "config", "user.email", "t@example.com");
|
|
43
|
+
writeFiles(root, {
|
|
44
|
+
"chant.workspace.json": declaration([{ name: "app", dir: "app", kind: "other", because: "the app" }]),
|
|
45
|
+
".gitignore": ".chant/pr/\n",
|
|
46
|
+
"app/a.txt": "a\n",
|
|
47
|
+
});
|
|
48
|
+
const commit = (msg: string) => {
|
|
49
|
+
execFileSync("git", ["-c", "commit.gpgsign=false", "add", "-A"], { cwd: root, stdio: "ignore" });
|
|
50
|
+
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-q", "-m", msg], { cwd: root, stdio: "ignore" });
|
|
51
|
+
return git(root, "rev-parse", "HEAD");
|
|
52
|
+
};
|
|
53
|
+
commit("init");
|
|
54
|
+
writeFiles(root, { "app/a.txt": "b\n" });
|
|
55
|
+
commit("change");
|
|
56
|
+
return root;
|
|
57
|
+
}, "chant-ci-run-index-");
|
|
58
|
+
|
|
59
|
+
/** A repository with one member, and a stub chant that writes the report FAKE_REPORT gives it and exits $FAKE_EXIT. */
|
|
60
|
+
function workspace(): { app: string; base: string; sha: string; chant: string } {
|
|
61
|
+
const root = repoTemplate();
|
|
62
|
+
const base = git(root, "rev-parse", "HEAD~1");
|
|
63
|
+
const sha = git(root, "rev-parse", "HEAD");
|
|
64
|
+
const file = join(scratchDir("chant-ci-run-index-bin-"), "chant");
|
|
65
|
+
writeFileSync(
|
|
66
|
+
file,
|
|
67
|
+
[
|
|
68
|
+
"#!/bin/sh",
|
|
69
|
+
"mkdir -p .chant/pr",
|
|
70
|
+
'if [ -n "$FAKE_REPORT" ]; then printf "%s" "$FAKE_REPORT" > ".chant/pr/$2.json"; else echo "{}" > ".chant/pr/$2.json"; fi',
|
|
71
|
+
'exit "${FAKE_EXIT:-0}"',
|
|
72
|
+
"",
|
|
73
|
+
].join("\n"),
|
|
74
|
+
);
|
|
75
|
+
chmodSync(file, 0o755);
|
|
76
|
+
return { app: join(root, "app"), base, sha, chant: file };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const reportsDir = () => scratchDir("chant-ci-run-index-reports-");
|
|
80
|
+
const urlOf = (dir: string) => pathToFileURL(dir).href;
|
|
81
|
+
const indexOf = (dir: string): CiRunIndex => JSON.parse(readFileSync(join(dir, CI_RUN_INDEX_KEY), "utf-8"));
|
|
82
|
+
|
|
83
|
+
const { expectValid } = contract(schema);
|
|
84
|
+
|
|
85
|
+
async function job(w: ReturnType<typeof workspace>, dir: string, over: Partial<Parameters<typeof ciRun>[0]> & { hostEnv?: Record<string, string | undefined> } = {}) {
|
|
86
|
+
return ciRun({
|
|
87
|
+
image: JOB_IMAGE,
|
|
88
|
+
cwd: w.app,
|
|
89
|
+
job: "plan",
|
|
90
|
+
env: "prod",
|
|
91
|
+
forge: "forgejo",
|
|
92
|
+
base: w.base,
|
|
93
|
+
pr: 7,
|
|
94
|
+
chant: w.chant,
|
|
95
|
+
hostEnv: HOST,
|
|
96
|
+
push: false,
|
|
97
|
+
output: join(scratchDir("chant-ci-run-index-out-"), "pr"),
|
|
98
|
+
loop: { reportUrl: urlOf(dir) },
|
|
99
|
+
...over,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const ENTRY: CiRunIndexEntry = {
|
|
104
|
+
job: "plan",
|
|
105
|
+
pr: 1,
|
|
106
|
+
commit: "a".repeat(40),
|
|
107
|
+
status: "ok",
|
|
108
|
+
started: "2026-10-18T12:00:00.000Z",
|
|
109
|
+
ended: "2026-10-18T12:00:01.000Z",
|
|
110
|
+
runId: "r0",
|
|
111
|
+
key: reportKey(1, "a".repeat(40)),
|
|
112
|
+
runOrigin: { forge: "chant" },
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
describe("the ci-run-index schema (#4098)", () => {
|
|
116
|
+
test("is a valid schema with the contract's id", () => {
|
|
117
|
+
expect(validSchema(schema)).toBe(true);
|
|
118
|
+
expect(schema.$id).toBe(CI_RUN_INDEX_SCHEMA_ID);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("a failed entry without a failure code does not validate", () => {
|
|
122
|
+
const doc = { $schema: CI_RUN_INDEX_SCHEMA_ID, contract: 1, chant: "0.124.30", updated: "2026-10-18T12:00:00.000Z", runs: [{ ...ENTRY, status: "fail" }] };
|
|
123
|
+
expect(contract(schema).validate(doc)).toBe(false);
|
|
124
|
+
expectValid({ ...doc, runs: [{ ...ENTRY, status: "fail", failure: { code: "command-failed" } }] });
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("chant ci run with --report-url keeps the run index (#4098)", () => {
|
|
129
|
+
test("the index is created, grows newest first, carries every field and validates", async () => {
|
|
130
|
+
const w = workspace();
|
|
131
|
+
const dir = reportsDir();
|
|
132
|
+
const plan = await job(w, dir, { runId: "run-plan" });
|
|
133
|
+
expect(plan.status).toBe("ok");
|
|
134
|
+
expect(plan.warnings).toEqual([]);
|
|
135
|
+
const apply = await job(w, dir, { job: "apply", pr: undefined, runId: "run-apply", hostEnv: { ...HOST, FAKE_REPORT: JSON.stringify({ pr: 7, status: "applied" }) }, passEnv: ["FAKE_REPORT"] });
|
|
136
|
+
expect(apply.status).toBe("ok");
|
|
137
|
+
|
|
138
|
+
const index = indexOf(dir);
|
|
139
|
+
expectValid(index);
|
|
140
|
+
expect(index.runs.map((r) => r.runId)).toEqual(["run-apply", "run-plan"]);
|
|
141
|
+
expect(index.runs[1]).toEqual({
|
|
142
|
+
job: "plan",
|
|
143
|
+
pr: 7,
|
|
144
|
+
commit: w.sha,
|
|
145
|
+
status: "ok",
|
|
146
|
+
started: plan.record.started,
|
|
147
|
+
ended: plan.record.ended,
|
|
148
|
+
runId: "run-plan",
|
|
149
|
+
key: `7/${w.sha}/app`,
|
|
150
|
+
member: "app",
|
|
151
|
+
runOrigin: { forge: "chant" },
|
|
152
|
+
});
|
|
153
|
+
// The apply found its pull request in its own report.
|
|
154
|
+
expect(index.runs[0]).toMatchObject({ job: "apply", pr: 7, key: `7/${w.sha}/app` });
|
|
155
|
+
expect(readdirSync(join(dir, "runs"))).toEqual(["index.json"]);
|
|
156
|
+
expect(formatCiRunIndex(index).split("\n")).toHaveLength(2);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("a job with no pull request is keyed unknown", async () => {
|
|
160
|
+
const w = workspace();
|
|
161
|
+
const dir = reportsDir();
|
|
162
|
+
await job(w, dir, { job: "apply", pr: undefined, runId: "r-apply" });
|
|
163
|
+
const [run] = indexOf(dir).runs;
|
|
164
|
+
expect(run).toMatchObject({ pr: null, key: `unknown/${w.sha}/app` });
|
|
165
|
+
expectValid(indexOf(dir));
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("the index keeps the newest entries up to the limit", async () => {
|
|
169
|
+
const w = workspace();
|
|
170
|
+
const dir = reportsDir();
|
|
171
|
+
for (const n of [1, 2, 3]) await job(w, dir, { runId: `run-${n}`, runIndexLimit: 2 });
|
|
172
|
+
expect(indexOf(dir).runs.map((r) => r.runId)).toEqual(["run-3", "run-2"]);
|
|
173
|
+
expect(CI_RUN_INDEX_LIMIT).toBe(200);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("a failed job carries its failure code", async () => {
|
|
177
|
+
const w = workspace();
|
|
178
|
+
const dir = reportsDir();
|
|
179
|
+
const failed = await job(w, dir, { runId: "run-fail", hostEnv: { ...HOST, FAKE_EXIT: "3" }, passEnv: ["FAKE_EXIT"] });
|
|
180
|
+
expect(failed.status).toBe("fail");
|
|
181
|
+
const [run] = indexOf(dir).runs;
|
|
182
|
+
expect(run).toMatchObject({ status: "fail", failure: { code: "command-failed" }, runId: "run-fail" });
|
|
183
|
+
expectValid(indexOf(dir));
|
|
184
|
+
|
|
185
|
+
const refused = await job(w, dir, {
|
|
186
|
+
job: "apply",
|
|
187
|
+
runId: "run-refused",
|
|
188
|
+
hostEnv: { ...HOST, FAKE_EXIT: "1", FAKE_REPORT: JSON.stringify({ pr: 7, status: "refused", refusal: "not-approved" }) },
|
|
189
|
+
passEnv: ["FAKE_EXIT", "FAKE_REPORT"],
|
|
190
|
+
});
|
|
191
|
+
expect(refused.status).toBe("fail");
|
|
192
|
+
expect(indexOf(dir).runs[0]).toMatchObject({ failure: { code: "not-approved" } });
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("a secret the job's report names as its failure never reaches the index (#4096)", async () => {
|
|
196
|
+
const w = workspace();
|
|
197
|
+
const dir = reportsDir();
|
|
198
|
+
const SECRET = "s3cret-pass-1";
|
|
199
|
+
const secretsFile = join(scratchDir("chant-ci-run-index-secrets-"), "secrets.env");
|
|
200
|
+
writeFileSync(secretsFile, `DB_PASSWORD=${SECRET}\n`, { mode: 0o600 });
|
|
201
|
+
const leaky = join(scratchDir("chant-ci-run-index-bin-"), "chant");
|
|
202
|
+
writeFileSync(
|
|
203
|
+
leaky,
|
|
204
|
+
[
|
|
205
|
+
"#!/bin/sh",
|
|
206
|
+
"mkdir -p .chant/pr",
|
|
207
|
+
// The refusal becomes the index's failure code, and the message and output carry the secret too.
|
|
208
|
+
'printf \'{"stage":"apply","pr":7,"status":"refused","refusal":"denied-%s","message":"failed with %s"}\' "$DB_PASSWORD" "$DB_PASSWORD" > .chant/pr/pr-apply.json',
|
|
209
|
+
'echo "login refused for $DB_PASSWORD" >&2',
|
|
210
|
+
"exit 3",
|
|
211
|
+
"",
|
|
212
|
+
].join("\n"),
|
|
213
|
+
);
|
|
214
|
+
chmodSync(leaky, 0o755);
|
|
215
|
+
const r = await job(w, dir, { job: "apply", chant: leaky, runId: "run-leak", secrets: readCiSecrets(secretsFile) });
|
|
216
|
+
expect(r.status).toBe("fail");
|
|
217
|
+
const text = readFileSync(join(dir, CI_RUN_INDEX_KEY), "utf-8");
|
|
218
|
+
expect(text).not.toContain(SECRET);
|
|
219
|
+
expect(indexOf(dir).runs[0]).toMatchObject({ status: "fail", failure: { code: "denied-***" } });
|
|
220
|
+
expectValid(indexOf(dir));
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("a corrupt index is replaced with a warning and the job stays ok", async () => {
|
|
224
|
+
const w = workspace();
|
|
225
|
+
const dir = reportsDir();
|
|
226
|
+
mkdirSync(join(dir, "runs"));
|
|
227
|
+
writeFileSync(join(dir, CI_RUN_INDEX_KEY), "{ not json");
|
|
228
|
+
const r = await job(w, dir, { runId: "run-after-corrupt" });
|
|
229
|
+
expect(r.status).toBe("ok");
|
|
230
|
+
expect(r.warnings).toHaveLength(1);
|
|
231
|
+
expect(r.warnings[0]).toMatch(/run index at .*runs\/index\.json is replaced, since it is not JSON/);
|
|
232
|
+
expect(indexOf(dir).runs.map((x) => x.runId)).toEqual(["run-after-corrupt"]);
|
|
233
|
+
expectValid(indexOf(dir));
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("an index that cannot be read or written warns and leaves the job ok", async () => {
|
|
237
|
+
const w = workspace();
|
|
238
|
+
const dir = reportsDir();
|
|
239
|
+
// A file where the runs folder goes: the index can be neither read nor written.
|
|
240
|
+
writeFileSync(join(dir, "runs"), "in the way");
|
|
241
|
+
const r = await job(w, dir, { runId: "run-blocked" });
|
|
242
|
+
expect(r.status).toBe("ok");
|
|
243
|
+
expect(r.warnings).toHaveLength(1);
|
|
244
|
+
expect(r.warnings[0]).toMatch(/could not read the run index at .*, so it is not updated/);
|
|
245
|
+
expect(readFileSync(join(dir, "runs"), "utf-8")).toBe("in the way");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("a report URL that does not parse warns, and without --report-url nothing is written", async () => {
|
|
249
|
+
const w = workspace();
|
|
250
|
+
const bad = await job(w, reportsDir(), { loop: { reportUrl: "ftp://example.invalid/x" } });
|
|
251
|
+
expect(bad.status).toBe("ok");
|
|
252
|
+
expect(bad.warnings[0]).toMatch(/the run index is not updated: --report-url ftp/);
|
|
253
|
+
const dir = reportsDir();
|
|
254
|
+
const none = await job(w, dir, { loop: {} });
|
|
255
|
+
expect(none.warnings).toEqual([]);
|
|
256
|
+
expect(existsSync(join(dir, "runs"))).toBe(false);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe("updating the index", () => {
|
|
261
|
+
const target = (dir: string): ReportTarget => parseReportUrl(urlOf(dir)) as ReportTarget;
|
|
262
|
+
|
|
263
|
+
test("an upload error warns and nothing throws", async () => {
|
|
264
|
+
const warnings: string[] = [];
|
|
265
|
+
await updateCiRunIndex({
|
|
266
|
+
target: target(reportsDir()),
|
|
267
|
+
entry: ENTRY,
|
|
268
|
+
warn: (m) => warnings.push(m),
|
|
269
|
+
put: async () => {
|
|
270
|
+
throw new Error("aws s3 cp exited 1: AccessDenied");
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
expect(warnings).toEqual([expect.stringMatching(/could not write the run index at .*: aws s3 cp exited 1: AccessDenied/)]);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("a read error other than a missing object leaves the index alone", async () => {
|
|
277
|
+
const warnings: string[] = [];
|
|
278
|
+
let wrote = false;
|
|
279
|
+
await updateCiRunIndex({
|
|
280
|
+
target: target(reportsDir()),
|
|
281
|
+
entry: ENTRY,
|
|
282
|
+
warn: (m) => warnings.push(m),
|
|
283
|
+
get: async () => {
|
|
284
|
+
throw new Error("aws s3 cp exited 1: AccessDenied");
|
|
285
|
+
},
|
|
286
|
+
put: async () => {
|
|
287
|
+
wrote = true;
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
expect(wrote).toBe(false);
|
|
291
|
+
expect(warnings[0]).toMatch(/could not read the run index/);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("an index of a newer contract is left as it is", async () => {
|
|
295
|
+
const warnings: string[] = [];
|
|
296
|
+
let wrote = false;
|
|
297
|
+
await updateCiRunIndex({
|
|
298
|
+
target: target(reportsDir()),
|
|
299
|
+
entry: ENTRY,
|
|
300
|
+
warn: (m) => warnings.push(m),
|
|
301
|
+
get: async () => JSON.stringify({ contract: 2, runs: [] }),
|
|
302
|
+
put: async () => {
|
|
303
|
+
wrote = true;
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
expect(wrote).toBe(false);
|
|
307
|
+
expect(warnings[0]).toMatch(/is contract 2, which this chant does not write/);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("updates in one process take turns, and a run id is kept once", async () => {
|
|
311
|
+
const dir = reportsDir();
|
|
312
|
+
const warn = (m: string) => {
|
|
313
|
+
throw new Error(m);
|
|
314
|
+
};
|
|
315
|
+
await Promise.all([1, 2, 3, 4].map((n) => updateCiRunIndex({ target: target(dir), entry: { ...ENTRY, runId: `r${n}` }, warn })));
|
|
316
|
+
expect(indexOf(dir).runs.map((r) => r.runId).sort()).toEqual(["r1", "r2", "r3", "r4"]);
|
|
317
|
+
await updateCiRunIndex({ target: target(dir), entry: { ...ENTRY, runId: "r2", status: "fail", failure: { code: "x" } }, warn });
|
|
318
|
+
const runs = indexOf(dir).runs;
|
|
319
|
+
expect(runs).toHaveLength(4);
|
|
320
|
+
expect(runs[0]).toMatchObject({ runId: "r2", status: "fail" });
|
|
321
|
+
expect(addRunEntry([ENTRY], { ...ENTRY, runId: "r9" }, 1).map((r) => r.runId)).toEqual(["r9"]);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("the file write goes through a rename, so no temporary file is left", async () => {
|
|
325
|
+
const dir = reportsDir();
|
|
326
|
+
await updateCiRunIndex({ target: target(dir), entry: ENTRY, warn: () => {} });
|
|
327
|
+
expect(readdirSync(join(dir, "runs"))).toEqual(["index.json"]);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("readCiRunIndex reads what was written, null for none, and says why for a bad one", async () => {
|
|
331
|
+
const dir = reportsDir();
|
|
332
|
+
expect(await readCiRunIndex(target(dir))).toBeNull();
|
|
333
|
+
await updateCiRunIndex({ target: target(dir), entry: ENTRY, warn: () => {} });
|
|
334
|
+
expect((await readCiRunIndex(target(dir)))?.runs).toEqual([ENTRY]);
|
|
335
|
+
writeFileSync(join(dir, CI_RUN_INDEX_KEY), "nope");
|
|
336
|
+
await expect(readCiRunIndex(target(dir))).rejects.toThrow(/is unusable: it is not JSON/);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("an s3 get treats a missing object as none, with a stub in place of the AWS CLI", async () => {
|
|
340
|
+
const s3 = parseReportUrl("s3://bucket/prefix") as ReportTarget;
|
|
341
|
+
const calls: string[][] = [];
|
|
342
|
+
const absent = reportGet(s3, async (args) => {
|
|
343
|
+
calls.push(args);
|
|
344
|
+
throw new Error("aws s3 cp exited 1: An error occurred (404) when calling the HeadObject operation: Key not found");
|
|
345
|
+
});
|
|
346
|
+
expect(await absent(CI_RUN_INDEX_KEY)).toBeNull();
|
|
347
|
+
expect(calls[0]).toEqual(["s3", "cp", "s3://bucket/prefix/runs/index.json", "-", "--only-show-errors"]);
|
|
348
|
+
const denied = reportGet(s3, async () => {
|
|
349
|
+
throw new Error("aws s3 cp exited 1: AccessDenied");
|
|
350
|
+
});
|
|
351
|
+
await expect(denied(CI_RUN_INDEX_KEY)).rejects.toThrow(/AccessDenied/);
|
|
352
|
+
});
|
|
353
|
+
});
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The run index (#4098, epic #4101): where the chant runner says which jobs it
|
|
3
|
+
* ran, at the `--report-url` root, for behold and the demo kiosk to find runs
|
|
4
|
+
* without a forge or a workflow file.
|
|
5
|
+
*
|
|
6
|
+
* ```
|
|
7
|
+
* <url>/runs/index.json
|
|
8
|
+
* ```
|
|
9
|
+
*
|
|
10
|
+
* One document, `ci-run-index/v1` (`ci-run-index.schema.json`). Each settled
|
|
11
|
+
* `chant ci run` job with `--report-url` set adds an entry to its front, so
|
|
12
|
+
* the newest run is first, and the list is cut to the newest `limit` entries,
|
|
13
|
+
* 200 by default. An entry's `key` is the folder the job's report went to
|
|
14
|
+
* (`reportKey`, `<pr>/<commit>`), so a reader goes from the index to
|
|
15
|
+
* `<url>/<key>/pr-<job>.json`.
|
|
16
|
+
*
|
|
17
|
+
* The update reads the index, changes it and writes it back through the same
|
|
18
|
+
* `file://` and `s3://` paths the report upload uses (pr-report-url.ts). A
|
|
19
|
+
* `file://` write goes through a temporary name and a rename. Two runners
|
|
20
|
+
* updating one index at once can lose an entry, and that is out of scope:
|
|
21
|
+
* `chant ci serve` is one host. Within one process the updates for one URL
|
|
22
|
+
* take turns, so `ci serve --max-jobs 2` does not race itself.
|
|
23
|
+
*
|
|
24
|
+
* An index that can't be parsed is replaced with a warning. An index this
|
|
25
|
+
* chant can't read for any other reason, or a newer contract, is left alone
|
|
26
|
+
* with a warning. Nothing here fails a job.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { RunOrigin } from "../lifecycle/release-ledger";
|
|
30
|
+
import type { CommandContext } from "../cli/registry";
|
|
31
|
+
import { formatError } from "../cli/format";
|
|
32
|
+
import { parseReportUrl, reportGet, reportPut, type ReportGet, type ReportPut, type ReportTarget } from "../pr-report-url";
|
|
33
|
+
import { readerVersion } from "./declaration";
|
|
34
|
+
|
|
35
|
+
export const CI_RUN_INDEX_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/ci-run-index/v1/ci-run-index.schema.json";
|
|
36
|
+
export const CI_RUN_INDEX_CONTRACT = 1;
|
|
37
|
+
/** The first chant that writes the index. */
|
|
38
|
+
export const CI_RUN_INDEX_FLOOR = "0.124.31";
|
|
39
|
+
/** Where the index is kept, below the report URL. */
|
|
40
|
+
export const CI_RUN_INDEX_KEY = "runs/index.json";
|
|
41
|
+
/** How many entries the index keeps unless told otherwise. */
|
|
42
|
+
export const CI_RUN_INDEX_LIMIT = 200;
|
|
43
|
+
|
|
44
|
+
/** One job the runner ran. */
|
|
45
|
+
export interface CiRunIndexEntry {
|
|
46
|
+
job: "plan" | "apply";
|
|
47
|
+
/** The pull request, or null when the job found none. */
|
|
48
|
+
pr: number | null;
|
|
49
|
+
commit: string;
|
|
50
|
+
status: "ok" | "fail";
|
|
51
|
+
/** Why a failed job failed: the `failure.code` of its run record. */
|
|
52
|
+
failure?: { code: string };
|
|
53
|
+
member?: string;
|
|
54
|
+
started: string;
|
|
55
|
+
ended: string;
|
|
56
|
+
runId: string;
|
|
57
|
+
/** The folder the job's report is under, below the report URL: `<pr>/<commit>[/<member>]`. */
|
|
58
|
+
key: string;
|
|
59
|
+
runOrigin: RunOrigin;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface CiRunIndex {
|
|
63
|
+
$schema: typeof CI_RUN_INDEX_SCHEMA_ID;
|
|
64
|
+
contract: typeof CI_RUN_INDEX_CONTRACT;
|
|
65
|
+
chant: string;
|
|
66
|
+
/** When the index was last written. */
|
|
67
|
+
updated: string;
|
|
68
|
+
/** Newest first. */
|
|
69
|
+
runs: CiRunIndexEntry[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const messageOf = (err: unknown): string => (err instanceof Error ? err.message : String(err));
|
|
73
|
+
|
|
74
|
+
/** The index as `text` holds it, or why it can't be used: `corrupt` is replaced, `newer` is left alone. */
|
|
75
|
+
export function parseCiRunIndex(text: string): { index: CiRunIndex } | { corrupt: string } | { newer: number } {
|
|
76
|
+
let doc: unknown;
|
|
77
|
+
try {
|
|
78
|
+
doc = JSON.parse(text);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
return { corrupt: `it is not JSON (${messageOf(err)})` };
|
|
81
|
+
}
|
|
82
|
+
if (doc === null || typeof doc !== "object" || Array.isArray(doc)) return { corrupt: "it is not an object" };
|
|
83
|
+
const d = doc as { contract?: unknown; runs?: unknown };
|
|
84
|
+
if (typeof d.contract === "number" && d.contract > CI_RUN_INDEX_CONTRACT) return { newer: d.contract };
|
|
85
|
+
if (d.contract !== CI_RUN_INDEX_CONTRACT) return { corrupt: "it is not a ci-run-index document" };
|
|
86
|
+
if (!Array.isArray(d.runs)) return { corrupt: "it has no runs list" };
|
|
87
|
+
const runs = d.runs.filter((r): r is CiRunIndexEntry => r !== null && typeof r === "object" && typeof (r as { runId?: unknown }).runId === "string");
|
|
88
|
+
return { index: { ...(doc as CiRunIndex), runs } };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** `entry` at the front of `runs`, replacing an entry with its run id, cut to the newest `limit`. */
|
|
92
|
+
export function addRunEntry(runs: readonly CiRunIndexEntry[], entry: CiRunIndexEntry, limit: number): CiRunIndexEntry[] {
|
|
93
|
+
return [entry, ...runs.filter((r) => r.runId !== entry.runId)].slice(0, limit);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The turn each index URL is waiting for, so updates in one process run one after another. */
|
|
97
|
+
const turns = new Map<string, Promise<void>>();
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Add `entry` to the index at `target`. Never throws: a problem is a warning
|
|
101
|
+
* and the index stays as it was, or is replaced when it was unreadable JSON.
|
|
102
|
+
*/
|
|
103
|
+
export function updateCiRunIndex(input: {
|
|
104
|
+
target: ReportTarget;
|
|
105
|
+
entry: CiRunIndexEntry;
|
|
106
|
+
limit?: number;
|
|
107
|
+
warn: (message: string) => void;
|
|
108
|
+
now?: () => Date;
|
|
109
|
+
put?: ReportPut;
|
|
110
|
+
get?: ReportGet;
|
|
111
|
+
}): Promise<void> {
|
|
112
|
+
const where = `${input.target.url}/${CI_RUN_INDEX_KEY}`;
|
|
113
|
+
const put = input.put ?? reportPut(input.target);
|
|
114
|
+
const get = input.get ?? reportGet(input.target);
|
|
115
|
+
const limit = Math.max(1, Math.floor(input.limit ?? CI_RUN_INDEX_LIMIT));
|
|
116
|
+
const work = async (): Promise<void> => {
|
|
117
|
+
let runs: CiRunIndexEntry[] = [];
|
|
118
|
+
try {
|
|
119
|
+
const text = await get(CI_RUN_INDEX_KEY);
|
|
120
|
+
if (text !== null) {
|
|
121
|
+
const parsed = parseCiRunIndex(text);
|
|
122
|
+
if ("corrupt" in parsed) input.warn(`the run index at ${where} is replaced, since ${parsed.corrupt}`);
|
|
123
|
+
else if ("newer" in parsed) return input.warn(`the run index at ${where} is contract ${parsed.newer}, which this chant does not write; it is left as it is`);
|
|
124
|
+
else runs = parsed.index.runs;
|
|
125
|
+
}
|
|
126
|
+
} catch (err) {
|
|
127
|
+
return input.warn(`could not read the run index at ${where}, so it is not updated: ${messageOf(err)}`);
|
|
128
|
+
}
|
|
129
|
+
const index: CiRunIndex = {
|
|
130
|
+
$schema: CI_RUN_INDEX_SCHEMA_ID,
|
|
131
|
+
contract: CI_RUN_INDEX_CONTRACT,
|
|
132
|
+
chant: readerVersion(),
|
|
133
|
+
updated: (input.now?.() ?? new Date()).toISOString(),
|
|
134
|
+
runs: addRunEntry(runs, input.entry, limit),
|
|
135
|
+
};
|
|
136
|
+
try {
|
|
137
|
+
await put(CI_RUN_INDEX_KEY, JSON.stringify(index, null, 2) + "\n", "application/json");
|
|
138
|
+
} catch (err) {
|
|
139
|
+
input.warn(`could not write the run index at ${where}: ${messageOf(err)}`);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
const previous = turns.get(where) ?? Promise.resolve();
|
|
143
|
+
const next = previous.then(work, work);
|
|
144
|
+
const settled = next.catch(() => undefined);
|
|
145
|
+
turns.set(where, settled);
|
|
146
|
+
void settled.then(() => {
|
|
147
|
+
if (turns.get(where) === settled) turns.delete(where);
|
|
148
|
+
});
|
|
149
|
+
return next.catch((err) => input.warn(`could not update the run index at ${where}: ${messageOf(err)}`));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The index at `target`, or null when none is there. Throws a line a person can read when it can't be read. */
|
|
153
|
+
export async function readCiRunIndex(target: ReportTarget, get: ReportGet = reportGet(target)): Promise<CiRunIndex | null> {
|
|
154
|
+
const where = `${target.url}/${CI_RUN_INDEX_KEY}`;
|
|
155
|
+
const text = await get(CI_RUN_INDEX_KEY);
|
|
156
|
+
if (text === null) return null;
|
|
157
|
+
const parsed = parseCiRunIndex(text);
|
|
158
|
+
if ("corrupt" in parsed) throw new Error(`the run index at ${where} is unusable: ${parsed.corrupt}`);
|
|
159
|
+
if ("newer" in parsed) throw new Error(`the run index at ${where} is contract ${parsed.newer}; this chant reads contract ${CI_RUN_INDEX_CONTRACT}`);
|
|
160
|
+
return parsed.index;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The text `chant ci runs` prints: one line per run, newest first. */
|
|
164
|
+
export function formatCiRunIndex(index: CiRunIndex): string {
|
|
165
|
+
if (index.runs.length === 0) return "no runs in the index";
|
|
166
|
+
return index.runs
|
|
167
|
+
.map((r) => {
|
|
168
|
+
const pr = r.pr === null ? "no PR" : `PR ${r.pr}`;
|
|
169
|
+
const why = r.status === "fail" ? ` (${r.failure?.code ?? "failed"})` : "";
|
|
170
|
+
return `${r.ended} ${r.job.padEnd(5)} ${r.status}${why} ${pr} ${r.commit.slice(0, 12)} ${r.runId} ${r.key}`;
|
|
171
|
+
})
|
|
172
|
+
.join("\n");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const RUNS_USAGE = "chant ci runs --report-url <s3://<bucket>/<prefix> | file://<directory>> [--json]";
|
|
176
|
+
|
|
177
|
+
/** `chant ci runs` (#4098): list the index at the report URL. */
|
|
178
|
+
export async function runCiRuns(ctx: CommandContext): Promise<number> {
|
|
179
|
+
try {
|
|
180
|
+
const raw = ctx.args.reportUrl;
|
|
181
|
+
if (!raw) throw new Error("--report-url names where the index is kept");
|
|
182
|
+
const target = parseReportUrl(raw);
|
|
183
|
+
if ("error" in target) throw new Error(target.error);
|
|
184
|
+
const index = await readCiRunIndex(target);
|
|
185
|
+
if (index === null) throw new Error(`there is no run index at ${target.url}/${CI_RUN_INDEX_KEY}`);
|
|
186
|
+
console.log(ctx.args.json ? JSON.stringify(index, null, 2) : formatCiRunIndex(index));
|
|
187
|
+
return 0;
|
|
188
|
+
} catch (err) {
|
|
189
|
+
console.error(formatError({ message: `chant ci runs: ${messageOf(err)}`, hint: RUNS_USAGE }));
|
|
190
|
+
return 1;
|
|
191
|
+
}
|
|
192
|
+
}
|