Open History to see the last commit
1// @effect-diagnostics nodeBuiltinImport:off - hashes inputs with node:crypto and sizes the budget with node:os.
2// Runs CI's fast checks for this branch's changes: format and lint of the
3// changed files, knip, generated GraphQL operations, typecheck of the affected
4// packages and their dependents, the test files beside the changed files, and
5// the cross-file checks those miss (agent context budget, React Compiler
6// coverage of the changed client files).
7// A dependency change also gets the unused-patch install check below. CI still
8// runs the full test suites and the jobs listed in the summary.
9//
10// vp run check:changed compare against the closest merge base with any remote main
11// vp run check:changed <ref> compare against the merge base with <ref>
12// vp run check:changed --concurrency 1 run the steps one at a time
13//
14// Uncommitted and untracked files count. Affected packages come from
15// .github/scripts/ci-changes.cjs, the same mapping CI uses to gate its jobs.
16// The steps run side by side within a budget derived from the machine's cores
17// and available memory, and each prints its output when it finishes. A step
18// running past the timeout is killed with its process group and reported as
19// TIMEOUT. A rerun on exactly the files of the last passing run prints that
20// run's summary.
21import * as NodeCrypto from "node:crypto";
22import * as NodeOS from "node:os";
23
24import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
25import * as NodeServices from "@effect/platform-node/NodeServices";
26import * as HostProcess from "@t3tools/shared/HostProcess";
27import * as Clock from "effect/Clock";
28import * as Config from "effect/Config";
29import * as Console from "effect/Console";
30import * as Effect from "effect/Effect";
31import * as FileSystem from "effect/FileSystem";
32import * as Option from "effect/Option";
33import * as Path from "effect/Path";
34import type * as PlatformError from "effect/PlatformError";
35import * as Schema from "effect/Schema";
36import * as Semaphore from "effect/Semaphore";
37import * as Stream from "effect/Stream";
38import { Argument, Command, Flag } from "effect/cli";
39import { ChildProcess, ChildProcessSpawner } from "effect/process";
40
41import { decide, readWorkspace } from "../.github/scripts/ci-changes.cjs";
42import { vitePlusCommand } from "./check-changed-command.ts";
43
44const GiB = 1024 ** 3;
45
46// A typecheck or test step can take several cores and gigabytes of memory, so
47// allow one step per two cores and per 6 GiB of available memory.
48export const defaultConcurrency = (cores: number, availableBytes: number) =>
49 Math.max(1, Math.min(Math.floor(cores / 2), Math.floor(availableBytes / (6 * GiB))));
50
51export type Outcome = "pass" | "FAIL" | "TIMEOUT";
52
53export interface Step<R> {
54 readonly name: string;
55 readonly run: Effect.Effect<{ readonly ok: boolean; readonly output: string }, never, R>;
56 // Budget slots the step holds: one per worker it runs in parallel.
57 readonly weight?: number;
58 // Vitest runs started together race to rebuild node_modules/.vitest-cache (ENOTEMPTY).
59 readonly serial?: boolean;
60}
61
62// Interrupting a step at its timeout closes the scopes of the processes it
63// spawned, which kills each one's process group.
64export const runSteps = Effect.fn("checkChanged.runSteps")(function* <R>(
65 steps: ReadonlyArray<Step<R>>,
66 options: { readonly concurrency: number; readonly timeoutSeconds: number },
67) {
68 const budget = yield* Semaphore.make(options.concurrency);
69 // Steps claim budget one at a time, in order: Semaphore lets a light request
70 // take free slots ahead of a waiting heavier one, which would delay typecheck.
71 const admission = yield* Semaphore.make(1);
72 const serial = yield* Semaphore.make(1);
73 return yield* Effect.forEach(
74 steps,
75 (step) => {
76 const timed = Effect.gen(function* () {
77 const started = yield* Clock.currentTimeMillis;
78 const result = yield* Effect.timeoutOption(step.run, `${options.timeoutSeconds} seconds`);
79 const seconds = ((yield* Clock.currentTimeMillis) - started) / 1000;
80 const outcome: Outcome = Option.match(result, {
81 onNone: () => "TIMEOUT",
82 onSome: ({ ok }) => (ok ? "pass" : "FAIL"),
83 });
84 const time =
85 outcome === "TIMEOUT"
86 ? `killed after ${options.timeoutSeconds}s`
87 : `${seconds.toFixed(1)}s`;
88 yield* Console.log(`\n> vp ${step.name} ${outcome} ${time}`);
89 const output = Option.getOrUndefined(result)?.output.trimEnd();
90 if (output) yield* Console.log(output);
91 return { name: step.name, outcome, time };
92 });
93 const slots = Math.min(step.weight ?? 1, options.concurrency);
94 const limited = Effect.acquireUseRelease(
95 admission.withPermit(budget.take(slots)),
96 () => timed,
97 () => budget.release(slots),
98 );
99 return step.serial ? serial.withPermit(limited) : limited;
100 },
101 { concurrency: "unbounded" },
102 );
103});
104
105const CacheEntry = Schema.fromJsonString(
106 Schema.Struct({ key: Schema.String, summary: Schema.Array(Schema.String) }),
107);
108const decodeCacheEntry = Schema.decodeEffect(CacheEntry);
109const encodeCacheEntry = Schema.encodeEffect(CacheEntry);
110const decodeRootPackageJson = Schema.decodeEffect(
111 Schema.fromJsonString(
112 Schema.Struct({
113 scripts: Schema.Struct({
114 "knip:check:files": Schema.String,
115 "knip:check:exports": Schema.String,
116 }),
117 }),
118 ),
119);
120
121const collectBytes = (stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>) =>
122 Stream.runCollect(stream).pipe(Effect.map((chunks) => Buffer.concat(chunks)));
123
124// Fails on a nonzero exit: an empty file list from a broken index would
125// otherwise pass as "no changes". quotePath=false keeps non-ASCII paths as real
126// paths instead of quoted escapes.
127export const git = Effect.fn("checkChanged.git")(function* (
128 cwd: string,
129 args: ReadonlyArray<string>,
130) {
131 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
132 return yield* Effect.scoped(
133 Effect.gen(function* () {
134 const handle = yield* spawner.spawn(
135 ChildProcess.make("git", ["-c", "core.quotePath=false", ...args], { cwd }),
136 );
137 const [stdout, stderr, exitCode] = yield* Effect.all(
138 [collectBytes(handle.stdout), collectBytes(handle.stderr), handle.exitCode],
139 { concurrency: "unbounded" },
140 );
141 if (exitCode !== 0) {
142 return yield* Effect.fail(
143 `check:changed: git ${args.join(" ")} exited with ${exitCode}: ${stderr.toString().trim()}`,
144 );
145 }
146 return stdout;
147 }),
148 );
149});
150export const gitLines = (cwd: string, args: ReadonlyArray<string>) =>
151 git(cwd, args).pipe(Effect.map((stdout) => stdout.toString().split("\n").filter(Boolean)));
152
153export const comparisonBase = Effect.fn("checkChanged.comparisonBase")(function* (
154 root: string,
155 explicitRef: Option.Option<string>,
156) {
157 const candidates = Option.isSome(explicitRef)
158 ? [{ ref: explicitRef.value, rev: explicitRef.value }]
159 : (yield* gitLines(root, ["remote"])).map((name) => ({
160 ref: name + "/main",
161 rev: "refs/remotes/" + name + "/main",
162 }));
163 let closest: { ref: string; base: string; ahead: number } | undefined;
164 for (const { ref, rev } of candidates) {
165 const base = yield* git(root, ["merge-base", "HEAD", rev]).pipe(
166 Effect.map((stdout) => stdout.toString().trim()),
167 Effect.orElseSucceed(() => undefined),
168 );
169 if (!base) continue;
170 const ahead = Number((yield* git(root, ["rev-list", "--count", base + "..HEAD"])).toString());
171 if (!closest || ahead < closest.ahead) closest = { ref, base, ahead };
172 }
173 if (!closest) {
174 const tried = candidates.map(({ ref }) => ref).join(", ") || "a remote main";
175 return yield* Effect.fail(
176 "check:changed: no merge base with " + tried + ". Run git fetch or pass a ref.",
177 );
178 }
179 return closest;
180});
181
182// CI's lint and knip also read Astro components' frontmatter.
183const CODE = /\.(?:[cm]?[jt]sx?|astro)$/;
184const TEST = /\.test\.[cm]?[jt]sx?$/;
185// CI runs the .github/scripts tests with `node --test`; vitest passes a failing node:test file.
186const NODE_TEST = /^\.github[/\\]scripts[/\\]/;
187export const splitTests = (tests: Iterable<string>) => {
188 const all = [...tests];
189 return {
190 vitestTests: all.filter((file) => !NODE_TEST.test(file)),
191 nodeTests: all.filter((file) => NODE_TEST.test(file)),
192 };
193};
194const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
195
196// A flag wins over its environment variable; either must be a positive integer.
197const override = Effect.fn("checkChanged.override")(function* (
198 flagName: string,
199 flag: Option.Option<number>,
200 envName: string,
201) {
202 const [name, value] = Option.isSome(flag)
203 ? [`--${flagName}`, flag]
204 : [envName, yield* Config.option(Config.Int(envName))];
205 if (Option.isSome(value) && value.value < 1) {
206 return yield* Effect.fail(`check:changed: ${name} must be a positive integer.`);
207 }
208 return value;
209});
210
211// Tests that read a doc; a changed doc has no test beside it to select them.
212export const docReaderTests = (files: ReadonlyArray<string>) =>
213 files.includes("docs/user/t3-connect-troubleshooting.md")
214 ? ["apps/server/src/cloud/ConnectReachability.test.ts"]
215 : [];
216
217const checkChanged = Effect.fn("checkChanged")(function* (input: {
218 readonly ref: Option.Option<string>;
219 readonly concurrency: Option.Option<number>;
220 readonly timeout: Option.Option<number>;
221}) {
222 const fs = yield* FileSystem.FileSystem;
223 const path = yield* Path.Path;
224 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
225 const root = yield* path.fromFileUrl(new URL("..", import.meta.url));
226 const inRoot = (file: string) => path.join(root, file);
227
228 // macOS counts its file cache as used, so available memory there would
229 // starve a large Mac. Elsewhere availableMemory() honors a cgroup limit.
230 const platform = yield* HostProcess.Platform;
231 const concurrency = Option.getOrElse(
232 yield* override("concurrency", input.concurrency, "CHECK_CHANGED_CONCURRENCY"),
233 () =>
234 defaultConcurrency(
235 NodeOS.availableParallelism(),
236 platform === "darwin" ? NodeOS.totalmem() : process.availableMemory(),
237 ),
238 );
239 const timeoutSeconds = Option.getOrElse(
240 yield* override("timeout", input.timeout, "CHECK_CHANGED_TIMEOUT"),
241 () => 900,
242 );
243
244 const lines = (...args: Array<string>) => gitLines(root, args);
245
246 const { ref, base } = yield* comparisonBase(root, input.ref);
247 // --no-renames lists a rename as its old and new path, as CI does.
248 const changedFiles = lines("diff", "--name-only", "--no-renames", base, "--");
249 const untrackedFiles = lines("ls-files", "--others", "--exclude-standard");
250 const files = [...new Set([...(yield* changedFiles), ...(yield* untrackedFiles)])].sort();
251
252 if (files.length === 0) {
253 yield* Console.log(`check:changed: no changes since ${base.slice(0, 12)} (${ref}).`);
254 return true;
255 }
256 yield* Console.log(`check:changed: ${files.length} files since ${base.slice(0, 12)} (${ref})`);
257
258 // Ignored generated sources, declarations, env files, and install state affect check results.
259 // Include them in the key, but skip large ignored folders that no check reads.
260 // Recompute after a passing run so edits made during it are never cached as passing.
261 const ci = Option.getOrElse(yield* Config.option(Config.String("CI")), () => "");
262 const inputKey = Effect.gen(function* () {
263 const hash = NodeCrypto.createHash("sha256");
264 // CI changes test behavior (vite.config.ts rejects .only under CI).
265 hash.update(`${process.version}\0${base}\0${ci}\0`);
266 hash.update(
267 yield* git(root, [
268 "diff",
269 "--binary",
270 "--no-renames",
271 "--no-ext-diff",
272 "--no-textconv",
273 base,
274 "--",
275 ]),
276 );
277 const ignored = yield* lines(
278 "ls-files",
279 "--others",
280 "--ignored",
281 "--exclude-standard",
282 "--",
283 ":(glob)**/.generated/**",
284 ":(glob)**/.astro/**",
285 ":(glob)**/*.d.ts",
286 ":(glob)**/.env*",
287 ":(exclude,glob)**/node_modules/**",
288 );
289 for (const file of [
290 // Raw bytes of changed files too: .gitattributes normalizes line endings in
291 // the diff, but the formatter reads the file as it is on disk.
292 ...(yield* changedFiles),
293 ...(yield* untrackedFiles),
294 ...ignored,
295 "node_modules/.modules.yaml",
296 "node_modules/.pnpm/lock.yaml",
297 ]) {
298 hash.update(`\0${file}\0`);
299 // Untracked nested repositories are listed as directories.
300 const info = yield* Effect.option(fs.stat(inRoot(file)));
301 if (Option.isSome(info) && info.value.type === "File") {
302 hash.update(yield* fs.readFile(inRoot(file)));
303 }
304 }
305 return hash.digest("hex");
306 });
307 const key = yield* inputKey;
308 const cachePath = inRoot("node_modules/.cache/check-changed.json");
309 // A missing or unreadable cache is a miss, never a failure.
310 const cached = yield* fs
311 .readFileString(cachePath)
312 .pipe(Effect.flatMap(decodeCacheEntry), Effect.option);
313 if (Option.isSome(cached) && cached.value.key === key) {
314 yield* Console.log("Nothing changed since the last passing run.\n");
315 for (const line of cached.value.summary) yield* Console.log(line);
316 return true;
317 }
318
319 const present = yield* Effect.filter(files, (file) => fs.exists(inRoot(file)));
320 const code = present.filter((file) => CODE.test(file));
321 const packages = readWorkspace();
322 const { affected, outputs } = decide(files, packages, false);
323
324 // Changed test files, and the test files beside a changed source file
325 // (foo.ts -> foo.test.ts, foo.browser.test.tsx). Vitest's import-graph
326 // `related` reaches most of a package: one leaf server file pulled in 252 of
327 // 406 server test files and took 7 minutes. CI runs the rest.
328 const tests = new Set(code.filter((file) => TEST.test(file)));
329 for (const file of code) {
330 const dir = path.dirname(file);
331 const prefix = `${path.basename(file).replace(CODE, "")}.`;
332 for (const entry of yield* fs.readDirectory(inRoot(dir))) {
333 if (entry.startsWith(prefix) && TEST.test(entry)) tests.add(path.join(dir, entry));
334 }
335 }
336 for (const test of docReaderTests(files)) tests.add(test);
337 // It reads files other than the ones beside it, so no changed file selects it.
338 if (affected.includes("t3")) tests.add("apps/server/src/provider/AgentContextBudget.test.ts");
339 const { vitestTests, nodeTests } = splitTests(tests);
340
341 // React Compiler coverage compiles every client source file, so run only the
342 // changed files' cases, in one extra step: a -t filter in the main test step
343 // would drop every other test. An app is skipped when the main step already
344 // runs its coverage file unfiltered. File filters mirror each coverage test.
345 const compilerApps = [
346 {
347 test: "apps/web/src/reactCompilerCoverage.test.ts",
348 root: "apps/web/src/",
349 // Test names keep the repo-relative path.
350 name: (file: string) => file,
351 compiled: (file: string) =>
352 /\.tsx?$/.test(file) && !/\.(test|browser)\.tsx?$/.test(file) && !file.endsWith(".gen.ts"),
353 // Web has only per-file cases.
354 always: [],
355 },
356 {
357 test: "apps/mobile/src/reactCompilerCoverage.test.ts",
358 root: "apps/mobile/src/",
359 name: (file: string) => file.slice("apps/mobile/".length),
360 compiled: (file: string) => /\.tsx?$/.test(file) && !/\.(test\.tsx?|d\.ts)$/.test(file),
361 // The Metro, cache, and stale-allowlist cases check more than one file, deletions included.
362 always: ["^(?!.*compiles )"],
363 },
364 ].flatMap(({ test, root: appRoot, name, compiled, always }) => {
365 const changed = files.filter((file) => file.startsWith(appRoot) && compiled(file));
366 if (changed.length === 0 || tests.has(test)) return [];
367 const names = changed
368 .filter((file) => present.includes(file))
369 .map((file) => `compiles ${escapeRegExp(name(file))}$`);
370 const pattern = [...names, ...always];
371 return pattern.length > 0 ? [{ test, names, pattern }] : [];
372 });
373
374 // knip:check cannot be scoped to a package. Any code change can leave a file,
375 // export, or dependency unused (removing the last import of a helper does), so
376 // it runs whenever code or a manifest changes.
377 const runKnip = files.some(
378 (file) => CODE.test(file) || path.basename(file) === "package.json" || file === "knip.jsonc",
379 );
380 // CI runs knip:check as a task that depends on one script per pass. The passes
381 // do not depend on each other, so they run side by side here.
382 const rootPackage = yield* fs
383 .readFileString(inRoot("package.json"))
384 .pipe(Effect.flatMap(decodeRootPackageJson), Effect.orDie);
385 const knipPasses: Array<Array<string>> = [];
386 for (const command of [
387 rootPackage.scripts["knip:check:files"],
388 rootPackage.scripts["knip:check:exports"],
389 ]) {
390 const [bin, ...args] = command.trim().split(/\s+/);
391 if (bin !== "knip" || /["'$\\|;&<>]/.test(command)) {
392 return yield* Effect.fail(`check:changed cannot split knip:check: ${command}`);
393 }
394 knipPasses.push(["exec", "knip", ...args]);
395 }
396
397 // A version bump can leave a patch unused, causing pnpm to reject the install
398 // with ERR_PNPM_UNUSED_PATCH. Check it here as Release Smoke does in CI.
399 const runPatchCheck = files.some(
400 (file) =>
401 file === "pnpm-workspace.yaml" ||
402 file === "pnpm-lock.yaml" ||
403 file === ".pnpmfile.cjs" ||
404 file.startsWith("patches/") ||
405 file.startsWith("apps/mobile/deps/") ||
406 path.basename(file) === "package.json",
407 );
408
409 // Output is piped, so keep the colors a terminal would get.
410 const env = process.stdout.isTTY ? { FORCE_COLOR: "1" } : {};
411 const vp = (args: ReadonlyArray<string>, cwd = root) =>
412 Effect.scoped(
413 Effect.gen(function* () {
414 const [command, commandArgs] = vitePlusCommand(args, platform);
415 const handle = yield* spawner.spawn(
416 ChildProcess.make(command, commandArgs, {
417 cwd,
418 env,
419 extendEnv: true,
420 forceKillAfter: "5 seconds",
421 }),
422 );
423 const [output, exitCode] = yield* Effect.all(
424 [Stream.mkString(Stream.decodeText(handle.all)), handle.exitCode],
425 { concurrency: 2 },
426 );
427 return { ok: exitCode === 0, output };
428 }),
429 ).pipe(Effect.catch((error) => Effect.succeed({ ok: false, output: error.message })));
430
431 // Resolve without the lockfile in a temp copy to preserve the working tree and lockfile.
432 // Copy mobile modules too: they are file: dependencies, not workspace packages.
433 // Cached registry metadata limits this to patches stranded by local changes, not registry drift.
434 const checkPatches = Effect.scoped(
435 Effect.gen(function* () {
436 const temp = yield* fs.makeTempDirectoryScoped({ prefix: "t3-patch-check-" });
437 const copy = Effect.fnUntraced(function* (relative: string) {
438 const dest = path.join(temp, relative);
439 yield* fs.makeDirectory(path.dirname(dest), { recursive: true });
440 yield* fs.copy(inRoot(relative), dest);
441 });
442 yield* copy("package.json");
443 yield* copy("pnpm-workspace.yaml");
444 for (const pkg of packages) yield* copy(`${pkg.dir}/package.json`);
445 const modulesDir = "apps/mobile/modules";
446 if (yield* fs.exists(inRoot(modulesDir))) {
447 for (const entry of yield* fs.readDirectory(inRoot(modulesDir))) {
448 const manifest = `${modulesDir}/${entry}/package.json`;
449 if (yield* fs.exists(inRoot(manifest))) yield* copy(manifest);
450 }
451 }
452 for (const extra of ["apps/mobile/deps", "patches", ".pnpmfile.cjs"]) {
453 if (yield* fs.exists(inRoot(extra))) yield* copy(extra);
454 }
455 const result = yield* vp(["install", "--lockfile-only", "--ignore-scripts"], temp);
456 if (result.ok || !result.output.includes("ERR_PNPM_UNUSED_PATCH")) return result;
457 return {
458 ok: false,
459 output:
460 `${result.output}\nUnused dependency patches: remove each patch named above from ` +
461 "patchedDependencies in pnpm-workspace.yaml and from patches/, " +
462 "update it to the version the install resolves, " +
463 "or pin the dependency that pulls it in to the patched version.",
464 };
465 }),
466 ).pipe(Effect.catch((error) => Effect.succeed({ ok: false, output: error.message })));
467
468 // CI's Lint job checks that generated GraphQL operations match their sources.
469 const graphql = ["apps/server", "packages/source-control-github"].filter((dir) =>
470 files.some((file) => file.startsWith(`${dir}/`)),
471 );
472 const filters = (names: ReadonlyArray<string>) => names.flatMap((name) => ["--filter", name]);
473 const typecheckWorkers = Math.min(concurrency, affected.length);
474 const command = (name: string, args: ReadonlyArray<string>) => ({ name, run: vp(args) });
475 const compilerFiles = compilerApps.reduce((count, app) => count + app.names.length, 0);
476
477 const steps: Array<Step<never>> = [
478 ...(runPatchCheck ? [{ name: "unused patches", run: checkPatches }] : []),
479 ...(present.length > 0
480 ? [command("format", ["fmt", "--check", "--no-error-on-unmatched-pattern", ...present])]
481 : []),
482 ...(code.length > 0
483 ? [command("lint", ["lint", "--no-error-on-unmatched-pattern", ...code])]
484 : []),
485 ...(runKnip
486 ? knipPasses.map((args, index) => command(`knip ${index + 1}/${knipPasses.length}`, args))
487 : []),
488 ...graphql.map((dir) =>
489 command(`graphql (${dir})`, ["run", "--filter", `./${dir}`, "generate", "--check"]),
490 ),
491 ...(affected.length > 0
492 ? [
493 {
494 // `effect-tsgo tsrs --noEmit` writes nothing another package reads, so dependency order
495 // only made dependents wait: --parallel cut a contracts change from 13 s to 9 s.
496 ...command(`typecheck (${affected.length} packages)`, [
497 "run",
498 "--cache",
499 "--parallel",
500 "--concurrency-limit",
501 String(typecheckWorkers),
502 ...filters(affected),
503 "typecheck",
504 ]),
505 weight: typecheckWorkers,
506 },
507 ]
508 : []),
509 // From the repo root, the root vite.config.ts hands each file to its package's config.
510 ...(vitestTests.length > 0
511 ? [
512 {
513 ...command(`test (${vitestTests.length} files)`, [
514 "test",
515 "run",
516 "--passWithNoTests",
517 ...vitestTests,
518 ]),
519 serial: true,
520 },
521 ]
522 : []),
523 ...(nodeTests.length > 0
524 ? [
525 command(`node --test (${nodeTests.length} files)`, [
526 "exec",
527 "node",
528 "--test",
529 ...nodeTests,
530 ]),
531 ]
532 : []),
533 ...(compilerApps.length > 0
534 ? [
535 {
536 ...command(`react compiler (${compilerFiles} files)`, [
537 "test",
538 "run",
539 ...compilerApps.map((app) => app.test),
540 "-t",
541 compilerApps.flatMap((app) => app.pattern).join("|"),
542 ]),
543 serial: true,
544 },
545 ]
546 : []),
547 ];
548
549 const results = yield* runSteps(steps, { concurrency, timeoutSeconds });
550
551 const summary = ["check:changed summary"];
552 for (const result of results) {
553 summary.push(` ${result.outcome} ${result.name} ${result.time}`);
554 }
555 if (results.some((result) => result.outcome === "TIMEOUT")) {
556 summary.push(
557 ` TIMEOUT: ran past ${timeoutSeconds}s and was killed; this is not a check failure. ` +
558 "Raise --timeout or lower --concurrency.",
559 );
560 }
561 const ciOnly = Object.entries({
562 "full test suites": outputs.test || outputs.test_web || outputs.test_server,
563 build: outputs.build,
564 rust: outputs.rust,
565 "mobile native lint": outputs.mobile_native,
566 "release smoke": outputs.release_smoke,
567 }).flatMap(([job, runs]) => (runs ? [job] : []));
568 if (ciOnly.length > 0) summary.push(` CI also runs: ${ciOnly.join(", ")}`);
569 if (!runPatchCheck) summary.push(" unused patch check skipped: no dependency files changed");
570 if (!runKnip) summary.push(" knip skipped: no code or manifest changed");
571 yield* Console.log(`\n${summary.join("\n")}`);
572
573 if (results.some((result) => result.outcome !== "pass")) return false;
574 if ((yield* inputKey) !== key) {
575 yield* Console.log(" pass not cached: files changed during the run");
576 return true;
577 }
578 // Write then rename, so a concurrent run never reads half a file.
579 const temp = `${cachePath}.${process.pid}`;
580 yield* Effect.gen(function* () {
581 yield* fs.makeDirectory(path.dirname(cachePath), { recursive: true });
582 yield* fs.writeFileString(temp, yield* encodeCacheEntry({ key, summary }));
583 yield* fs.rename(temp, cachePath);
584 }).pipe(Effect.catch((error) => Console.warn(` pass not cached: ${error.message}`)));
585 return true;
586});
587
588export const checkChangedCommand = Command.make(
589 "check-changed",
590 {
591 ref: Argument.String("ref").pipe(
592 Argument.withDescription("Compare against the merge base with this ref."),
593 Argument.optional,
594 ),
595 concurrency: Flag.Int("concurrency").pipe(
596 Flag.withDescription(
597 "Budget of parallel steps and typecheck workers; also CHECK_CHANGED_CONCURRENCY. Defaults to one per two cores and per 6 GiB of available memory.",
598 ),
599 Flag.optional,
600 ),
601 timeout: Flag.Int("timeout").pipe(
602 Flag.withDescription(
603 "Seconds before a step is killed; also CHECK_CHANGED_TIMEOUT. Defaults to 900.",
604 ),
605 Flag.optional,
606 ),
607 },
608 (input) =>
609 checkChanged(input).pipe(
610 Effect.catch((message) => Console.error(message).pipe(Effect.as(false))),
611 // exitCode, not a failed exit: runMain calls process.exit() on failure, and
612 // piped stdout is asynchronous on macOS, so it would cut off step output.
613 Effect.flatMap((ok) =>
614 Effect.sync(() => {
615 if (!ok) process.exitCode = 1;
616 }),
617 ),
618 ),
619).pipe(Command.withDescription("Run CI's fast checks for this branch's changes."));
620
621if (import.meta.main) {
622 Command.run(checkChangedCommand, { version: "0.0.0" }).pipe(
623 Effect.provide(NodeServices.layer),
624 NodeRuntime.runMain,
625 );
626}