Skip to content
Source
Sign inSign up

ci-changes.cjs

211 lines7.7 KB

1// Unknown paths run every job; recognized packages pull in their dependents.
2// scripts/check-changed.ts shares these decisions so local checks match CI.
3const fs = require("node:fs");
4const path = require("node:path");
5​
6const root = path.resolve(__dirname, "../..");
7​
8const INERT = [
9 /^docs\//,
10 /^\.(agents|cursor|vscode|devcontainer|macroscope|repos)\//,
11 /^[^/]+\.md$/,
12 /^LICENSE$/,
13];
14​
15// Shared test setup and vendored mobile dependencies affect every package.
16const RUNS_EVERYTHING = [
17 /^packages\/shared\/src\/(HostProcess|testing\/(longTempDir|providerEnv))\.ts$/,
18 /^apps\/mobile\/deps\//,
19];
20​
21// Includes cross-package reads absent from package.json dependencies.
22const EXTRA_OWNERS = [
23 // Vite configs and tsconfigs of these apps import scripts/lib directly.
24 [/^scripts\/lib\//, ["@t3tools/desktop", "@t3tools/mobile", "@t3tools/web", "t3"]],
25 // scripts/build-desktop-artifact.ts reads the GNOME extension manifest.
26 [/^apps\/desktop\/gnome-extension\//, ["@t3tools/scripts"]],
27 [/^native\/browser-secret\//, ["@t3tools/desktop"]],
28 [/^native\/kde-snap-shot\//, ["@t3tools/desktop"]],
29 [/^native\/libghostty-vt\//, ["@t3tools/web"]],
30 // apps/server/src/cloud/ConnectReachability.test.ts checks the guide's headings.
31 [/^docs\/user\/t3-connect-troubleshooting\.md$/, ["t3"]],
32];
33const RUST = /^native\/(resource-monitor|kde-snap-shot|hyprland-snap-shot)\//;
34​
35// These tests read other packages without declaring dependencies.
36const TEST_ONLY_DEPS = {
37 // Evals import the server's source to test its real model-facing text.
38 "@t3tools/evals": ["t3"],
39 "@t3tools/provider-pi": ["@t3tools/client-runtime"],
40 "@t3tools/scripts": [
41 "@t3tools/desktop",
42 "@t3tools/mobile",
43 "@t3tools/web",
44 "t3",
45 "@t3tools/provider-cursor",
46 ],
47};
48// The server bundles the web build without importing it.
49const BUILD_ONLY_DEPS = { t3: ["@t3tools/web"] };
50​
51const MOBILE_NATIVE =
52 /^apps\/mobile\/.*\.(swift|kt|kts)$|^apps\/mobile\/(\.swiftlint\.yml|detekt\.yml|\.editorconfig|Brewfile)$|^scripts\/mobile-native-static-check\.ts$|^package\.json$|^\.github\/workflows\/ci\.yml$|^\.github\/scripts\/ci-changes\.cjs$|^\.github\/actions\/ci-changes\//;
53​
54// Startup, migration, settlement, and sidebar paths that decide whether an upgraded user's
55// threads show after launch. Build runs scripts/smoke-desktop-app.ts for them, so they do not
56// first fail in the release launch check.
57const DESKTOP_LAUNCH = [
58 /^apps\/desktop\/src\/((main|boot|compileCache|serverCompileCache)\.ts$|(app|backend|window)\/)/,
59 /^apps\/desktop\/src\/electron\/(ElectronApp|ElectronProtocol|ElectronWindow)\.ts$/,
60 /^apps\/web\/src\/((main|AppRoot|appInit)\.tsx|(bootstrap|router)\.ts)$/,
61 /^apps\/web\/src\/.*sidebar/i,
62 /^apps\/server\/src\/(bin|binMain|server|serverRuntimeStartup)\.ts$/,
63 /^apps\/server\/src\/persistence\/Migrations/,
64 /^apps\/server\/src\/orchestration-v2\/((ThreadSettlementService|ProjectionStore)\.ts$|legacy\/)/,
65 /^packages\/contracts\/src\/settings\.ts$/,
66 /^scripts\/(smoke-desktop-app\.ts|fixtures\/desktop-launch\/)/,
67];
68​
69const OWN_TEST_JOB = new Set(["t3", "@t3tools/web", "@t3tools/monorepo"]);
70​
71function readWorkspace() {
72 const patterns = [];
73 let inPackages = false;
74 for (const line of fs.readFileSync(path.join(root, "pnpm-workspace.yaml"), "utf8").split("\n")) {
75 if (/^\S/.test(line)) inPackages = line.startsWith("packages:");
76 const entry = inPackages && line.match(/^\s+-\s+["']?([^"'\s]+)/);
77 if (entry) patterns.push(entry[1]);
78 }
79​
80 const dirs = patterns.flatMap((pattern) => {
81 if (!pattern.endsWith("/*")) return [pattern];
82 const parent = pattern.slice(0, -2);
83 return fs.readdirSync(path.join(root, parent)).map((name) => `${parent}/${name}`);
84 });
85​
86 const packages = [];
87 for (const dir of dirs) {
88 const manifestPath = path.join(root, dir, "package.json");
89 if (!fs.existsSync(manifestPath)) continue;
90 const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
91 const deps = {
92 ...manifest.dependencies,
93 ...manifest.devDependencies,
94 ...manifest.peerDependencies,
95 };
96 packages.push({
97 name: manifest.name,
98 dir,
99 hasTest: Boolean(manifest.scripts?.test),
100 deps: Object.keys(deps).filter(
101 (dep) =>
102 String(deps[dep]).startsWith("workspace:") &&
103 !BUILD_ONLY_DEPS[manifest.name]?.includes(dep),
104 ),
105 });
106 }
107 return packages;
109​
110// [] means inert; null means unknown and requires every job.
111function ownersOf(file, packages) {
112 const extra = EXTRA_OWNERS.flatMap(([pattern, names]) => (pattern.test(file) ? names : []));
113 if (INERT.some((pattern) => pattern.test(file))) return extra;
114 // A manifest can change dependencies or versions other packages read.
115 if (path.posix.basename(file) === "package.json") return null;
116 if (RUNS_EVERYTHING.some((pattern) => pattern.test(file))) return null;
117 // scripts/build-desktop-artifact.test.ts reads the crates' sources.
118 if (RUST.test(file)) return ["@t3tools/scripts", ...extra];
119​
120 const owner = packages
121 .filter((pkg) => file.startsWith(`${pkg.dir}/`))
122 .sort((a, b) => b.dir.length - a.dir.length)[0];
123 if (owner) return [owner.name, ...extra];
124 return extra.length > 0 ? extra : null;
126​
127function decide(files, packages, full) {
128 const all = new Set(packages.map((pkg) => pkg.name));
129 let affected = new Set();
130 let everything = full || files === null;
131 let rust = false;
132​
133 for (const file of files ?? []) {
134 if (RUST.test(file)) rust = true;
135 const owners = ownersOf(file, packages);
136 if (owners === null) {
137 everything = true;
138 console.error(`Runs everything: ${file}`);
139 } else {
140 for (const owner of owners) affected.add(owner);
141 }
142 }
143​
144 // Release smoke reads scripts/ itself, not what the scripts tests read.
145 let scriptsChanged = everything;
146 if (everything) {
147 affected = all;
148 } else {
149 let grew = true;
150 while (grew) {
151 grew = false;
152 for (const pkg of packages) {
153 if (!affected.has(pkg.name) && pkg.deps.some((dep) => affected.has(dep))) {
154 affected.add(pkg.name);
155 grew = true;
156 }
157 }
158 }
159 scriptsChanged = affected.has("@t3tools/scripts");
160 for (const [name, deps] of Object.entries(TEST_ONLY_DEPS)) {
161 if (deps.some((dep) => affected.has(dep))) affected.add(name);
162 }
163 }
164​
165 const tested = packages.filter(
166 (pkg) => pkg.hasTest && !OWN_TEST_JOB.has(pkg.name) && affected.has(pkg.name),
167 );
168 const has = (name) => affected.has(name);
169 const desktopLaunch =
170 everything ||
171 files.some(
172 (file) => !/\.test\.tsx?$/.test(file) && DESKTOP_LAUNCH.some((pattern) => pattern.test(file)),
173 );
174​
175 return {
176 affected: [...affected].sort(),
177 outputs: {
178 typecheck: affected.size > 0,
179 build: has("@t3tools/desktop") || has("t3") || has("@t3tools/web") || desktopLaunch,
180 desktop_launch: desktopLaunch,
181 test: tested.length > 0,
182 // Empty means every package the Test job covers.
183 test_packages: everything ? "" : tested.map((pkg) => pkg.name).join(" "),
184 test_web: has("@t3tools/web"),
185 test_server: has("t3"),
186 rust: everything || rust,
187 release_smoke: scriptsChanged,
188 mobile_native: files === null || files.some((file) => MOBILE_NATIVE.test(file)),
189 },
190 };
192​
193function main() {
194 const args = process.argv.slice(2);
195 const pathsIndex = args.indexOf("--paths");
196 const files =
197 pathsIndex === -1
198 ? null
199 : fs
200 .readFileSync(args[pathsIndex + 1], "utf8")
201 .split("\n")
202 .filter(Boolean);
203 const { affected, outputs } = decide(files, readWorkspace(), args.includes("--full"));
204​
205 console.error(`Affected packages: ${affected.join(", ") || "none"}`);
206 for (const [key, value] of Object.entries(outputs)) console.log(`${key}=${value}`);
208​
209module.exports = { readWorkspace, decide };
210​
211if (require.main === module) main();