@avi2dg/checks 0.41.0 → 0.41.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/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.41.1
6
+
7
+ Released 2026-10-14.
8
+
9
+ ### Fixes
10
+
11
+ - **delivery:** refuse workflow references to missing workflows or jobs in ci-wiring [#153](https://github.com/avi2d/checks/pull/153)
12
+
5
13
  ## 0.41.0
6
14
 
7
15
  Released 2026-10-12.
@@ -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
 
@@ -57,7 +65,7 @@ It takes no arguments.
57
65
  | Code | Result |
58
66
  | --- | --- |
59
67
  | 0 | Every required command has a reachable step. |
60
- | 1 | A required command is missing or blocked, or a job runs on the wrong runner. |
68
+ | 1 | A required command is missing or blocked, a job runs on the wrong runner, or a workflow names a missing workflow or job. |
61
69
  | 2 | A workflow or `package.json` cannot be decoded. |
62
70
 
63
71
  ## Sample output
@@ -77,6 +85,13 @@ ci-wiring: 1 job(s) run on the wrong runner:
77
85
  .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
86
  ```
79
87
 
88
+ A reference to a missing workflow produces a report like this:
89
+
90
+ ```
91
+ ci-wiring: 1 reference(s) name a missing workflow or job:
92
+ .github/workflows/daily-release.yml line 31: names missing workflow commitlint.yml
93
+ ```
94
+
80
95
  ## When it runs
81
96
 
82
97
  `checks-lint` runs it in every repository.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.41.0",
3
+ "version": "0.41.1",
4
4
  "description": "Deterministic checks shared across a set of TypeScript repositories",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -51,6 +51,7 @@
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",
54
55
  "src/delivery/mutation-job.ts",
55
56
  "src/delivery/secrets.ts",
56
57
  "src/delivery/gitleaks.ts",
@@ -5,6 +5,7 @@ import { runMain } from "../core/main.ts";
5
5
  import { ENTRY_POINT, KIT_GATES, type KitGate } from "../core/gates.ts";
6
6
  import { readShellFiles, runsMutation, within, type Run } from "./mutation-job.ts";
7
7
  import { invokes, mentions, plainCommand, type Command } from "./shell-command.ts";
8
+ import { findMissingWorkflows, formatMissingReport, isRecord, names, type Workflow } from "./workflow-references.ts";
8
9
 
9
10
  export type { Command };
10
11
 
@@ -20,11 +21,6 @@ export type Declaration = {
20
21
  readonly lintGates: readonly KitGate[];
21
22
  };
22
23
 
23
- export type Workflow = {
24
- readonly path: string;
25
- readonly document: unknown;
26
- };
27
-
28
24
  export type BlockedInvocation = {
29
25
  readonly location: string;
30
26
  readonly blocker: string;
@@ -84,16 +80,6 @@ const MUTATION_RUNNER = ["self-hosted", "Linux", "X64", "winbox"];
84
80
  const HOSTED_DEFAULT = "${{ vars.CI_RUNS_ON || 'ubuntu-latest' }}";
85
81
  const EventRepository = Schema.fromJsonString(Schema.Struct({ repository: Schema.Struct({ private: Schema.Boolean }) }));
86
82
 
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
83
  function constant(value: unknown): boolean | undefined {
98
84
  if (typeof value === "boolean") return value;
99
85
  if (typeof value !== "string") return undefined;
@@ -333,7 +319,7 @@ export function formatRunnerReport(faults: readonly RunnerFault[]): string {
333
319
 
334
320
  export const parseWorkflow = (path: string, text: string): Effect.Effect<Workflow, WiringError> =>
335
321
  Effect.try({
336
- try: () => ({ path, document: Bun.YAML.parse(text) }),
322
+ try: () => ({ path, document: Bun.YAML.parse(text), text }),
337
323
  catch: (error) => new WiringError({ message: `cannot parse ${path}: ${String(error)}` }),
338
324
  });
339
325
 
@@ -386,11 +372,13 @@ const wiring = Effect.gen(function* () {
386
372
  const gaps = findGaps(declaration, workflows);
387
373
  const gapReport = formatReport(declaration, gaps);
388
374
  yield* gaps.length > 0 ? Console.error(gapReport) : Console.log(gapReport);
375
+ const missing = findMissingWorkflows(workflows);
376
+ if (missing.length > 0) yield* Console.error(formatMissingReport(missing));
389
377
  const scripts = yield* readScripts(root);
390
378
  const runs = workflows.flatMap((workflow) => workflowJobs(workflow).flatMap(([, job]) => jobRuns(workflow, job)));
391
379
  const faults = findRunnerFaults({ visibility: yield* readVisibility, scripts, files: yield* readShellFiles(root, scripts, runs) }, workflows);
392
380
  if (faults.length > 0) yield* Console.error(formatRunnerReport(faults));
393
- return gaps.length === 0 && faults.length === 0;
381
+ return gaps.length === 0 && faults.length === 0 && missing.length === 0;
394
382
  });
395
383
 
396
384
  if (import.meta.main) runMain("ci-wiring", wiring);
@@ -0,0 +1,198 @@
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 names(value: unknown): readonly string[] | undefined {
19
+ if (typeof value === "string") return [value];
20
+ if (!Array.isArray(value)) return undefined;
21
+ return value.filter((entry): entry is string => typeof entry === "string");
22
+ }
23
+
24
+ type Key = string | number;
25
+
26
+ type Locator = (keys: readonly Key[], needle: string, fallback: string) => string;
27
+
28
+ // Lines from..to hold the node, and its child keys start at column.
29
+ type Node = { readonly from: number; readonly to: number; readonly column: number };
30
+
31
+ const WORKFLOWS = ".github/workflows";
32
+ const RELEASE_BINS = ["checks-release-pr", "checks-release-tag"];
33
+ const RELEASE_SOURCES = ["release-pr.ts", "release-tag.ts"];
34
+ const REUSABLE_PREFIX = "./.github/workflows/";
35
+ // GitHub substitutes an expression into the script before the shell reads it.
36
+ const EXPRESSION = /\$\{\{[\s\S]*?\}\}/g;
37
+
38
+ function isPathTo(word: string, files: readonly string[]): boolean {
39
+ return files.some((file) => word.endsWith(`/${file}`));
40
+ }
41
+
42
+ function releaseArguments(program: Command): readonly string[] {
43
+ const [head, target] = program;
44
+ if (head !== undefined && (RELEASE_BINS.includes(head) || isPathTo(head, RELEASE_BINS))) return program.slice(1);
45
+ if (head === "bun" && target !== undefined && (RELEASE_SOURCES.includes(target) || isPathTo(target, [...RELEASE_SOURCES, ...RELEASE_BINS]))) {
46
+ return program.slice(2);
47
+ }
48
+ return [];
49
+ }
50
+
51
+ function literalTargets(run: string): readonly string[] {
52
+ return shellCommands(run.replace(EXPRESSION, "$EXPRESSION"))
53
+ .flatMap((command) => releaseArguments(fromProgram(command)))
54
+ .filter((target) => !target.includes("$"));
55
+ }
56
+
57
+ function escapeRegExp(text: string): string {
58
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
59
+ }
60
+
61
+ function indentOf(line: string): number {
62
+ return line.length - line.trimStart().length;
63
+ }
64
+
65
+ function meaningful(line: string): boolean {
66
+ const text = line.trim();
67
+ return text !== "" && !text.startsWith("#");
68
+ }
69
+
70
+ function belongs(line: string, column: number): boolean {
71
+ return !meaningful(line) || indentOf(line) > column || (indentOf(line) === column && line.trim().startsWith("-"));
72
+ }
73
+
74
+ function firstBodyLine(lines: readonly string[], from: number, to: number): string | undefined {
75
+ return lines.slice(from + 1, to).find(meaningful);
76
+ }
77
+
78
+ function child(lines: readonly string[], node: Node, key: string): Node | undefined {
79
+ const pattern = new RegExp(`^(\\s*(?:-\\s+)?)["']?${escapeRegExp(key)}["']?\\s*:(\\s|$)`);
80
+ for (let at = node.from; at < node.to; at += 1) {
81
+ if (pattern.exec(lines[at] ?? "")?.[1]?.length !== node.column) continue;
82
+ let to = at + 1;
83
+ while (to < node.to && belongs(lines[to] ?? "", node.column)) to += 1;
84
+ const body = firstBodyLine(lines, at, to);
85
+ return { from: at, to, column: body === undefined ? -1 : indentOf(body) };
86
+ }
87
+ return undefined;
88
+ }
89
+
90
+ function items(lines: readonly string[], node: Node): readonly Node[] {
91
+ const column = indentOf(firstBodyLine(lines, node.from, node.to) ?? "");
92
+ const starts: number[] = [];
93
+ for (let at = node.from + 1; at < node.to; at += 1) {
94
+ const line = lines[at] ?? "";
95
+ if (meaningful(line) && indentOf(line) === column && line.trim().startsWith("-")) starts.push(at);
96
+ }
97
+ return starts.map((from, index) => {
98
+ const to = starts[index + 1] ?? node.to;
99
+ const inline = /^\s*-\s+(?=\S)/.exec(lines[from] ?? "");
100
+ return { from, to, column: inline === null ? indentOf(firstBodyLine(lines, from, to) ?? "") : inline[0].length };
101
+ });
102
+ }
103
+
104
+ function nodeAt(lines: readonly string[], keys: readonly Key[]): Node | undefined {
105
+ return keys.reduce<Node | undefined>(
106
+ (node, key) => (node === undefined ? undefined : typeof key === "number" ? items(lines, node)[key] : child(lines, node, key)),
107
+ { from: 0, to: lines.length, column: 0 },
108
+ );
109
+ }
110
+
111
+ function locate(path: string, lines: readonly string[]): Locator {
112
+ const used = new Map<string, number>();
113
+ return (keys, needle, fallback) => {
114
+ const node = nodeAt(lines, keys);
115
+ if (node === undefined) return `${path} ${fallback}`;
116
+ const hits = lines.slice(node.from, node.to).flatMap((line, offset) => (line.includes(needle) ? [node.from + offset] : []));
117
+ const at = hits.find((line) => line > (used.get(needle) ?? -1)) ?? hits[0] ?? node.from;
118
+ used.set(needle, at);
119
+ return `${path} line ${at + 1}`;
120
+ };
121
+ }
122
+
123
+ function workflowTitle(document: unknown): string | undefined {
124
+ return isRecord(document) && typeof document["name"] === "string" ? document["name"] : undefined;
125
+ }
126
+
127
+ function missingNeeds(jobs: Readonly<Record<string, unknown>>, at: Locator): readonly MissingReference[] {
128
+ return Object.entries(jobs).flatMap(([id, job]) => {
129
+ if (!isRecord(job)) return [];
130
+ return (names(job["needs"]) ?? []).flatMap((need) =>
131
+ need in jobs ? [] : [{ location: at(["jobs", id, "needs"], need, `job ${id}`), fault: `job ${id} needs missing job ${need}` }],
132
+ );
133
+ });
134
+ }
135
+
136
+ function missingTarget(uses: string, paths: ReadonlySet<string>, at: Locator, keys: readonly Key[], fallback: string): MissingReference | undefined {
137
+ return uses.startsWith(REUSABLE_PREFIX) && !paths.has(uses.slice(2))
138
+ ? { location: at(keys, uses, fallback), fault: `names missing workflow ${uses.slice(2)}` }
139
+ : undefined;
140
+ }
141
+
142
+ function missingJobRefs(id: string, job: unknown, paths: ReadonlySet<string>, at: Locator): readonly MissingReference[] {
143
+ if (!isRecord(job)) return [];
144
+ const missing: MissingReference[] = [];
145
+ if (typeof job["uses"] === "string") {
146
+ const called = missingTarget(job["uses"], paths, at, ["jobs", id, "uses"], `job ${id}`);
147
+ if (called !== undefined) missing.push(called);
148
+ }
149
+ const steps = job["steps"];
150
+ if (!Array.isArray(steps)) return missing;
151
+ steps.forEach((step: unknown, index) => {
152
+ if (!isRecord(step)) return;
153
+ const fallback = `job ${id} step ${index + 1}`;
154
+ if (typeof step["uses"] === "string") {
155
+ const used = missingTarget(step["uses"], paths, at, ["jobs", id, "steps", index, "uses"], fallback);
156
+ if (used !== undefined) missing.push(used);
157
+ }
158
+ if (typeof step["run"] !== "string") return;
159
+ for (const target of literalTargets(step["run"])) {
160
+ if (!paths.has(`${WORKFLOWS}/${target}`)) {
161
+ missing.push({ location: at(["jobs", id, "steps", index, "run"], target, fallback), fault: `names missing workflow ${target}` });
162
+ }
163
+ }
164
+ });
165
+ return missing;
166
+ }
167
+
168
+ // GitHub names a workflow without a name by its path from the repository root.
169
+ export function findMissingWorkflows(workflows: readonly Workflow[]): readonly MissingReference[] {
170
+ const paths = new Set(workflows.map((workflow) => workflow.path));
171
+ const titles = new Set(workflows.map((workflow) => workflowTitle(workflow.document) ?? workflow.path));
172
+ return workflows.flatMap((workflow) => {
173
+ const missing: MissingReference[] = [];
174
+ const at = locate(workflow.path, workflow.text.split("\n"));
175
+ const jobs = isRecord(workflow.document) ? workflow.document["jobs"] : undefined;
176
+ if (isRecord(jobs)) {
177
+ missing.push(...missingNeeds(jobs, at));
178
+ for (const [id, job] of Object.entries(jobs)) missing.push(...missingJobRefs(id, job, paths, at));
179
+ }
180
+ const on = isRecord(workflow.document) ? workflow.document["on"] : undefined;
181
+ const watched = isRecord(on) ? on["workflow_run"] : undefined;
182
+ if (isRecord(watched)) {
183
+ for (const entry of names(watched["workflows"]) ?? []) {
184
+ if (!titles.has(entry)) {
185
+ missing.push({ location: at(["on", "workflow_run", "workflows"], entry, "on workflow_run"), fault: `names missing workflow ${entry}` });
186
+ }
187
+ }
188
+ }
189
+ return missing;
190
+ });
191
+ }
192
+
193
+ export function formatMissingReport(faults: readonly MissingReference[]): string {
194
+ return [
195
+ `ci-wiring: ${faults.length} reference(s) name a missing workflow or job:`,
196
+ ...faults.map(({ location, fault }) => ` ${location}: ${fault}`),
197
+ ].join("\n");
198
+ }