Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/scripts/verify-workflow-node-runtime.mjs
T

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;
}