From 09b94227e9a4b1e60f79259796cf556995650092 Mon Sep 17 00:00:00 2001 From: Diego Carlino Date: Mon, 6 Apr 2026 07:55:26 +0200 Subject: [PATCH] Gate preflight jobs by affected changes Use the preflight dependency graph to decide which CI jobs need to run so pull requests skip unrelated checks. --- .github/workflows/preflight.yml | 49 +++++++++++ scripts/preflight.test.ts | 97 +++++++++++++++++++++ scripts/preflight.ts | 148 ++++++++++++++++++++++++++++++-- 3 files changed, 286 insertions(+), 8 deletions(-) create mode 100644 scripts/preflight.test.ts diff --git a/.github/workflows/preflight.yml b/.github/workflows/preflight.yml index 8151a40..7756490 100644 --- a/.github/workflows/preflight.yml +++ b/.github/workflows/preflight.yml @@ -8,8 +8,37 @@ permissions: contents: read jobs: + detect: + name: Detect Affected Jobs + runs-on: ubuntu-latest + outputs: + run_biome_format: ${{ steps.detect.outputs.run_biome_format }} + run_typescript_packages: ${{ steps.detect.outputs.run_typescript_packages }} + run_extension: ${{ steps.detect.outputs.run_extension }} + run_docs: ${{ steps.detect.outputs.run_docs }} + run_landing: ${{ steps.detect.outputs.run_landing }} + run_demo: ${{ steps.detect.outputs.run_demo }} + run_playground: ${{ steps.detect.outputs.run_playground }} + run_desktop_macos: ${{ steps.detect.outputs.run_desktop_macos }} + run_python_sdk: ${{ steps.detect.outputs.run_python_sdk }} + run_rust_sdk: ${{ steps.detect.outputs.run_rust_sdk }} + run_go_sdk: ${{ steps.detect.outputs.run_go_sdk }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Detect affected preflight jobs + id: detect + run: bun run scripts/preflight.ts --list --since "${{ github.event.pull_request.base.sha }}" --github-output "$GITHUB_OUTPUT" + biome-format: name: Biome Format Check + needs: detect + if: needs.detect.outputs.run_biome_format == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -25,6 +54,8 @@ jobs: typescript-packages: name: TypeScript Packages + needs: detect + if: needs.detect.outputs.run_typescript_packages == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -43,6 +74,8 @@ jobs: extension: name: Chrome Extension + needs: detect + if: needs.detect.outputs.run_extension == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -63,6 +96,8 @@ jobs: docs: name: Docs + needs: detect + if: needs.detect.outputs.run_docs == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -89,6 +124,8 @@ jobs: landing: name: Landing Site + needs: detect + if: needs.detect.outputs.run_landing == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -111,6 +148,8 @@ jobs: demo: name: Demo Site + needs: detect + if: needs.detect.outputs.run_demo == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -130,6 +169,8 @@ jobs: playground: name: Playground + needs: detect + if: needs.detect.outputs.run_playground == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -149,6 +190,8 @@ jobs: desktop-macos: name: Desktop macOS Smoke Build + needs: detect + if: needs.detect.outputs.run_desktop_macos == 'true' runs-on: macos-latest steps: - uses: actions/checkout@v4 @@ -172,6 +215,8 @@ jobs: python-sdk: name: Python SDK + needs: detect + if: needs.detect.outputs.run_python_sdk == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -189,6 +234,8 @@ jobs: rust-sdk: name: Rust SDK + needs: detect + if: needs.detect.outputs.run_rust_sdk == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -202,6 +249,8 @@ jobs: go-sdk: name: Go SDK + needs: detect + if: needs.detect.outputs.run_go_sdk == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/scripts/preflight.test.ts b/scripts/preflight.test.ts new file mode 100644 index 0000000..2b177ea --- /dev/null +++ b/scripts/preflight.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, test } from "bun:test"; + +import { buildComponents, computeAffected, computeCiJobPlan } from "./preflight"; + +const components = buildComponents(); + +function getCiJobPlan(changedFiles: string[]) { + const { ordered, repoWide } = computeAffected(components, changedFiles, false); + return computeCiJobPlan(ordered, changedFiles, repoWide); +} + +describe("preflight CI detection", () => { + test("root Bun dependency changes only trigger Bun-backed jobs", () => { + const plan = getCiJobPlan(["package.json"]); + + expect(plan.run_biome_format).toBe(false); + expect(plan.run_typescript_packages).toBe(true); + expect(plan.run_extension).toBe(true); + expect(plan.run_docs).toBe(true); + expect(plan.run_landing).toBe(true); + expect(plan.run_demo).toBe(true); + expect(plan.run_playground).toBe(true); + expect(plan.run_desktop_macos).toBe(true); + expect(plan.run_python_sdk).toBe(false); + expect(plan.run_rust_sdk).toBe(false); + expect(plan.run_go_sdk).toBe(false); + }); + + test("Rust SDK changes propagate to desktop but not other language jobs", () => { + const plan = getCiJobPlan(["packages/rust/slop-ai/Cargo.toml"]); + + expect(plan.run_biome_format).toBe(false); + expect(plan.run_typescript_packages).toBe(false); + expect(plan.run_extension).toBe(false); + expect(plan.run_docs).toBe(false); + expect(plan.run_landing).toBe(false); + expect(plan.run_demo).toBe(false); + expect(plan.run_playground).toBe(false); + expect(plan.run_desktop_macos).toBe(true); + expect(plan.run_python_sdk).toBe(false); + expect(plan.run_rust_sdk).toBe(true); + expect(plan.run_go_sdk).toBe(false); + }); + + test("Biome config changes only trigger the formatting job", () => { + const plan = getCiJobPlan(["biome.json"]); + + expect(plan.run_biome_format).toBe(true); + expect(plan.run_typescript_packages).toBe(false); + expect(plan.run_extension).toBe(false); + expect(plan.run_docs).toBe(false); + expect(plan.run_landing).toBe(false); + expect(plan.run_demo).toBe(false); + expect(plan.run_playground).toBe(false); + expect(plan.run_desktop_macos).toBe(false); + expect(plan.run_python_sdk).toBe(false); + expect(plan.run_rust_sdk).toBe(false); + expect(plan.run_go_sdk).toBe(false); + }); + + test("root TypeScript build script changes trigger only jobs that use it", () => { + const plan = getCiJobPlan(["scripts/build-typescript-packages.ts"]); + + expect(plan.run_biome_format).toBe(true); + expect(plan.run_typescript_packages).toBe(true); + expect(plan.run_extension).toBe(false); + expect(plan.run_docs).toBe(false); + expect(plan.run_landing).toBe(false); + expect(plan.run_demo).toBe(true); + expect(plan.run_playground).toBe(true); + expect(plan.run_desktop_macos).toBe(false); + expect(plan.run_python_sdk).toBe(false); + expect(plan.run_rust_sdk).toBe(false); + expect(plan.run_go_sdk).toBe(false); + }); + + test("TypeScript source changes trigger the Biome formatter job", () => { + const plan = getCiJobPlan(["packages/typescript/sdk/core/src/index.ts"]); + + expect(plan.run_biome_format).toBe(true); + expect(plan.run_typescript_packages).toBe(true); + }); + + test("Biome excludes do not trigger the formatter job", () => { + const plan = getCiJobPlan(["examples/full-stack/tanstack-start/src/routeTree.gen.ts"]); + + expect(plan.run_biome_format).toBe(false); + }); + + test("workflow changes force every preflight job to run", () => { + const plan = getCiJobPlan([".github/workflows/preflight.yml"]); + + for (const shouldRun of Object.values(plan)) { + expect(shouldRun).toBe(true); + } + }); +}); diff --git a/scripts/preflight.ts b/scripts/preflight.ts index c557553..aaa55ab 100644 --- a/scripts/preflight.ts +++ b/scripts/preflight.ts @@ -1,4 +1,4 @@ -import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { dirname, join, relative, resolve } from "node:path"; import { spawnSync } from "node:child_process"; import { getTypeScriptPackages, readJson, repoRoot, type PackageManifest } from "./release/shared"; @@ -38,8 +38,17 @@ type Args = { listOnly: boolean; since?: string; files: string[]; + githubOutput?: string; }; +type CiJob = { + label: string; + output: string; + matches: (affectedIds: Set, changedFiles: string[], repoWide: boolean) => boolean; +}; + +type CiJobPlan = Record; + const SCAN_DIRS = ["apps", "examples", "website", "benchmarks"]; const WALK_IGNORE = new Set([ ".git", @@ -53,6 +62,12 @@ const WALK_IGNORE = new Set([ ".tanstack", ".angular", ]); +const JS_WORKSPACE_SHARED_ROOTS = ["package.json", "bun.lock"]; +const TYPESCRIPT_BUILD_TRIGGER = "scripts/build-typescript-packages.ts"; +const REPO_WIDE_TRIGGER_FILES = new Set([".github/workflows/preflight.yml", "scripts/preflight.ts"]); +const BIOME_TRIGGER_FILES = new Set(["biome.json"]); +const BIOME_EXCLUDED_FILES = new Set(["examples/full-stack/tanstack-start/src/routeTree.gen.ts"]); +const BIOME_FORMAT_FILE_REGEX = /\.(?:[cm]?[jt]sx?)$/; function parseArgs(argv: string[]): Args { const args: Args = { @@ -75,6 +90,9 @@ function parseArgs(argv: string[]): Args { args.files.push(normalizeRepoPath(argv[i + 1])); i += 1; } + } else if (arg === "--github-output") { + args.githubOutput = argv[i + 1]; + i += 1; } else { throw new Error(`Unknown argument: ${arg}`); } @@ -201,6 +219,14 @@ function createPythonCheck(relativeDir: string, args: string[], label: string): }; } +function uniqueRoots(...rootGroups: string[][]): string[] { + return [...new Set(rootGroups.flat())]; +} + +function getSharedPackageRoots(): string[] { + return [...JS_WORKSPACE_SHARED_ROOTS]; +} + function buildPackageChecks(relativeDir: string, manifest: PackageManifest): Check[] { const scripts = manifest.scripts ?? {}; @@ -243,7 +269,7 @@ function collectPackageComponents(): Component[] { manifestPath: pkg.manifestPath, relativeDir: pkg.relativeDir, absoluteRoot: pkg.dir, - roots: [pkg.relativeDir], + roots: uniqueRoots([pkg.relativeDir], getSharedPackageRoots()), }); } @@ -264,7 +290,10 @@ function collectPackageComponents(): Component[] { manifestPath, relativeDir, absoluteRoot, - roots: relativeDir === "website/docs" ? [relativeDir, "docs", "spec"] : [relativeDir], + roots: uniqueRoots( + relativeDir === "website/docs" ? [relativeDir, "docs", "spec"] : [relativeDir], + getSharedPackageRoots(), + ), excludedRoots: relativeDir === "apps/desktop" ? ["apps/desktop/src-tauri"] : undefined, }); } @@ -481,7 +510,7 @@ function collectPythonComponents(): Component[] { }); } -function buildComponents(): Component[] { +export function buildComponents(): Component[] { return [ ...collectPackageComponents(), ...collectCargoComponents(), @@ -504,7 +533,19 @@ function fileMatchesComponent(filePath: string, component: Component): boolean { } function isRepoWideTrigger(filePath: string): boolean { - return filePath === "package.json" || filePath === "bun.lock" || filePath.startsWith("scripts/"); + return REPO_WIDE_TRIGGER_FILES.has(filePath); +} + +function isBiomeTrigger(filePath: string): boolean { + if (BIOME_TRIGGER_FILES.has(filePath)) { + return true; + } + + if (BIOME_EXCLUDED_FILES.has(filePath)) { + return false; + } + + return BIOME_FORMAT_FILE_REGEX.test(filePath); } function topologicalSort(components: Component[], affectedIds: Set): Component[] { @@ -555,7 +596,7 @@ function topologicalSort(components: Component[], affectedIds: Set): Com return ordered; } -function computeAffected( +export function computeAffected( components: Component[], changedFiles: string[], runAll: boolean, @@ -565,7 +606,6 @@ function computeAffected( repoWide: boolean; } { const causes = new Map(); - const componentMap = new Map(components.map((component) => [component.id, component])); const reverseDeps = new Map(); for (const component of components) { @@ -617,6 +657,89 @@ function computeAffected( }; } +const CI_JOBS: CiJob[] = [ + { + label: "Biome Format Check", + output: "run_biome_format", + matches: (_affectedIds, changedFiles, repoWide) => repoWide || changedFiles.some(isBiomeTrigger), + }, + { + label: "TypeScript Packages", + output: "run_typescript_packages", + matches: (affectedIds, changedFiles, repoWide) => + repoWide || + changedFiles.includes(TYPESCRIPT_BUILD_TRIGGER) || + [...affectedIds].some((id) => id.startsWith("pkg:packages/typescript/")), + }, + { + label: "Chrome Extension", + output: "run_extension", + matches: (affectedIds) => affectedIds.has("pkg:apps/extension"), + }, + { + label: "Docs", + output: "run_docs", + matches: (affectedIds) => affectedIds.has("pkg:website/docs"), + }, + { + label: "Landing Site", + output: "run_landing", + matches: (affectedIds) => affectedIds.has("pkg:website/landing"), + }, + { + label: "Demo Site", + output: "run_demo", + matches: (affectedIds, changedFiles, repoWide) => + repoWide || changedFiles.includes(TYPESCRIPT_BUILD_TRIGGER) || affectedIds.has("pkg:website/demo"), + }, + { + label: "Playground", + output: "run_playground", + matches: (affectedIds, changedFiles, repoWide) => + repoWide || changedFiles.includes(TYPESCRIPT_BUILD_TRIGGER) || affectedIds.has("pkg:website/playground"), + }, + { + label: "Desktop macOS Smoke Build", + output: "run_desktop_macos", + matches: (affectedIds) => affectedIds.has("pkg:apps/desktop") || affectedIds.has("cargo:apps/desktop/src-tauri"), + }, + { + label: "Python SDK", + output: "run_python_sdk", + matches: (affectedIds) => affectedIds.has("py:packages/python/slop-ai"), + }, + { + label: "Rust SDK", + output: "run_rust_sdk", + matches: (affectedIds) => affectedIds.has("cargo:packages/rust/slop-ai"), + }, + { + label: "Go SDK", + output: "run_go_sdk", + matches: (affectedIds) => affectedIds.has("go:packages/go/slop-ai"), + }, +]; + +export function computeCiJobPlan(ordered: Component[], changedFiles: string[], repoWide: boolean): CiJobPlan { + const affectedIds = new Set(ordered.map((component) => component.id)); + + return Object.fromEntries( + CI_JOBS.map((job) => [job.output, job.matches(affectedIds, changedFiles, repoWide)]), + ) as CiJobPlan; +} + +function printCiJobPlan(plan: CiJobPlan): void { + console.log("CI jobs:\n"); + for (const job of CI_JOBS) { + console.log(`- ${job.label}: ${plan[job.output] ? "run" : "skip"}`); + } +} + +function writeGithubOutputs(filePath: string, plan: CiJobPlan): void { + const lines = CI_JOBS.map((job) => `${job.output}=${plan[job.output] ? "true" : "false"}`); + writeFileSync(filePath, `${lines.join("\n")}\n`); +} + function printPlan(ordered: Component[], causes: Map, repoWide: boolean): void { if (ordered.length === 0) { console.log("No affected components."); @@ -677,8 +800,15 @@ function main(): void { const changedFiles = detectChangedFiles(args); const components = buildComponents(); const { ordered, causes, repoWide } = computeAffected(components, changedFiles, args.all); + const ciJobPlan = computeCiJobPlan(ordered, changedFiles, repoWide); printPlan(ordered, causes, repoWide); + console.log(""); + printCiJobPlan(ciJobPlan); + + if (args.githubOutput) { + writeGithubOutputs(args.githubOutput, ciJobPlan); + } if (!args.listOnly) { console.log(""); @@ -686,4 +816,6 @@ function main(): void { } } -main(); +if (import.meta.main) { + main(); +}