import { execFileSync } from "node:child_process"; import { appendFileSync, readFileSync } from "node:fs"; import { uiP0CiMatrix, visualCiMatrix } from "../e2e/lib/playwright/suites.ts"; type CiMode = "hot" | "full"; type ScopeOutputs = { daemon_tests_required: boolean; web_tests_required: boolean; tools_dev_tests_required: boolean; tools_pack_tests_required: boolean; nix_validation_required: boolean; ui_p0_validation_required: boolean; visual_validation_required: boolean; docker_validation_required: boolean; workspace_validation_required: boolean; }; type ScopePlan = ScopeOutputs & { ci_mode: CiMode; run_docker_build: boolean; run_e2e_vitest: boolean; run_nix_validation: boolean; run_playwright_critical: boolean; run_playwright_visual: boolean; run_preflight: boolean; run_ui_p0: boolean; run_web_workspace_tests: boolean; run_windows_tools_pack_payload_tests: boolean; run_workspace_unit_tests: boolean; ui_p0_matrix: string; visual_matrix: string; }; type GitHubEvent = { pull_request?: { number?: number; }; inputs?: { ci_mode?: string; }; }; const commandName = process.argv[2] ?? "github-output"; if (commandName === "github-output") { writeGithubOutputs(createScopePlan()); } else if (commandName === "print") { console.log(JSON.stringify(createScopePlan(), null, 2)); } else if (commandName === "help") { printUsage(); } else { console.error(`Unknown scopes command: ${commandName}`); printUsage(); process.exitCode = 1; } function createScopePlan(): ScopePlan { const outputs: ScopeOutputs = { daemon_tests_required: false, web_tests_required: false, tools_dev_tests_required: false, tools_pack_tests_required: false, nix_validation_required: false, ui_p0_validation_required: false, visual_validation_required: false, docker_validation_required: false, workspace_validation_required: false, }; const eventName = requiredEnv("GITHUB_EVENT_NAME"); const ciMode = resolveCiMode(eventName); if (eventName === "pull_request") { for (const file of changedPullRequestFiles()) { applyChangedFile(file, outputs); if (allScopeOutputsTrue(outputs)) break; } if ( outputs.daemon_tests_required || outputs.web_tests_required || outputs.tools_dev_tests_required || outputs.tools_pack_tests_required ) { outputs.workspace_validation_required = true; } } else if (eventName === "workflow_dispatch" && ciMode === "hot") { for (const file of changedManualFiles()) { applyChangedFile(file, outputs); if (allScopeOutputsTrue(outputs)) break; } } else { outputs.daemon_tests_required = true; outputs.web_tests_required = true; outputs.tools_dev_tests_required = true; outputs.tools_pack_tests_required = true; outputs.nix_validation_required = true; outputs.ui_p0_validation_required = true; outputs.visual_validation_required = true; outputs.docker_validation_required = true; outputs.workspace_validation_required = true; } return { ...outputs, ...createRunPlan(outputs, ciMode), ui_p0_matrix: JSON.stringify(uiP0CiMatrix), visual_matrix: JSON.stringify(visualCiMatrix), }; } function resolveCiMode(eventName: string): CiMode { if (eventName === "pull_request") return "hot"; if (eventName === "workflow_dispatch") { const event = JSON.parse(readFileSync(requiredEnv("GITHUB_EVENT_PATH"), "utf8")) as GitHubEvent; const input = event.inputs?.ci_mode ?? "full"; if (input === "hot" || input === "full") return input; throw new Error(`Unsupported workflow_dispatch ci_mode: ${input}`); } return "full"; } function createRunPlan( outputs: ScopeOutputs, ciMode: CiMode, ): Omit { const isFull = ciMode === "full"; return { ci_mode: ciMode, run_docker_build: isFull || outputs.docker_validation_required, run_e2e_vitest: isFull || outputs.web_tests_required || outputs.ui_p0_validation_required, run_nix_validation: isFull || outputs.nix_validation_required, run_playwright_critical: isFull || (outputs.workspace_validation_required && !outputs.ui_p0_validation_required), run_playwright_visual: isFull || outputs.visual_validation_required, run_preflight: true, run_ui_p0: isFull || outputs.ui_p0_validation_required, run_web_workspace_tests: isFull || outputs.web_tests_required, run_windows_tools_pack_payload_tests: isFull || outputs.tools_pack_tests_required, run_workspace_unit_tests: true, }; } function changedPullRequestFiles(): string[] { const eventPath = requiredEnv("GITHUB_EVENT_PATH"); const repository = requiredEnv("GITHUB_REPOSITORY"); const event = JSON.parse(readFileSync(eventPath, "utf8")) as GitHubEvent; const prNumber = event.pull_request?.number; if (prNumber == null) { throw new Error("pull_request event payload did not include pull_request.number"); } const stdout = runGh(["api", "--paginate", `repos/${repository}/pulls/${prNumber}/files`, "--jq", ".[].filename"]); return stdout.split(/\r?\n/).filter(Boolean); } function changedManualFiles(): string[] { const repository = requiredEnv("GITHUB_REPOSITORY"); const sha = requiredEnv("GITHUB_SHA"); const stdout = runGh([ "api", "--paginate", `repos/${repository}/compare/main...${sha}`, "--jq", '(.files // [])[] | select(.status != "removed") | .filename', ]); return stdout.split(/\r?\n/).filter(Boolean); } function runGh(args: string[]): string { const nodeScript = process.env.OPEN_DESIGN_GH_NODE_SCRIPT; if (nodeScript != null && nodeScript.length > 0) { return execFileSync(process.execPath, [nodeScript, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"], }); } return execFileSync("gh", args, { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] }); } function applyChangedFile(file: string, target: ScopeOutputs): void { if ( startsWithAny(file, [ "apps/daemon/", "packages/release/", "packages/contracts/", "packages/platform/", "packages/sidecar/", "packages/sidecar-proto/", ]) ) { target.daemon_tests_required = true; } if ( startsWithAny(file, [ "apps/web/", "packages/release/", "packages/components/", "packages/contracts/", "packages/host/", "packages/platform/", "packages/sidecar/", "packages/sidecar-proto/", ]) ) { target.web_tests_required = true; } if (startsWithAny(file, ["scripts/", "assets/", "skills/", "prompt-templates/", "design-systems/", "design-templates/", "craft/"])) { target.daemon_tests_required = true; target.web_tests_required = true; } if (startsWithAny(file, ["tools/dev/", "packages/platform/", "packages/sidecar/", "packages/sidecar-proto/"])) { target.tools_dev_tests_required = true; } if ( startsWithAny(file, [ "tools/pack/", "apps/packaged/", "apps/desktop/", "packages/release/", "packages/components/", "packages/host/", "packages/platform/", "packages/sidecar/", "packages/sidecar-proto/", ]) ) { target.tools_pack_tests_required = true; } if (isWorkspaceManifestOrCiFile(file)) { target.daemon_tests_required = true; target.web_tests_required = true; target.tools_dev_tests_required = true; target.tools_pack_tests_required = true; } if (isUiP0RelevantFile(file)) { target.ui_p0_validation_required = true; } if (isVisualRelevantFile(file)) { target.visual_validation_required = true; } if (isDockerRelevantFile(file)) { target.docker_validation_required = true; } if (isNixRelevantFile(file)) { target.nix_validation_required = true; } if (!isWorkspaceValidationExemptFile(file)) { target.workspace_validation_required = true; } } function isWorkspaceManifestOrCiFile(file: string): boolean { return ( file === "package.json" || file === "pnpm-lock.yaml" || file === "pnpm-workspace.yaml" || file === ".github/workflows/ci.yml" || /^apps\/[^/]+\/package\.json$/.test(file) || /^packages\/[^/]+\/package\.json$/.test(file) || /^tools\/[^/]+\/package\.json$/.test(file) || file === "e2e/package.json" ); } function isUiP0RelevantFile(file: string): boolean { return ( startsWithAny(file, [ "apps/web/", "apps/daemon/", "packages/release/", "packages/components/", "packages/contracts/", "packages/host/", "packages/platform/", "packages/sidecar/", "packages/sidecar-proto/", "e2e/ui/", "e2e/lib/", "e2e/resources/", "e2e/scripts/", ".github/actions/setup-playwright/", ".github/actions/setup-workspace/", ]) || [ "e2e/package.json", "e2e/playwright.config.ts", "package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml", ".github/workflows/ci.yml", ".github/workflows/ui-extended-main.yml", ].includes(file) ); } function isVisualRelevantFile(file: string): boolean { return ( startsWithAny(file, [ "apps/web/", "e2e/lib/playwright/", ".github/actions/setup-playwright/", ".github/actions/setup-workspace/", ]) || /^e2e\/ui\/visual-[^/]+\.test\.ts$/.test(file) || [ "e2e/package.json", "e2e/playwright.visual.config.ts", "e2e/scripts/playwright.ts", "e2e/scripts/visual-report.ts", "pnpm-lock.yaml", ".github/scripts/handoff.py", ".github/workflows/ci.yml", ".github/workflows/comment.atom.yml", ".github/workflows/report.atom.yml", ".github/workflows/visual-baseline.yml", ].includes(file) ); } function isDockerRelevantFile(file: string): boolean { return ( startsWithAny(file, [ "deploy/", ]) || [ "package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml", ".dockerignore", ".github/workflows/ci.yml", ".github/workflows/docker-image.yml", ].includes(file) || isWorkspaceManifestOrCiFile(file) ); } function isNixRelevantFile(file: string): boolean { return ( startsWithAny(file, [ "nix/", "packages/release/", ]) || [ "package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml", "flake.nix", "flake.lock", ".github/scripts/handoff.py", ".github/workflows/autofix.atom.yml", ".github/workflows/ci.yml", ".github/workflows/comment.atom.yml", "scripts/update-nix-pnpm-deps-hash.ts", ].includes(file) || isWorkspaceManifestOrCiFile(file) ); } function isWorkspaceValidationExemptFile(file: string): boolean { return ( isDocumentationOrMetadataFile(file) || startsWithAny(file, [ "apps/landing-page/", "nix/", ".github/ISSUE_TEMPLATE/", ]) || [ "flake.nix", "flake.lock", ".github/workflows/landing-page-ci.yml", ".github/workflows/landing-page-staging.yml", ".github/workflows/landing-page-production.yml", ".github/workflows/blog-indexing-on-deploy.yml", ".github/workflows/autofix.atom.yml", ".github/workflows/comment.atom.yml", ".github/workflows/report.atom.yml", ".github/workflows/docker-image.yml", ].includes(file) ); } function isDocumentationOrMetadataFile(file: string): boolean { return ( /\.(?:md|mdx|txt)$/.test(file) || file === "LICENSE" || file === ".gitignore" || file === ".editorconfig" || startsWithAny(file, [".vscode/", ".idea/", "docs/"]) || file === ".github/CODEOWNERS" ); } function startsWithAny(value: string, prefixes: string[]): boolean { return prefixes.some((prefix) => value.startsWith(prefix)); } function allScopeOutputsTrue(value: ScopeOutputs): boolean { return Object.values(value).every(Boolean); } function writeGithubOutputs(value: ScopePlan): void { const lines = Object.entries(value).map(([key, output]) => `${key}=${formatOutput(output)}`); console.log(lines.join("\n")); const outputPath = process.env.GITHUB_OUTPUT; if (outputPath != null && outputPath.length > 0) { appendFileSync(outputPath, `${lines.join("\n")}\n`); } } function formatOutput(value: boolean | string): string { return typeof value === "boolean" ? (value ? "true" : "false") : value; } function requiredEnv(name: string): string { const value = process.env[name]; if (value == null || value.length === 0) { throw new Error(`${name} is required`); } return value; } function printUsage(): void { console.log(`Usage: node --experimental-strip-types scripts/scopes.ts Commands: github-output Write validation scope outputs for GitHub Actions print Print the validation scope plan as JSON help Show this help `); }