#!/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; }