Every monorepo has this problem: you changed three files, but the repo has a hundred packages. Which tests do you run? Run everything and you’re burning 45 minutes of CI on every PR. Run nothing and you’re pushing broken code to main.
The usual answer is “use Bazel” or “use some affected-test-detection tool.” But what does that tool actually do? Like, concretely, what happens between git diff and “here’s your test matrix”? I’ve been staring at our test selector’s source code for a week, and the internals are more interesting than I expected.
This is about find_changed — the ~2,650-line Python system that decides which tests run on every PR in our monorepo. Not a Bazel pitch. Not a comparison post. Just: here’s what’s under the hood of a real, production test selector.
Two graphs, one question
The core insight is that find_changed maintains two parallel dependency graphs and walks them both.
Graph 1: Package-level, built from uv.lock. Every workspace package is a node. Every external dependency (numpy, pydantic, torch) is a node. Edges are dependency relationships from the lockfile — including version numbers. This graph answers: “did package X or any of its transitive dependencies change?”
Graph 2: File-level, built from ruff analyze graph plus Python AST scanning. Every .py file is a node. Edges are import relationships. This graph answers the finer question: “did the specific files that this test file transitively imports change?”
The two graphs feed into two different modes:
| Mode | Graph Used | Granularity | Who consumes it |
|---|---|---|---|
find_changed |
Package-level | “Did package X change?” (bool) | GPU workflow gating |
tests / tests-by-package |
File-level | “Which test files are affected?” (list) | CPU test matrix, GPU test selection |
The GPU workflow uses both: find_changed for coarse gating (“should we build the Docker image at all?”) and tests for fine-grained file selection (“which specific test files need a GPU?”). Two questions, two granularities, same system.
Here’s the data flow:
┌─────────────┐
│ git diff │
│ --name-only │
└──────┬──────┘
│
▼
┌──────────────────────────────────────┐
│ Two Parallel Graph Builders │
│ │
│ PACKAGE GRAPH FILE GRAPH │
│ uv.lock ──────► ruff analyze ──► │
│ parse_uv_lock() _run_ruff_graph()│
│ │ │ │
│ ▼ ▼ │
│ WorkspaceDist WorkspaceFile │
│ + ExternalDist + AST scanning │
│ + PkgDepGlobs + yolo.ci hdrs │
└──────┬──────────────────┬─────────────┘
│ │
▼ ▼
┌─────────────┐ ┌──────────────────┐
│ Package DFS │ │ File-level DFS │
│ (version │ │ (imports + │
│ bumps, │ │ conftest + │
│ dep sets) │ │ globs + │
│ │ │ externals) │
└──────┬──────┘ └──────┬───────────┘
│ │
▼ ▼
┌────────────────────────────────────┐
│ GitHub Actions Workflows │
│ CPU tests → tests-by-package │
│ GPU tests → find_changed + tests │
└────────────────────────────────────┘
Building the package graph
The package graph comes from uv.lock — our lockfile. The builder is build_workspace_distributions(), and the logic is:
- Parse
uv.lockvia Pydantic (the lockfile is TOML) - Filter by environment — evaluate uv’s resolution markers, PEP 508 markers, Python version. Default target: Linux x86_64 Python 3.12
- Partition — workspace members become
WorkspaceDistributionnodes, everything else becomesExternalDistributionnodes with name + version - Wire edges — regular + optional + dev dependencies per workspace member
- Inject non-Python deps from
package_extra_dependencies.yml(more on this later)
The data structures are clean:
@dataclass(slots=True, kw_only=True)
class ExternalDistribution:
distribution: Distribution # name + version
dependencies: list["ExternalDistribution"]
@dataclass(slots=True, kw_only=True)
class WorkspaceDistribution:
name: DistributionName
dependencies: list[WorkspaceDistribution | ExternalDistribution | PackageDependencyGlob]
Notice that a workspace distribution’s dependencies can be other workspace packages, external packages, or glob patterns for non-Python files. That third type is how the system knows that if you change a Dockerfile, the packages that depend on it need retesting.
Building the file graph
This is where it gets clever. The file graph is built in three layers:
Layer 1: ruff. ruff analyze graph --direction=dependencies produces a JSON map of file → imported files. One subprocess call for the entire workspace. Fast, but it only sees static Python imports.
def _run_ruff_graph(repo_root: Path, paths: Iterable[Path]) -> dict[Path, list[Path]]:
cmd = [
"uv", "run", "ruff", "analyze", "graph",
"--direction=dependencies",
"--config", "namespace-packages=[]",
*expanded_paths,
]
r = subprocess.run(cmd, capture_output=True, text=True, cwd=str(repo_root), check=False)
raw = json.loads(r.stdout or "{}")
graph: dict[Path, list[Path]] = {}
for key, deps in raw.items():
if _should_skip_file(key):
continue
filtered_deps = [d for d in deps if not _should_skip_file(d)]
graph[Path(key)] = [Path(dep) for dep in filtered_deps or []]
return graph
Layer 2: AST scanning. Every file in the import graph gets parsed to extract:
- All
import X/from X import Ystatements → top-level package names # /// [yolo.ci]headers (inline TOML, more on this below)
This runs in parallel — up to 32 workers via ProcessPoolExecutor. Results are cached to disk by SHA256 of file content, sharded into .cache/ci/ast_scan/. The cache survives across CI runs via GitHub Actions cache.
Layer 3: Package name resolution. This is the gnarly part. You have an import like import numpy. Which distribution is that? Usually numpy. But import PIL is the pillow distribution. import yaml is pyyaml. import cv2 is opencv-python. The mapping isn’t always obvious.
The resolver tries four strategies in order:
def package_to_distributions(pkg: PackageName) -> set[DistributionName]:
if is_stdlib_base(pkg.base): return set() # stdlib — skip
if _is_private_package(pkg): return set() # _foo — skip
if pkg.base in IGNORE_PACKAGES: return set() # known unresolvable
# Strategy 1: installed metadata (top_level.txt)
dists = _module_to_distnames().get(pkg.base)
if not dists:
# Strategy 2: uv.lock fallback
uv_dists = _module_to_distnames_from_uv_lock().get(pkg.base)
if uv_dists:
return {d for d in uv_dists if not _is_stub(d.canonical)}
raise KeyError(f'Could not find "{pkg.base}"')
return {d for d in dists if not _is_stub(d.canonical)}
When that chain fails, there’s a manual override table — KNOWN_DIST_TO_IMPORT — with ~30 hardcoded mappings. And ~35 packages in IGNORE_PACKAGES that are known unresolvable (CUDA extensions, internal packages, test helpers).
Every time someone adds a new package with a non-standard import name and forgets to add it to the mapping table, find_changed throws a KeyError. Ask me how I know.
The DFS walk
Both graphs get traversed with the same core primitive — a decorator called dfs_cached that handles memoization and cycle detection in 40 lines:
def dfs_cached(memo, key, return_on_cycle):
visiting: set[K] = set()
def decorator(fn):
def wrapper(*args, **kwargs):
k = key(*args, **kwargs)
if k in memo: # Already computed — return cached
return memo[k]
if k in visiting: # Cycle detected — bail
return return_on_cycle
visiting.add(k)
try:
res = fn(*args, **kwargs)
finally:
visiting.remove(k)
memo[k] = res
return res
return wrapper
return decorator
Three states per node: unvisited, visiting, computed. When you hit a node that’s currently being visited, you’ve found a cycle — return the conservative default and move on. No separate visited/path sets. No recursion limit tricks. Just clean state tracking.
This decorator wraps the real change-detection logic. The file-level checker’s _file_differs() runs a 5-step priority cascade for each file:
@dfs_cached(self._fd_memo, key=lambda file: id(file), return_on_cycle=None)
def _file_differs(file: WorkspaceFile) -> DiffResult | None:
# 1. Direct change — is this file in the diff?
if self._path_changed(file.path):
return DiffResult(path=file.path, trigger=Trigger(kind="path", ...))
files, exts, globs = self._partition_file(file)
# 2. Glob direct hit — do any declared glob patterns match a changed path?
glob_hit = self._glob_hits(globs)
if glob_hit is not None:
return DiffResult(...)
# 3. Glob transitive — expand globs, then check if matched files' imports changed
for glob in globs:
glob_res = self._glob_transitive_diff(glob, _file_differs)
if glob_res is not None:
return DiffResult(path=file.path, trigger=glob_res)
# 4. External version bump — compare base vs head external deps
for name, head_ext in exts.items():
base_ext = self.base_externals.get(name)
if base_ext is None or self.dist_checker.externals_differ(base_ext, head_ext):
return DiffResult(...)
# 5. Recursive import walk — follow the import graph
for child in files.values():
child_res = _file_differs(child)
if child_res is not None:
return DiffResult(path=file.path, trigger=child_res)
return None
Step 3 is the subtle one. If a test declares dependencies = ["scripts/*.py"] via a yolo.ci header, and one of those scripts’ imports changed (not the script itself), the test still gets flagged. Two-level expansion — globs to files, then files to their transitive import chains.
Step 4 is the one that catches what most CI systems miss. If someone bumps numpy in uv.lock without changing any Python files, every file that transitively depends on numpy gets flagged. A new version of a dependency is a change — the behavior might be different.
For test files specifically, the checker also walks the conftest chain:
# For test files, also check conftest.py and __init__.py chains
if is_test_file(rel):
files.extend(self._applicable_conftest_files(rel))
files.extend(self._applicable_init_files(rel))
For a/b/c/test_foo.py, this finds: a/b/c/conftest.py, a/b/conftest.py, a/conftest.py, conftest.py. If any of them changed, the test is affected. Very pytest-aware.
The escape hatches
Static analysis misses things. find_changed has three mechanisms for the stuff that imports can’t see.
yolo.ci inline headers
Files can declare extra dependencies via embedded TOML in comments:
# /// [yolo.ci]
# dependencies = [
# "mai_trainer/src/mai_trainer/checkpointing/offline_resharding.py",
# ]
# ///
This handles the “test runs a script via subprocess” case, the “test loads a config file by path” case, the “dynamic dispatch that no import scanner can see” case. About 20 files in the repo use these headers.
Available fields:
| Field | Type | Purpose |
|---|---|---|
dependencies |
list[str] |
Glob patterns for file-level deps |
dependency_packages |
list[str] |
Extra distribution names to track |
skip |
bool |
Exclude this file entirely from tracking |
package_extra_dependencies.yml
This config connects non-Python artifacts to the package graph:
mai_kernels: &mai_kernels
- .gitmodules
- mai_kernels/CMakeLists.txt
- mai_kernels/csrc/**
simplertransformer: &simplertransformer
- *mai_kernels # YAML anchor inheritance
- docker-bake.hcl
- docker/simplertransformer/Dockerfile
- simplertransformer/CMakeLists.txt
- simplertransformer/csrc/**
rocket:
- *simplertransformer
- docker/Dockerfile
- docker/rocket/Dockerfile
YAML anchors allow inheritance chains: mai_kernels globs feed into simplertransformer, which feeds into rocket. So if mai_kernels/csrc/attention.cu changes, rocket tests get flagged. An import scanner would never see this.
conftest.py propagation
This one’s automatic, not opt-in. For every test file, the system finds all conftest.py and __init__.py files in ancestor directories and checks them for changes. The discovery is a clean one-liner:
def find_chain(rel: Path, name: str) -> list[Path]:
"""For 'a/b/c/test_foo.py', returns:
['a/b/c/conftest.py', 'a/b/conftest.py', 'a/conftest.py', 'conftest.py']
"""
rel_posix = PurePosixPath(rel.as_posix().rstrip("/"))
return [Path((d / name).as_posix()) for d in rel_posix.parents]
Conservative? Yes — any conftest change triggers every test under it. But that’s the correct semantic for pytest, where conftest fixtures propagate downward through the filesystem tree with no explicit declaration.
How CI consumes the output
The system feeds into GitHub Actions in two main paths.
CPU tests
- Changed files hit the
find-changedcomposite action - If not a blanket trigger-all or skip-all, runs
ci.cli tests-by-package --config ci/configs/cpu_tests.yml - Output: a JSON matrix mapping each affected package to a runner type, pytest flags, and environment
- Matrix fans out into three runner types:
ubuntu-latest,big(self-hosted),cpu(condor cluster)
Each package in cpu_tests.yml specifies its test path, extra flags, paths to ignore, and which runner to use. ~80 packages configured this way.
GPU tests
Three stages:
Stage 1: Coarse gating. ci.cli find_changed checks 8 logical dependency specs (like “did anything in simplertransformer or mai_distributed change?”). Each resolves to true/false. These booleans gate expensive downstream jobs — no point spinning up a GPU runner if nothing GPU-related changed.
Stage 2: Fine-grained selection. ci.cli tests --filter "$GPU_TEST_PKGS" identifies the specific test files. GPU_TEST_PKGS is a hardcoded list of ~13 test paths in the workflow YAML.
Stage 3: Sharding. Tests are distributed across GPU shards using historical duration weights from gpu_test_durations.json. Tests are classified into execution modes — serial (uses all GPUs), isolated (separate process), normal (parallel), or skip.
The coarse gating saves real money. If your PR only touches a docs package, the GPU workflow’s find_changed job runs in seconds, returns “nothing changed,” and no GPU runners spin up.
The trigger-all / skip-all system
Two more escape hatches, but at the workflow level.
Trigger-all: If any changed file matches certain patterns, everything runs. No analysis.
| Workflow | Trigger-all patterns |
|---|---|
| CPU tests | .github/workflows/run_cpu_tests.yml, ci/configs/** |
| GPU tests | gpu_test_durations.json, test_exec_mode.yml, the GPU workflow file itself |
Skip-all: If every changed file matches these patterns, nothing runs.
| Workflow | Skip-all patterns |
|---|---|
| Both | *.md, docs/**, user_scripts/**, experimental/** |
Makes sense. If you changed the CI config, you want full validation. If you only changed markdown, you want zero tests. The escape hatches bracket the analysis from both sides.
The numbers
Let’s make this concrete.
Core system:
| File | LOC | Purpose |
|---|---|---|
cli.py |
631 | CLI entry point, orchestration |
utils/packaging.py |
447 | Import → distribution name resolution |
workspace_graph.py |
329 | Dual graph builder |
changed_files.py |
229 | File-level DFS |
tools/find_changed.py |
205 | Package-level change evaluator |
changed_distributions.py |
163 | Package-level DFS |
utils/ast.py |
111 | AST scanner + yolo.ci parser |
| 9 more files | ~532 | Parsers, utilities, git wrappers |
| Total Python | ~2,647 |
Config and workflow files:
| File | LOC | Purpose |
|---|---|---|
run_gpu_tests.yml |
1,223 | GPU test workflow |
run_cpu_tests.yml |
683 | CPU test workflow |
lint_checks.yml |
481 | Lint + typecheck |
cpu_tests.yml |
322 | Package → test config (~80 packages) |
| 3 more files | ~309 | Composite action, exec modes, extra deps |
| Total config | ~3,018 |
Grand total: ~5,665 lines. Python logic plus the workflow YAML that wires it together.
16 key data structures. 3 caching layers (DFS memoization, AST disk cache, GitHub Actions cache). 7 config files. 8 workflow files consuming the output.
The maintenance surface
Here’s the cost of a homegrown system — the stuff someone has to keep alive.
~35 ignored packages in IGNORE_PACKAGES. CUDA extensions, internal packages, test helpers that can’t be resolved to a distribution name. If one of these changes version, the system won’t detect it.
~30 manual distribution→import mappings in KNOWN_DIST_TO_IMPORT. The PIL → pillow stuff. Every new PyPI package with a non-obvious import name needs a manual entry. Miss one, get a KeyError.
15+ ruff skip paths in RUFF_GRAPH_ANALYZE_SKIP_IMPORTS. Paths that ruff’s import analyzer chokes on or that shouldn’t be tracked (.github/, mai_kernels/csrc/, user_scripts/). Tests in skipped directories don’t get proper import tracking.
3 shadowed packages with special handling. megatron, ds1000, sglang have workspace members that shadow external package names. Fragile.
~80 package entries in cpu_tests.yml, manually maintained. There’s a verify_test_configs.py check to catch missing entries, but someone still has to write the config.
~13 GPU test paths hardcoded in a workflow env var. Adding a new GPU test package means editing YAML.
This is the tax on homegrown. Every one of these is a thing that works until it doesn’t, and “doesn’t” usually manifests as a false negative — a test that should have run, didn’t, and now main is broken.
What it gets right
Credit where it’s due. This system catches things that most CI selectors — including Bazel — don’t.
External version bumps. Bump numpy in the lockfile with zero code changes? Every package that depends on numpy gets retested. Bazel’s @pypi//numpy is @pypi//numpy regardless of version. find_changed actually diffs versions.
Non-Python artifacts. C++ source changes, Dockerfile changes, CMakeLists changes — all wired into the package graph via package_extra_dependencies.yml. Bazel’s Python rules don’t natively know about Dockerfiles.
conftest propagation. Automatic, correct for pytest semantics. Bazel requires you to explicitly declare conftest as a library dependency on each test target. Miss one and it’s a sandbox failure.
The yolo.ci escape hatch. For dynamic dependencies that static analysis can’t see, there’s a simple, file-local way to declare them. Twenty lines of TOML in a comment. No build system changes, no external config.
Content-hash caching. AST scan results are cached by SHA256 of file content, not path. Rename a file, same hash, cache hit. Survives across CI runs.
What it misses
No target-level precision. The system operates at package level (for find_changed) or file level (for tests). It can’t say “only these 3 tests out of 200 in this package are affected.” Bazel can.
Dynamic imports are invisible. importlib.import_module(), __import__(), conditional imports inside functions — none of these create edges in the ruff graph. The yolo.ci header is the escape hatch, but it requires someone to think about it.
The graph is inferred, not declared. Bazel’s dependency graph is authoritative — you declared it, it’s enforced. find_changed infers the graph from imports and lockfiles. If the inference is wrong, you get a false negative.
No test-level caching. Bazel caches individual test results by input hash. find_changed only decides run or skip — it can’t say “this test already passed with this exact set of inputs, don’t bother.”
Cycle handling is conservative. The dfs_cached decorator returns “unchanged” on cycle detection. That means if you have circular imports (Python allows them; they exist), the system might miss a real change.
The bigger picture
find_changed is ~5,665 lines of code and config that answers one question: “what should CI run?” It does this by building two dependency graphs, walking them with DFS, and handling a dozen edge cases that pure import scanning misses.
Is it elegant? Parts of it — the dfs_cached decorator, the dual-graph architecture, the content-hash caching. Those are genuinely good engineering.
Is it sustainable? That’s the harder question. ~35 ignored packages, ~30 manual mappings, ~15 skip paths, ~80 manually-configured test entries. Every one is a thing someone wrote because they understood the system, and every one is a thing that’ll confuse the next person.
The important thing is that this system works. Every PR in our monorepo goes through it. It’s been catching real changes and skipping real non-changes for months. When we talk about graduating to Bazel, we’re not fleeing a broken system. We’re graduating from one that does its job — at a maintenance cost that scales with the repo.
Understanding the incumbent is the first step to replacing it well. You don’t deprecate a system you haven’t studied. That’s how you lose the edge cases it was built for.
This is part of a series on CI and build systems in a Python monorepo. Related: Your Monorepo Has Two Dependency Graphs (and They Disagree) covers the conceptual framing. The Dep Graph Is the Product covers the Bazel side. The Test Hierarchy Map covers the testing concepts. What a Full Dep Graph Actually Unlocks covers what comes next.