@avi2dg/checks 0.66.0 → 0.67.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.67.0
6
+
7
+ Released 2026-11-06.
8
+
9
+ ### Features
10
+
11
+ - **ci-wiring:** accept shared runner pick and setup action [#48](https://github.com/avi2d/checks/pull/48)
12
+
5
13
  ## 0.66.0
6
14
 
7
15
  Released 2026-11-05.
@@ -68,6 +68,10 @@ Any other expression refuses as unresolvable.
68
68
  A job on `push` or another trigger no fork reaches may still name self-hosted labels.
69
69
  In a private repository each mutation job carries exactly the labels the consumer declares in `package.json` under `checks.mutationRunner`.
70
70
  Every other job there carries exactly the hosted-default expression, or exactly one of the label sets the consumer declares under `checks.selfHostedRunners`.
71
+ A job there also carries `runs-on: ${{ needs.<job>.outputs.runs-on }}` when the same workflow needs that job and that job calls the runner pick.
72
+ The pick is the repository file `runner.yml` under `.github/workflows/` or `avi2d/workflows/.github/workflows/runner.yml` at a pinned full SHA.
73
+ A public repository still refuses that expression on a job a fork can reach.
74
+ The resolved label cannot be shown to be a hosted runner before the run.
71
75
  A repository without those declarations fails each job they would pin until it declares them.
72
76
  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.
73
77
 
@@ -90,7 +94,9 @@ The fleet version is the version the kit ships in its own `.bun-version`.
90
94
  The step fails the check when the version is missing or differs from the fleet bun version.
91
95
  A range fails because it differs from the fleet version.
92
96
  A version built from an expression fails because it cannot be shown equal before the run.
93
- This rule skips a repository with no setup-bun step.
97
+ A step that uses `avi2d/workflows/actions/setup` reads `.bun-version`.
98
+ The step fails the check when `.bun-version` is missing or differs from the fleet bun version.
99
+ This rule skips a repository with no setup-bun step and no shared setup step.
94
100
  A kit release that moves the fleet version turns each consumer red until its pin follows.
95
101
  Each refusal names the workflow file and line, the version found and where it was read, and the fleet version.
96
102
 
@@ -133,8 +139,10 @@ Each refusal names the workflow file, the job and the limit.
133
139
  ## What it reads
134
140
 
135
141
  The bin reads `.github/workflows/*.yml`, `*.yaml`, the `scripts`, `private` and `checks` runners in `package.json` and the shell scripts those steps and scripts run from the working tree.
142
+ It reads each shell script that a shell script it reads runs, until none is left.
136
143
  It lists the `*.ts` and `*.tsx` files `git ls-files` tracks to decide whether the night run applies.
137
144
  It reads each file a setup-bun step names for its version.
145
+ It reads `.bun-version` for each step that uses the shared setup action.
138
146
  It reads the fleet pin from the kit's own `.bun-version`.
139
147
  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.
140
148
  It reads whether the repository is private from `repository.private` in that event file.
@@ -149,7 +157,7 @@ It takes no arguments.
149
157
  | Code | Result |
150
158
  | --- | --- |
151
159
  | 0 | Every required command has a reachable step. |
152
- | 1 | A required command is missing or blocked, a job runs on the wrong runner, a workflow names a missing workflow or job, a setup-bun step drifts from the fleet bun version, a workflow that releases runs on a schedule outside a public npm package or more than once a day, a workflow other than a weekly public advisory scan runs on a schedule, a job can run for more than 60 minutes, or a repository that tracks TypeScript never runs `checks-lint --night`. |
160
+ | 1 | A required command is missing or blocked, a job runs on the wrong runner, a workflow names a missing workflow or job, a bun setup step drifts from the fleet bun version, a workflow that releases runs on a schedule outside a public npm package or more than once a day, a workflow other than a weekly public advisory scan runs on a schedule, a job can run for more than 60 minutes, or a repository that tracks TypeScript never runs `checks-lint --night`. |
153
161
  | 2 | A workflow or `package.json` cannot be decoded, or the kit's own `.bun-version` is missing, unreadable or blank. |
154
162
 
155
163
  ## Sample output
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.66.0",
3
+ "version": "0.67.0",
4
4
  "description": "Deterministic checks shared across a set of TypeScript repositories",
5
5
  "author": {
6
6
  "name": "avi2d",
@@ -81,6 +81,7 @@
81
81
  "src/delivery/mutation-job.ts",
82
82
  "src/delivery/job-timeout.ts",
83
83
  "src/delivery/runner-policy.ts",
84
+ "src/delivery/runner-pick.ts",
84
85
  "src/delivery/secrets.ts",
85
86
  "src/delivery/secrets-pack.ts",
86
87
  "src/delivery/pr-text.ts",
@@ -17,12 +17,19 @@ export type BunPinFault = {
17
17
 
18
18
  const SETUP_BUN = /^oven-sh\/setup-bun(?:@(?<ref>.*))?$/i;
19
19
  const V2_REF = /^v2(?:\.\d+){0,2}$/;
20
+ const SHARED_SETUP = /^avi2d\/workflows\/actions\/setup(?:@(?<ref>.*))?$/i;
21
+ const SETUP_VERSION_FILE = ".bun-version";
20
22
 
21
23
  function setupBunRef(uses: unknown): string | undefined {
22
24
  const match = typeof uses === "string" ? SETUP_BUN.exec(uses.trim()) : null;
23
25
  return match === null ? undefined : (match.groups?.["ref"] ?? "");
24
26
  }
25
27
 
28
+ function sharedSetupRef(uses: unknown): string | undefined {
29
+ const match = typeof uses === "string" ? SHARED_SETUP.exec(uses.trim()) : null;
30
+ return match === null ? undefined : (match.groups?.["ref"] ?? "");
31
+ }
32
+
26
33
  function isExpression(value: string): boolean {
27
34
  return value.includes("${{");
28
35
  }
@@ -106,6 +113,15 @@ function unknownRefFault(ref: string): string {
106
113
  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
114
  }
108
115
 
116
+ function sharedSetupFault(policy: BunPinPolicy, ref: string): string | undefined {
117
+ const version = nonEmpty(policy.files.get(SETUP_VERSION_FILE));
118
+ const uses = ref === "" ? "avi2d/workflows/actions/setup" : `avi2d/workflows/actions/setup@${ref}`;
119
+ if (version === undefined) {
120
+ return `uses ${uses}, which reads ${SETUP_VERSION_FILE}, but the repository holds no ${SETUP_VERSION_FILE} file, want ${policy.fleet}`;
121
+ }
122
+ return version === policy.fleet ? undefined : `requests bun ${version} from ${SETUP_VERSION_FILE}, want ${policy.fleet}`;
123
+ }
124
+
109
125
  export function findBunPinFaults(policy: BunPinPolicy, workflows: readonly Workflow[]): readonly BunPinFault[] {
110
126
  return workflows.flatMap((workflow) => {
111
127
  const at = locate(workflow.path, workflow.text.split("\n"));
@@ -113,17 +129,25 @@ export function findBunPinFaults(policy: BunPinPolicy, workflows: readonly Workf
113
129
  const steps = Array.isArray(job["steps"]) ? job["steps"] : [];
114
130
  return steps.flatMap((step: unknown, index: number) => {
115
131
  if (!isRecord(step)) return [];
132
+ const atStep = (fault: string) => [
133
+ { location: at(["jobs", id, "steps", index, "uses"], String(step["uses"]), `job ${id} step ${index + 1}`), fault },
134
+ ];
116
135
  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 }];
136
+ if (ref !== undefined) {
137
+ const fault = V2_REF.test(ref) ? bunPinFault(policy, step) : unknownRefFault(ref);
138
+ return fault === undefined ? [] : atStep(fault);
139
+ }
140
+ const sharedRef = sharedSetupRef(step["uses"]);
141
+ if (sharedRef === undefined) return [];
142
+ const fault = sharedSetupFault(policy, sharedRef);
143
+ return fault === undefined ? [] : atStep(fault);
120
144
  });
121
145
  });
122
146
  });
123
147
  }
