@avi2dg/checks 0.70.0 → 0.72.0
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/CHANGELOG.md +16 -0
- package/docs/bins.md +6 -6
- package/docs/configs/native-settings.md +1 -1
- package/docs/design.md +2 -2
- package/docs/gates/checks-browser.md +2 -2
- package/docs/gates/checks-ci-wiring.md +3 -22
- package/docs/gates/checks-frontend-syntax.md +2 -2
- package/docs/gates/checks-lint-coverage.md +1 -1
- package/docs/gates/checks-lint.md +5 -5
- package/docs/gates/checks-mutation-compare.md +2 -2
- package/docs/gates/checks-quarantine-clock.md +2 -2
- package/docs/gates/checks-repetition.md +3 -3
- package/docs/gates/checks-setup.md +4 -4
- package/docs/gates/checks-suppressions-ratchet.md +3 -3
- package/docs/gates/checks-test.md +3 -3
- package/docs/gates/checks-whole-tree-run.md +281 -0
- package/docs/how-to/set-up-a-repository.md +3 -3
- package/docs/how-to/upgrade-the-kit.md +5 -2
- package/package.json +4 -5
- package/src/core/gates.ts +7 -7
- package/src/core/lint.ts +12 -13
- package/src/delivery/ci-wiring.ts +2 -7
- package/src/delivery/setup-workflow.ts +0 -25
- package/src/delivery/setup.ts +8 -3
- package/src/delivery/shell-command.ts +0 -4
- package/src/testing/{night-run-findings.ts → whole-tree-run-findings.ts} +10 -10
- package/src/testing/{night-run.ts → whole-tree-run.ts} +44 -44
- package/docs/gates/checks-night-run.md +0 -257
- package/src/delivery/night-step.ts +0 -79
|
@@ -5,21 +5,21 @@ import { DEFAULT_BRANCH } from "../core/gates.ts";
|
|
|
5
5
|
import { runMain, Usage } from "../core/main.ts";
|
|
6
6
|
import { gh, type GitHubFailure, gitHubJson, REPOSITORY } from "../delivery/github.ts";
|
|
7
7
|
import { parseReport as parseMutationReport, type ReportFile } from "./mutation-compare.ts";
|
|
8
|
-
import { cleared, type Finding, findingsOf, gatesDecided, gatesOf, keyOf,
|
|
8
|
+
import { cleared, type Finding, findingsOf, gatesDecided, gatesOf, keyOf, WholeTreeRecordSchema, noted, type Observed, type WholeTreeRecord, percent, type PreviousRun, scoresOf, type TestReport } from "./whole-tree-run-findings.ts";
|
|
9
9
|
import { parseReport as parseTestReport } from "./test-report.ts";
|
|
10
10
|
|
|
11
|
-
const NAME = "checks-
|
|
11
|
+
const NAME = "checks-whole-tree-run";
|
|
12
12
|
const USAGE = `usage: ${NAME} [--mutation <mutation-report>]... [--tests <junit-report>]... [--gates <lint-log>]`;
|
|
13
|
-
export const LABEL = "
|
|
13
|
+
export const LABEL = "whole-tree-run";
|
|
14
14
|
const LABEL_COLOR = "5319e7";
|
|
15
|
-
const LABEL_DESCRIPTION = "A regression a
|
|
16
|
-
const ARTIFACT = "
|
|
17
|
-
const RECORD_NAME = "
|
|
15
|
+
const LABEL_DESCRIPTION = "A regression a whole-tree run on main found";
|
|
16
|
+
const ARTIFACT = "whole-tree-run";
|
|
17
|
+
const RECORD_NAME = "whole-tree-run.json";
|
|
18
18
|
export const RECORD = `reports/${RECORD_NAME}`;
|
|
19
19
|
const PAGE_SIZE = 100;
|
|
20
20
|
const GITHUB_TITLE_LIMIT = 256;
|
|
21
21
|
|
|
22
|
-
class
|
|
22
|
+
class WholeTreeRunFailure extends Schema.TaggedError<WholeTreeRunFailure>()("WholeTreeRunFailure", {
|
|
23
23
|
message: Schema.String,
|
|
24
24
|
}) {}
|
|
25
25
|
|
|
@@ -81,11 +81,11 @@ const decodeIssues = Schema.decodeUnknownEffect(
|
|
|
81
81
|
const decodeKey = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.String));
|
|
82
82
|
const decodeComments = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Array(Schema.Struct({ body: Schema.String }))));
|
|
83
83
|
|
|
84
|
-
const MARKER = /<!-- checks-
|
|
84
|
+
const MARKER = /<!-- checks-whole-tree-run ("(?:[^"\\]|\\.)*") -->/;
|
|
85
85
|
|
|
86
86
|
// The key is JSON with every > escaped, so no key can close the HTML comment early.
|
|
87
87
|
export function marker(key: string): string {
|
|
88
|
-
return `<!-- checks-
|
|
88
|
+
return `<!-- checks-whole-tree-run ${JSON.stringify(key).replaceAll(">", "\\u003e")} -->`;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
export function markedKey(body: string | null): Option.Option<string> {
|
|
@@ -117,11 +117,11 @@ const issueComments = Effect.fn("issueComments")(function* (issue: number) {
|
|
|
117
117
|
});
|
|
118
118
|
|
|
119
119
|
function fullTitleOf(finding: Finding): string {
|
|
120
|
-
if (finding.kind === "mutation") return `
|
|
121
|
-
if (finding.kind === "test") return `
|
|
122
|
-
if (finding.kind === "gates") return `
|
|
123
|
-
if (finding.kind === "mutation-report") return `
|
|
124
|
-
return `
|
|
120
|
+
if (finding.kind === "mutation") return `Whole-tree run: mutation score fell in ${finding.file}`;
|
|
121
|
+
if (finding.kind === "test") return `Whole-tree run: ${finding.file} > ${finding.name} fails`;
|
|
122
|
+
if (finding.kind === "gates") return `Whole-tree run: whole-tree gate ${finding.file} failed`;
|
|
123
|
+
if (finding.kind === "mutation-report") return `Whole-tree run: no mutation report at ${finding.file}`;
|
|
124
|
+
return `Whole-tree run: ${finding.file} crashed its test worker`;
|
|
125
125
|
}
|
|
126
126
|
|
|
127
127
|
export function titleOf(finding: Finding): string {
|
|
@@ -136,25 +136,25 @@ export function findingText(finding: Finding, run: string): string {
|
|
|
136
136
|
if (finding.kind === "mutation") {
|
|
137
137
|
const { file, before, after, since } = finding;
|
|
138
138
|
return [
|
|
139
|
-
`The
|
|
139
|
+
`The whole-tree run ${run} scored \`${file}\` lower than the whole-tree run ${since}.`,
|
|
140
140
|
"",
|
|
141
|
-
"| File | Previous
|
|
141
|
+
"| File | Previous run | This run |",
|
|
142
142
|
"| --- | --- | --- |",
|
|
143
143
|
`| \`${file}\` | ${percent(before)}, ${before.detected} of ${before.valid} mutants detected | ${percent(after)}, ${after.detected} of ${after.valid} mutants detected |`,
|
|
144
144
|
].join("\n");
|
|
145
145
|
}
|
|
146
|
-
if (finding.kind === "test") return `The
|
|
147
|
-
if (finding.kind === "gates") return `The
|
|
148
|
-
if (finding.kind === "mutation-report") return `The
|
|
149
|
-
return `The
|
|
146
|
+
if (finding.kind === "test") return `The whole-tree run ${run} failed \`${finding.name}\` in \`${finding.file}\`.`;
|
|
147
|
+
if (finding.kind === "gates") return `The whole-tree run ${run} failed whole-tree gate \`${finding.file}\`.`;
|
|
148
|
+
if (finding.kind === "mutation-report") return `The whole-tree run ${run} found no mutation report at \`${finding.file}\`: the mutation job that writes it failed, timed out or was cancelled, so no file it mutates was scored.`;
|
|
149
|
+
return `The whole-tree run ${run} lost every result of \`${finding.file}\`, because the worker running it crashed.`;
|
|
150
150
|
}
|
|
151
151
|
|
|
152
152
|
export function issueBody(finding: Finding, run: string): string {
|
|
153
153
|
return `${findingText(finding, run)}\n\nFinding key: \`${keyOf(finding)}\`\n\n${marker(keyOf(finding))}\n`;
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
-
const REPORTED = "<!-- checks-
|
|
157
|
-
const NOTED = "<!-- checks-
|
|
156
|
+
const REPORTED = "<!-- checks-whole-tree-run reported -->";
|
|
157
|
+
const NOTED = "<!-- checks-whole-tree-run noted -->";
|
|
158
158
|
|
|
159
159
|
export function reportedText(finding: Finding, run: string): string {
|
|
160
160
|
return `${findingText(finding, run)}\n\n${REPORTED}\n`;
|
|
@@ -166,11 +166,11 @@ export function alreadyNoted(comments: readonly string[]): boolean {
|
|
|
166
166
|
}
|
|
167
167
|
|
|
168
168
|
export function clearedText(key: string, run: string): string {
|
|
169
|
-
return `The
|
|
169
|
+
return `The whole-tree run ${run} no longer reports \`${key}\`, so it closes this issue.`;
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
export function notedText(key: string, run: string): string {
|
|
173
|
-
return `The
|
|
173
|
+
return `The whole-tree run ${run} no longer reports \`${key}\`. This issue stays open because a mutation score can recover and drop again.\n\n${NOTED}\n`;
|
|
174
174
|
}
|
|
175
175
|
|
|
176
176
|
export const fileFindings = Effect.fn("fileFindings")(function* (findings: readonly Finding[], observed: Observed, run: string) {
|
|
@@ -208,7 +208,7 @@ export const fileFindings = Effect.fn("fileFindings")(function* (findings: reado
|
|
|
208
208
|
});
|
|
209
209
|
|
|
210
210
|
const required = (name: string) =>
|
|
211
|
-
Config.String(name).pipe(Effect.mapError(() => new
|
|
211
|
+
Config.String(name).pipe(Effect.mapError(() => new WholeTreeRunFailure({ message: `${name} is unset, so ${NAME} runs only inside a GitHub Actions job` })));
|
|
212
212
|
|
|
213
213
|
const WORKFLOW_FILE = /\/\.github\/workflows\/([^/@]+)@/;
|
|
214
214
|
|
|
@@ -218,18 +218,18 @@ const actionsRun = Effect.gen(function* () {
|
|
|
218
218
|
const runs = `${yield* required("GITHUB_SERVER_URL")}/${yield* required("GITHUB_REPOSITORY")}/actions/runs`;
|
|
219
219
|
const reference = yield* required("GITHUB_WORKFLOW_REF");
|
|
220
220
|
const workflow = WORKFLOW_FILE.exec(reference)?.[1];
|
|
221
|
-
if (workflow === undefined) return yield* new
|
|
221
|
+
if (workflow === undefined) return yield* new WholeTreeRunFailure({ message: `GITHUB_WORKFLOW_REF ${reference} names no workflow file` });
|
|
222
222
|
return { runs, run: `${runs}/${yield* required("GITHUB_RUN_ID")}`, workflow, ref: yield* required("GITHUB_REF") } satisfies Actions;
|
|
223
223
|
});
|
|
224
224
|
|
|
225
225
|
const decodeRuns = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Array(Schema.Struct({ databaseId: Schema.Int }))));
|
|
226
226
|
const decodeArtifacts = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ artifacts: Schema.Array(Schema.Struct({ name: Schema.String, expired: Schema.Boolean })) })));
|
|
227
|
-
const decodeRecord = Schema.decodeUnknownEffect(Schema.fromJsonString(
|
|
227
|
+
const decodeRecord = Schema.decodeUnknownEffect(Schema.fromJsonString(WholeTreeRecordSchema));
|
|
228
228
|
|
|
229
|
-
const unreadable = (source: string) => (cause: { readonly message: string }) => new
|
|
229
|
+
const unreadable = (source: string) => (cause: { readonly message: string }) => new WholeTreeRunFailure({ message: `cannot read ${source}: ${cause.message}` });
|
|
230
230
|
|
|
231
|
-
// A skipped
|
|
232
|
-
const
|
|
231
|
+
// A skipped run records no artifact, so the newest successful run can carry no baseline.
|
|
232
|
+
const previousRun = Effect.fn("previousRun")(
|
|
233
233
|
function* ({ runs, workflow }: Actions) {
|
|
234
234
|
const listed = ["run", "list", "--workflow", workflow, "--branch", DEFAULT_BRANCH, "--status", "success", "--limit", "30", "--json", "databaseId,event,createdAt"];
|
|
235
235
|
const candidates = yield* gh(listed).pipe(Effect.flatMap(decodeRuns), Effect.mapError(unreadable(`the runs of ${workflow}`)));
|
|
@@ -241,13 +241,13 @@ const previousNight = Effect.fn("previousNight")(
|
|
|
241
241
|
);
|
|
242
242
|
if (!artifacts.some((artifact) => artifact.name === ARTIFACT && !artifact.expired)) continue;
|
|
243
243
|
const fs = yield* FileSystem.FileSystem;
|
|
244
|
-
const dir = yield* fs.makeTempDirectoryScoped({ prefix: "checks-
|
|
244
|
+
const dir = yield* fs.makeTempDirectoryScoped({ prefix: "checks-whole-tree-run-" });
|
|
245
245
|
yield* gh(["run", "download", String(run), "--name", ARTIFACT, "--dir", dir]);
|
|
246
246
|
const file = (yield* Path.Path).join(dir, RECORD_NAME);
|
|
247
247
|
const record = yield* fs.readFileString(file).pipe(Effect.flatMap(decodeRecord), Effect.mapError(unreadable(`the ${ARTIFACT} artifact of run ${run}`)));
|
|
248
|
-
return Option.some<
|
|
248
|
+
return Option.some<PreviousRun>({ url: `${runs}/${run}`, record });
|
|
249
249
|
}
|
|
250
|
-
return Option.none<
|
|
250
|
+
return Option.none<PreviousRun>();
|
|
251
251
|
},
|
|
252
252
|
Effect.scoped,
|
|
253
253
|
);
|
|
@@ -260,7 +260,7 @@ const testReport = Effect.fn("testReport")(function* (file: string) {
|
|
|
260
260
|
return yield* parseTestReport(yield* readText(file)).pipe(Effect.mapError(unreadable(file)));
|
|
261
261
|
});
|
|
262
262
|
|
|
263
|
-
const writeRecord = Effect.fn("writeRecord")(function* (record:
|
|
263
|
+
const writeRecord = Effect.fn("writeRecord")(function* (record: WholeTreeRecord) {
|
|
264
264
|
const fs = yield* FileSystem.FileSystem;
|
|
265
265
|
yield* fs.makeDirectory((yield* Path.Path).dirname(RECORD), { recursive: true });
|
|
266
266
|
yield* fs.writeFileString(RECORD, `${JSON.stringify(record, null, 2)}\n`);
|
|
@@ -282,21 +282,21 @@ function readReports(reports: readonly MutationReport[]): ReadonlySet<string> {
|
|
|
282
282
|
return new Set(reports.flatMap(({ file, files }) => (Option.isSome(files) ? [file] : [])));
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
-
function
|
|
285
|
+
function currentScores(reports: readonly MutationReport[]): Option.Option<WholeTreeRecord["scores"]> {
|
|
286
286
|
const read = reports.flatMap(({ files }) => (Option.isSome(files) ? [...files.value] : []));
|
|
287
287
|
return reports.some(({ files }) => Option.isSome(files)) ? Option.some(scoresOf(new Map(read))) : Option.none();
|
|
288
288
|
}
|
|
289
289
|
|
|
290
290
|
type Filing = { readonly onMain: true; readonly filed: readonly Filed[] } | { readonly onMain: false; readonly ref: string };
|
|
291
291
|
|
|
292
|
-
// A
|
|
292
|
+
// A run missing one report records nothing, so the next run still compares every file with the last run that scored it.
|
|
293
293
|
type Scored = { readonly files: number; readonly missing: number };
|
|
294
294
|
|
|
295
|
-
export function report(run: string, scored: Option.Option<Scored>, previous: Option.Option<
|
|
295
|
+
export function report(run: string, scored: Option.Option<Scored>, previous: Option.Option<PreviousRun>, findings: readonly Finding[], filing: Filing): string {
|
|
296
296
|
const against = Option.isNone(scored)
|
|
297
297
|
? "had no mutation report, so it compared no score"
|
|
298
298
|
: Option.isSome(previous)
|
|
299
|
-
? `compared with the
|
|
299
|
+
? `compared with the whole-tree run ${previous.value.url}`
|
|
300
300
|
: `found no earlier successful run on ${DEFAULT_BRANCH} holding a ${ARTIFACT} artifact, so it compared no score`;
|
|
301
301
|
const verdict = findings.length === 0 ? "no finding" : `${findings.length} finding(s)`;
|
|
302
302
|
const listed = filing.onMain ? filing.filed.map((one) => ` ${one.action} #${one.issue} for ${one.key}`) : findings.map((finding) => ` found ${keyOf(finding)}`);
|
|
@@ -307,30 +307,30 @@ export function report(run: string, scored: Option.Option<Scored>, previous: Opt
|
|
|
307
307
|
: scored.value.missing === 0
|
|
308
308
|
? `${NAME}: recorded ${scored.value.files} file score(s) in ${RECORD}`
|
|
309
309
|
: `${NAME}: recorded no score, since ${scored.value.missing} mutation report(s) were missing`;
|
|
310
|
-
return [`${NAME}: the
|
|
310
|
+
return [`${NAME}: the whole-tree run ${run} ${against}; ${verdict}`, ...listed, recorded].join("\n");
|
|
311
311
|
}
|
|
312
312
|
|
|
313
|
-
const
|
|
313
|
+
const wholeTreeRun = Effect.gen(function* () {
|
|
314
314
|
const options = yield* parseArgs(process.argv.slice(2));
|
|
315
315
|
const actions = yield* actionsRun;
|
|
316
316
|
const reports = yield* Effect.forEach(options.mutation, mutationReport);
|
|
317
317
|
const missing = missingReports(reports);
|
|
318
|
-
const scores =
|
|
318
|
+
const scores = currentScores(reports);
|
|
319
319
|
const tests: TestReport[] = yield* Effect.forEach(options.tests, testReport);
|
|
320
320
|
const log = Option.isSome(options.gates) ? Option.some(yield* readText(options.gates.value)) : Option.none<string>();
|
|
321
321
|
const gates: readonly Finding[] = Option.isSome(log) ? gatesOf(log.value) : [];
|
|
322
|
-
const previous = Option.isSome(scores) ? yield*
|
|
322
|
+
const previous = Option.isSome(scores) ? yield* previousRun(actions) : Option.none<PreviousRun>();
|
|
323
323
|
const observed: Observed = { tests: options.tests.length > 0, gates: Option.isSome(log) && gatesDecided(log.value), mutation: Option.isSome(previous) && missing.length === 0, mutationReports: readReports(reports) };
|
|
324
324
|
const findings = findingsOf(previous, { scores: Option.getOrElse(scores, () => ({})), tests }, [...missing, ...gates]);
|
|
325
325
|
const filing: Filing = actions.ref === `refs/heads/${DEFAULT_BRANCH}` ? { onMain: true, filed: yield* fileFindings(findings, observed, actions.run) } : { onMain: false, ref: actions.ref };
|
|
326
326
|
if (filing.onMain && Option.isSome(scores) && missing.length === 0) yield* writeRecord({ scores: scores.value });
|
|
327
|
-
const scored = Option.map(scores, (
|
|
327
|
+
const scored = Option.map(scores, (current): Scored => ({ files: Object.keys(current).length, missing: missing.length }));
|
|
328
328
|
yield* Console.log(report(actions.run, scored, previous, findings, filing));
|
|
329
329
|
return true;
|
|
330
330
|
});
|
|
331
331
|
|
|
332
332
|
export function main(issues: Layer.Layer<Issues, never, ChildProcessSpawner.ChildProcessSpawner>): void {
|
|
333
|
-
runMain(NAME,
|
|
333
|
+
runMain(NAME, wholeTreeRun.pipe(Effect.provide(issues)));
|
|
334
334
|
}
|
|
335
335
|
|
|
336
336
|
if (import.meta.main) main(Issues.gh);
|
|
@@ -1,257 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
kind: reference
|
|
3
|
-
audience: consumers
|
|
4
|
-
---
|
|
5
|
-
# checks-night-run
|
|
6
|
-
|
|
7
|
-
`checks-night-run` turns each finding of a night run on `main` into a GitHub issue.
|
|
8
|
-
A finding is a file whose mutation score fell, a failing test, a failed nightly gate or a missing mutation report.
|
|
9
|
-
It closes the issue of a test, gate or missing mutation report that a later night no longer reports, and it comments on a mutation issue it no longer reports without closing it, but only when a previous night exists and every requested mutation report is present.
|
|
10
|
-
|
|
11
|
-
## What it checks
|
|
12
|
-
|
|
13
|
-
It scores each file of tonight's Stryker reports as Stryker does, the `Killed` and `Timeout` mutants over every mutant but the `Ignored`, `CompileError`, `RuntimeError` and `Pending` ones.
|
|
14
|
-
It compares each file's score with the same file's score from the previous night.
|
|
15
|
-
A file whose score is lower than the previous night's is a finding.
|
|
16
|
-
A file only one night holds is no finding, since it lacks one of the two scores.
|
|
17
|
-
A file with no scored mutant has no score, so it is no finding either.
|
|
18
|
-
An equal score is no finding, even when it counts other mutants.
|
|
19
|
-
Each failing test in a JUnit report is a finding, whatever the previous night holds.
|
|
20
|
-
Each nightly gate the lint log names as failed is a finding.
|
|
21
|
-
A log where every named gate is undecided files `checks-lint` itself, with no per-gate issue.
|
|
22
|
-
A log with no passing run and no named gate files `checks-lint` itself, so a gate run that never finished still reaches an issue.
|
|
23
|
-
A file whose test worker crashed is one finding, since Bun reports none of its tests.
|
|
24
|
-
With no previous night it compares no score and still files each failing test.
|
|
25
|
-
Each mutation report `--mutation` names that does not exist is a finding, since the mutation job that writes it failed, timed out or was cancelled.
|
|
26
|
-
With no mutation report it compares and records no score and still files each failing test.
|
|
27
|
-
With some mutation reports missing it compares the files the others hold and records no score.
|
|
28
|
-
The next night then compares every file with the last night that scored them all.
|
|
29
|
-
|
|
30
|
-
Each finding has a key that stays the same from night to night:
|
|
31
|
-
|
|
32
|
-
| Finding | Key |
|
|
33
|
-
| --- | --- |
|
|
34
|
-
| a file whose mutation score fell | `mutation:<file>` |
|
|
35
|
-
| a failing test | `test:<file> > <describe> > <name>` |
|
|
36
|
-
| a file whose test worker crashed | `test:<file>` |
|
|
37
|
-
| a nightly gate that failed | `gates:<gate>` |
|
|
38
|
-
| a missing mutation report | `mutation-report:<path>` |
|
|
39
|
-
|
|
40
|
-
## How it files a finding
|
|
41
|
-
|
|
42
|
-
It creates the `night-run` label when the repository lacks it.
|
|
43
|
-
It reads every open issue labelled `night-run`, and reads the key each one carries in a hidden marker in its body.
|
|
44
|
-
A finding whose key an open issue carries gets one comment on that issue, so a regression that lasts two nights stays one issue.
|
|
45
|
-
Any other finding opens one issue labelled `night-run`.
|
|
46
|
-
The issue and the comment name tonight's run, the file and the test, the score of both nights or the failed gate.
|
|
47
|
-
The body holds the file, the full test name or the failed gate, and the finding key.
|
|
48
|
-
A title longer than GitHub's 256 characters is cut to at most 256 and ends in `…`.
|
|
49
|
-
A night with no finding creates no label and writes no issue, and only reads the open issues to close any test, gate or mutation-report issue whose finding is gone and, when a previous night exists and every requested mutation report is present, to comment on any mutation issue whose finding is gone.
|
|
50
|
-
Closing an issue ends it, and the same finding on a later night opens a new one.
|
|
51
|
-
|
|
52
|
-
## How it closes an issue
|
|
53
|
-
|
|
54
|
-
An open test, gate or mutation-report issue whose key tonight's findings lack gets one comment naming tonight's run and the key.
|
|
55
|
-
It then closes as completed.
|
|
56
|
-
When a previous night exists and every requested mutation report is present, an open mutation issue whose key tonight's findings lack gets one comment that names tonight's run and the key, and stays open.
|
|
57
|
-
A closed issue is no longer read, so a later night without the finding comments on it no more.
|
|
58
|
-
A comment that reports the finding carries a reported marker, and a note that a mutation finding is gone carries a noted marker.
|
|
59
|
-
A mutation issue whose latest marked comment already notes the finding gone gets no further note.
|
|
60
|
-
So a mutation finding gone for many nights is noted once, and noted again only after a later night reports it again.
|
|
61
|
-
A person's comment carries no marker, so it never makes the run note an issue twice.
|
|
62
|
-
It judges a key only from a report it read tonight:
|
|
63
|
-
|
|
64
|
-
| Key | Closes when |
|
|
65
|
-
| --- | --- |
|
|
66
|
-
| `test:<file> > <describe> > <name>` | tonight read a JUnit report, the test is not failing, and `<file>`'s worker did not crash |
|
|
67
|
-
| `test:<file>` | tonight read a JUnit report and `<file>`'s worker did not crash |
|
|
68
|
-
| `gates:<gate>` | tonight read a lint log that passes or names its failed gates, and `<gate>` is not among them |
|
|
69
|
-
| `mutation-report:<path>` | tonight read the mutation report at `<path>` |
|
|
70
|
-
| `mutation:<file>` | never |
|
|
71
|
-
|
|
72
|
-
A crashed worker loses every result of its file, so the issue of a test in that file stays open.
|
|
73
|
-
A lint log that is undecided or never finished says nothing of any one gate, so every gate issue stays open.
|
|
74
|
-
A mutation finding is a fall from one night to the next.
|
|
75
|
-
A later night that does not fall again still holds the lower score.
|
|
76
|
-
Only a night that compared scores with every requested mutation report present comments on the mutation issue without closing it, since a score can recover and drop again.
|
|
77
|
-
So a person closes a mutation issue once the score is back.
|
|
78
|
-
An issue with no key in its body is never read, so a person's issue under the `night-run` label is never commented on or closed.
|
|
79
|
-
A run off `main` comments on and closes no issue.
|
|
80
|
-
Agents and people fix each issue as a task of its own, so no pull request waits for a night run.
|
|
81
|
-
A run on any ref but `refs/heads/main` files no issue and records no score, and lists each finding in its log only.
|
|
82
|
-
|
|
83
|
-
## What it reads
|
|
84
|
-
|
|
85
|
-
It reads tonight's Stryker `mutation.json` reports and any number of JUnit reports, such as the ones `checks-test --junit=<file>` keeps, as [checks-test](checks-test.md#arguments) says.
|
|
86
|
-
It reads the lint log `--gates` names for the `gate(s) failed:` line `checks-lint --night` prints.
|
|
87
|
-
It scans the newest 30 successful runs of the same workflow on `main` from newest to oldest.
|
|
88
|
-
It takes the first run whose `night-run` artifact still exists as the previous night.
|
|
89
|
-
That artifact holds `night-run.json`, the scores the run recorded.
|
|
90
|
-
When no scanned run holds the artifact it compares no score.
|
|
91
|
-
A run on another branch never becomes the previous night.
|
|
92
|
-
On `main` with every mutation report present it writes tonight's scores to `reports/night-run.json`, and the workflow uploads that file as the `night-run` artifact.
|
|
93
|
-
It reads the run from `GITHUB_SERVER_URL`, `GITHUB_REPOSITORY`, `GITHUB_RUN_ID`, `GITHUB_WORKFLOW_REF` and `GITHUB_REF`, so it runs only inside a GitHub Actions job.
|
|
94
|
-
It calls `gh` with the job's `GH_TOKEN`, which needs `actions: read` for the previous night and `issues: write` for the issues.
|
|
95
|
-
|
|
96
|
-
## Arguments
|
|
97
|
-
|
|
98
|
-
```sh
|
|
99
|
-
checks-night-run [--mutation <mutation-report>]... [--tests <junit-report>]... [--gates <lint-log>]
|
|
100
|
-
```
|
|
101
|
-
|
|
102
|
-
`--mutation` names one of tonight's Stryker reports and is given once per report the workflow's mutation jobs write, whether or not each job wrote it.
|
|
103
|
-
`--tests` names one JUnit report and is given once per report.
|
|
104
|
-
`--gates` names tonight's `checks-lint --night` log and is given at most once.
|
|
105
|
-
At least one report is given.
|
|
106
|
-
|
|
107
|
-
## Exit codes
|
|
108
|
-
|
|
109
|
-
| Code | When |
|
|
110
|
-
| --- | --- |
|
|
111
|
-
| 0 | it filed every finding, closed each issue whose finding is gone and recorded tonight's scores, it found nothing to file, or the run is off `main` and it listed each finding |
|
|
112
|
-
| 2 | an argument does not parse, a report or the lint log does not decode, a GitHub Actions variable is unset or a `gh` call fails |
|
|
113
|
-
|
|
114
|
-
It never exits 1, because a finding becomes an issue rather than a failed run.
|
|
115
|
-
|
|
116
|
-
## Sample output
|
|
117
|
-
|
|
118
|
-
```
|
|
119
|
-
checks-night-run: the night run https://github.com/acme/widget/actions/runs/12 compared with the night run https://github.com/acme/widget/actions/runs/11; 2 finding(s)
|
|
120
|
-
opened #31 for mutation:src/cart.ts
|
|
121
|
-
commented on #27 for test:tests/unit/cart.test.ts > totals > rounds half to even
|
|
122
|
-
closed #24 for gates:checks-repetition
|
|
123
|
-
checks-night-run: recorded 42 file score(s) in reports/night-run.json
|
|
124
|
-
```
|
|
125
|
-
|
|
126
|
-
## When it runs
|
|
127
|
-
|
|
128
|
-
Only a night workflow the repository writes runs it, after that workflow's full mutation run, its long test tiers and its nightly lint gates.
|
|
129
|
-
|
|
130
|
-
## Running it by hand
|
|
131
|
-
|
|
132
|
-
A repository runs it from a workflow named `night-run`, which someone starts with `gh workflow run night-run`.
|
|
133
|
-
The workflow holds no schedule, since [checks-ci-wiring](checks-ci-wiring.md#schedules) refuses one:
|
|
134
|
-
|
|
135
|
-
```yaml
|
|
136
|
-
name: night-run
|
|
137
|
-
on:
|
|
138
|
-
workflow_dispatch:
|
|
139
|
-
permissions:
|
|
140
|
-
contents: read
|
|
141
|
-
concurrency:
|
|
142
|
-
group: night-run
|
|
143
|
-
cancel-in-progress: false
|
|
144
|
-
jobs:
|
|
145
|
-
mutation:
|
|
146
|
-
runs-on: ubuntu-latest
|
|
147
|
-
timeout-minutes: 60
|
|
148
|
-
steps:
|
|
149
|
-
- uses: actions/checkout@v5
|
|
150
|
-
- uses: oven-sh/setup-bun@v2
|
|
151
|
-
with:
|
|
152
|
-
bun-version-file: .bun-version
|
|
153
|
-
- run: bun install --frozen-lockfile
|
|
154
|
-
- run: bunx stryker run
|
|
155
|
-
- uses: actions/upload-artifact@v4
|
|
156
|
-
with:
|
|
157
|
-
name: night-run-mutation
|
|
158
|
-
path: reports/mutation/mutation.json
|
|
159
|
-
retention-days: 1
|
|
160
|
-
tests:
|
|
161
|
-
runs-on: ubuntu-latest
|
|
162
|
-
timeout-minutes: 20
|
|
163
|
-
steps:
|
|
164
|
-
- uses: actions/checkout@v5
|
|
165
|
-
- uses: oven-sh/setup-bun@v2
|
|
166
|
-
with:
|
|
167
|
-
bun-version-file: .bun-version
|
|
168
|
-
- run: bun install --frozen-lockfile
|
|
169
|
-
- run: |
|
|
170
|
-
bunx checks-test --tier=live --junit=reports/night-run-tests/live.xml || true
|
|
171
|
-
bunx checks-test --tier=pixel --junit=reports/night-run-tests/pixel.xml || true
|
|
172
|
-
- uses: actions/upload-artifact@v4
|
|
173
|
-
with:
|
|
174
|
-
name: night-run-tests
|
|
175
|
-
path: reports/night-run-tests/
|
|
176
|
-
retention-days: 1
|
|
177
|
-
gates:
|
|
178
|
-
runs-on: ubuntu-latest
|
|
179
|
-
timeout-minutes: 20
|
|
180
|
-
steps:
|
|
181
|
-
- uses: actions/checkout@v5
|
|
182
|
-
with:
|
|
183
|
-
fetch-depth: 0
|
|
184
|
-
- uses: oven-sh/setup-bun@v2
|
|
185
|
-
with:
|
|
186
|
-
bun-version-file: .bun-version
|
|
187
|
-
- run: bun install --frozen-lockfile
|
|
188
|
-
- run: mkdir -p reports/night-run-gates && bunx checks-lint --night > reports/night-run-gates/gates.log 2>&1 || true
|
|
189
|
-
- uses: actions/upload-artifact@v4
|
|
190
|
-
with:
|
|
191
|
-
name: night-run-gates
|
|
192
|
-
path: reports/night-run-gates/
|
|
193
|
-
retention-days: 1
|
|
194
|
-
report:
|
|
195
|
-
needs: [mutation, gates, tests]
|
|
196
|
-
if: ${{ !cancelled() && needs.tests.result == 'success' }}
|
|
197
|
-
runs-on: ubuntu-latest
|
|
198
|
-
timeout-minutes: 15
|
|
199
|
-
permissions:
|
|
200
|
-
contents: read
|
|
201
|
-
actions: read
|
|
202
|
-
issues: write
|
|
203
|
-
steps:
|
|
204
|
-
- uses: actions/checkout@v5
|
|
205
|
-
- uses: oven-sh/setup-bun@v2
|
|
206
|
-
with:
|
|
207
|
-
bun-version-file: .bun-version
|
|
208
|
-
- run: bun install --frozen-lockfile
|
|
209
|
-
- uses: actions/download-artifact@v4
|
|
210
|
-
with:
|
|
211
|
-
pattern: night-run-mutation
|
|
212
|
-
path: reports/mutation
|
|
213
|
-
- if: needs.gates.result == 'success'
|
|
214
|
-
uses: actions/download-artifact@v4
|
|
215
|
-
with:
|
|
216
|
-
name: night-run-gates
|
|
217
|
-
path: reports/night-run-gates
|
|
218
|
-
- uses: actions/download-artifact@v4
|
|
219
|
-
with:
|
|
220
|
-
name: night-run-tests
|
|
221
|
-
path: reports/night-run-tests
|
|
222
|
-
- env:
|
|
223
|
-
GH_TOKEN: ${{ github.token }}
|
|
224
|
-
run: bunx checks-night-run --mutation reports/mutation/night-run-mutation/mutation.json --tests reports/night-run-tests/live.xml --tests reports/night-run-tests/pixel.xml ${{ needs.gates.result == 'success' && '--gates reports/night-run-gates/gates.log' || '' }}
|
|
225
|
-
- uses: actions/upload-artifact@v4
|
|
226
|
-
with:
|
|
227
|
-
name: night-run
|
|
228
|
-
path: reports/night-run.json
|
|
229
|
-
if-no-files-found: ignore
|
|
230
|
-
retention-days: 90
|
|
231
|
-
```
|
|
232
|
-
|
|
233
|
-
The tests job runs each long tier the repository holds, `live` and `pixel` as [checks-test](checks-test.md#test-tiers) names them, and keeps each JUnit report.
|
|
234
|
-
Each `|| true` lets the job go on to the upload after a failing test, since that test becomes an issue.
|
|
235
|
-
A tier that writes no report fails the report job, because `checks-night-run` cannot read it.
|
|
236
|
-
The gates job runs the nightly lint gates and keeps the log, since a failing gate becomes an issue.
|
|
237
|
-
The report job runs whenever the tests job succeeds, so a failing test that also stops Stryker still becomes an issue.
|
|
238
|
-
It always passes `--mutation`, so a mutation job that failed or timed out leaves a missing report that becomes an issue.
|
|
239
|
-
The download names the artifact by `pattern`, which downloads nothing rather than failing when the artifact is missing.
|
|
240
|
-
A full run too long for one job splits into a matrix of shards.
|
|
241
|
-
Each shard uploads its own artifact under the one pattern, and each report gets its own `--mutation`.
|
|
242
|
-
It passes `--gates` only when the gates job succeeded, and the failed gates job still fails the run.
|
|
243
|
-
The concurrency group queues a run behind one already going, so two runs never file the same finding twice.
|
|
244
|
-
A dispatch on a branch runs both jobs and lists its findings in the report job's log, and `if-no-files-found: ignore` lets that run upload no record.
|
|
245
|
-
`issues: write` on the report job is the one permission beyond reading the repository and its runs.
|
|
246
|
-
A public repository keeps `runs-on: ubuntu-latest` on every job.
|
|
247
|
-
A private repository sets `runs-on` on the mutation job to the labels declared under `checks.mutationRunner` in `package.json`, and [checks-ci-wiring](checks-ci-wiring.md#runners) refuses a mutation job that names `CI_RUNS_ON`.
|
|
248
|
-
A private repository sets `runs-on: ${{ vars.CI_RUNS_ON || 'ubuntu-latest' }}` on the tests, gates, and report jobs.
|
|
249
|
-
A successful run with every mutation report present uploads `night-run`, so the next night compares with it, and a run that failed or missed a report uploads nothing a later night reads.
|
|
250
|
-
The artifact keeps 90 days, so a gap of nights still leaves a previous night to compare with.
|
|
251
|
-
|
|
252
|
-
## Related topics
|
|
253
|
-
|
|
254
|
-
- [checks-test](checks-test.md)
|
|
255
|
-
- [checks-mutation](checks-mutation.md)
|
|
256
|
-
- [checks-mutation-compare](checks-mutation-compare.md)
|
|
257
|
-
- [checks-ci-wiring](checks-ci-wiring.md)
|
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
import { ENTRY_POINT } from "../core/gates.ts";
|
|
2
|
-
import { runsBin } from "./mutation-job.ts";
|
|
3
|
-
import type { ScheduleContext } from "./release-schedule.ts";
|
|
4
|
-
import { jobRuns } from "./runner-policy.ts";
|
|
5
|
-
import type { Command } from "./shell-command.ts";
|
|
6
|
-
import { isRecord, localUses, names, skippedBy, switchedOff, type Workflow } from "./workflow-references.ts";
|
|
7
|
-
|
|
8
|
-
const NIGHT_STEP = `${ENTRY_POINT.bin} --night`;
|
|
9
|
-
|
|
10
|
-
export type NightStepFault = {
|
|
11
|
-
readonly gate: string;
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
const NIGHT_BINS = [ENTRY_POINT.bin, ENTRY_POINT.script];
|
|
15
|
-
|
|
16
|
-
function isNightLint([head = "", ...rest]: Command): boolean {
|
|
17
|
-
return NIGHT_BINS.some((name) => head === name || head.endsWith("/" + name)) && rest.length === 1 && rest[0] === "--night";
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function rootBlocker(workflow: Workflow): string | undefined {
|
|
21
|
-
const on = isRecord(workflow.document) ? workflow.document["on"] : undefined;
|
|
22
|
-
const events = isRecord(on) ? Object.keys(on) : (names(on) ?? []);
|
|
23
|
-
if (events.length > 0 && events.every((event) => event.toLowerCase() === "workflow_call")) {
|
|
24
|
-
return `${workflow.path} runs only when called`;
|
|
25
|
-
}
|
|
26
|
-
return undefined;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function workflowRunsNightStep(
|
|
30
|
-
workflow: Workflow,
|
|
31
|
-
context: ScheduleContext,
|
|
32
|
-
byPath: ReadonlyMap<string, Workflow>,
|
|
33
|
-
walkedWorkflows: readonly string[],
|
|
34
|
-
inherited: string | undefined,
|
|
35
|
-
): boolean {
|
|
36
|
-
const files = (file: string) => context.files.get(file);
|
|
37
|
-
const jobs = isRecord(workflow.document) ? workflow.document["jobs"] : undefined;
|
|
38
|
-
if (!isRecord(jobs)) return false;
|
|
39
|
-
return Object.entries(jobs).some(([id, job]) => {
|
|
40
|
-
if (!isRecord(job)) return false;
|
|
41
|
-
const skipped = skippedBy(jobs, id, []);
|
|
42
|
-
const jobBlocker =
|
|
43
|
-
inherited ?? switchedOff(job, `job ${id}`) ?? (skipped === undefined ? undefined : `job ${id} needs a job that never runs: ${skipped}`);
|
|
44
|
-
const steps = Array.isArray(job["steps"]) ? job["steps"] : [];
|
|
45
|
-
const runRecords: readonly Readonly<Record<string, unknown>>[] = steps.flatMap((step: unknown) =>
|
|
46
|
-
isRecord(step) && typeof step["run"] === "string" ? [step] : [],
|
|
47
|
-
);
|
|
48
|
-
const unblocked = jobRuns(workflow, job).some(
|
|
49
|
-
(run, index) =>
|
|
50
|
-
(jobBlocker ?? switchedOff(runRecords[index] ?? {}, "the step")) === undefined &&
|
|
51
|
-
runsBin(run, context.scripts, files, isNightLint, true),
|
|
52
|
-
);
|
|
53
|
-
if (unblocked) return true;
|
|
54
|
-
const called = localUses(job["uses"]);
|
|
55
|
-
if (called === undefined || walkedWorkflows.includes(called)) return false;
|
|
56
|
-
const next = byPath.get(called);
|
|
57
|
-
return next !== undefined && workflowRunsNightStep(next, context, byPath, [...walkedWorkflows, called], jobBlocker);
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function runsNightStep(workflows: readonly Workflow[], context: ScheduleContext): boolean {
|
|
62
|
-
const byPath = new Map(workflows.map((workflow) => [workflow.path, workflow] as const));
|
|
63
|
-
return workflows.some((workflow) => workflowRunsNightStep(workflow, context, byPath, [workflow.path], rootBlocker(workflow)));
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export function findNightStepFaults(
|
|
67
|
-
tracksTypeScript: boolean,
|
|
68
|
-
workflows: readonly Workflow[],
|
|
69
|
-
context: ScheduleContext,
|
|
70
|
-
): readonly NightStepFault[] {
|
|
71
|
-
return tracksTypeScript && !runsNightStep(workflows, context) ? [{ gate: NIGHT_STEP }] : [];
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export function formatNightStepReport(faults: readonly NightStepFault[]): string {
|
|
75
|
-
return [
|
|
76
|
-
"ci-wiring: " + faults.length + " gate(s) never run on the night run:",
|
|
77
|
-
...faults.flatMap(({ gate }) => [" " + gate, " no run step invokes it"]),
|
|
78
|
-
].join("\n");
|
|
79
|
-
}
|