#!/usr/bin/env node // Classifies changed repository paths into release areas so CI and release QA // run only the checks a change can affect. No dependencies. The rule table below // is the single source of truth; the first matching rule wins. A path no rule // matches maps to `root`, which runs everything. import { execFileSync } from "node:child_process"; import { appendFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; export const AREAS = ["root", "web", "docs", "installers", "wizard-ui", "sidecar", "siwc-auth", "vps", "local-docker", "local-model", "supergrok", "assistant", "hosting", "packaging", "ci"]; export const RULES = [ // Repository tooling, release process and tests. [/^AGENTS\.md$/u, ["ci"]], [/^\.agents\/skills\/(ship|release-relmio-everywhere|release-qa|changelog)\//u, ["ci", "root"]], [/^\.agents\/skills\/(installer-reliability|maintain-relmio-installers)\//u, ["ci", "installers"]], [/^\.agents\//u, ["ci"]], [/^(\.github|scripts)\//u, ["ci", "root"]], [/^(package\.json|package-lock\.json|\.npmrc|\.gitattributes)$/u, ["ci", "root"]], [/^(\.gitignore|\.coderabbit\.yaml)$/u, ["ci"]], [/^test\/integration\/n8n-nodes\//u, ["ci", "sidecar"]], [/^(test|dev|qa)\//u, ["ci"]], // Documentation. LICENSE and NOTICE ship in the npm package. [/^(LICENSE|NOTICE)$/u, ["docs", "packaging"]], [/^[^/]+\.md$/u, ["docs"]], [/^npm\/README\.md$/u, ["docs"]], [/^(docs|design-system|tasks|\.impeccable|\.hallmark)\//u, ["docs"]], // Hosted website and the installers it serves. [/^web\/public\/install\./u, ["installers"]], [/^web\//u, ["web"]], [/^Start Wizard\.(bat|command)$/u, ["installers"]], [/^packaging\//u, ["packaging"]], // Local browser wizard. The UI kit, fonts and icons are copied into web/. [/^src\/ui\/(relmio-ui\.css|fonts\/|relmio-icon)/u, ["wizard-ui", "web"]], [/^src\/ui\/hosting/u, ["wizard-ui", "hosting"]], [/^src\/ui\/local-model-vps/u, ["wizard-ui", "local-model", "vps"]], [/^src\/ui\/supergrok-vps/u, ["wizard-ui", "supergrok", "vps"]], [/^src\/ui\/assistant/u, ["wizard-ui", "assistant"]], [/^src\/ui\/(siwc|oauth-popup)/u, ["wizard-ui", "siwc-auth"]], [/^src\/ui\//u, ["wizard-ui"]], [/^src\/services\/(ui-preferences|project-meta)\.js$/u, ["wizard-ui"]], // Entry points and shared validation reach every feature. [/^src\/(cli\.js|web\/server\.js)$/u, ["root"]], [/^src\/domain\/(safety|validation|provider-lifecycle)\.js$/u, ["root"]], [/^src\/legacy-vps-cli\.js$/u, ["vps"]], [/^src\/(browser\.js|services\/browser-handoff\.js)$/u, ["installers"]], [/^src\/infrastructure\/(local-process|process-identity)\.js$/u, ["local-docker", "installers"]], // Experimental Claude adapter and its wizard services. Reached from the main CLI and server, so they run every check. [/^src\/(claude\/|gateway\/claude-chat\.js$|templates\/claude-companion\/|domain\/local-n8n-claude\.js$|services\/(local-n8n-claude-installer|claude-login|vps-claude)\.js$)/u, ["root"]], // ChatGPT sidecar, its image files and SIWC sign-in. [/^src\/gateway\//u, ["sidecar"]], [/^src\/services\/(model-discovery|codex-images)\.mjs$/u, ["sidecar"]], [/^src\/(domain\/templates|domain\/vps-build-state|services\/installer)\.js$/u, ["vps", "sidecar"]], [/^src\/(domain\/local-n8n-sidecar|services\/local-n8n-sidecar-installer)\.js$/u, ["sidecar", "local-docker"]], [/^src\/domain\/codex-images\.js$/u, ["vps", "sidecar", "local-docker"]], [/^src\/services\/(siwc-|oauth\.js|codex-login\.js|local-chat-test\.js)/u, ["siwc-auth"]], [/^src\/domain\/local-endpoints\.js$/u, ["siwc-auth"]], [/^src\/services\/local-installer\.js$/u, ["local-docker", "siwc-auth", "supergrok"]], // Local model. [/^src\/(domain\/local-n8n-model|domain\/local-model-network|services\/local-n8n-model-installer)\.js$/u, ["local-model", "local-docker"]], [/^src\/services\/vps-local-model\.js$/u, ["local-model", "vps"]], [/^src\/local-model\//u, ["local-model"]], // SuperGrok. [/^src\/(domain\/local-n8n-supergrok|services\/local-n8n-supergrok-installer)\.js$/u, ["supergrok", "local-docker"]], [/^src\/services\/vps-supergrok\.js$/u, ["supergrok", "vps"]], [/^src\/(supergrok\/|services\/grok-login\.js)/u, ["supergrok"]], // n8n AI Assistant. [/^src\/(domain\/local-n8n-assistant|services\/local-n8n-assistant-installer)\.js$/u, ["assistant", "local-docker"]], [/^src\/(domain\/assistant|domain\/assistant-templates|services\/assistant-installer)\.js$/u, ["assistant", "vps"]], // Local Docker stack and dashboard. [/^src\/(domain\/local-n8n-stack|services\/local-n8n-stack-installer|services\/local-n8n-stack-credentials)\.js$/u, ["local-docker"]], [/^src\/templates\/local-n8n-stack\//u, ["local-docker"]], [/^src\/services\/(local-dashboard|local-dashboard-control|local-integration-lifecycle-lock)\.js$/u, ["local-docker"]], // Hosting profiles and VPS access. [/^src\/domain\/hosting-/u, ["hosting"]], [/^src\/(infrastructure\/ssh|services\/vps-operation-lock|services\/discovery|domain\/vps-host-report|domain\/vps-network)/u, ["vps"]], ]; export function areasForPath(path) { return RULES.find(([pattern]) => pattern.test(path))?.[1]; } export function classifyPaths(paths) { const areas = new Set(); const unknown = []; for (const path of paths) { const matched = areasForPath(path); if (matched) { matched.forEach((area) => areas.add(area)); } else { areas.add("root"); unknown.push(path); } } // An empty change list usually means a wrong base; run everything. if (paths.length === 0) areas.add("root"); const sorted = [...areas].sort(); return { areas: sorted, docsOnly: sorted.every((area) => area === "docs"), webOnly: sorted.every((area) => area === "web" || area === "docs"), unknown, }; } export function runFlags({ areas, docsOnly, webOnly }) { const has = (...names) => names.some((name) => areas.includes(name)); const runRoot = !(docsOnly || webOnly); return { run_root: runRoot, run_web: has("root", "web", "docs", "installers"), run_windows: runRoot, run_macos: runRoot, run_linux: runRoot, run_n8n_nodes: has("root", "sidecar", "siwc-auth", "assistant"), }; } export function formatGithubOutput(result) { const lines = [ `areas=${result.areas.join(",")}`, `docs_only=${result.docsOnly}`, `web_only=${result.webOnly}`, ...Object.entries(runFlags(result)).map(([name, value]) => `${name}=${value}`), ]; return `${lines.join("\n")}\n`; } export function formatSummary(result, { base, head }) { const rows = Object.entries(runFlags(result)).map(([name, value]) => `| \`${name}\` | ${value} |`); const unknown = result.unknown.length ? `\nUnmapped paths (classified as \`root\`, so everything runs):\n\n${result.unknown.map((path) => `- \`${path}\``).join("\n")}\n` : ""; return [ "## Release impact", "", `Compared \`${base}...${head}\`. Areas: ${result.areas.map((area) => `\`${area}\``).join(", ")}.`, "", "| Flag | Value |", "| --- | --- |", `| \`docs_only\` | ${result.docsOnly} |`, `| \`web_only\` | ${result.webOnly} |`, ...rows, unknown, ].join("\n"); } export function changedPaths(base, head, { cwd = process.cwd() } = {}) { const output = execFileSync("git", ["diff", "--name-only", "--no-renames", "-z", `${base}...${head}`], { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024, }); return output.split("\0").filter(Boolean); } function parseArgs(argv) { const options = { head: "HEAD", format: "json" }; for (let index = 0; index < argv.length; index += 2) { const [flag, value] = [argv[index], argv[index + 1]]; if (!["--base", "--head", "--format"].includes(flag) || value === undefined) { throw new Error(`Unknown or incomplete option: ${flag}`); } options[flag.slice(2)] = value; } if (!options.base) throw new Error("--base is required"); if (!["json", "github"].includes(options.format)) throw new Error("--format must be json or github"); return options; } export function main(argv = process.argv.slice(2), env = process.env) { const { base, head, format } = parseArgs(argv); const paths = changedPaths(base, head); const result = classifyPaths(paths); if (format === "json") { process.stdout.write(`${JSON.stringify({ ...result, ...runFlags(result), paths }, null, 2)}\n`); return; } const output = formatGithubOutput(result); if (env.GITHUB_OUTPUT) appendFileSync(env.GITHUB_OUTPUT, output); else process.stdout.write(output); if (env.GITHUB_STEP_SUMMARY) appendFileSync(env.GITHUB_STEP_SUMMARY, `${formatSummary(result, { base, head })}\n`); } if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { try { main(); } catch (error) { process.stderr.write(`release-impact: ${error.message}\n`); process.exitCode = 1; } }