254 lines
7.2 KiB
TypeScript
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);
|