@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
package/src/run-event-stream.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
type RunEventRun,
|
|
27
27
|
type RunEventRuntime,
|
|
28
28
|
type RunEventSettleStatus,
|
|
29
|
+
type RunEventFailure,
|
|
29
30
|
} from "./run-event";
|
|
30
31
|
|
|
31
32
|
/** Where an emitter hands a run's events, before the writer stamps them. */
|
|
@@ -174,7 +175,7 @@ export interface RunEventStream {
|
|
|
174
175
|
*/
|
|
175
176
|
nested: RunEventSink;
|
|
176
177
|
/** End the stream with `run-settle`, unless it has ended. */
|
|
177
|
-
settle(status: RunEventSettleStatus, error?: string): void;
|
|
178
|
+
settle(status: RunEventSettleStatus, error?: string, failure?: RunEventFailure): void;
|
|
178
179
|
/** Whether `run-settle` has been written. */
|
|
179
180
|
readonly settled: boolean;
|
|
180
181
|
}
|
|
@@ -219,9 +220,9 @@ export function openRunEventStream(
|
|
|
219
220
|
nested: (body) => {
|
|
220
221
|
if (body.type !== "run-start" && body.type !== "run-settle") emit(body);
|
|
221
222
|
},
|
|
222
|
-
settle(status, error) {
|
|
223
|
+
settle(status, error, failure) {
|
|
223
224
|
if (settled) return;
|
|
224
|
-
emit({ type: "run-settle", status, durationMs: null, ...(error !== undefined ? { error } : {}) });
|
|
225
|
+
emit({ type: "run-settle", status, durationMs: null, ...(error !== undefined ? { error } : {}), ...(failure ? { failure } : {}) });
|
|
225
226
|
},
|
|
226
227
|
get settled() {
|
|
227
228
|
return settled;
|
package/src/run-event.ts
CHANGED
|
@@ -105,8 +105,25 @@ export interface RunEventRunSettle {
|
|
|
105
105
|
status: RunEventSettleStatus;
|
|
106
106
|
durationMs: number | null;
|
|
107
107
|
error?: string;
|
|
108
|
+
/**
|
|
109
|
+
* Why a failed run failed, as its run record's `failure` says (#4094):
|
|
110
|
+
* `chant ci run` writes it on a failed job. Optional, added within
|
|
111
|
+
* contract 1 by chant {@link RUN_EVENT_FAILURE_FLOOR}.
|
|
112
|
+
*/
|
|
113
|
+
failure?: RunEventFailure;
|
|
108
114
|
}
|
|
109
115
|
|
|
116
|
+
/** Why a run failed: a short code, a line for a person, the phase and the exit code when known (#4094). */
|
|
117
|
+
export interface RunEventFailure {
|
|
118
|
+
code: string;
|
|
119
|
+
reason: string;
|
|
120
|
+
phase?: string;
|
|
121
|
+
exitCode?: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The first chant that writes `failure` on `run-settle` (#4094). */
|
|
125
|
+
export const RUN_EVENT_FAILURE_FLOOR = "0.124.30";
|
|
126
|
+
|
|
110
127
|
export interface RunEventWaveStart {
|
|
111
128
|
type: "wave-start";
|
|
112
129
|
/** 1-based, into `run-start`'s `waves`. */
|
|
@@ -37,6 +37,8 @@ export interface CheckRun {
|
|
|
37
37
|
* time in milliseconds, which orders a re-run after the run it replaced.
|
|
38
38
|
*/
|
|
39
39
|
opRun?: OpRunRef;
|
|
40
|
+
/** For a failed Op run, its record's `failure.code` (#4094), which a revoke note names. Absent otherwise. */
|
|
41
|
+
failureCode?: string;
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
/** The Op run a {@link CheckRun} stands for (#4044). */
|
|
@@ -117,6 +117,8 @@ export interface RunVerdict {
|
|
|
117
117
|
verdict: Verdict;
|
|
118
118
|
/** The Op run this is, when the source is chant runs (#4044). */
|
|
119
119
|
opRun?: OpRunRef;
|
|
120
|
+
/** Why the Op run failed, its record's `failure.code` (#4094), when it says. */
|
|
121
|
+
failureCode?: string;
|
|
120
122
|
}
|
|
121
123
|
|
|
122
124
|
export interface PhaseVerdict {
|
|
@@ -144,7 +146,7 @@ export function evaluatePhase(phase: CiPhase, latest: readonly CheckRun[]): Phas
|
|
|
144
146
|
if (matched.length === 0) unmatched.push(pattern);
|
|
145
147
|
for (const r of matched) {
|
|
146
148
|
if (runs.some((x) => x.name === r.name)) continue;
|
|
147
|
-
runs.push({ name: r.name, id: r.id, status: r.status, conclusion: r.conclusion, url: r.url, verdict: runVerdict(phase, r), ...(r.opRun ? { opRun: r.opRun } : {}) });
|
|
149
|
+
runs.push({ name: r.name, id: r.id, status: r.status, conclusion: r.conclusion, url: r.url, verdict: runVerdict(phase, r), ...(r.opRun ? { opRun: r.opRun } : {}), ...(r.failureCode ? { failureCode: r.failureCode } : {}) });
|
|
148
150
|
}
|
|
149
151
|
}
|
|
150
152
|
const verdict: Verdict = runs.some((r) => r.verdict === "fail")
|
|
@@ -263,7 +265,8 @@ export function greenAnnotation(green: CiGreen, sha: string, verdict: CommitVerd
|
|
|
263
265
|
/** The annotation of a revoked tag: the phase and the run that failed, and the time, signed when `signer` is given (#4031). */
|
|
264
266
|
export function revokedAnnotation(green: CiGreen, sha: string, failure: NonNullable<CommitVerdict["failure"]>, at: Date, signer?: CiTagSigner): string {
|
|
265
267
|
const record: CiTagRecord = { schema: 1, tag: "revoked", commit: sha, branch: green.branch, at: iso(at), ...sourceField(green), phase: failure.phase, run: runRecord(failure.run) };
|
|
266
|
-
|
|
268
|
+
const code = failure.run.failureCode ? `, ${failure.run.failureCode}` : "";
|
|
269
|
+
return `ci: phase ${failure.phase} failed in ${failure.run.name} (${failure.run.conclusion ?? failure.run.status}${code})\n\n${recordJson(record, signer)}\n`;
|
|
267
270
|
}
|
|
268
271
|
|
|
269
272
|
/**
|
|
@@ -627,6 +630,7 @@ export function opRunCheckRun(record: OpRunRecord, signer: string): CheckRun {
|
|
|
627
630
|
completedAt: settled ? record.ended : null,
|
|
628
631
|
url: record.runOrigin?.url ?? null,
|
|
629
632
|
opRun: { id: record.id, env: record.env, signer, statement: record.statement! },
|
|
633
|
+
...(record.status === "fail" && record.failure ? { failureCode: record.failure.code } : {}),
|
|
630
634
|
};
|
|
631
635
|
}
|
|
632
636
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://intentius.io/chant/schemas/workspace/ci-run-index/v1/ci-run-index.schema.json",
|
|
4
|
+
"title": "chant ci run index",
|
|
5
|
+
"description": "The runs the chant runner has run, kept at <report-url>/runs/index.json (#4098, epic #4101) so a reader outside CI finds them without a forge or a workflow file. `chant ci run` adds an entry to the front after each job settles when --report-url is set, so the list is newest first, and keeps the newest 200 entries unless --run-index-limit says otherwise. `chant ci runs --json` prints the same document. An entry's `key` is the folder the job's report is under, below the report URL. The update is a read, a change and a write of the whole file, through the same s3:// and file:// paths the report upload uses; a file:// write goes through a temporary name and a rename. Two runners updating one index at once can lose an entry. That is out of scope, because `chant ci serve` is one host. Version 1 of the workspace read contract. Readers ignore fields they do not know; a field is only ever added within a version. Written by chant 0.124.31 and newer.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["$schema", "contract", "chant", "updated", "runs"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"$schema": { "const": "https://intentius.io/chant/schemas/workspace/ci-run-index/v1/ci-run-index.schema.json" },
|
|
10
|
+
"contract": { "const": 1 },
|
|
11
|
+
"chant": { "type": "string", "description": "The chant that last wrote the index." },
|
|
12
|
+
"updated": { "type": "string", "description": "When the index was last written." },
|
|
13
|
+
"runs": {
|
|
14
|
+
"type": "array",
|
|
15
|
+
"description": "Newest first, at most the limit the writer was given (200 by default).",
|
|
16
|
+
"items": { "$ref": "#/$defs/run" }
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"$defs": {
|
|
20
|
+
"run": {
|
|
21
|
+
"type": "object",
|
|
22
|
+
"required": ["job", "pr", "commit", "status", "started", "ended", "runId", "key", "runOrigin"],
|
|
23
|
+
"if": { "properties": { "status": { "const": "fail" } } },
|
|
24
|
+
"then": { "required": ["failure"], "properties": { "failure": { "type": "object" } } },
|
|
25
|
+
"properties": {
|
|
26
|
+
"job": { "enum": ["plan", "apply"] },
|
|
27
|
+
"pr": { "type": ["integer", "null"], "minimum": 1, "description": "The pull or merge request number: the one the job's report names, else --pr. null when neither has one." },
|
|
28
|
+
"commit": { "type": "string", "pattern": "^[0-9a-f]{40,64}$", "description": "The full commit the job ran at." },
|
|
29
|
+
"status": { "enum": ["ok", "fail"] },
|
|
30
|
+
"failure": {
|
|
31
|
+
"type": "object",
|
|
32
|
+
"required": ["code"],
|
|
33
|
+
"description": "Present when status is fail. The same code the run record's failure carries: setup-failed, build-failed, a report's refusal or status, or command-failed.",
|
|
34
|
+
"properties": { "code": { "type": "string" } }
|
|
35
|
+
},
|
|
36
|
+
"member": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]{0,39}$", "description": "The workspace member whose pipeline the job ran for. Absent outside a member." },
|
|
37
|
+
"started": { "type": "string", "description": "ISO-8601 instant the job started." },
|
|
38
|
+
"ended": { "type": "string", "description": "ISO-8601 instant the job settled." },
|
|
39
|
+
"runId": { "type": "string", "description": "The Op run id, as in the run ledger on chant/lifecycle." },
|
|
40
|
+
"key": { "type": "string", "description": "reportKey(pr, commit): <pr>/<commit>, with /<member> after for a member, and unknown for a missing pr. The job's report is <report-url>/<key>/pr-<job>.json." },
|
|
41
|
+
"runOrigin": {
|
|
42
|
+
"type": "object",
|
|
43
|
+
"required": ["forge"],
|
|
44
|
+
"description": "Where the run says it ran. { forge: \"chant\" } for a job the chant runner ran.",
|
|
45
|
+
"properties": { "forge": { "type": "string" } }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -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
|
+
});
|