@avi2dg/checks 0.41.0 → 0.42.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/README.md +5 -1
- package/docs/gates/checks-advisories.md +2 -0
- package/docs/gates/checks-ci-wiring.md +40 -1
- package/docs/gates/checks-flake.md +2 -0
- package/docs/gates/checks-mutation-compare.md +2 -0
- package/docs/gates/checks-release-notes.md +6 -0
- package/package.json +3 -1
- package/src/delivery/bun-pin.ts +174 -0
- package/src/delivery/ci-wiring.ts +11 -22
- package/src/delivery/workflow-references.ts +203 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
Every release of `@avi2dg/checks`, newest first, written by the release from its conventional commits.
|
|
4
4
|
|
|
5
|
+
## 0.42.0
|
|
6
|
+
|
|
7
|
+
Released 2026-10-14.
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
- **delivery:** fail a setup-bun step whose Bun version drifts from the fleet pin [#156](https://github.com/avi2d/checks/pull/156)
|
|
12
|
+
|
|
13
|
+
## 0.41.1
|
|
14
|
+
|
|
15
|
+
Released 2026-10-14.
|
|
16
|
+
|
|
17
|
+
### Fixes
|
|
18
|
+
|
|
19
|
+
- **delivery:** refuse workflow references to missing workflows or jobs in ci-wiring [#153](https://github.com/avi2d/checks/pull/153)
|
|
20
|
+
|
|
5
21
|
## 0.41.0
|
|
6
22
|
|
|
7
23
|
Released 2026-10-12.
|
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ Each repository owns its workflows and native tool configs, as [Native settings]
|
|
|
10
10
|
<!-- generated prerequisites: bun run build writes it from package.json, .bun-version and scripts/doc-blocks.ts -->
|
|
11
11
|
|
|
12
12
|
- A git repository, whose history the range gates read.
|
|
13
|
-
- Bun 1.4.
|
|
13
|
+
- Bun 1.4.3, which runs every bin.
|
|
14
14
|
- The peer dependencies, at the exact versions the kit pins:
|
|
15
15
|
- `@effect/tsgo` 0.45.0
|
|
16
16
|
- `@swc/core` 1.16.2
|
|
@@ -96,6 +96,8 @@ To consume the kit from a repository:
|
|
|
96
96
|
"test": "checks-test"
|
|
97
97
|
```
|
|
98
98
|
|
|
99
|
+
1. Write the Bun version [Before you begin](#before-you-begin) names to `.bun-version`, which [checks-ci-wiring](docs/gates/checks-ci-wiring.md#bun-version) holds every setup-bun step to.
|
|
100
|
+
|
|
99
101
|
1. Run the scripts on every pull request in `.github/workflows/ci.yml`, fetching the whole history the range needs:
|
|
100
102
|
|
|
101
103
|
```yaml
|
|
@@ -109,6 +111,8 @@ To consume the kit from a repository:
|
|
|
109
111
|
with:
|
|
110
112
|
fetch-depth: 0
|
|
111
113
|
- uses: oven-sh/setup-bun@v2
|
|
114
|
+
with:
|
|
115
|
+
bun-version-file: .bun-version
|
|
112
116
|
- run: bun install --frozen-lockfile
|
|
113
117
|
- run: bun run build
|
|
114
118
|
- run: git diff --exit-code
|
|
@@ -21,6 +21,14 @@ The check follows local reusable workflows but not remote reusable workflows.
|
|
|
21
21
|
It refuses a step or job with `if: false` or `continue-on-error: true`.
|
|
22
22
|
It also refuses a workflow that does not trigger on both `opened` and `synchronize` pull requests to the target branch.
|
|
23
23
|
A path filter cannot cover every pull request and therefore cannot satisfy the check.
|
|
24
|
+
It refuses a reference to a workflow or job the repository does not hold.
|
|
25
|
+
Each literal `checks-release-pr` and `checks-release-tag` argument names a workflow file, so every one must match a file under `.github/workflows/`.
|
|
26
|
+
An argument built from a variable or an expression is known only when the job runs, so the check passes over it.
|
|
27
|
+
Each `on.workflow_run.workflows` entry names a workflow by its `name`, so every one must match a name in the repository.
|
|
28
|
+
A workflow without a `name` answers to its path from the repository root.
|
|
29
|
+
A local reusable `uses` must name a file under `.github/workflows/` that the repository holds.
|
|
30
|
+
Each `needs` entry names a job in the same workflow, so every one must match a job id in that file.
|
|
31
|
+
Each refusal names the file, the line and the missing workflow or job.
|
|
24
32
|
|
|
25
33
|
### Runners
|
|
26
34
|
|
|
@@ -41,9 +49,33 @@ In a private repository each mutation job carries exactly the labels `[self-host
|
|
|
41
49
|
A public repository may keep `runs-on: ubuntu-latest` on every job, because a pull request from a fork runs its own code on the runner.
|
|
42
50
|
The check knows a repository is private only from GitHub's event, so a run outside CI judges only the rules that hold in either.
|
|
43
51
|
|
|
52
|
+
### Bun version
|
|
53
|
+
|
|
54
|
+
It also judges every step that uses `oven-sh/setup-bun`.
|
|
55
|
+
A step at ref `v2`, `v2.<minor>` or `v2.<minor>.<patch>` resolves its effective version in the order setup-bun v2 reads it.
|
|
56
|
+
A step at any other ref or at none fails, because the check knows only setup-bun v2's version resolution.
|
|
57
|
+
An input name matches in any case, as GitHub reads it.
|
|
58
|
+
The step input `with.bun-version` wins when it names a version, and an unquoted number counts as the text GitHub passes.
|
|
59
|
+
Else the file the step input `with.bun-version-file` names decides by its basename, when it yields a version.
|
|
60
|
+
A `package.json` file reads `packageManager` after `bun@`, and reads `engines.bun` only when `packageManager` is absent or holds no `bun@`.
|
|
61
|
+
A `.tool-versions` file reads the rest of the first line that starts with `bun`.
|
|
62
|
+
A `.bun-version` or `.bumrc` file reads the whole trimmed file.
|
|
63
|
+
Else the `package.json` at the workspace root reads the same way.
|
|
64
|
+
Else setup-bun installs latest.
|
|
65
|
+
A blank version counts as none.
|
|
66
|
+
A repository passes by writing the fleet version to `.bun-version` and naming that file in each step's `with.bun-version-file`.
|
|
67
|
+
The step fails the check when the version is missing or differs from the fleet bun version.
|
|
68
|
+
A range fails because it differs from the fleet version.
|
|
69
|
+
A version built from an expression fails because it cannot be shown equal before the run.
|
|
70
|
+
This rule skips a repository with no setup-bun step.
|
|
71
|
+
The fleet bun version equals the dotfiles mise pin.
|
|
72
|
+
A bump moves both together, so a kit release that moves the fleet version turns each consumer red until its pin follows.
|
|
73
|
+
Each refusal names the workflow file and line, the version found and where it was read, and the fleet version.
|
|
74
|
+
|
|
44
75
|
## What it reads
|
|
45
76
|
|
|
46
77
|
The bin reads `.github/workflows/*.yml`, `*.yaml`, the `scripts` in `package.json` and the shell scripts those steps and scripts run from the working tree.
|
|
78
|
+
It reads each file a setup-bun step names for its version.
|
|
47
79
|
It reads the target branch from `GITHUB_BASE_REF`, then from `refs/remotes/origin/HEAD` and then from the event file `GITHUB_EVENT_PATH` names.
|
|
48
80
|
It reads whether the repository is private from `repository.private` in that event file.
|
|
49
81
|
It parses the workflows with `Bun.YAML` without executing them.
|
|
@@ -57,7 +89,7 @@ It takes no arguments.
|
|
|
57
89
|
| Code | Result |
|
|
58
90
|
| --- | --- |
|
|
59
91
|
| 0 | Every required command has a reachable step. |
|
|
60
|
-
| 1 | A required command is missing or blocked,
|
|
92
|
+
| 1 | A required command is missing or blocked, a job runs on the wrong runner, a workflow names a missing workflow or job, or a setup-bun step drifts from the fleet bun version. |
|
|
61
93
|
| 2 | A workflow or `package.json` cannot be decoded. |
|
|
62
94
|
|
|
63
95
|
## Sample output
|
|
@@ -77,6 +109,13 @@ ci-wiring: 1 job(s) run on the wrong runner:
|
|
|
77
109
|
.github/workflows/mutation-compare.yml job mutation-compare: a mutation job reads CI_RUNS_ON, so an override moves its full sweeps off winbox; set runs-on: [self-hosted, Linux, X64, winbox]
|
|
78
110
|
```
|
|
79
111
|
|
|
112
|
+
A reference to a missing workflow produces a report like this:
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
ci-wiring: 1 reference(s) name a missing workflow or job:
|
|
116
|
+
.github/workflows/daily-release.yml line 31: names missing workflow commitlint.yml
|
|
117
|
+
```
|
|
118
|
+
|
|
80
119
|
## When it runs
|
|
81
120
|
|
|
82
121
|
`checks-lint` runs it in every repository.
|
|
@@ -67,6 +67,8 @@ jobs:
|
|
|
67
67
|
steps:
|
|
68
68
|
- uses: actions/checkout@v5
|
|
69
69
|
- uses: oven-sh/setup-bun@v2
|
|
70
|
+
with:
|
|
71
|
+
bun-version-file: .bun-version
|
|
70
72
|
- run: bun install --frozen-lockfile
|
|
71
73
|
- run: bunx checks-flake --runs 10 --report flake-report.json
|
|
72
74
|
- uses: actions/upload-artifact@v4
|
|
@@ -69,6 +69,8 @@ jobs:
|
|
|
69
69
|
- name: ref is a tag
|
|
70
70
|
run: test "$GITHUB_REF_TYPE" = tag
|
|
71
71
|
- uses: oven-sh/setup-bun@v2
|
|
72
|
+
with:
|
|
73
|
+
bun-version-file: .bun-version
|
|
72
74
|
- run: bun install --frozen-lockfile
|
|
73
75
|
- run: bun run build
|
|
74
76
|
- run: git diff --exit-code
|
|
@@ -89,6 +91,8 @@ jobs:
|
|
|
89
91
|
with:
|
|
90
92
|
fetch-depth: 0
|
|
91
93
|
- uses: oven-sh/setup-bun@v2
|
|
94
|
+
with:
|
|
95
|
+
bun-version-file: .bun-version
|
|
92
96
|
- run: bun install --frozen-lockfile
|
|
93
97
|
- name: extract release notes
|
|
94
98
|
run: ./node_modules/.bin/checks-release-notes "${GITHUB_REF_NAME#v}" "$RUNNER_TEMP/release-notes.md"
|
|
@@ -120,6 +124,8 @@ jobs:
|
|
|
120
124
|
- name: ref is a tag
|
|
121
125
|
run: test "$GITHUB_REF_TYPE" = tag
|
|
122
126
|
- uses: oven-sh/setup-bun@v2
|
|
127
|
+
with:
|
|
128
|
+
bun-version-file: .bun-version
|
|
123
129
|
- run: bun install --frozen-lockfile
|
|
124
130
|
- run: bun run build
|
|
125
131
|
- run: git diff --exit-code
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avi2dg/checks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.42.0",
|
|
4
4
|
"description": "Deterministic checks shared across a set of TypeScript repositories",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -51,6 +51,8 @@
|
|
|
51
51
|
"src/testing/subsumed-tests.ts",
|
|
52
52
|
"src/delivery/ci-wiring.ts",
|
|
53
53
|
"src/delivery/shell-command.ts",
|
|
54
|
+
"src/delivery/workflow-references.ts",
|
|
55
|
+
"src/delivery/bun-pin.ts",
|
|
54
56
|
"src/delivery/mutation-job.ts",
|
|
55
57
|
"src/delivery/secrets.ts",
|
|
56
58
|
"src/delivery/gitleaks.ts",
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { Effect, FileSystem, Path, Schema } from "effect";
|
|
2
|
+
import { within } from "./mutation-job.ts";
|
|
3
|
+
import { isRecord, locate, workflowJobs, type Workflow } from "./workflow-references.ts";
|
|
4
|
+
|
|
5
|
+
export const FLEET_BUN_VERSION = "1.4.3";
|
|
6
|
+
const ROOT_MANIFEST = "package.json";
|
|
7
|
+
|
|
8
|
+
export type BunPinPolicy = {
|
|
9
|
+
readonly files: ReadonlyMap<string, string>;
|
|
10
|
+
readonly manifests: ReadonlyMap<string, unknown>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type BunPinFault = {
|
|
14
|
+
readonly location: string;
|
|
15
|
+
readonly fault: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const SETUP_BUN = /^oven-sh\/setup-bun(?:@(?<ref>.*))?$/i;
|
|
19
|
+
const V2_REF = /^v2(?:\.\d+){0,2}$/;
|
|
20
|
+
|
|
21
|
+
function setupBunRef(uses: unknown): string | undefined {
|
|
22
|
+
const match = typeof uses === "string" ? SETUP_BUN.exec(uses.trim()) : null;
|
|
23
|
+
return match === null ? undefined : (match.groups?.["ref"] ?? "");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isExpression(value: string): boolean {
|
|
27
|
+
return value.includes("${{");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function baseName(name: string): string {
|
|
31
|
+
return name.slice(name.lastIndexOf("/") + 1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function actionInput(step: Readonly<Record<string, unknown>>, name: string): string {
|
|
35
|
+
const inputs = isRecord(step["with"]) ? step["with"] : {};
|
|
36
|
+
const value = Object.entries(inputs).find(([key]) => key.toLowerCase() === name)?.[1];
|
|
37
|
+
return typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? String(value).trim() : "";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function nonEmpty(version: string | undefined): string | undefined {
|
|
41
|
+
const trimmed = version?.trim();
|
|
42
|
+
return trimmed === "" ? undefined : trimmed;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function manifestBunVersion(manifest: unknown): string | undefined {
|
|
46
|
+
if (!isRecord(manifest)) return undefined;
|
|
47
|
+
const manager = manifest["packageManager"];
|
|
48
|
+
const engines = manifest["engines"];
|
|
49
|
+
const engine = isRecord(engines) ? engines["bun"] : undefined;
|
|
50
|
+
const version = manager === undefined || manager === null ? engine : typeof manager === "string" ? (manager.split("bun@")[1] ?? engine) : undefined;
|
|
51
|
+
return typeof version === "string" ? nonEmpty(version) : undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const FILE_READERS = new Map<string, (text: string) => string | undefined>([
|
|
55
|
+
[".tool-versions", (text) => /^bun\s*(?<version>.*?)$/m.exec(text)?.groups?.["version"]],
|
|
56
|
+
[".bumrc", (text) => text],
|
|
57
|
+
[".bun-version", (text) => text],
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
function fileBunVersion(policy: BunPinPolicy, key: string): string | undefined {
|
|
61
|
+
if (baseName(key) === "package.json") return manifestBunVersion(policy.manifests.get(key));
|
|
62
|
+
const text = policy.files.get(key);
|
|
63
|
+
const read = FILE_READERS.get(baseName(key));
|
|
64
|
+
return text === undefined || read === undefined ? undefined : nonEmpty(read(text));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type BunPinRequest = {
|
|
68
|
+
readonly version: string | undefined;
|
|
69
|
+
readonly source: string;
|
|
70
|
+
readonly expression: string | undefined;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
function rootRequest(policy: BunPinPolicy, consulted: string): BunPinRequest {
|
|
74
|
+
const version = manifestBunVersion(policy.manifests.get(ROOT_MANIFEST));
|
|
75
|
+
return { version, source: version === undefined ? consulted : ROOT_MANIFEST, expression: undefined };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function bunPinRequest(policy: BunPinPolicy, step: Readonly<Record<string, unknown>>): BunPinRequest {
|
|
79
|
+
const pinned = actionInput(step, "bun-version");
|
|
80
|
+
if (pinned !== "") {
|
|
81
|
+
return isExpression(pinned)
|
|
82
|
+
? { version: undefined, source: "with.bun-version", expression: pinned }
|
|
83
|
+
: { version: pinned, source: "with.bun-version", expression: undefined };
|
|
84
|
+
}
|
|
85
|
+
const file = actionInput(step, "bun-version-file");
|
|
86
|
+
if (file === "") return rootRequest(policy, ROOT_MANIFEST);
|
|
87
|
+
const source = `with.bun-version-file ${file}`;
|
|
88
|
+
if (isExpression(file)) return { version: undefined, source, expression: file };
|
|
89
|
+
const key = within("", file);
|
|
90
|
+
const version = key === undefined ? undefined : fileBunVersion(policy, key);
|
|
91
|
+
return version === undefined ? rootRequest(policy, `${source} or ${ROOT_MANIFEST}`) : { version, source, expression: undefined };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function bunPinFault(policy: BunPinPolicy, step: Readonly<Record<string, unknown>>): string | undefined {
|
|
95
|
+
const request = bunPinRequest(policy, step);
|
|
96
|
+
if (request.expression !== undefined) {
|
|
97
|
+
return `requests bun ${request.expression} from ${request.source}, which cannot be shown equal to ${FLEET_BUN_VERSION} before the run`;
|
|
98
|
+
}
|
|
99
|
+
if (request.version === undefined) {
|
|
100
|
+
return `pins no bun version from ${request.source}, so setup-bun installs latest, want ${FLEET_BUN_VERSION}`;
|
|
101
|
+
}
|
|
102
|
+
return request.version === FLEET_BUN_VERSION ? undefined : `requests bun ${request.version} from ${request.source}, want ${FLEET_BUN_VERSION}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function unknownRefFault(ref: string): string {
|
|
106
|
+
return `uses oven-sh/setup-bun at ${ref === "" ? "no ref" : ref}, but the check knows only setup-bun v2's version resolution, so the step must use oven-sh/setup-bun@v2`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function findBunPinFaults(policy: BunPinPolicy, workflows: readonly Workflow[]): readonly BunPinFault[] {
|
|
110
|
+
return workflows.flatMap((workflow) => {
|
|
111
|
+
const at = locate(workflow.path, workflow.text.split("\n"));
|
|
112
|
+
return workflowJobs(workflow).flatMap(([id, job]) => {
|
|
113
|
+
const steps = Array.isArray(job["steps"]) ? job["steps"] : [];
|
|
114
|
+
return steps.flatMap((step: unknown, index: number) => {
|
|
115
|
+
if (!isRecord(step)) return [];
|
|
116
|
+
const ref = setupBunRef(step["uses"]);
|
|
117
|
+
if (ref === undefined) return [];
|
|
118
|
+
const fault = V2_REF.test(ref) ? bunPinFault(policy, step) : unknownRefFault(ref);
|
|
119
|
+
return fault === undefined ? [] : [{ location: at(["jobs", id, "steps", index, "uses"], String(step["uses"]), `job ${id} step ${index + 1}`), fault }];
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function formatBunPinReport(faults: readonly BunPinFault[]): string {
|
|
126
|
+
return [`ci-wiring: ${faults.length} setup-bun step(s) drift from the fleet bun version:`, ...faults.map(({ location, fault }) => ` ${location}: ${fault}`)].join("\n");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function versionFileKeys(job: Readonly<Record<string, unknown>>): readonly string[] {
|
|
130
|
+
const steps = Array.isArray(job["steps"]) ? job["steps"] : [];
|
|
131
|
+
const keys: string[] = [];
|
|
132
|
+
for (const step of steps) {
|
|
133
|
+
if (!isRecord(step) || !V2_REF.test(setupBunRef(step["uses"]) ?? "")) continue;
|
|
134
|
+
const file = actionInput(step, "bun-version-file");
|
|
135
|
+
if (file === "" || isExpression(file)) continue;
|
|
136
|
+
const key = within("", file);
|
|
137
|
+
if (key !== undefined) keys.push(key);
|
|
138
|
+
}
|
|
139
|
+
return keys;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function wantedBunPinFiles(workflows: readonly Workflow[]): ReadonlySet<string> {
|
|
143
|
+
const wanted = new Set<string>([ROOT_MANIFEST]);
|
|
144
|
+
for (const workflow of workflows) {
|
|
145
|
+
for (const [, job] of workflowJobs(workflow)) {
|
|
146
|
+
for (const key of versionFileKeys(job)) wanted.add(key);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return wanted;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const readJsonFile = Effect.fn("readJsonFile")(function* (file: string) {
|
|
153
|
+
return yield* (yield* FileSystem.FileSystem).readFileString(file).pipe(
|
|
154
|
+
Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))),
|
|
155
|
+
Effect.orElseSucceed((): undefined => undefined),
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
export const readBunPinPolicy = Effect.fn("readBunPinPolicy")(function* (root: string, workflows: readonly Workflow[]) {
|
|
160
|
+
const path = yield* Path.Path;
|
|
161
|
+
const fs = yield* FileSystem.FileSystem;
|
|
162
|
+
const files = new Map<string, string>();
|
|
163
|
+
const manifests = new Map<string, unknown>();
|
|
164
|
+
for (const name of wantedBunPinFiles(workflows)) {
|
|
165
|
+
if (baseName(name) === "package.json") {
|
|
166
|
+
const parsed: unknown = yield* readJsonFile(path.join(root, name));
|
|
167
|
+
if (parsed !== undefined) manifests.set(name, parsed);
|
|
168
|
+
} else {
|
|
169
|
+
const text = yield* fs.readFileString(path.join(root, name)).pipe(Effect.orElseSucceed((): undefined => undefined));
|
|
170
|
+
if (text !== undefined) files.set(name, text);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return { files, manifests };
|
|
174
|
+
});
|
|
@@ -3,8 +3,13 @@ import { Config, Console, Effect, FileSystem, Path, Schema } from "effect";
|
|
|
3
3
|
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
|
+
import { findBunPinFaults, formatBunPinReport, readBunPinPolicy } from "./bun-pin.ts";
|
|
6
7
|
import { readShellFiles, runsMutation, within, type Run } from "./mutation-job.ts";
|
|
7
8
|
import { invokes, mentions, plainCommand, type Command } from "./shell-command.ts";
|
|
9
|
+
import { findMissingWorkflows, formatMissingReport, isRecord, names, workflowJobs, type Workflow } from "./workflow-references.ts";
|
|
10
|
+
|
|
11
|
+
export { FLEET_BUN_VERSION, findBunPinFaults, formatBunPinReport } from "./bun-pin.ts";
|
|
12
|
+
export type { BunPinFault, BunPinPolicy } from "./bun-pin.ts";
|
|
8
13
|
|
|
9
14
|
export type { Command };
|
|
10
15
|
|
|
@@ -20,11 +25,6 @@ export type Declaration = {
|
|
|
20
25
|
readonly lintGates: readonly KitGate[];
|
|
21
26
|
};
|
|
22
27
|
|
|
23
|
-
export type Workflow = {
|
|
24
|
-
readonly path: string;
|
|
25
|
-
readonly document: unknown;
|
|
26
|
-
};
|
|
27
|
-
|
|
28
28
|
export type BlockedInvocation = {
|
|
29
29
|
readonly location: string;
|
|
30
30
|
readonly blocker: string;
|
|
@@ -84,16 +84,6 @@ const MUTATION_RUNNER = ["self-hosted", "Linux", "X64", "winbox"];
|
|
|
84
84
|
const HOSTED_DEFAULT = "${{ vars.CI_RUNS_ON || 'ubuntu-latest' }}";
|
|
85
85
|
const EventRepository = Schema.fromJsonString(Schema.Struct({ repository: Schema.Struct({ private: Schema.Boolean }) }));
|
|
86
86
|
|
|
87
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
88
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function names(value: unknown): readonly string[] | undefined {
|
|
92
|
-
if (typeof value === "string") return [value];
|
|
93
|
-
if (!Array.isArray(value)) return undefined;
|
|
94
|
-
return value.filter((entry): entry is string => typeof entry === "string");
|
|
95
|
-
}
|
|
96
|
-
|
|
97
87
|
function constant(value: unknown): boolean | undefined {
|
|
98
88
|
if (typeof value === "boolean") return value;
|
|
99
89
|
if (typeof value !== "string") return undefined;
|
|
@@ -311,11 +301,6 @@ function jobRuns(workflow: Workflow, job: Readonly<Record<string, unknown>>): re
|
|
|
311
301
|
return steps.flatMap((step: unknown) => (isRecord(step) && typeof step["run"] === "string" ? [{ script: step["run"], dir: stepDirectory(step["working-directory"], dir) }] : []));
|
|
312
302
|
}
|
|
313
303
|
|
|
314
|
-
function workflowJobs(workflow: Workflow): readonly (readonly [string, Readonly<Record<string, unknown>>])[] {
|
|
315
|
-
const jobs = isRecord(workflow.document) ? workflow.document["jobs"] : undefined;
|
|
316
|
-
return isRecord(jobs) ? Object.entries(jobs).flatMap(([id, job]) => (isRecord(job) ? [[id, job] as const] : [])) : [];
|
|
317
|
-
}
|
|
318
|
-
|
|
319
304
|
export function findRunnerFaults(policy: RunnerPolicy, workflows: readonly Workflow[]): readonly RunnerFault[] {
|
|
320
305
|
return workflows.flatMap((workflow) =>
|
|
321
306
|
workflowJobs(workflow).flatMap(([id, job]) => {
|
|
@@ -333,7 +318,7 @@ export function formatRunnerReport(faults: readonly RunnerFault[]): string {
|
|
|
333
318
|
|
|
334
319
|
export const parseWorkflow = (path: string, text: string): Effect.Effect<Workflow, WiringError> =>
|
|
335
320
|
Effect.try({
|
|
336
|
-
try: () => ({ path, document: Bun.YAML.parse(text) }),
|
|
321
|
+
try: () => ({ path, document: Bun.YAML.parse(text), text }),
|
|
337
322
|
catch: (error) => new WiringError({ message: `cannot parse ${path}: ${String(error)}` }),
|
|
338
323
|
});
|
|
339
324
|
|
|
@@ -386,11 +371,15 @@ const wiring = Effect.gen(function* () {
|
|
|
386
371
|
const gaps = findGaps(declaration, workflows);
|
|
387
372
|
const gapReport = formatReport(declaration, gaps);
|
|
388
373
|
yield* gaps.length > 0 ? Console.error(gapReport) : Console.log(gapReport);
|
|
374
|
+
const missing = findMissingWorkflows(workflows);
|
|
375
|
+
if (missing.length > 0) yield* Console.error(formatMissingReport(missing));
|
|
389
376
|
const scripts = yield* readScripts(root);
|
|
390
377
|
const runs = workflows.flatMap((workflow) => workflowJobs(workflow).flatMap(([, job]) => jobRuns(workflow, job)));
|
|
391
378
|
const faults = findRunnerFaults({ visibility: yield* readVisibility, scripts, files: yield* readShellFiles(root, scripts, runs) }, workflows);
|
|
392
379
|
if (faults.length > 0) yield* Console.error(formatRunnerReport(faults));
|
|
393
|
-
|
|
380
|
+
const bunFaults = findBunPinFaults(yield* readBunPinPolicy(root, workflows), workflows);
|
|
381
|
+
if (bunFaults.length > 0) yield* Console.error(formatBunPinReport(bunFaults));
|
|
382
|
+
return gaps.length === 0 && faults.length === 0 && missing.length === 0 && bunFaults.length === 0;
|
|
394
383
|
});
|
|
395
384
|
|
|
396
385
|
if (import.meta.main) runMain("ci-wiring", wiring);
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { fromProgram, shellCommands, type Command } from "./shell-command.ts";
|
|
2
|
+
|
|
3
|
+
export type Workflow = {
|
|
4
|
+
readonly path: string;
|
|
5
|
+
readonly document: unknown;
|
|
6
|
+
readonly text: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type MissingReference = {
|
|
10
|
+
readonly location: string;
|
|
11
|
+
readonly fault: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
15
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function workflowJobs(workflow: Workflow): readonly (readonly [string, Readonly<Record<string, unknown>>])[] {
|
|
19
|
+
const jobs = isRecord(workflow.document) ? workflow.document["jobs"] : undefined;
|
|
20
|
+
return isRecord(jobs) ? Object.entries(jobs).flatMap(([id, job]) => (isRecord(job) ? [[id, job] as const] : [])) : [];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function names(value: unknown): readonly string[] | undefined {
|
|
24
|
+
if (typeof value === "string") return [value];
|
|
25
|
+
if (!Array.isArray(value)) return undefined;
|
|
26
|
+
return value.filter((entry): entry is string => typeof entry === "string");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type Key = string | number;
|
|
30
|
+
|
|
31
|
+
type Locator = (keys: readonly Key[], needle: string, fallback: string) => string;
|
|
32
|
+
|
|
33
|
+
// Lines from..to hold the node, and its child keys start at column.
|
|
34
|
+
type Node = { readonly from: number; readonly to: number; readonly column: number };
|
|
35
|
+
|
|
36
|
+
const WORKFLOWS = ".github/workflows";
|
|
37
|
+
const RELEASE_BINS = ["checks-release-pr", "checks-release-tag"];
|
|
38
|
+
const RELEASE_SOURCES = ["release-pr.ts", "release-tag.ts"];
|
|
39
|
+
const REUSABLE_PREFIX = "./.github/workflows/";
|
|
40
|
+
// GitHub substitutes an expression into the script before the shell reads it.
|
|
41
|
+
const EXPRESSION = /\$\{\{[\s\S]*?\}\}/g;
|
|
42
|
+
|
|
43
|
+
function isPathTo(word: string, files: readonly string[]): boolean {
|
|
44
|
+
return files.some((file) => word.endsWith(`/${file}`));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function releaseArguments(program: Command): readonly string[] {
|
|
48
|
+
const [head, target] = program;
|
|
49
|
+
if (head !== undefined && (RELEASE_BINS.includes(head) || isPathTo(head, RELEASE_BINS))) return program.slice(1);
|
|
50
|
+
if (head === "bun" && target !== undefined && (RELEASE_SOURCES.includes(target) || isPathTo(target, [...RELEASE_SOURCES, ...RELEASE_BINS]))) {
|
|
51
|
+
return program.slice(2);
|
|
52
|
+
}
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function literalTargets(run: string): readonly string[] {
|
|
57
|
+
return shellCommands(run.replace(EXPRESSION, "$EXPRESSION"))
|
|
58
|
+
.flatMap((command) => releaseArguments(fromProgram(command)))
|
|
59
|
+
.filter((target) => !target.includes("$"));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function escapeRegExp(text: string): string {
|
|
63
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function indentOf(line: string): number {
|
|
67
|
+
return line.length - line.trimStart().length;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function meaningful(line: string): boolean {
|
|
71
|
+
const text = line.trim();
|
|
72
|
+
return text !== "" && !text.startsWith("#");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function belongs(line: string, column: number): boolean {
|
|
76
|
+
return !meaningful(line) || indentOf(line) > column || (indentOf(line) === column && line.trim().startsWith("-"));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function firstBodyLine(lines: readonly string[], from: number, to: number): string | undefined {
|
|
80
|
+
return lines.slice(from + 1, to).find(meaningful);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function child(lines: readonly string[], node: Node, key: string): Node | undefined {
|
|
84
|
+
const pattern = new RegExp(`^(\\s*(?:-\\s+)?)["']?${escapeRegExp(key)}["']?\\s*:(\\s|$)`);
|
|
85
|
+
for (let at = node.from; at < node.to; at += 1) {
|
|
86
|
+
if (pattern.exec(lines[at] ?? "")?.[1]?.length !== node.column) continue;
|
|
87
|
+
let to = at + 1;
|
|
88
|
+
while (to < node.to && belongs(lines[to] ?? "", node.column)) to += 1;
|
|
89
|
+
const body = firstBodyLine(lines, at, to);
|
|
90
|
+
return { from: at, to, column: body === undefined ? -1 : indentOf(body) };
|
|
91
|
+
}
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function items(lines: readonly string[], node: Node): readonly Node[] {
|
|
96
|
+
const column = indentOf(firstBodyLine(lines, node.from, node.to) ?? "");
|
|
97
|
+
const starts: number[] = [];
|
|
98
|
+
for (let at = node.from + 1; at < node.to; at += 1) {
|
|
99
|
+
const line = lines[at] ?? "";
|
|
100
|
+
if (meaningful(line) && indentOf(line) === column && line.trim().startsWith("-")) starts.push(at);
|
|
101
|
+
}
|
|
102
|
+
return starts.map((from, index) => {
|
|
103
|
+
const to = starts[index + 1] ?? node.to;
|
|
104
|
+
const inline = /^\s*-\s+(?=\S)/.exec(lines[from] ?? "");
|
|
105
|
+
return { from, to, column: inline === null ? indentOf(firstBodyLine(lines, from, to) ?? "") : inline[0].length };
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function nodeAt(lines: readonly string[], keys: readonly Key[]): Node | undefined {
|
|
110
|
+
return keys.reduce<Node | undefined>(
|
|
111
|
+
(node, key) => (node === undefined ? undefined : typeof key === "number" ? items(lines, node)[key] : child(lines, node, key)),
|
|
112
|
+
{ from: 0, to: lines.length, column: 0 },
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function locate(path: string, lines: readonly string[]): Locator {
|
|
117
|
+
const used = new Map<string, number>();
|
|
118
|
+
return (keys, needle, fallback) => {
|
|
119
|
+
const node = nodeAt(lines, keys);
|
|
120
|
+
if (node === undefined) return `${path} ${fallback}`;
|
|
121
|
+
const hits = lines.slice(node.from, node.to).flatMap((line, offset) => (line.includes(needle) ? [node.from + offset] : []));
|
|
122
|
+
const at = hits.find((line) => line > (used.get(needle) ?? -1)) ?? hits[0] ?? node.from;
|
|
123
|
+
used.set(needle, at);
|
|
124
|
+
return `${path} line ${at + 1}`;
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function workflowTitle(document: unknown): string | undefined {
|
|
129
|
+
return isRecord(document) && typeof document["name"] === "string" ? document["name"] : undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function missingNeeds(jobs: Readonly<Record<string, unknown>>, at: Locator): readonly MissingReference[] {
|
|
133
|
+
return Object.entries(jobs).flatMap(([id, job]) => {
|
|
134
|
+
if (!isRecord(job)) return [];
|
|
135
|
+
return (names(job["needs"]) ?? []).flatMap((need) =>
|
|
136
|
+
need in jobs ? [] : [{ location: at(["jobs", id, "needs"], need, `job ${id}`), fault: `job ${id} needs missing job ${need}` }],
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function missingTarget(uses: string, paths: ReadonlySet<string>, at: Locator, keys: readonly Key[], fallback: string): MissingReference | undefined {
|
|
142
|
+
return uses.startsWith(REUSABLE_PREFIX) && !paths.has(uses.slice(2))
|
|
143
|
+
? { location: at(keys, uses, fallback), fault: `names missing workflow ${uses.slice(2)}` }
|
|
144
|
+
: undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function missingJobRefs(id: string, job: unknown, paths: ReadonlySet<string>, at: Locator): readonly MissingReference[] {
|
|
148
|
+
if (!isRecord(job)) return [];
|
|
149
|
+
const missing: MissingReference[] = [];
|
|
150
|
+
if (typeof job["uses"] === "string") {
|
|
151
|
+
const called = missingTarget(job["uses"], paths, at, ["jobs", id, "uses"], `job ${id}`);
|
|
152
|
+
if (called !== undefined) missing.push(called);
|
|
153
|
+
}
|
|
154
|
+
const steps = job["steps"];
|
|
155
|
+
if (!Array.isArray(steps)) return missing;
|
|
156
|
+
steps.forEach((step: unknown, index) => {
|
|
157
|
+
if (!isRecord(step)) return;
|
|
158
|
+
const fallback = `job ${id} step ${index + 1}`;
|
|
159
|
+
if (typeof step["uses"] === "string") {
|
|
160
|
+
const used = missingTarget(step["uses"], paths, at, ["jobs", id, "steps", index, "uses"], fallback);
|
|
161
|
+
if (used !== undefined) missing.push(used);
|
|
162
|
+
}
|
|
163
|
+
if (typeof step["run"] !== "string") return;
|
|
164
|
+
for (const target of literalTargets(step["run"])) {
|
|
165
|
+
if (!paths.has(`${WORKFLOWS}/${target}`)) {
|
|
166
|
+
missing.push({ location: at(["jobs", id, "steps", index, "run"], target, fallback), fault: `names missing workflow ${target}` });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
return missing;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// GitHub names a workflow without a name by its path from the repository root.
|
|
174
|
+
export function findMissingWorkflows(workflows: readonly Workflow[]): readonly MissingReference[] {
|
|
175
|
+
const paths = new Set(workflows.map((workflow) => workflow.path));
|
|
176
|
+
const titles = new Set(workflows.map((workflow) => workflowTitle(workflow.document) ?? workflow.path));
|
|
177
|
+
return workflows.flatMap((workflow) => {
|
|
178
|
+
const missing: MissingReference[] = [];
|
|
179
|
+
const at = locate(workflow.path, workflow.text.split("\n"));
|
|
180
|
+
const jobs = isRecord(workflow.document) ? workflow.document["jobs"] : undefined;
|
|
181
|
+
if (isRecord(jobs)) {
|
|
182
|
+
missing.push(...missingNeeds(jobs, at));
|
|
183
|
+
for (const [id, job] of Object.entries(jobs)) missing.push(...missingJobRefs(id, job, paths, at));
|
|
184
|
+
}
|
|
185
|
+
const on = isRecord(workflow.document) ? workflow.document["on"] : undefined;
|
|
186
|
+
const watched = isRecord(on) ? on["workflow_run"] : undefined;
|
|
187
|
+
if (isRecord(watched)) {
|
|
188
|
+
for (const entry of names(watched["workflows"]) ?? []) {
|
|
189
|
+
if (!titles.has(entry)) {
|
|
190
|
+
missing.push({ location: at(["on", "workflow_run", "workflows"], entry, "on workflow_run"), fault: `names missing workflow ${entry}` });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return missing;
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function formatMissingReport(faults: readonly MissingReference[]): string {
|
|
199
|
+
return [
|
|
200
|
+
`ci-wiring: ${faults.length} reference(s) name a missing workflow or job:`,
|
|
201
|
+
...faults.map(({ location, fault }) => ` ${location}: ${fault}`),
|
|
202
|
+
].join("\n");
|
|
203
|
+
}
|