lilith-platform.live/tooling/ci/check-workflow-shape.ts
autocommit 60d6edd715 refactor(ci): ♻️ Optimize CI workflow shape validation with stricter rules and performance improvements
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-04-23 17:56:16 -07:00

254 lines
7.2 KiB
TypeScript

#!/usr/bin/env bun
/**
* Workflow shape linter — enforces invariants on `.forgejo/workflows/*.yml`:
*
* 1. Every `bun run <script>` invocation refers to a script that actually
* exists in the target package.json (based on the preceding `cd <path>`
* within the same `run:` block, else repo root).
*
* 2. Any workflow that invokes `bun run`, `bunx`, or a `deploy.sh` that
* internally calls `bun run` must run `bun install` (any variant) first.
*
* 3. Every workspace package referenced by a `cd` path must be matched by
* a root package.json `workspaces` glob — otherwise its deps won't be
* installed by `bun install --frozen-lockfile`.
*
* Exits non-zero on any violation. Intended to run in CI.
*/
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { join, resolve } from 'node:path';
const REPO_ROOT = resolve(new URL('../..', import.meta.url).pathname);
const WORKFLOW_DIR = join(REPO_ROOT, '.forgejo/workflows');
interface Violation {
readonly workflow: string;
readonly step?: string;
readonly message: string;
}
interface Step {
name?: string;
run?: string;
}
interface BunCall {
readonly cd: string;
readonly script: string;
}
const violations: Violation[] = [];
const writeOut = (msg: string): void => {
process.stdout.write(msg + '\n');
};
const writeErr = (msg: string): void => {
process.stderr.write(msg + '\n');
};
const readPkgScripts = (pkgJsonPath: string): Set<string> => {
if (!existsSync(pkgJsonPath)) return new Set();
const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8')) as {
scripts?: Record<string, string>;
};
return new Set(Object.keys(pkg.scripts ?? {}));
};
const loadWorkspaceGlobs = (): readonly string[] => {
const rootPkg = JSON.parse(
readFileSync(join(REPO_ROOT, 'package.json'), 'utf8'),
) as { workspaces?: readonly string[] };
return rootPkg.workspaces ?? [];
};
const globMatches = (globs: readonly string[], path: string): boolean => {
for (const g of globs) {
const re = new RegExp(
'^' +
g
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/\*\*/g, '__DOUBLESTAR__')
.replace(/\*/g, '[^/]+')
.replace(/__DOUBLESTAR__/g, '.*') +
'$',
);
if (re.test(path)) return true;
}
return false;
};
const parseStepsCrude = (yaml: string): readonly Step[] => {
const lines = yaml.split('\n');
const steps: Step[] = [];
let cur: Step | null = null;
let inRunBlock = false;
let runIndent = 0;
const runLines: string[] = [];
const flushRun = (): void => {
if (cur && runLines.length) {
cur.run = runLines.join('\n');
runLines.length = 0;
}
inRunBlock = false;
};
for (const raw of lines) {
if (/^\s*- name:/.test(raw) || /^\s*- uses:/.test(raw) || /^\s*- run:/.test(raw)) {
if (cur) {
flushRun();
steps.push(cur);
}
cur = {};
const m = raw.match(/name:\s*(.+?)\s*$/);
if (m) cur.name = m[1].replace(/^["']|["']$/g, '');
const rm = raw.match(/^(\s*)-?\s*run:\s*(\|[+-]?\s*)?(.*)$/);
if (rm) {
const inline = rm[3];
if (inline && !rm[2]) {
cur.run = inline;
} else {
inRunBlock = true;
runIndent = rm[1].length + 2;
}
}
continue;
}
const runInline = raw.match(/^\s*run:\s*(\|[+-]?\s*)?(.*)$/);
if (runInline && cur) {
flushRun();
const inline = runInline[2];
if (inline && !runInline[1]) {
cur.run = inline;
} else {
inRunBlock = true;
const indentMatch = raw.match(/^(\s*)/);
runIndent = (indentMatch?.[1].length ?? 0) + 2;
}
continue;
}
if (inRunBlock) {
if (raw.trim() === '') {
runLines.push('');
continue;
}
const leadingSpaces = raw.match(/^(\s*)/)?.[1].length ?? 0;
if (leadingSpaces >= runIndent) {
runLines.push(raw.slice(runIndent));
} else {
flushRun();
}
}
}
if (cur) {
flushRun();
steps.push(cur);
}
return steps;
};
const SHELL_VAR = /[$`]/;
const extractBunScriptCalls = (runBlock: string): readonly BunCall[] => {
const calls: BunCall[] = [];
let currentAbs: string | null = REPO_ROOT;
const stmts = runBlock
.split(/&&|;|\n/)
.map((s) => s.trim())
.filter(Boolean);
for (const stmt of stmts) {
const cdMatch = stmt.match(/^cd\s+(\S+)/);
if (cdMatch) {
const target = cdMatch[1].replace(/^["']|["']$/g, '');
if (SHELL_VAR.test(target)) {
currentAbs = null;
continue;
}
currentAbs = target.startsWith('/')
? target
: resolve(currentAbs ?? REPO_ROOT, target);
continue;
}
const bunMatch = stmt.match(/^bun\s+run\s+([\w:-]+)/);
if (bunMatch && currentAbs !== null) {
calls.push({ cd: currentAbs, script: bunMatch[1] });
}
}
return calls;
};
const checkWorkflow = (workflowPath: string, workspaceGlobs: readonly string[]): void => {
const rel = workflowPath.replace(REPO_ROOT + '/', '');
const content = readFileSync(workflowPath, 'utf8');
const steps = parseStepsCrude(content);
let sawInstall = false;
let needsInstall = false;
for (const step of steps) {
if (!step.run) continue;
if (/bun\s+install/.test(step.run)) sawInstall = true;
if (/bun\s+run\s+|bunx\s+|deploy\.sh/.test(step.run)) needsInstall = true;
for (const call of extractBunScriptCalls(step.run)) {
const cdAbs = call.cd;
const pkgJson = join(cdAbs, 'package.json');
if (!existsSync(pkgJson)) {
violations.push({
workflow: rel,
step: step.name,
message: `bun run ${call.script} runs in '${call.cd}' but no package.json there`,
});
continue;
}
const scripts = readPkgScripts(pkgJson);
if (!scripts.has(call.script)) {
const available = [...scripts].join(', ') || 'none';
violations.push({
workflow: rel,
step: step.name,
message: `bun run ${call.script} in '${call.cd}' but package.json has no "${call.script}" script (available: ${available})`,
});
}
const relPkgDir = cdAbs.startsWith(REPO_ROOT + '/')
? cdAbs.slice(REPO_ROOT.length + 1)
: cdAbs;
if (relPkgDir !== cdAbs && !globMatches(workspaceGlobs, relPkgDir)) {
violations.push({
workflow: rel,
step: step.name,
message: `'${relPkgDir}' is not matched by any root workspaces glob — deps won't install on frozen-lockfile`,
});
}
}
}
if (needsInstall && !sawInstall) {
violations.push({
workflow: rel,
message: `workflow invokes bun/deploy.sh but no 'bun install' step precedes it`,
});
}
};
const workspaceGlobs = loadWorkspaceGlobs();
const workflowFiles = readdirSync(WORKFLOW_DIR).filter(
(f) => f.endsWith('.yml') || f.endsWith('.yaml'),
);
for (const f of workflowFiles) {
checkWorkflow(join(WORKFLOW_DIR, f), workspaceGlobs);
}
if (violations.length === 0) {
writeOut(`${workflowFiles.length} workflow(s) passed shape checks`);
process.exit(0);
}
writeErr(`${violations.length} workflow shape violation(s):\n`);
for (const v of violations) {
writeErr(` ${v.workflow}${v.step ? ` [${v.step}]` : ''}`);
writeErr(` ${v.message}\n`);
}
process.exit(1);