205 lines
8.2 KiB
JavaScript
205 lines
8.2 KiB
JavaScript
#!/usr/bin/env node
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const WORKFLOW_ROOTS = [".gitea/workflows"];
|
|
const SETUP_NODE_REFERENCE = "actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020";
|
|
const GITEA_PERSIST_TOOLCHAIN_COMMAND = "run: bash scripts/ci/persist-node-toolchain.sh";
|
|
const GITEA_COREPACK_ENABLE_COMMAND = "corepack enable --install-directory /usr/local/bin";
|
|
const PERSIST_TOOLCHAIN_SCRIPT = "scripts/ci/persist-node-toolchain.sh";
|
|
const MINIMUM_NODE_VERSION = [22, 13, 0];
|
|
const RUNTIME_DOCKERFILES = ["Dockerfile", "apps/hocuspocus/Dockerfile"];
|
|
|
|
function listWorkflowFiles(rootDir, relativeDirectory) {
|
|
const absoluteDirectory = path.join(rootDir, relativeDirectory);
|
|
if (!fs.existsSync(absoluteDirectory)) return [];
|
|
|
|
return fs.readdirSync(absoluteDirectory, { withFileTypes: true }).flatMap((entry) => {
|
|
const relativePath = path.join(relativeDirectory, entry.name);
|
|
if (entry.isDirectory()) return listWorkflowFiles(rootDir, relativePath);
|
|
return /\.ya?ml$/i.test(entry.name) ? [relativePath] : [];
|
|
});
|
|
}
|
|
|
|
function jobSections(source) {
|
|
const lines = source.split(/\r?\n/);
|
|
const jobsIndex = lines.findIndex((line) => /^jobs:\s*$/.test(line));
|
|
if (jobsIndex === -1) return [];
|
|
|
|
const starts = [];
|
|
for (let index = jobsIndex + 1; index < lines.length; index += 1) {
|
|
const match = lines[index].match(/^ ([A-Za-z0-9_-]+):\s*(?:#.*)?$/);
|
|
if (match) starts.push({ index, name: match[1] });
|
|
}
|
|
|
|
return starts.map((start, index) => ({
|
|
name: start.name,
|
|
lines: lines.slice(start.index, starts[index + 1]?.index ?? lines.length),
|
|
}));
|
|
}
|
|
|
|
function isPnpmCommandLine(line) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.startsWith("#") || /^-\s+name:/.test(trimmed)) return false;
|
|
return /\bpnpm(?:@|\s|$)/.test(trimmed);
|
|
}
|
|
|
|
export function inspectWorkflowNodeRuntime(source, relativePath) {
|
|
const passes = [];
|
|
const failures = [];
|
|
const isGiteaWorkflow = relativePath.startsWith(".gitea/workflows/");
|
|
|
|
for (const section of jobSections(source)) {
|
|
const firstPnpmIndex = section.lines.findIndex(isPnpmCommandLine);
|
|
const setupIndex = section.lines.findIndex((line) => line.includes(`uses: ${SETUP_NODE_REFERENCE}`));
|
|
|
|
if (isGiteaWorkflow && setupIndex !== -1) {
|
|
const persistIndex = section.lines.findIndex((line) => line.trim() === GITEA_PERSIST_TOOLCHAIN_COMMAND);
|
|
if (persistIndex <= setupIndex) {
|
|
failures.push(`${relativePath} job "${section.name}": setup-node must persist its runtime with scripts/ci/persist-node-toolchain.sh`);
|
|
} else {
|
|
passes.push(`${relativePath} job "${section.name}": setup-node runtime persists across Gitea act steps`);
|
|
}
|
|
}
|
|
|
|
if (firstPnpmIndex === -1) continue;
|
|
|
|
if (setupIndex === -1 || setupIndex >= firstPnpmIndex) {
|
|
failures.push(`${relativePath} job "${section.name}": ${SETUP_NODE_REFERENCE} must run before the first pnpm command`);
|
|
continue;
|
|
}
|
|
|
|
const versionFileIndex = section.lines.findIndex(
|
|
(line, index) => index > setupIndex && index < firstPnpmIndex && /^\s+node-version-file:\s*["']?\.nvmrc["']?\s*$/.test(line)
|
|
);
|
|
if (versionFileIndex === -1) {
|
|
failures.push(`${relativePath} job "${section.name}": setup-node must use node-version-file: .nvmrc`);
|
|
continue;
|
|
}
|
|
|
|
if (isGiteaWorkflow) {
|
|
const persistIndex = section.lines.findIndex((line) => line.trim() === GITEA_PERSIST_TOOLCHAIN_COMMAND);
|
|
if (persistIndex <= versionFileIndex || persistIndex >= firstPnpmIndex) {
|
|
failures.push(`${relativePath} job "${section.name}": the persistent Node.js toolchain must be installed before pnpm`);
|
|
continue;
|
|
}
|
|
|
|
const corepackIndex = section.lines.findIndex((line) => {
|
|
const trimmed = line.trim();
|
|
return trimmed === GITEA_COREPACK_ENABLE_COMMAND || trimmed === `run: ${GITEA_COREPACK_ENABLE_COMMAND}`;
|
|
});
|
|
if (corepackIndex <= persistIndex || corepackIndex >= firstPnpmIndex) {
|
|
failures.push(`${relativePath} job "${section.name}": Corepack must install pnpm into persistent /usr/local/bin before use`);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
passes.push(`${relativePath} job "${section.name}": pnpm runs on the pinned .nvmrc runtime`);
|
|
}
|
|
|
|
return { passes, failures };
|
|
}
|
|
|
|
function parseNodeVersion(source) {
|
|
const match = source.trim().match(/^v?(\d+)\.(\d+)\.(\d+)$/);
|
|
return match ? match.slice(1).map(Number) : null;
|
|
}
|
|
|
|
function isAtLeast(actual, minimum) {
|
|
for (let index = 0; index < minimum.length; index += 1) {
|
|
if (actual[index] > minimum[index]) return true;
|
|
if (actual[index] < minimum[index]) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export function verifyWorkflowNodeRuntime(rootDir = process.cwd()) {
|
|
const result = { passes: [], failures: [] };
|
|
const nvmrcPath = path.join(rootDir, ".nvmrc");
|
|
|
|
if (!fs.existsSync(nvmrcPath)) {
|
|
result.failures.push(".nvmrc is required to pin the CI Node.js runtime");
|
|
return result;
|
|
}
|
|
|
|
const nvmrcSource = fs.readFileSync(nvmrcPath, "utf8");
|
|
const nodeVersion = parseNodeVersion(nvmrcSource);
|
|
if (!nodeVersion) {
|
|
result.failures.push(".nvmrc must contain one exact semantic Node.js version");
|
|
} else if (!isAtLeast(nodeVersion, MINIMUM_NODE_VERSION)) {
|
|
result.failures.push(`.nvmrc Node.js ${nodeVersion.join(".")} does not satisfy pnpm's minimum 22.13.0 runtime`);
|
|
} else {
|
|
result.passes.push(`.nvmrc pins supported Node.js ${nodeVersion.join(".")}`);
|
|
}
|
|
|
|
if (nodeVersion) {
|
|
const expectedImagePrefix = `node:${nodeVersion.join(".")}-`;
|
|
for (const relativePath of RUNTIME_DOCKERFILES) {
|
|
const absolutePath = path.join(rootDir, relativePath);
|
|
if (!fs.existsSync(absolutePath)) {
|
|
result.failures.push(`${relativePath}: missing runtime Dockerfile`);
|
|
continue;
|
|
}
|
|
const source = fs.readFileSync(absolutePath, "utf8");
|
|
if (source.includes(expectedImagePrefix)) {
|
|
result.passes.push(`${relativePath}: runtime image matches .nvmrc`);
|
|
} else {
|
|
result.failures.push(`${relativePath}: runtime image must match .nvmrc (${expectedImagePrefix}*)`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const persistScriptPath = path.join(rootDir, PERSIST_TOOLCHAIN_SCRIPT);
|
|
if (!fs.existsSync(persistScriptPath)) {
|
|
result.failures.push(`${PERSIST_TOOLCHAIN_SCRIPT}: missing Gitea setup-node persistence helper`);
|
|
} else {
|
|
const persistScript = fs.readFileSync(persistScriptPath, "utf8");
|
|
const requiredFragments = [
|
|
".nvmrc",
|
|
"RUNNER_TOOL_CACHE",
|
|
'ln -sfn "$node_binary" "$install_directory/node"',
|
|
'ln -sfn "$corepack_binary" "$install_directory/corepack"',
|
|
'actual_version="$("$install_directory/node" --version)"',
|
|
];
|
|
const missingFragments = requiredFragments.filter((fragment) => !persistScript.includes(fragment));
|
|
if (missingFragments.length > 0) {
|
|
result.failures.push(`${PERSIST_TOOLCHAIN_SCRIPT}: incomplete persistent Node.js/Corepack contract`);
|
|
} else {
|
|
result.passes.push(`${PERSIST_TOOLCHAIN_SCRIPT}: persists and verifies setup-node's pinned runtime`);
|
|
}
|
|
}
|
|
|
|
const workflowFiles = WORKFLOW_ROOTS.flatMap((relativeDirectory) => listWorkflowFiles(rootDir, relativeDirectory));
|
|
if (workflowFiles.length === 0) {
|
|
result.failures.push("No GitHub or Gitea workflow files were found");
|
|
return result;
|
|
}
|
|
|
|
for (const relativePath of workflowFiles) {
|
|
const source = fs.readFileSync(path.join(rootDir, relativePath), "utf8");
|
|
const inspected = inspectWorkflowNodeRuntime(source, relativePath);
|
|
result.passes.push(...inspected.passes);
|
|
result.failures.push(...inspected.failures);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
export function formatWorkflowNodeRuntimeResult(result) {
|
|
const status = result.failures.length === 0 ? "PASS" : "FAIL";
|
|
return [
|
|
`${status} workflow Node.js runtime verification`,
|
|
...result.failures.map((failure) => `FAIL ${failure}`),
|
|
`Summary: ${result.passes.length} runtime checks passed, ${result.failures.length} failures`,
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
|
|
if (isCli) {
|
|
const result = verifyWorkflowNodeRuntime();
|
|
process.stdout.write(formatWorkflowNodeRuntimeResult(result));
|
|
if (result.failures.length > 0) process.exitCode = 1;
|
|
}
|