| 1 | #!/usr/bin/env node |
| 2 | |
| 3 | import { execFileSync } from "node:child_process"; |
| 4 | import { appendFileSync, readFileSync, realpathSync } from "node:fs"; |
| 5 | import { pathToFileURL } from "node:url"; |
| 6 | |
| 7 | const ZERO_SHA = /^0{40}$/; |
| 8 | const ROOT_DOC = /^[^/]+\.md$/; |
| 9 | const DESKTOP_DOC = /^(?:desktop\/(?:AGENTS|README)\.md|desktop\/electron\/README\.md|desktop\/third_party\/systray\/PATCHES\.md)$/; |
| 10 | const ROOT_UNRELATED = /^(?:docs\/|site\/|release-notes\/|desktop\/|workers\/)/; |
| 11 | const KNOWN_INDEPENDENT = /^(?:docs\/|site\/|release-notes\/|workers\/|benchmarks\/|npm\/|sdk\/|\.github\/|tools\/|scripts\/)/; |
| 12 | const ROOT_TOOLING = /^(?:Makefile|\.golangci[^/]*)$/; |
| 13 | const FRONTEND = /^desktop\/frontend\//; |
| 14 | const DESKTOP_MANIFEST = /^(?:desktop\/(?:package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|\.npmrc)|desktop\/frontend\/(?:package\.json|pnpm-lock\.yaml|vite\.config\.[cm]?[jt]s|tsconfig[^/]*\.json))$/; |
| 15 | const ELECTRON = /^(?:desktop\/electron\/|desktop\/(?:package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml)$)/; |
| 16 | const PACKAGING = /^(?:desktop\/(?:packaging\/|build\/)|scripts\/(?:desktop-build|package-windows-desktop|install-nsis|check-windows-uninstaller|finalize-windows-signed-candidate)\b)/; |
| 17 | const RELEASE_CONTROL = /^(?:\.github\/workflows\/(?:release[^/]*|prepare-release-notes|pages)\.yml|scripts\/(?:release|resolve-release-candidate|validate-release-candidate|build-release-cli-candidate|publish-homebrew-cask|desktop-release-artifacts|finalize-windows-signed-candidate|verify-release-artifact-archive|verify-stable-release-artifacts)[^/]*|npm\/publish(?:-candidate)?(?:\.test)?\.mjs)$/; |
| 18 | const DESKTOP_GO = /^(?:desktop\/(?:[^/]+\.go|go\.(?:mod|sum)|cmd\/|internal\/)|internal\/|cmd\/|go\.(?:mod|sum)$)/; |
| 19 | const SDK = /^(?:sdk\/|internal\/extension\/)/; |
| 20 | const WINDOWS_BUILTIN = /^(?:internal\/(?:tool\/builtin\/|tool\/tool\.go$|sandbox\/|permission\/|permissionpreset\/)|scripts\/windows-pr-contract-tests(?:\.test)?\.mjs$)/; |
| 21 | const CI_CONTROL = /^(?:\.github\/workflows\/ci\.yml|scripts\/ci-paths(?:\.test)?\.mjs)$/; |
| 22 | const MEMORY_CONTROL = /^(?:\.github\/workflows\/app-memory\.yml|scripts\/ci-paths(?:\.test)?\.mjs)$/; |
| 23 | const MEMORY_FULL = /^(?:desktop\/frontend\/(?:bench\/app-(?:memory|browser|page-actions)[^/]*|src\/(?:App(?:Runtime)?\.tsx|app-runtime\/.*|app-shell\/.*|components\/Transcript(?:Cards)?\.tsx|lib\/(?:useController[^/]*|subscriptionScope|useNavigationSurface|navigationSurfaceTransition|keyedResource|fileResource|useWorkspaceChangesResource|mcpServerLifecycle|fileNavigationLifetime|bridge(?:BenchFixtures|HistoryFixtures)?)\.[^/]+))|\.github\/workflows\/app-memory\.yml|scripts\/ci-paths(?:\.test)?\.mjs)$/; |
| 24 | |
| 25 | const FLAG_NAMES = ["code", "desktop", "desktop_go", "frontend", "browser", "memory", "memory_full", "electron", "native", "packaging", "site", "sdk", "windows_builtin", "release_control", "notes_only"]; |
| 26 | |
| 27 | function normalized(path) { |
| 28 | return path.replaceAll("\\", "/").replace(/^\.\//, ""); |
| 29 | } |
| 30 | |
| 31 | function setReason(reasons, flag, path, reason) { |
| 32 | if (!reasons[flag]) reasons[flag] = []; |
| 33 | reasons[flag].push(`${path} (${reason})`); |
| 34 | } |
| 35 | |
| 36 | export function classifyPaths(input, { full = false } = {}) { |
| 37 | const files = [...new Set(input.map(normalized).filter(Boolean))].sort(); |
| 38 | const flags = Object.fromEntries(FLAG_NAMES.map(name => [name, false])); |
| 39 | const reasons = {}; |
| 40 | const notesOnly = files.length > 0 && files.every(path => path.startsWith("release-notes/")); |
| 41 | if (full && notesOnly) { |
| 42 | flags.notes_only = true; |
| 43 | setReason(reasons, "notes_only", "event", "release-notes-only push exception"); |
| 44 | return { files, flags, reasons, unknown: [] }; |
| 45 | } |
| 46 | if (full) { |
| 47 | for (const name of FLAG_NAMES) flags[name] = name !== "notes_only"; |
| 48 | for (const name of FLAG_NAMES.filter(name => name !== "notes_only")) setReason(reasons, name, "event", "full validation"); |
| 49 | return { files, flags, reasons, unknown: [] }; |
| 50 | } |
| 51 | flags.notes_only = notesOnly; |
| 52 | const unknown = []; |
| 53 | for (const path of files) { |
| 54 | const explicitDoc = ROOT_DOC.test(path) || DESKTOP_DOC.test(path); |
| 55 | if (path.startsWith("site/")) { |
| 56 | flags.site = true; |
| 57 | setReason(reasons, "site", path, "site source"); |
| 58 | } |
| 59 | if (RELEASE_CONTROL.test(path)) { |
| 60 | flags.release_control = true; |
| 61 | setReason(reasons, "release_control", path, "release control plane"); |
| 62 | if (path === ".github/workflows/pages.yml") { |
| 63 | flags.site = true; |
| 64 | setReason(reasons, "site", path, "site deployment control"); |
| 65 | } |
| 66 | } |
| 67 | if (SDK.test(path)) { |
| 68 | flags.sdk = true; |
| 69 | setReason(reasons, "sdk", path, "SDK or generated protocol source"); |
| 70 | } |
| 71 | if (WINDOWS_BUILTIN.test(path)) { |
| 72 | flags.windows_builtin = true; |
| 73 | setReason(reasons, "windows_builtin", path, "Windows shell, workspace or sandbox contract"); |
| 74 | } |
| 75 | // site and sdk belong here too: a PR that edits the routing contract must |
| 76 | // exercise every gate it can change, and without them such a PR skips site |
| 77 | // and no-ops sdk while the aggregates trivially accept those skips. |
| 78 | if (CI_CONTROL.test(path)) { |
| 79 | for (const flag of ["desktop_go", "frontend", "browser", "electron", "native", "packaging", "site", "sdk", "windows_builtin"]) { |
| 80 | flags[flag] = true; |
| 81 | setReason(reasons, flag, path, "CI routing contract"); |
| 82 | } |
| 83 | } |
| 84 | if (MEMORY_CONTROL.test(path)) { |
| 85 | for (const flag of ["memory", "memory_full"]) { |
| 86 | flags[flag] = true; |
| 87 | setReason(reasons, flag, path, "memory workflow or shared routing contract"); |
| 88 | } |
| 89 | } |
| 90 | if (!RELEASE_CONTROL.test(path) && !ROOT_UNRELATED.test(path) && !ROOT_DOC.test(path)) { |
| 91 | flags.code = true; |
| 92 | setReason(reasons, "code", path, "root module input"); |
| 93 | } |
| 94 | if (explicitDoc || ROOT_TOOLING.test(path) || (KNOWN_INDEPENDENT.test(path) && !PACKAGING.test(path))) continue; |
| 95 | |
| 96 | const frontend = FRONTEND.test(path) || DESKTOP_MANIFEST.test(path); |
| 97 | const electron = ELECTRON.test(path); |
| 98 | const packaging = PACKAGING.test(path); |
| 99 | const desktopGo = DESKTOP_GO.test(path); |
| 100 | if (frontend) { |
| 101 | for (const flag of ["frontend", "browser", "memory"]) { |
| 102 | flags[flag] = true; |
| 103 | setReason(reasons, flag, path, "frontend build input"); |
| 104 | } |
| 105 | if (MEMORY_FULL.test(path)) { |
| 106 | flags.memory_full = true; |
| 107 | setReason(reasons, "memory_full", path, "App lifecycle or memory screening input"); |
| 108 | } |
| 109 | } |
| 110 | if (electron) { |
| 111 | flags.electron = true; |
| 112 | setReason(reasons, "electron", path, "Electron shell input"); |
| 113 | } |
| 114 | if (packaging) { |
| 115 | flags.packaging = true; |
| 116 | setReason(reasons, "packaging", path, "packaging input"); |
| 117 | } |
| 118 | if (desktopGo) { |
| 119 | flags.desktop_go = true; |
| 120 | setReason(reasons, "desktop_go", path, "Desktop or shared Go input"); |
| 121 | } |
| 122 | if (!(frontend || electron || packaging || desktopGo)) unknown.push(path); |
| 123 | } |
| 124 | if (unknown.length > 0) { |
| 125 | for (const flag of ["code", "desktop_go", "frontend", "browser", "memory", "memory_full", "electron", "native", "packaging"]) { |
| 126 | flags[flag] = true; |
| 127 | for (const path of unknown) setReason(reasons, flag, path, "unknown path; fail closed"); |
| 128 | } |
| 129 | } |
| 130 | flags.native ||= flags.desktop_go || flags.frontend || flags.electron || flags.packaging; |
| 131 | if (flags.native && !reasons.native) setReason(reasons, "native", "derived", "Desktop integration input"); |
| 132 | flags.desktop = flags.native || flags.browser; |
| 133 | if (flags.desktop) setReason(reasons, "desktop", "derived", "one or more Desktop surfaces changed"); |
| 134 | return { files, flags, reasons, unknown }; |
| 135 | } |
| 136 | |
| 137 | export function changedFiles({ base, head = "HEAD", mode = "pull_request", cwd = process.cwd() }) { |
| 138 | if (!base || ZERO_SHA.test(base) || !head || ZERO_SHA.test(head)) throw new Error("a non-zero base and head SHA are required"); |
| 139 | for (const sha of [base, head]) execFileSync("git", ["cat-file", "-e", `${sha}^{commit}`], { cwd, stdio: "ignore" }); |
| 140 | const range = mode === "pull_request" ? `${base}...${head}` : `${base}..${head}`; |
| 141 | return execFileSync("git", ["diff", "--name-only", "-z", range], { cwd, encoding: "utf8" }).split("\0").filter(Boolean); |
| 142 | } |
| 143 | |
| 144 | function parseArgs(argv) { |
| 145 | const args = {}; |
| 146 | for (let i = 0; i < argv.length; i++) { |
| 147 | if (argv[i] === "--full") args.full = true; |
| 148 | else if (argv[i].startsWith("--")) args[argv[i].slice(2).replaceAll("-", "_")] = argv[++i]; |
| 149 | else throw new Error(`unexpected argument ${argv[i]}`); |
| 150 | } |
| 151 | return args; |
| 152 | } |
| 153 | |
| 154 | function summary(result) { |
| 155 | const lines = ["## CI path decision", "", "| Surface | Run | Trigger |", "| --- | --- | --- |"]; |
| 156 | for (const name of FLAG_NAMES) lines.push(`| ${name} | ${result.flags[name]} | ${(result.reasons[name] ?? ["none"]).join("<br>")} |`); |
| 157 | lines.push("", `Changed files: ${result.files.length}`, "", ...result.files.map(path => `- \`${path}\``)); |
| 158 | return lines.join("\n") + "\n"; |
| 159 | } |
| 160 | |
| 161 | function isMainModule() { |
| 162 | // stdin/eval hosts can provide a sentinel or a nonexistent argv[1]. Importing |
| 163 | // this module must not require that host argument to name a filesystem entry. |
| 164 | if (!process.argv[1] || process.argv[1] === "-") return false; |
| 165 | try { |
| 166 | return import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href; |
| 167 | } catch { |
| 168 | return false; |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | if (isMainModule()) { |
| 173 | try { |
| 174 | const args = parseArgs(process.argv.slice(2)); |
| 175 | let files; |
| 176 | if (args.files) files = readFileSync(args.files, "utf8").split("\0").filter(Boolean); |
| 177 | else if (args.full && !args.base) files = []; |
| 178 | else files = changedFiles({ base: args.base, head: args.head, mode: args.mode }); |
| 179 | const result = classifyPaths(files, { full: args.full }); |
| 180 | const output = Object.entries(result.flags).map(([key, value]) => `${key}=${value}\n`).join(""); |
| 181 | if (args.github_output) appendFileSync(args.github_output, output); |
| 182 | else process.stdout.write(output); |
| 183 | if (args.summary) appendFileSync(args.summary, summary(result)); |
| 184 | } catch (error) { |
| 185 | console.error(`ci-paths: ${error.message}`); |
| 186 | process.exitCode = 1; |
| 187 | } |
| 188 | } |
| 189 |