@avi2dg/checks 0.51.0 → 0.52.1
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/.bun-version +1 -0
- package/CHANGELOG.md +22 -0
- package/README.md +24 -249
- package/commitlint.config.ts +0 -1
- package/dist/presets/knip.d.ts +1 -1
- package/dist/presets/knip.js +1 -1
- package/dist/templates/how-to.md +1 -1
- package/dist/templates/readme.md +1 -1
- package/dist/templates/tutorial.md +1 -1
- package/docs/bins.md +67 -0
- package/docs/gates/checks-advisories.md +2 -2
- package/docs/gates/checks-browser.md +5 -1
- package/docs/gates/checks-ci-wiring.md +18 -9
- package/docs/gates/checks-commit-identity.md +3 -3
- package/docs/gates/checks-copy.md +4 -0
- package/docs/gates/checks-docs.md +24 -3
- package/docs/gates/checks-exports.md +1 -1
- package/docs/gates/checks-frontend-syntax.md +5 -1
- package/docs/gates/checks-lint.md +2 -2
- package/docs/gates/checks-mutation-baseline.md +9 -5
- package/docs/gates/checks-mutation-compare.md +3 -3
- package/docs/gates/checks-mutation.md +9 -5
- package/docs/gates/checks-night-run.md +1 -1
- package/docs/gates/checks-secrets.md +2 -2
- package/docs/gates/checks-unused.md +1 -1
- package/docs/how-to/set-up-a-repository.md +142 -0
- package/docs/how-to/turn-on-the-css-families.md +35 -0
- package/docs/how-to/upgrade-the-kit.md +42 -0
- package/docs/shipped-files.md +36 -0
- package/package.json +11 -1
- package/src/delivery/bun-pin.ts +19 -5
- package/src/delivery/ci-wiring.ts +11 -77
- package/src/delivery/commit-identity.ts +6 -5
- package/src/delivery/runner-policy.ts +385 -0
- package/src/delivery/workflow-references.ts +1 -1
- package/src/docs/doc-templates.ts +1 -1
- package/src/docs/doc-versions.ts +137 -0
- package/src/docs/docs.ts +7 -0
- package/src/quality/comment-matchers.ts +1 -1
- package/src/testing/mutation-baseline-full.ts +23 -47
- package/src/testing/mutation-baseline-saved.ts +1 -1
- package/src/testing/mutation-baseline.ts +10 -8
- package/src/testing/mutation-scope-graph.ts +10 -19
- package/src/testing/mutation-scope.ts +8 -41
|
@@ -4,11 +4,13 @@ import { defaultBranch, git } from "../core/git.ts";
|
|
|
4
4
|
import { runMain } from "../core/main.ts";
|
|
5
5
|
import { ENTRY_POINT, KIT_GATES, type KitGate } from "../core/gates.ts";
|
|
6
6
|
import { findBunPinFaults, formatBunPinReport, readBunPinPolicy } from "./bun-pin.ts";
|
|
7
|
-
import { readShellFiles
|
|
7
|
+
import { readShellFiles } from "./mutation-job.ts";
|
|
8
|
+
import { findRunnerFaults, formatRunnerReport, jobRuns, readRunnerConfig } from "./runner-policy.ts";
|
|
8
9
|
import { invokes, mentions, plainCommand, type Command } from "./shell-command.ts";
|
|
9
10
|
import { findMissingWorkflows, formatMissingReport, isRecord, names, workflowJobs, type Workflow } from "./workflow-references.ts";
|
|
10
11
|
|
|
11
|
-
export {
|
|
12
|
+
export { BunPinError, findBunPinFaults, formatBunPinReport } from "./bun-pin.ts";
|
|
13
|
+
export { RunnerPolicyError } from "./runner-policy.ts";
|
|
12
14
|
export type { BunPinFault, BunPinPolicy } from "./bun-pin.ts";
|
|
13
15
|
|
|
14
16
|
export type { Command };
|
|
@@ -39,17 +41,6 @@ export type Gap = {
|
|
|
39
41
|
|
|
40
42
|
export type Visibility = "private" | "public" | "unknown";
|
|
41
43
|
|
|
42
|
-
export type RunnerPolicy = {
|
|
43
|
-
readonly visibility: Visibility;
|
|
44
|
-
readonly scripts: ReadonlyMap<string, string>;
|
|
45
|
-
readonly files?: ReadonlyMap<string, string>;
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
export type RunnerFault = {
|
|
49
|
-
readonly location: string;
|
|
50
|
-
readonly fault: string;
|
|
51
|
-
};
|
|
52
|
-
|
|
53
44
|
type RunStep = {
|
|
54
45
|
readonly location: string;
|
|
55
46
|
readonly blocker: string | undefined;
|
|
@@ -71,7 +62,9 @@ const TITLE_LINT = "bunx checks-commit-title";
|
|
|
71
62
|
// The gate holds until every consumer migrates, so the direct commitlint call still counts.
|
|
72
63
|
// The kit calls its own file through bun, since bunx never resolves the working tree.
|
|
73
64
|
const TITLE_CALLS = ["bun src/delivery/commit-title.ts", "./node_modules/.bin/commitlint"];
|
|
74
|
-
const
|
|
65
|
+
const ScriptsManifest = Schema.Struct({
|
|
66
|
+
scripts: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)),
|
|
67
|
+
});
|
|
75
68
|
// Without these a pull_request workflow never sees the commits a pull request pushes.
|
|
76
69
|
const GATING_TYPES = ["opened", "synchronize"];
|
|
77
70
|
const CONSTANTS = new Map([
|
|
@@ -79,13 +72,6 @@ const CONSTANTS = new Map([
|
|
|
79
72
|
["false", false],
|
|
80
73
|
]);
|
|
81
74
|
const STATUS_OVERRIDE = /\b(?:always|failure|cancelled)\s*\(/;
|
|
82
|
-
// GitHub expressions match context and property names without regard to case.
|
|
83
|
-
const OVERRIDE_ACCESS = String.raw`vars\s*(?:\.\s*CI_RUNS_ON\b|\[\s*'CI_RUNS_ON'\s*\])`;
|
|
84
|
-
const RUNNER_OVERRIDE = new RegExp(String.raw`\b${OVERRIDE_ACCESS}`, "i");
|
|
85
|
-
const HOSTED_OVERRIDE = new RegExp(String.raw`^\$\{\{\s*${OVERRIDE_ACCESS}\s*\|\|\s*'ubuntu-latest'\s*\}\}$`, "i");
|
|
86
|
-
const MUTATION_RUNNER = ["self-hosted", "Linux", "X64", "winbox"];
|
|
87
|
-
const DEVICE_RUNNER = ["self-hosted", "macOS", "iphone"];
|
|
88
|
-
const HOSTED_DEFAULT = "${{ vars.CI_RUNS_ON || 'ubuntu-latest' }}";
|
|
89
75
|
const EventRepository = Schema.fromJsonString(Schema.Struct({ repository: Schema.Struct({ private: Schema.Boolean }) }));
|
|
90
76
|
|
|
91
77
|
function constant(value: unknown): boolean | undefined {
|
|
@@ -278,59 +264,6 @@ export function declarationFor(scripts: readonly string[], branch: string): Decl
|
|
|
278
264
|
};
|
|
279
265
|
}
|
|
280
266
|
|
|
281
|
-
function sameLabels(runsOn: unknown, labels: readonly string[]): boolean {
|
|
282
|
-
const named = Array.isArray(runsOn) ? names(runsOn) : undefined;
|
|
283
|
-
return named?.length === labels.length && labels.every((label) => named.includes(label));
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
function readsOverride(runsOn: unknown): boolean {
|
|
287
|
-
if (typeof runsOn === "string") return RUNNER_OVERRIDE.test(runsOn);
|
|
288
|
-
const values = isRecord(runsOn) ? Object.values(runsOn) : Array.isArray(runsOn) ? runsOn : [];
|
|
289
|
-
return values.some(readsOverride);
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
function runnerFault(runsOn: unknown, mutation: boolean, visibility: Visibility): string | undefined {
|
|
293
|
-
const overridden = readsOverride(runsOn);
|
|
294
|
-
const pin = `set runs-on: [${MUTATION_RUNNER.join(", ")}]`;
|
|
295
|
-
if (mutation && overridden) return `a mutation job reads CI_RUNS_ON, so an override moves its mutation runs off winbox; ${pin}`;
|
|
296
|
-
if (mutation) return visibility === "private" && !sameLabels(runsOn, MUTATION_RUNNER) ? `a mutation job in a private repository runs off winbox; ${pin}` : undefined;
|
|
297
|
-
const hosted = typeof runsOn === "string" && HOSTED_OVERRIDE.test(runsOn.trim());
|
|
298
|
-
if (hosted || sameLabels(runsOn, DEVICE_RUNNER) || (visibility !== "private" && !overridden)) return undefined;
|
|
299
|
-
return `the job is not on a hosted runner CI_RUNS_ON can override; set runs-on: ${HOSTED_DEFAULT}`;
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
function stepDirectory(value: unknown, inherited: string | undefined): string | undefined {
|
|
303
|
-
if (value === undefined) return inherited;
|
|
304
|
-
return typeof value === "string" ? within("", value) : undefined;
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
function defaultDirectory(node: unknown, inherited: string | undefined): string | undefined {
|
|
308
|
-
const defaults = isRecord(node) ? node["defaults"] : undefined;
|
|
309
|
-
const run = isRecord(defaults) ? defaults["run"] : undefined;
|
|
310
|
-
return stepDirectory(isRecord(run) ? run["working-directory"] : undefined, inherited);
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
function jobRuns(workflow: Workflow, job: Readonly<Record<string, unknown>>): readonly Run[] {
|
|
314
|
-
const dir = defaultDirectory(job, defaultDirectory(workflow.document, ""));
|
|
315
|
-
const steps = Array.isArray(job["steps"]) ? job["steps"] : [];
|
|
316
|
-
return steps.flatMap((step: unknown) => (isRecord(step) && typeof step["run"] === "string" ? [{ script: step["run"], dir: stepDirectory(step["working-directory"], dir) }] : []));
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
export function findRunnerFaults(policy: RunnerPolicy, workflows: readonly Workflow[]): readonly RunnerFault[] {
|
|
320
|
-
return workflows.flatMap((workflow) =>
|
|
321
|
-
workflowJobs(workflow).flatMap(([id, job]) => {
|
|
322
|
-
if (job["runs-on"] === undefined) return [];
|
|
323
|
-
const mutation = jobRuns(workflow, job).some((run) => runsMutation(run, policy.scripts, (file) => policy.files?.get(file)));
|
|
324
|
-
const fault = runnerFault(job["runs-on"], mutation, policy.visibility);
|
|
325
|
-
return fault === undefined ? [] : [{ location: `${workflow.path} job ${id}`, fault }];
|
|
326
|
-
}),
|
|
327
|
-
);
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
export function formatRunnerReport(faults: readonly RunnerFault[]): string {
|
|
331
|
-
return [`ci-wiring: ${faults.length} job(s) run on the wrong runner:`, ...faults.map(({ location, fault }) => ` ${location}: ${fault}`)].join("\n");
|
|
332
|
-
}
|
|
333
|
-
|
|
334
267
|
export const parseWorkflow = (path: string, text: string): Effect.Effect<Workflow, WiringError> =>
|
|
335
268
|
Effect.try({
|
|
336
269
|
try: () => ({ path, document: Bun.YAML.parse(text), text }),
|
|
@@ -342,10 +275,10 @@ const readScripts = Effect.fn("readScripts")(function* (root: string) {
|
|
|
342
275
|
const file = (yield* Path.Path).join(root, "package.json");
|
|
343
276
|
const { scripts = {} } = (yield* fs.exists(file))
|
|
344
277
|
? yield* fs.readFileString(file).pipe(
|
|
345
|
-
Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(
|
|
278
|
+
Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(ScriptsManifest))),
|
|
346
279
|
Effect.mapError((cause) => new WiringError({ message: `cannot read ${file}: ${cause.message}` })),
|
|
347
280
|
)
|
|
348
|
-
:
|
|
281
|
+
: ScriptsManifest.make({});
|
|
349
282
|
return new Map(Object.entries(scripts));
|
|
350
283
|
});
|
|
351
284
|
|
|
@@ -390,7 +323,8 @@ const wiring = Effect.gen(function* () {
|
|
|
390
323
|
if (missing.length > 0) yield* Console.error(formatMissingReport(missing));
|
|
391
324
|
const scripts = yield* readScripts(root);
|
|
392
325
|
const runs = workflows.flatMap((workflow) => workflowJobs(workflow).flatMap(([, job]) => jobRuns(workflow, job)));
|
|
393
|
-
const
|
|
326
|
+
const runners = yield* readRunnerConfig(root);
|
|
327
|
+
const faults = findRunnerFaults({ visibility: yield* readVisibility, scripts, files: yield* readShellFiles(root, scripts, runs), runners }, workflows);
|
|
394
328
|
if (faults.length > 0) yield* Console.error(formatRunnerReport(faults));
|
|
395
329
|
const bunFaults = findBunPinFaults(yield* readBunPinPolicy(root, workflows), workflows);
|
|
396
330
|
if (bunFaults.length > 0) yield* Console.error(formatBunPinReport(bunFaults));
|
|
@@ -16,8 +16,6 @@ type Commit = {
|
|
|
16
16
|
|
|
17
17
|
type Offence = { readonly commit: Commit; readonly reasons: readonly string[] };
|
|
18
18
|
|
|
19
|
-
const DEFAULT_AUTHORS: readonly Identity[] = [{ name: "avi2d", email: "avi2dg@gmail.com" }];
|
|
20
|
-
|
|
21
19
|
// GitHub writes the squash commit, so it commits what the owner authored and never authors.
|
|
22
20
|
const SQUASH_COMMITTER: Identity = { name: "GitHub", email: "noreply@github.com" };
|
|
23
21
|
|
|
@@ -74,13 +72,12 @@ const allowedAuthors = Effect.gen(function* () {
|
|
|
74
72
|
const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
|
|
75
73
|
const fs = yield* FileSystem.FileSystem;
|
|
76
74
|
const file = (yield* Path.Path).join(root, "package.json");
|
|
77
|
-
if (!(yield* fs.exists(file))) return
|
|
75
|
+
if (!(yield* fs.exists(file))) return [];
|
|
78
76
|
const manifest = yield* fs.readFileString(file).pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(Authors))));
|
|
79
|
-
|
|
77
|
+
return [manifest.author, ...(manifest.contributors ?? [])]
|
|
80
78
|
.filter((entry) => entry !== undefined)
|
|
81
79
|
.map(identityOf)
|
|
82
80
|
.filter((entry) => entry !== undefined);
|
|
83
|
-
return listed.length === 0 ? DEFAULT_AUTHORS : listed;
|
|
84
81
|
});
|
|
85
82
|
|
|
86
83
|
const readCommits = Effect.fn("readCommits")(function* (revisions: readonly string[]) {
|
|
@@ -152,6 +149,10 @@ const check = Effect.gen(function* () {
|
|
|
152
149
|
const revisions = second === undefined ? ["-1", range] : [range];
|
|
153
150
|
|
|
154
151
|
const allowed = yield* allowedAuthors;
|
|
152
|
+
if (allowed.length === 0) {
|
|
153
|
+
yield* Console.error("commit-identity: package.json declares no author or contributors, so no identity is allowed; declare the owners there");
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
155
156
|
const commits = yield* readCommits(revisions);
|
|
156
157
|
const offences = commits
|
|
157
158
|
.map((commit) => inspect(commit, allowed))
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import { Effect, FileSystem, Path, Schema } from "effect";
|
|
2
|
+
import { runsMutation, within, type Run } from "./mutation-job.ts";
|
|
3
|
+
import { isRecord, names, workflowJobs, workflowTitle, type Workflow } from "./workflow-references.ts";
|
|
4
|
+
|
|
5
|
+
export type RunnerConfig = {
|
|
6
|
+
readonly mutation: readonly string[] | undefined;
|
|
7
|
+
readonly selfHosted: readonly (readonly string[])[];
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type RunnerPolicy = {
|
|
11
|
+
readonly visibility: "private" | "public" | "unknown";
|
|
12
|
+
readonly runners: RunnerConfig;
|
|
13
|
+
readonly scripts: ReadonlyMap<string, string>;
|
|
14
|
+
readonly files?: ReadonlyMap<string, string>;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type RunnerFault = {
|
|
18
|
+
readonly location: string;
|
|
19
|
+
readonly fault: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export class RunnerPolicyError extends Schema.TaggedError<RunnerPolicyError>()("RunnerPolicyError", {
|
|
23
|
+
message: Schema.String,
|
|
24
|
+
}) {}
|
|
25
|
+
|
|
26
|
+
const Runners = Schema.Struct({
|
|
27
|
+
mutationRunner: Schema.optional(Schema.NonEmptyArray(Schema.NonEmptyString)),
|
|
28
|
+
selfHostedRunners: Schema.optional(Schema.Array(Schema.NonEmptyArray(Schema.NonEmptyString))),
|
|
29
|
+
});
|
|
30
|
+
const Manifest = Schema.Struct({
|
|
31
|
+
checks: Schema.optional(Runners),
|
|
32
|
+
});
|
|
33
|
+
// GitHub expressions match context and property names without regard to case.
|
|
34
|
+
const OVERRIDE_ACCESS = String.raw`vars\s*(?:\.\s*CI_RUNS_ON\b|\[\s*'CI_RUNS_ON'\s*\])`;
|
|
35
|
+
const RUNNER_OVERRIDE = new RegExp(String.raw`\b${OVERRIDE_ACCESS}`, "i");
|
|
36
|
+
const HOSTED_OVERRIDE = new RegExp(String.raw`^\$\{\{\s*${OVERRIDE_ACCESS}\s*\|\|\s*'ubuntu-latest'\s*\}\}$`, "i");
|
|
37
|
+
const HOSTED_DEFAULT = "${{ vars.CI_RUNS_ON || 'ubuntu-latest' }}";
|
|
38
|
+
|
|
39
|
+
function sameLabels(runsOn: unknown, labels: readonly string[]): boolean {
|
|
40
|
+
const named = Array.isArray(runsOn) ? names(runsOn) : undefined;
|
|
41
|
+
return named?.length === labels.length && labels.every((label) => named.includes(label));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readsOverride(runsOn: unknown): boolean {
|
|
45
|
+
if (typeof runsOn === "string") return RUNNER_OVERRIDE.test(runsOn);
|
|
46
|
+
const values = isRecord(runsOn) ? Object.values(runsOn) : Array.isArray(runsOn) ? runsOn : [];
|
|
47
|
+
return values.some(readsOverride);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function forkReachable(workflow: Workflow): boolean {
|
|
51
|
+
const on = isRecord(workflow.document) ? workflow.document["on"] : undefined;
|
|
52
|
+
const events = isRecord(on) ? Object.keys(on) : (names(on) ?? []);
|
|
53
|
+
return events.some((event) => {
|
|
54
|
+
const name = event.toLowerCase();
|
|
55
|
+
return name === "pull_request" || name === "pull_request_target" || name === "pull_request_review" || name === "pull_request_review_comment" || name === "issue_comment";
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// An exact match would let a differently cased label slip past the refusal.
|
|
60
|
+
function namesSelfHosted(runsOn: unknown): boolean {
|
|
61
|
+
if (typeof runsOn === "string") return runsOn.trim().toLowerCase() === "self-hosted";
|
|
62
|
+
if (Array.isArray(runsOn)) return runsOn.some((label) => typeof label === "string" && label.toLowerCase() === "self-hosted");
|
|
63
|
+
if (isRecord(runsOn)) {
|
|
64
|
+
const labels = runsOn["labels"];
|
|
65
|
+
return Array.isArray(labels) && labels.some((label) => typeof label === "string" && label.toLowerCase() === "self-hosted");
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function mutationFault(runsOn: unknown, labels: readonly string[] | undefined, visibility: RunnerPolicy["visibility"], reachable: boolean): string | undefined {
|
|
71
|
+
const pin = labels === undefined ? undefined : `[${labels.join(", ")}]`;
|
|
72
|
+
if (readsOverride(runsOn)) {
|
|
73
|
+
if (visibility === "public" && reachable) {
|
|
74
|
+
return "a mutation job reads CI_RUNS_ON, so an override moves its mutation runs; set runs-on: ubuntu-latest";
|
|
75
|
+
}
|
|
76
|
+
return pin === undefined
|
|
77
|
+
? "a mutation job reads CI_RUNS_ON, so an override moves its mutation runs; declare the mutation runner labels in package.json checks.mutationRunner"
|
|
78
|
+
: `a mutation job reads CI_RUNS_ON, so an override moves its mutation runs off ${pin}; set runs-on: ${pin}`;
|
|
79
|
+
}
|
|
80
|
+
if (visibility !== "private") return undefined;
|
|
81
|
+
if (labels === undefined) {
|
|
82
|
+
return "a mutation job in a private repository names no checks.mutationRunner in package.json; declare the mutation runner labels there";
|
|
83
|
+
}
|
|
84
|
+
return sameLabels(runsOn, labels) ? undefined : `a mutation job in a private repository runs off ${pin}; set runs-on: ${pin}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function otherFault(runsOn: unknown, runners: RunnerConfig, visibility: RunnerPolicy["visibility"]): string | undefined {
|
|
88
|
+
const hosted = typeof runsOn === "string" && HOSTED_OVERRIDE.test(runsOn.trim());
|
|
89
|
+
if (hosted || runners.selfHosted.some((allowed) => sameLabels(runsOn, allowed))) return undefined;
|
|
90
|
+
if (visibility !== "private" && !readsOverride(runsOn)) return undefined;
|
|
91
|
+
return `the job is not on a hosted runner CI_RUNS_ON can override; set runs-on: ${HOSTED_DEFAULT}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function declaredLabels(labels: readonly string[], runners: RunnerConfig): boolean {
|
|
95
|
+
const declared = runners.mutation === undefined ? runners.selfHosted : [runners.mutation, ...runners.selfHosted];
|
|
96
|
+
const wanted = labels.map((label) => label.toLowerCase());
|
|
97
|
+
return wanted.length > 0 && declared.some((allowed) => wanted.every((label) => allowed.some((candidate) => candidate.toLowerCase() === label)));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function matrixKey(expression: string): string | undefined {
|
|
101
|
+
const match = /^\$\{\{\s*matrix\s*\.\s*([\w-]+)\s*\}\}$/i.exec(expression.trim());
|
|
102
|
+
return match?.[1]?.toLowerCase();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
type Scalar = string | number | boolean;
|
|
106
|
+
|
|
107
|
+
type Combo = Map<string, unknown>;
|
|
108
|
+
|
|
109
|
+
type Terms = readonly (readonly [string, unknown])[];
|
|
110
|
+
|
|
111
|
+
function scalarList(value: unknown): readonly Scalar[] | undefined {
|
|
112
|
+
if (!Array.isArray(value)) return undefined;
|
|
113
|
+
const kept = value.filter((entry): entry is Scalar => typeof entry === "string" || typeof entry === "number" || typeof entry === "boolean");
|
|
114
|
+
return kept.length === value.length ? kept : undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function matrixAxes(matrix: Readonly<Record<string, unknown>>): readonly (readonly [string, readonly Scalar[]])[] | undefined {
|
|
118
|
+
const axes: (readonly [string, readonly Scalar[]])[] = [];
|
|
119
|
+
for (const [name, axis] of Object.entries(matrix)) {
|
|
120
|
+
const lowered = name.toLowerCase();
|
|
121
|
+
if (lowered === "include" || lowered === "exclude") continue;
|
|
122
|
+
const values = scalarList(axis);
|
|
123
|
+
if (values === undefined) return undefined;
|
|
124
|
+
axes.push([lowered, values]);
|
|
125
|
+
}
|
|
126
|
+
return axes;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function expandCombos(axes: readonly (readonly [string, readonly Scalar[]])[]): Combo[] | undefined {
|
|
130
|
+
let combos: Combo[] = [new Map()];
|
|
131
|
+
for (const [name, axis] of axes) {
|
|
132
|
+
const next: Combo[] = [];
|
|
133
|
+
for (const combo of combos) {
|
|
134
|
+
for (const value of axis) next.push(new Map(combo).set(name, value));
|
|
135
|
+
}
|
|
136
|
+
combos = next;
|
|
137
|
+
if (combos.length > 512) return undefined;
|
|
138
|
+
}
|
|
139
|
+
return combos;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function exclusionBlocks(excluded: unknown): readonly Terms[] | undefined {
|
|
143
|
+
if (excluded === undefined) return [];
|
|
144
|
+
if (!Array.isArray(excluded)) return undefined;
|
|
145
|
+
const blocks: Terms[] = [];
|
|
146
|
+
for (const candidate of excluded) {
|
|
147
|
+
if (!isRecord(candidate)) return undefined;
|
|
148
|
+
const terms: (readonly [string, unknown])[] = [];
|
|
149
|
+
let usable = true;
|
|
150
|
+
for (const [name, value] of Object.entries(candidate)) {
|
|
151
|
+
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
|
|
152
|
+
usable = false;
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
terms.push([name.toLowerCase(), value]);
|
|
156
|
+
}
|
|
157
|
+
if (usable) blocks.push(terms);
|
|
158
|
+
}
|
|
159
|
+
return blocks;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function withoutExcluded(combos: readonly Combo[], blocks: readonly Terms[]): Combo[] {
|
|
163
|
+
return combos.filter((combo) => !blocks.some((terms) => terms.every(([name, value]) => combo.get(name) === value)));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function includeEntries(candidate: Readonly<Record<string, unknown>>): Terms | undefined {
|
|
167
|
+
const entries: (readonly [string, unknown])[] = [];
|
|
168
|
+
for (const [name, value] of Object.entries(candidate)) {
|
|
169
|
+
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") return undefined;
|
|
170
|
+
entries.push([name.toLowerCase(), value]);
|
|
171
|
+
}
|
|
172
|
+
return entries;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function mergeEntries(originals: readonly Combo[], axisNames: ReadonlySet<string>, entries: Terms, appended: Combo[]): void {
|
|
176
|
+
const matches = originals.filter((combo) => entries.every(([name, value]) => !axisNames.has(name) || combo.get(name) === value));
|
|
177
|
+
if (matches.length === 0) appended.push(new Map(entries));
|
|
178
|
+
else for (const combo of matches) for (const [name, value] of entries) combo.set(name, value);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function applyInclude(originals: readonly Combo[], axisNames: ReadonlySet<string>, extra: unknown): readonly Combo[] | undefined {
|
|
182
|
+
const appended: Combo[] = [];
|
|
183
|
+
if (!Array.isArray(extra)) return appended;
|
|
184
|
+
for (const candidate of extra) {
|
|
185
|
+
if (!isRecord(candidate)) return undefined;
|
|
186
|
+
const entries = includeEntries(candidate);
|
|
187
|
+
if (entries === undefined) return undefined;
|
|
188
|
+
mergeEntries(originals, axisNames, entries, appended);
|
|
189
|
+
}
|
|
190
|
+
return appended;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function runnerValues(combos: readonly Combo[], appended: readonly Combo[], key: string): readonly string[] | undefined {
|
|
194
|
+
const used = new Set<string>();
|
|
195
|
+
for (const combo of [...combos, ...appended]) {
|
|
196
|
+
const value = combo.get(key);
|
|
197
|
+
if (value === undefined) continue;
|
|
198
|
+
if (typeof value !== "string") return undefined;
|
|
199
|
+
used.add(value);
|
|
200
|
+
}
|
|
201
|
+
return used.size > 0 ? [...used] : undefined;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function matrixValues(job: Readonly<Record<string, unknown>>, key: string): readonly string[] | undefined {
|
|
205
|
+
const strategy = job["strategy"];
|
|
206
|
+
if (!isRecord(strategy)) return undefined;
|
|
207
|
+
const matrix = strategy["matrix"];
|
|
208
|
+
if (!isRecord(matrix)) return undefined;
|
|
209
|
+
if (key === "include" || key === "exclude") return undefined;
|
|
210
|
+
const axes = matrixAxes(matrix);
|
|
211
|
+
if (axes === undefined) return undefined;
|
|
212
|
+
const combos = expandCombos(axes);
|
|
213
|
+
if (combos === undefined) return undefined;
|
|
214
|
+
const blocks = exclusionBlocks(matrix["exclude"]);
|
|
215
|
+
if (blocks === undefined) return undefined;
|
|
216
|
+
const kept = withoutExcluded(combos, blocks);
|
|
217
|
+
const appended = applyInclude(axes.length === 0 ? [] : kept, new Set(axes.map(([name]) => name)), matrix["include"]);
|
|
218
|
+
if (appended === undefined) return undefined;
|
|
219
|
+
return runnerValues(kept, appended, key);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function hasInterpolation(value: string): boolean {
|
|
223
|
+
return value.includes("${{");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function mentionedSelfHosted(value: unknown): boolean {
|
|
227
|
+
if (typeof value === "string") return /self-hosted/i.test(value);
|
|
228
|
+
if (Array.isArray(value)) return value.some(mentionedSelfHosted);
|
|
229
|
+
if (isRecord(value)) return Object.values(value).some(mentionedSelfHosted);
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function plainLabels(value: unknown): readonly string[] | undefined {
|
|
234
|
+
if (typeof value === "string") return hasInterpolation(value) ? undefined : [value];
|
|
235
|
+
if (!Array.isArray(value) || !value.every((entry): entry is string => typeof entry === "string")) return undefined;
|
|
236
|
+
return value.some(hasInterpolation) ? undefined : value;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function matrixRefusal(
|
|
240
|
+
job: Readonly<Record<string, unknown>>,
|
|
241
|
+
shown: string,
|
|
242
|
+
key: string,
|
|
243
|
+
runners: RunnerConfig,
|
|
244
|
+
selfHosted: string,
|
|
245
|
+
unresolvable: (shown: string) => string,
|
|
246
|
+
): string | undefined {
|
|
247
|
+
const values = matrixValues(job, key);
|
|
248
|
+
if (values === undefined) return unresolvable(shown);
|
|
249
|
+
for (const value of values) {
|
|
250
|
+
if (hasInterpolation(value)) return mentionedSelfHosted(value) ? selfHosted : unresolvable(shown);
|
|
251
|
+
if (value.toLowerCase() === "self-hosted" || declaredLabels([value], runners)) return selfHosted;
|
|
252
|
+
}
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function stringFault(
|
|
257
|
+
job: Readonly<Record<string, unknown>>,
|
|
258
|
+
runsOn: string,
|
|
259
|
+
runners: RunnerConfig,
|
|
260
|
+
selfHosted: string,
|
|
261
|
+
unresolvable: (shown: string) => string,
|
|
262
|
+
): string | undefined {
|
|
263
|
+
const trimmed = runsOn.trim();
|
|
264
|
+
const key = matrixKey(trimmed);
|
|
265
|
+
if (key !== undefined) return matrixRefusal(job, trimmed, key, runners, selfHosted, unresolvable);
|
|
266
|
+
if (hasInterpolation(trimmed)) return mentionedSelfHosted(trimmed) ? selfHosted : unresolvable(trimmed);
|
|
267
|
+
return declaredLabels([trimmed], runners) ? selfHosted : undefined;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function arrayFault(runsOn: unknown, runners: RunnerConfig, selfHosted: string, unresolvable: (shown: string) => string): string | undefined {
|
|
271
|
+
const labels = plainLabels(runsOn);
|
|
272
|
+
if (labels === undefined) return mentionedSelfHosted(runsOn) ? selfHosted : unresolvable("its runs-on expression");
|
|
273
|
+
return declaredLabels(labels, runners) ? selfHosted : undefined;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function scalarLabelsFault(runsOn: unknown, runners: RunnerConfig, selfHosted: string, unresolvable: (shown: string) => string): string | undefined {
|
|
277
|
+
const labels = plainLabels(runsOn);
|
|
278
|
+
if (labels === undefined) return unresolvable("its runs-on expression");
|
|
279
|
+
return labels.some((label) => label.toLowerCase() === "self-hosted") || declaredLabels(labels, runners) ? selfHosted : undefined;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function recordFault(
|
|
283
|
+
runsOn: Readonly<Record<string, unknown>>,
|
|
284
|
+
runners: RunnerConfig,
|
|
285
|
+
selfHosted: string,
|
|
286
|
+
unresolvable: (shown: string) => string,
|
|
287
|
+
): string | undefined {
|
|
288
|
+
if (runsOn["group"] === undefined) return scalarLabelsFault(runsOn["labels"], runners, selfHosted, unresolvable);
|
|
289
|
+
const labels = plainLabels(runsOn["labels"]);
|
|
290
|
+
if (labels !== undefined && (labels.some((label) => label.toLowerCase() === "self-hosted") || declaredLabels(labels, runners))) return selfHosted;
|
|
291
|
+
const group = typeof runsOn["group"] === "string" ? runsOn["group"].trim() : "a runner group";
|
|
292
|
+
return "a public repository runs a fork-reachable job on runner group " + group + ", which cannot be shown to be hosted before the run; set runs-on: ubuntu-latest";
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function publicFault(job: Readonly<Record<string, unknown>>, runsOn: unknown, runners: RunnerConfig): string | undefined {
|
|
296
|
+
const selfHosted = "a public repository runs a fork-reachable job on self-hosted labels, so a fork pull request would run its code there; set runs-on: ubuntu-latest";
|
|
297
|
+
const unresolvable = (shown: string) =>
|
|
298
|
+
"a public repository runs a fork-reachable job on " + shown + ", which cannot be shown to be a hosted runner before the run; set runs-on: ubuntu-latest";
|
|
299
|
+
if (namesSelfHosted(runsOn)) return selfHosted;
|
|
300
|
+
if (typeof runsOn === "string") return stringFault(job, runsOn, runners, selfHosted, unresolvable);
|
|
301
|
+
if (Array.isArray(runsOn)) return arrayFault(runsOn, runners, selfHosted, unresolvable);
|
|
302
|
+
if (isRecord(runsOn)) return recordFault(runsOn, runners, selfHosted, unresolvable);
|
|
303
|
+
return unresolvable("its runs-on expression");
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function calledWorkflow(job: Readonly<Record<string, unknown>>): string | undefined {
|
|
307
|
+
const uses = job["uses"];
|
|
308
|
+
return typeof uses === "string" && uses.startsWith("./.github/workflows/") ? uses.slice(2) : undefined;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function watchedWorkflows(workflow: Workflow): readonly string[] {
|
|
312
|
+
const on = isRecord(workflow.document) ? workflow.document["on"] : undefined;
|
|
313
|
+
const watched = isRecord(on) ? on["workflow_run"] : undefined;
|
|
314
|
+
return isRecord(watched) ? (names(watched["workflows"]) ?? []) : [];
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function runnerFault(job: Readonly<Record<string, unknown>>, runsOn: unknown, mutation: boolean, policy: RunnerPolicy, reachable: boolean): string | undefined {
|
|
318
|
+
if (policy.visibility === "public" && reachable) {
|
|
319
|
+
const fault = publicFault(job, runsOn, policy.runners);
|
|
320
|
+
if (fault !== undefined) return fault;
|
|
321
|
+
}
|
|
322
|
+
return mutation ? mutationFault(runsOn, policy.runners.mutation, policy.visibility, reachable) : otherFault(runsOn, policy.runners, policy.visibility);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function stepDirectory(value: unknown, inherited: string | undefined): string | undefined {
|
|
326
|
+
if (value === undefined) return inherited;
|
|
327
|
+
return typeof value === "string" ? within("", value) : undefined;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function defaultDirectory(node: unknown, inherited: string | undefined): string | undefined {
|
|
331
|
+
const defaults = isRecord(node) ? node["defaults"] : undefined;
|
|
332
|
+
const run = isRecord(defaults) ? defaults["run"] : undefined;
|
|
333
|
+
return stepDirectory(isRecord(run) ? run["working-directory"] : undefined, inherited);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function jobRuns(workflow: Workflow, job: Readonly<Record<string, unknown>>): readonly Run[] {
|
|
337
|
+
const dir = defaultDirectory(job, defaultDirectory(workflow.document, ""));
|
|
338
|
+
const steps = Array.isArray(job["steps"]) ? job["steps"] : [];
|
|
339
|
+
return steps.flatMap((step: unknown) => (isRecord(step) && typeof step["run"] === "string" ? [{ script: step["run"], dir: stepDirectory(step["working-directory"], dir) }] : []));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function findRunnerFaults(policy: RunnerPolicy, workflows: readonly Workflow[]): readonly RunnerFault[] {
|
|
343
|
+
const reachable = new Map(workflows.map((workflow) => [workflow.path, forkReachable(workflow)] as const));
|
|
344
|
+
const titleOf = new Map(workflows.map((workflow) => [workflow.path, workflowTitle(workflow.document) ?? workflow.path] as const));
|
|
345
|
+
let changed = true;
|
|
346
|
+
while (changed) {
|
|
347
|
+
changed = false;
|
|
348
|
+
for (const workflow of workflows) {
|
|
349
|
+
if (reachable.get(workflow.path) === true) continue;
|
|
350
|
+
const called = workflows.some(
|
|
351
|
+
(other) => reachable.get(other.path) === true && workflowJobs(other).some(([, job]) => calledWorkflow(job) === workflow.path),
|
|
352
|
+
);
|
|
353
|
+
const watched = watchedWorkflows(workflow).some((parent) =>
|
|
354
|
+
workflows.some((other) => reachable.get(other.path) === true && titleOf.get(other.path) === parent),
|
|
355
|
+
);
|
|
356
|
+
if (called || watched) {
|
|
357
|
+
reachable.set(workflow.path, true);
|
|
358
|
+
changed = true;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return workflows.flatMap((workflow) =>
|
|
363
|
+
workflowJobs(workflow).flatMap(([id, job]) => {
|
|
364
|
+
if (job["runs-on"] === undefined) return [];
|
|
365
|
+
const mutation = jobRuns(workflow, job).some((run) => runsMutation(run, policy.scripts, (file) => policy.files?.get(file)));
|
|
366
|
+
const fault = runnerFault(job, job["runs-on"], mutation, policy, reachable.get(workflow.path) ?? forkReachable(workflow));
|
|
367
|
+
return fault === undefined ? [] : [{ location: workflow.path + " job " + id, fault }];
|
|
368
|
+
}),
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export function formatRunnerReport(faults: readonly RunnerFault[]): string {
|
|
373
|
+
return [`ci-wiring: ${faults.length} job(s) run on the wrong runner:`, ...faults.map(({ location, fault }) => ` ${location}: ${fault}`)].join("\n");
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export const readRunnerConfig = Effect.fn("readRunnerConfig")(function* (root: string) {
|
|
377
|
+
const fs = yield* FileSystem.FileSystem;
|
|
378
|
+
const file = (yield* Path.Path).join(root, "package.json");
|
|
379
|
+
if (!(yield* fs.exists(file))) return { mutation: undefined, selfHosted: [] } satisfies RunnerConfig;
|
|
380
|
+
const { checks } = yield* fs.readFileString(file).pipe(
|
|
381
|
+
Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(Manifest))),
|
|
382
|
+
Effect.mapError((cause) => new RunnerPolicyError({ message: `cannot read ${file}: ${cause.message}` })),
|
|
383
|
+
);
|
|
384
|
+
return { mutation: checks?.mutationRunner, selfHosted: checks?.selfHostedRunners ?? [] } satisfies RunnerConfig;
|
|
385
|
+
});
|
|
@@ -232,7 +232,7 @@ export function locate(path: string, lines: readonly string[]): Locator {
|
|
|
232
232
|
};
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
-
function workflowTitle(document: unknown): string | undefined {
|
|
235
|
+
export function workflowTitle(document: unknown): string | undefined {
|
|
236
236
|
return isRecord(document) && typeof document["name"] === "string" ? document["name"] : undefined;
|
|
237
237
|
}
|
|
238
238
|
|
|
@@ -53,7 +53,7 @@ const RELATED_TOPICS = fixed("Related topics", optional("there is no other page
|
|
|
53
53
|
"- [<page title>](<path to the page>)",
|
|
54
54
|
]);
|
|
55
55
|
|
|
56
|
-
const BEFORE_YOU_BEGIN = fixed("Before you begin", REQUIRED, ["- <each prerequisite,
|
|
56
|
+
const BEFORE_YOU_BEGIN = fixed("Before you begin", REQUIRED, ["- <each prerequisite, naming the file that pins its version rather than the version>"]);
|
|
57
57
|
|
|
58
58
|
const STEPS = ["To <do the task>:", "", "1. <step>", "1. <step>"];
|
|
59
59
|
|