124
148
 
125
149
  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");
150
+ return [`ci-wiring: ${faults.length} bun setup step(s) drift from the fleet bun version:`, ...faults.map(({ location, fault }) => ` ${location}: ${fault}`)].join("\n");
127
151
  }
128
152
 
129
153
  function versionFileKeys(job: Readonly<Record<string, unknown>>): readonly string[] {
@@ -139,11 +163,17 @@ function versionFileKeys(job: Readonly<Record<string, unknown>>): readonly strin
139
163
  return keys;
140
164
  }
141
165
 
166
+ function usesSharedSetup(job: Readonly<Record<string, unknown>>): boolean {
167
+ const steps = Array.isArray(job["steps"]) ? job["steps"] : [];
168
+ return steps.some((step) => isRecord(step) && sharedSetupRef(step["uses"]) !== undefined);
169
+ }
170
+
142
171
  function wantedBunPinFiles(workflows: readonly Workflow[]): ReadonlySet<string> {
143
172
  const wanted = new Set<string>([ROOT_MANIFEST]);
144
173
  for (const workflow of workflows) {
145
174
  for (const [, job] of workflowJobs(workflow)) {
146
175
  for (const key of versionFileKeys(job)) wanted.add(key);
176
+ if (usesSharedSetup(job)) wanted.add(SETUP_VERSION_FILE);
147
177
  }
148
178
  }
149
179
  return wanted;
@@ -0,0 +1,29 @@
1
+ import { names, type Workflow } from "./workflow-references.ts";
2
+
3
+ const NEEDS_RUNS_ON = /^\$\{\{\s*needs\.([\w-]+)\.outputs\.runs-on\s*\}\}$/i;
4
+ const SHARED_RUNNER = /^avi2d\/workflows\/\.github\/workflows\/runner\.yml@[0-9a-f]{40}$/i;
5
+ const LOCAL_RUNNER = ".github/workflows/runner.yml";
6
+
7
+ function callsRunnerPick(uses: unknown, workflows: readonly Workflow[]): boolean {
8
+ if (typeof uses !== "string") return false;
9
+ const target = uses.trim();
10
+ if (target.startsWith("./")) {
11
+ const path = target.slice(2);
12
+ return path === LOCAL_RUNNER && workflows.some((workflow) => workflow.path === path);
13
+ }
14
+ return SHARED_RUNNER.test(target);
15
+ }
16
+
17
+ // GitHub fills needs.<job> only for jobs the current job needs.
18
+ export function readsRunnerPick(
19
+ job: Readonly<Record<string, unknown>>,
20
+ runsOn: unknown,
21
+ jobs: ReadonlyMap<string, Readonly<Record<string, unknown>>>,
22
+ workflows: readonly Workflow[],
23
+ ): boolean {
24
+ if (typeof runsOn !== "string") return false;
25
+ const need = NEEDS_RUNS_ON.exec(runsOn.trim())?.[1];
26
+ if (need === undefined) return false;
27
+ if (!(names(job["needs"]) ?? []).includes(need)) return false;
28
+ return callsRunnerPick(jobs.get(need)?.["uses"], workflows);
29
+ }
@@ -1,5 +1,6 @@
1
1
  import { Effect, FileSystem, Path, Schema } from "effect";
2
2
  import { runsMutation, within, type Run } from "./mutation-job.ts";
3
+ import { readsRunnerPick } from "./runner-pick.ts";
3
4
  import { isRecord, names, workflowJobs, workflowTitle, type Workflow } from "./workflow-references.ts";
4
5
 
5
6
  export type RunnerConfig = {
@@ -314,11 +315,20 @@ function watchedWorkflows(workflow: Workflow): readonly string[] {
314
315
  return isRecord(watched) ? (names(watched["workflows"]) ?? []) : [];
315
316
  }
316
317
 
317
- function runnerFault(job: Readonly<Record<string, unknown>>, runsOn: unknown, mutation: boolean, policy: RunnerPolicy, reachable: boolean): string | undefined {
318
+ function runnerFault(
319
+ job: Readonly<Record<string, unknown>>,
320
+ runsOn: unknown,
321
+ mutation: boolean,
322
+ policy: RunnerPolicy,
323
+ reachable: boolean,
324
+ jobs: ReadonlyMap<string, Readonly<Record<string, unknown>>>,
325
+ workflows: readonly Workflow[],
326
+ ): string | undefined {
318
327
  if (policy.visibility === "public" && reachable) {
319
328
  const fault = publicFault(job, runsOn, policy.runners);
320
329
  if (fault !== undefined) return fault;
321
330
  }
331
+ if (readsRunnerPick(job, runsOn, jobs, workflows)) return undefined;
322
332
  return mutation ? mutationFault(runsOn, policy.runners.mutation, policy.visibility, reachable) : otherFault(runsOn, policy.runners, policy.visibility);
323
333
  }
324
334
 
@@ -359,14 +369,16 @@ export function findRunnerFaults(policy: RunnerPolicy, workflows: readonly Workf
359
369
  }
360
370
  }
361
371
  }
362
- return workflows.flatMap((workflow) =>
363
- workflowJobs(workflow).flatMap(([id, job]) => {
372
+ return workflows.flatMap((workflow) => {
373
+ const entries = workflowJobs(workflow);
374
+ const jobs = new Map(entries);
375
+ return entries.flatMap(([id, job]) => {
364
376
  if (job["runs-on"] === undefined) return [];
365
377
  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));
378
+ const fault = runnerFault(job, job["runs-on"], mutation, policy, reachable.get(workflow.path) ?? forkReachable(workflow), jobs, workflows);
367
379
  return fault === undefined ? [] : [{ location: workflow.path + " job " + id, fault }];
368
- }),
369
- );
380
+ });
381
+ });
370
382
  }
371
383
 
372
384
  export function formatRunnerReport(faults: readonly RunnerFault[]): string {