@avi2dg/checks 0.37.0 → 0.38.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 CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  Every release of `@avi2dg/checks`, newest first, written by the release from its conventional commits.
4
4
 
5
+ ## 0.38.0
6
+
7
+ Released 2026-10-10.
8
+
9
+ ### Features
10
+
11
+ - **testing:** add checks-mutation-baseline to restore the baseline from a runner cache [#140](https://github.com/avi2d/checks/pull/140)
12
+
5
13
  ## 0.37.0
6
14
 
7
15
  Released 2026-10-09.
package/README.md CHANGED
@@ -157,6 +157,7 @@ These bins run on their own:
157
157
  - [`checks-flake`](docs/gates/checks-flake.md) runs the suite on a schedule and records the seeds a flaky test fails with.
158
158
  - [`checks-mutation`](docs/gates/checks-mutation.md) runs Stryker for scoped checks and refuses a full run outside CI.
159
159
  - [`checks-mutation-compare`](docs/gates/checks-mutation-compare.md) compares the mutants of two reports and lists each one that regresses.
160
+ - [`checks-mutation-baseline`](docs/gates/checks-mutation-baseline.md) restores the newest mutation baseline from `main`, through a cache on a self-hosted runner.
160
161
  - [`checks-subsumed-tests`](docs/gates/checks-subsumed-tests.md) lists each test another test subsumes in a mutation run.
161
162
  - [`checks-changelog`](docs/gates/checks-changelog.md) writes the pending release into `CHANGELOG.md` from the conventional commits since the last release.
162
163
  - [`checks-release-notes`](docs/gates/checks-release-notes.md) writes one `CHANGELOG.md` section to a file for a GitHub release.
@@ -0,0 +1,99 @@
1
+ ---
2
+ kind: reference
3
+ audience: consumers
4
+ ---
5
+ # checks-mutation-baseline
6
+
7
+ `checks-mutation-baseline` restores the newest mutation baseline that `main` published, and on a self-hosted runner it keeps each baseline it downloads so the next job copies it instead.
8
+
9
+ ## What it checks
10
+
11
+ It reads the successful runs of `mutation.yml` on `main`.
12
+ Without `--full` it takes the newest of the 20 newest of those runs that a pull request did not start.
13
+ It restores that run's `mutation-baseline` artifact, and takes the run whether or not it holds one.
14
+ It tries no older run, so a newest run without the artifact restores nothing.
15
+ An artifact without `stryker-incremental.json` counts as no baseline.
16
+ It copies `stryker-incremental.json` to the first destination, and `mutation/mutation.json` to the second destination when the artifact holds it.
17
+
18
+ With `--full` it lists up to 50 `schedule` runs and up to 50 `workflow_dispatch` runs apart, so pushes cannot crowd the full runs out of one list.
19
+ It tries those runs newest first, and restores the first `mutation-baseline-full` artifact that has not expired, downloads and holds the report.
20
+ It tries no run after that one.
21
+ It takes the report from `mutation.json` at the artifact's root, or else from `mutation/mutation.json`, and copies it to the destination.
22
+
23
+ It creates the directories each destination needs.
24
+
25
+ ## What it reads
26
+
27
+ It reads the runs and their artifacts through `gh`, which needs a token that reads the repository's Actions, such as `GH_TOKEN: ${{ github.token }}` with `actions: read`.
28
+ The `mutation-baseline` artifact holds `stryker-incremental.json` and `mutation/mutation.json`, which an upload of `reports/stryker-incremental.json` and `reports/mutation/mutation.json` writes.
29
+ The `mutation-baseline-full` artifact holds `mutation.json` at its root, which an upload of `reports/mutation/mutation.json` alone writes.
30
+ It reads `RUNNER_ENVIRONMENT` and `HOME` to place the runner cache.
31
+
32
+ ## The runner cache
33
+
34
+ The cache sits under `$HOME/.cache/avi2dg-checks/mutation-baseline/`.
35
+ It lies outside the job's workspace and `RUNNER_TEMP`, so it outlives the job on a self-hosted runner.
36
+ Each entry sits at `<repository id>/<artifact name>/<artifact id>/`.
37
+ GitHub gives every upload a new artifact id, so an entry under a listed id is never stale.
38
+ On a hit the bin copies the files from the entry and downloads nothing.
39
+ On a miss it downloads into a staging directory beside the entry and renames it into place.
40
+ A job sharing the runner sees an entry whole or not at all.
41
+ If the entry already exists, because another job placed it or it lost a file, the bin keeps it and restores from its own download.
42
+ After each download it keeps the 2 highest artifact ids for that repository and artifact name, plus the entry it restores from.
43
+ It removes the rest.
44
+ A 100 MB baseline zip can unpack to about 500 MB, so a repository restoring both artifacts holds about 2 GB.
45
+
46
+ When `RUNNER_ENVIRONMENT` is `github-hosted`, it downloads into a temporary directory and writes no cache, since a hosted runner starts every job on a fresh machine.
47
+ When `HOME` is unset it works the same way, and prints to stderr that the runner cache is disabled.
48
+
49
+ ## Arguments
50
+
51
+ ```sh
52
+ checks-mutation-baseline <incremental-dest> [mutation-json-dest]
53
+ checks-mutation-baseline --full <mutation-json-dest>
54
+ ```
55
+
56
+ `--full` restores only the report of a run that started with no state.
57
+
58
+ ## Exit codes
59
+
60
+ | Code | When |
61
+ | --- | --- |
62
+ | 0 | it restored a baseline, or found none to restore |
63
+ | 2 | the arguments do not parse, `gh` cannot list the runs, the cache cannot be written, or another job removed the entry before it was copied |
64
+
65
+ A failed artifact lookup or download prints the `gh` error and moves on, as a missing artifact does.
66
+
67
+ ## Sample output
68
+
69
+ ```
70
+ mutation-baseline: restored it from the runner's cache, mutation-baseline artifact 11666313211 from run 38042411822
71
+ ```
72
+
73
+ A miss prints `downloaded it into the runner's cache` instead, and a hosted runner prints `downloaded it`.
74
+
75
+ ## When it runs
76
+
77
+ Only a CI step the repository writes runs it.
78
+ A baseline job restores the state the previous `main` run left before an incremental Stryker run:
79
+
80
+ ```yaml
81
+ - name: Restore previous baseline state
82
+ env:
83
+ GH_TOKEN: ${{ github.token }}
84
+ run: bunx checks-mutation-baseline reports/stryker-incremental.json
85
+ ```
86
+
87
+ A pull request's scope step restores the full report with `--full`:
88
+
89
+ ```yaml
90
+ - name: Select mutation scope
91
+ env:
92
+ GH_TOKEN: ${{ github.token }}
93
+ run: bunx checks-mutation-baseline --full "$RUNNER_TEMP/baseline-full/mutation.json"
94
+ ```
95
+
96
+ ## Related topics
97
+
98
+ - [checks-mutation](checks-mutation.md)
99
+ - [checks-mutation-compare](checks-mutation-compare.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
4
4
  "description": "Deterministic checks shared across a set of TypeScript repositories",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -44,6 +44,7 @@
44
44
  "src/testing/mutation-scope.js",
45
45
  "src/testing/mutation-guard-plugin.js",
46
46
  "src/testing/mutation-compare.ts",
47
+ "src/testing/mutation-baseline.ts",
47
48
  "src/testing/subsumed-tests.ts",
48
49
  "src/delivery/ci-wiring.ts",
49
50
  "src/delivery/shell-command.ts",
@@ -133,6 +134,7 @@
133
134
  "checks-commit-identity": "src/delivery/commit-identity.ts",
134
135
  "checks-mutation": "src/testing/mutation.ts",
135
136
  "checks-mutation-compare": "src/testing/mutation-compare.ts",
137
+ "checks-mutation-baseline": "src/testing/mutation-baseline.ts",
136
138
  "checks-subsumed-tests": "src/testing/subsumed-tests.ts",
137
139
  "checks-ci-wiring": "src/delivery/ci-wiring.ts",
138
140
  "checks-comment-gate": "src/quality/comment-gate.ts",
@@ -0,0 +1,211 @@
1
+ #!/usr/bin/env bun
2
+ import { Config, Console, Effect, FileSystem, Option, Path, Schema } from "effect";
3
+ import { ChildProcessSpawner } from "effect/process";
4
+ import { collect } from "../core/git.ts";
5
+ import { runMain, Usage } from "../core/main.ts";
6
+ import { cacheRoot } from "../dependencies/cache-root.ts";
7
+
8
+ class BaselineFailure extends Schema.TaggedError<BaselineFailure>()("BaselineFailure", {
9
+ message: Schema.String,
10
+ }) {}
11
+
12
+ const NAME = "checks-mutation-baseline";
13
+ const USAGE = "usage: checks-mutation-baseline <incremental-dest> [mutation-json-dest] | --full <mutation-json-dest>";
14
+ const WORKFLOW = "mutation.yml";
15
+ const BRANCH = "main";
16
+ const INCREMENTAL = "stryker-incremental.json";
17
+ const REPORT = "mutation/mutation.json";
18
+ const REPORT_ALONE = "mutation.json";
19
+ const KEPT_PER_ARTIFACT = 2;
20
+ const LATEST_ARTIFACT = "mutation-baseline";
21
+ const FULL_ARTIFACT = "mutation-baseline-full";
22
+ const LATEST_LIMIT = 20;
23
+ const FULL_LIMIT = 50;
24
+ const FULL_EVENTS = ["schedule", "workflow_dispatch"];
25
+
26
+ // The first of `from` the artifact holds is copied to `to`.
27
+ type Copy = { readonly from: readonly string[]; readonly to: string };
28
+
29
+ const Run = Schema.Struct({ databaseId: Schema.Int, event: Schema.String, createdAt: Schema.String });
30
+ type Run = typeof Run.Type;
31
+
32
+ const Artifact = Schema.Struct({
33
+ id: Schema.Int,
34
+ name: Schema.String,
35
+ expired: Schema.Boolean,
36
+ workflow_run: Schema.Struct({ repository_id: Schema.Int }),
37
+ });
38
+ type Artifact = typeof Artifact.Type;
39
+
40
+ const decodeRuns = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Array(Run)));
41
+ const decodeArtifacts = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ artifacts: Schema.Array(Artifact) })));
42
+
43
+ const gh = Effect.fn("gh")(function* (args: readonly string[]) {
44
+ const failed = (reason: string): BaselineFailure => new BaselineFailure({ message: `gh ${args.join(" ")}: ${reason.trim()}` });
45
+ const { stdout, stderr, exitCode } = yield* collect("gh", args).pipe(Effect.mapError((cause) => failed(cause.message)));
46
+ if (exitCode !== ChildProcessSpawner.ExitCode(0)) return yield* failed(stderr);
47
+ return stdout;
48
+ });
49
+
50
+ const warned = <A>(fallback: A) => (failure: BaselineFailure) => Console.error(`mutation-baseline: ${failure.message}`).pipe(Effect.as(fallback));
51
+
52
+ const listed = Effect.fn("listed")(function* (event: string | undefined, limit: number) {
53
+ const filter = event === undefined ? [] : ["--event", event];
54
+ const args = ["run", "list", "--workflow", WORKFLOW, "--branch", BRANCH, "--status", "success", ...filter, "--limit", String(limit), "--json", "databaseId,event,createdAt"];
55
+ return yield* gh(args).pipe(
56
+ Effect.flatMap(decodeRuns),
57
+ Effect.mapError((cause) => new BaselineFailure({ message: cause.message })),
58
+ );
59
+ });
60
+
61
+ export function newestFirst(runs: readonly Run[]): readonly number[] {
62
+ return [...runs].sort((one, other) => other.createdAt.localeCompare(one.createdAt)).map((run) => run.databaseId);
63
+ }
64
+
65
+ const latestCandidates = Effect.gen(function* () {
66
+ const newest = (yield* listed(undefined, LATEST_LIMIT)).find((run) => run.event !== "pull_request");
67
+ return newest === undefined ? [] : [newest.databaseId];
68
+ });
69
+
70
+ // Each full event is listed on its own, because one shared window lets push runs crowd out every full run.
71
+ const fullCandidates = Effect.gen(function* () {
72
+ const perEvent = yield* Effect.forEach(FULL_EVENTS, (event) => listed(event, FULL_LIMIT));
73
+ return newestFirst(perEvent.flat());
74
+ });
75
+
76
+ const artifactOf = Effect.fn("artifactOf")(function* (run: number, name: string) {
77
+ const found = yield* gh(["api", `repos/{owner}/{repo}/actions/runs/${run}/artifacts?name=${name}`]).pipe(
78
+ Effect.flatMap(decodeArtifacts),
79
+ Effect.mapError((cause) => new BaselineFailure({ message: cause.message })),
80
+ );
81
+ return Option.fromNullishOr(found.artifacts.find((artifact) => artifact.name === name && !artifact.expired));
82
+ });
83
+
84
+ const held = Effect.fn("held")(function* (dir: string, names: readonly string[]) {
85
+ const fs = yield* FileSystem.FileSystem;
86
+ const path = yield* Path.Path;
87
+ for (const name of names) {
88
+ const file = path.join(dir, name);
89
+ if (yield* fs.exists(file)) return Option.some(file);
90
+ }
91
+ return Option.none<string>();
92
+ });
93
+
94
+ const holds = (dir: string, needed: Copy) => held(dir, needed.from).pipe(Effect.map(Option.isSome));
95
+
96
+ const downloaded = Effect.fn("downloaded")(function* (run: number, artifact: Artifact, dir: string, needed: Copy) {
97
+ const fetched = yield* gh(["run", "download", String(run), "--name", artifact.name, "--dir", dir]).pipe(Effect.as(true), Effect.catch(warned(false)));
98
+ return fetched && (yield* holds(dir, needed));
99
+ });
100
+
101
+ export function pruned(entries: readonly string[], kept: number, restoring: string): readonly string[] {
102
+ return entries
103
+ .filter((entry) => /^\d+$/.test(entry))
104
+ .sort((one, other) => Number(other) - Number(one))
105
+ .slice(kept)
106
+ .filter((entry) => entry !== restoring);
107
+ }
108
+
109
+ const prune = Effect.fn("prune")(function* (shelf: string, restoring: string) {
110
+ const fs = yield* FileSystem.FileSystem;
111
+ const path = yield* Path.Path;
112
+ for (const entry of pruned(yield* fs.readDirectory(shelf), KEPT_PER_ARTIFACT, restoring)) {
113
+ yield* fs.remove(path.join(shelf, entry), { recursive: true, force: true });
114
+ }
115
+ });
116
+
117
+ type Fetched = { readonly dir: string; readonly how: string };
118
+
119
+ // The entry lands through a rename beside it, so a job sharing the runner sees it whole or not at all.
120
+ const throughCache = Effect.fn("throughCache")(function* (run: number, artifact: Artifact, root: string, needed: Copy) {
121
+ const fs = yield* FileSystem.FileSystem;
122
+ const path = yield* Path.Path;
123
+ const shelf = path.join(root, "mutation-baseline", String(artifact.workflow_run.repository_id), artifact.name);
124
+ const entry = path.join(shelf, String(artifact.id));
125
+ if (yield* holds(entry, needed)) return Option.some<Fetched>({ dir: entry, how: "restored it from the runner's cache" });
126
+ yield* fs.makeDirectory(shelf, { recursive: true });
127
+ const staged = path.join(yield* fs.makeTempDirectoryScoped({ directory: shelf, prefix: ".staging-" }), "artifact");
128
+ if (!(yield* downloaded(run, artifact, staged, needed))) return Option.none<Fetched>();
129
+ // An entry already there stays, since a job sharing the runner may be copying from it, and the staged copy is whole.
130
+ const placed = yield* fs.rename(staged, entry).pipe(
131
+ Effect.as(entry),
132
+ Effect.orElseSucceed(() => staged),
133
+ );
134
+ yield* prune(shelf, String(artifact.id));
135
+ return Option.some<Fetched>({ dir: placed, how: "downloaded it into the runner's cache" });
136
+ });
137
+
138
+ const uncached = Effect.fn("uncached")(function* (run: number, artifact: Artifact, needed: Copy) {
139
+ const dir = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped({ prefix: "mutation-baseline-" });
140
+ return (yield* downloaded(run, artifact, dir, needed)) ? Option.some<Fetched>({ dir, how: "downloaded it" }) : Option.none<Fetched>();
141
+ });
142
+
143
+ const restore = Effect.fn("restore")(function* (from: string, needed: Copy, optional: readonly Copy[]) {
144
+ const fs = yield* FileSystem.FileSystem;
145
+ const path = yield* Path.Path;
146
+ const copy = Effect.fn("copy")(function* (file: string, to: string) {
147
+ yield* fs.makeDirectory(path.dirname(to), { recursive: true });
148
+ yield* fs.copyFile(file, to);
149
+ });
150
+ const source = yield* held(from, needed.from);
151
+ if (Option.isNone(source)) return yield* new BaselineFailure({ message: `${from} lost ${needed.from.join(" and ")} before it was copied` });
152
+ yield* copy(source.value, needed.to);
153
+ for (const wanted of optional) {
154
+ const file = yield* held(from, wanted.from);
155
+ if (Option.isSome(file)) yield* copy(file.value, wanted.to);
156
+ }
157
+ });
158
+
159
+ const restoredFrom = Effect.fn("restoredFrom")(
160
+ function* (run: number, artifact: Artifact, root: Option.Option<string>, request: Request) {
161
+ const fetched = Option.isSome(root) ? yield* throughCache(run, artifact, root.value, request.needed) : yield* uncached(run, artifact, request.needed);
162
+ if (Option.isNone(fetched)) return false;
163
+ yield* restore(fetched.value.dir, request.needed, request.optional);
164
+ yield* Console.log(`mutation-baseline: ${fetched.value.how}, ${artifact.name} artifact ${artifact.id} from run ${run}`);
165
+ return true;
166
+ },
167
+ Effect.scoped,
168
+ );
169
+
170
+ // A hosted runner is a fresh machine every job, so a cache there only costs a copy.
171
+ const runnerCache = Effect.gen(function* () {
172
+ const environment = yield* Config.String("RUNNER_ENVIRONMENT").pipe(Config.withDefault(""));
173
+ if (environment === "github-hosted") return Option.none<string>();
174
+ return yield* cacheRoot().pipe(
175
+ Effect.asSome,
176
+ Effect.catchTag("CacheUnrooted", () =>
177
+ Console.error("mutation-baseline: the runner cache is disabled because HOME is unset, so the baseline is downloaded").pipe(Effect.as(Option.none<string>())),
178
+ ),
179
+ );
180
+ });
181
+
182
+ // An artifact that lacks `needed` counts as no baseline, and `optional` copies only what the artifact holds.
183
+ type Request = { readonly full: boolean; readonly needed: Copy; readonly optional: readonly Copy[] };
184
+
185
+ const isDestination = (arg: string | undefined): arg is string => arg !== undefined && !arg.startsWith("-");
186
+
187
+ function parsed(args: readonly string[]): Request | undefined {
188
+ if (args[0] === "--full") {
189
+ const [report, ...extra] = args.slice(1);
190
+ if (!isDestination(report) || extra.length > 0) return undefined;
191
+ return { full: true, needed: { from: [REPORT_ALONE, REPORT], to: report }, optional: [] };
192
+ }
193
+ const [incremental, report, ...extra] = args;
194
+ if (!isDestination(incremental) || extra.length > 0) return undefined;
195
+ return { full: false, needed: { from: [INCREMENTAL], to: incremental }, optional: report === undefined ? [] : [{ from: [REPORT], to: report }] };
196
+ }
197
+
198
+ const mutationBaseline = Effect.gen(function* () {
199
+ const request = parsed(process.argv.slice(2));
200
+ if (request === undefined) return yield* new Usage({ message: USAGE });
201
+ const name = request.full ? FULL_ARTIFACT : LATEST_ARTIFACT;
202
+ const root = yield* runnerCache;
203
+ for (const run of yield* request.full ? fullCandidates : latestCandidates) {
204
+ const artifact = yield* artifactOf(run, name).pipe(Effect.catch(warned(Option.none<Artifact>())));
205
+ if (Option.isSome(artifact) && (yield* restoredFrom(run, artifact.value, root, request))) return true;
206
+ }
207
+ yield* Console.log(`mutation-baseline: no successful ${BRANCH} run holds a ${name} artifact, so nothing was restored`);
208
+ return true;
209
+ });
210
+
211
+ if (import.meta.main) runMain(NAME, mutationBaseline);