返回 DeepSeek-Reasonix
ci-paths.mjs
根目录 / scripts / ci-paths.mjs
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
189 lines Plain Text