返回 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\/|internal\/upgradefixture\/|cmd\/windows-upgrade-fixture\/)|scripts\/(?:desktop-build|package-windows-desktop|install-nsis|check-windows-uninstaller|finalize-windows-signed-candidate|test-windows-(?:installer-startup|startup-recovery|upgrade-startup)|windows-(?:upgrade-ui-evidence|acceptance-environment))\b)/;
17 const RELEASE_CONTROL = /^(?:\.github\/workflows\/release[^/]*\.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-release-tag-identity|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 const releaseControl = RELEASE_CONTROL.test(path) || /^scripts\/(?:reconcile-release-publication|fetch-stable-release-manifest|check-release-public-access|test-release-control-contracts|validate-release-control-plane)[^/]*$/.test(path);
60 if (releaseControl) {
61 flags.release_control = true;
62 setReason(reasons, "release_control", path, "release control plane");
63 }
64 if (SDK.test(path)) {
65 flags.sdk = true;
66 setReason(reasons, "sdk", path, "SDK or generated protocol source");
67 }
68 if (WINDOWS_BUILTIN.test(path)) {
69 flags.windows_builtin = true;
70 setReason(reasons, "windows_builtin", path, "Windows shell, workspace or sandbox contract");
71 }
72 // site and sdk belong here too: a PR that edits the routing contract must
73 // exercise every gate it can change, and without them such a PR skips site
74 // and no-ops sdk while the aggregates trivially accept those skips.
75 if (CI_CONTROL.test(path)) {
76 for (const flag of ["desktop_go", "frontend", "browser", "electron", "native", "packaging", "site", "sdk", "windows_builtin"]) {
77 flags[flag] = true;
78 setReason(reasons, flag, path, "CI routing contract");
79 }
80 }
81 if (MEMORY_CONTROL.test(path)) {
82 for (const flag of ["memory", "memory_full"]) {
83 flags[flag] = true;
84 setReason(reasons, flag, path, "memory workflow or shared routing contract");
85 }
86 }
87 if (!releaseControl && !ROOT_UNRELATED.test(path) && !ROOT_DOC.test(path)) {
88 flags.code = true;
89 setReason(reasons, "code", path, "root module input");
90 }
91 if (explicitDoc || ROOT_TOOLING.test(path) || (KNOWN_INDEPENDENT.test(path) && !PACKAGING.test(path))) continue;
92
93 const frontend = FRONTEND.test(path) || DESKTOP_MANIFEST.test(path);
94 const electron = ELECTRON.test(path);
95 const packaging = PACKAGING.test(path);
96 const desktopGo = DESKTOP_GO.test(path);
97 if (frontend) {
98 for (const flag of ["frontend", "browser", "memory"]) {
99 flags[flag] = true;
100 setReason(reasons, flag, path, "frontend build input");
101 }
102 if (MEMORY_FULL.test(path)) {
103 flags.memory_full = true;
104 setReason(reasons, "memory_full", path, "App lifecycle or memory screening input");
105 }
106 }
107 if (electron) {
108 flags.electron = true;
109 setReason(reasons, "electron", path, "Electron shell input");
110 }
111 if (packaging) {
112 flags.packaging = true;
113 setReason(reasons, "packaging", path, "packaging input");
114 }
115 if (desktopGo) {
116 flags.desktop_go = true;
117 setReason(reasons, "desktop_go", path, "Desktop or shared Go input");
118 }
119 if (!(frontend || electron || packaging || desktopGo)) unknown.push(path);
120 }
121 if (unknown.length > 0) {
122 for (const flag of ["code", "desktop_go", "frontend", "browser", "memory", "memory_full", "electron", "native", "packaging"]) {
123 flags[flag] = true;
124 for (const path of unknown) setReason(reasons, flag, path, "unknown path; fail closed");
125 }
126 }
127 flags.native ||= flags.desktop_go || flags.frontend || flags.electron || flags.packaging;
128 if (flags.native && !reasons.native) setReason(reasons, "native", "derived", "Desktop integration input");
129 flags.desktop = flags.native || flags.browser;
130 if (flags.desktop) setReason(reasons, "desktop", "derived", "one or more Desktop surfaces changed");
131 return { files, flags, reasons, unknown };
132 }
133
134 export function changedFiles({ base, head = "HEAD", mode = "pull_request", cwd = process.cwd() }) {
135 if (!base || ZERO_SHA.test(base) || !head || ZERO_SHA.test(head)) throw new Error("a non-zero base and head SHA are required");
136 for (const sha of [base, head]) execFileSync("git", ["cat-file", "-e", `${sha}^{commit}`], { cwd, stdio: "ignore" });
137 const range = mode === "pull_request" ? `${base}...${head}` : `${base}..${head}`;
138 return execFileSync("git", ["diff", "--name-only", "-z", range], { cwd, encoding: "utf8" }).split("\0").filter(Boolean);
139 }
140
141 function parseArgs(argv) {
142 const args = {};
143 for (let i = 0; i < argv.length; i++) {
144 if (argv[i] === "--full") args.full = true;
145 else if (argv[i].startsWith("--")) args[argv[i].slice(2).replaceAll("-", "_")] = argv[++i];
146 else throw new Error(`unexpected argument ${argv[i]}`);
147 }
148 return args;
149 }
150
151 function summary(result) {
152 const lines = ["## CI path decision", "", "| Surface | Run | Trigger |", "| --- | --- | --- |"];
153 for (const name of FLAG_NAMES) lines.push(`| ${name} | ${result.flags[name]} | ${(result.reasons[name] ?? ["none"]).join("<br>")} |`);
154 lines.push("", `Changed files: ${result.files.length}`, "", ...result.files.map(path => `- \`${path}\``));
155 return lines.join("\n") + "\n";
156 }
157
158 function isMainModule() {
159 // stdin/eval hosts can provide a sentinel or a nonexistent argv[1]. Importing
160 // this module must not require that host argument to name a filesystem entry.
161 if (!process.argv[1] || process.argv[1] === "-") return false;
162 try {
163 return import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
164 } catch {
165 return false;
166 }
167 }
168
169 if (isMainModule()) {
170 try {
171 const args = parseArgs(process.argv.slice(2));
172 let files;
173 if (args.files) files = readFileSync(args.files, "utf8").split("\0").filter(Boolean);
174 else if (args.full && !args.base) files = [];
175 else files = changedFiles({ base: args.base, head: args.head, mode: args.mode });
176 const result = classifyPaths(files, { full: args.full });
177 const output = Object.entries(result.flags).map(([key, value]) => `${key}=${value}\n`).join("");
178 if (args.github_output) appendFileSync(args.github_output, output);
179 else process.stdout.write(output);
180 if (args.summary) appendFileSync(args.summary, summary(result));
181 } catch (error) {
182 console.error(`ci-paths: ${error.message}`);
183 process.exitCode = 1;
184 }
185 }
186
186 lines Plain Text