The last post had an “intellectual honesty moment” section. One of the gaps I called out: version bump detection. find_changed diffs the lockfile – if numpy goes from 1.24 to 1.25, every package using numpy gets retested. Bazel doesn’t do this. @pypi//numpy is @pypi//numpy regardless of version.
I said “this matters for correctness.”
Then I went and fixed it. 264 lines of Python.
The problem, precisely
Here’s what happens in the two systems when someone bumps numpy in uv.lock:
| System | What happens | Result |
|---|---|---|
find_changed |
Diffs lockfile, finds numpy changed, DFS through all consumers | Every numpy-dependent test runs |
| Bazel | Nothing | Nothing |
Bazel’s dep graph says @pypi//numpy. That label is the same whether numpy is 1.24 or 1.25 or 47.0. Bazel’s caching is content-addressed at the target level – the label IS the identity. If the label didn’t change, Bazel thinks nothing changed.
This is correct behavior for a build system. Bazel caches don’t care about version metadata. They care about the content hash of the resolved artifact. If you update uv.lock and run bazel test //..., Bazel will eventually notice the wheel changed and rerun affected tests.
But we don’t run bazel test //.... We run targeted test selection – “these files changed, so run these specific tests.” And uv.lock changing doesn’t appear in any rdeps() query, because uv.lock isn’t a Bazel target. It’s an input to the @pypi hub repo. The version bump is invisible to the query layer.
So if you’re using Bazel queries for test selection (which we are), version bumps are a blind spot. Tests that should run don’t run.
The insight
Here’s the thing that made this tractable: the two pieces of knowledge already exist. They just live in different systems.
The lockfile knows what changed. uv.lock is a structured file. Parse HEAD’s version, parse the base ref’s version, diff them. You get a clean list: numpy went 1.24 to 1.25, pandas was added, flask was removed.
Bazel knows who cares. bazel query 'kind(py_test, rdeps(//..., @pypi//numpy))' returns every test target that transitively depends on numpy. This query already works – we proved it in the dep graph post. External @pypi// labels are first-class nodes in the graph.
The script is just glue between these two facts. Detect what changed (lockfile diffing), then ask who cares (Bazel rdeps). Neither system can do both. Together, they cover the gap.
The algorithm
264 lines. Here’s what they do:
1. Parse uv.lock (HEAD) -- reuse existing ci.config_parsers.uv_lock
2. Parse uv.lock (base ref) -- git show origin/main:uv.lock | same parser
3. Filter to external packages -- exclude workspace members
4. Diff: added, removed, version-bumped
5. For each changed package (except removed):
a. Normalize name (PEP 503)
b. Map to @pypi//{name}
c. bazel query 'kind(py_test, rdeps(//..., @pypi//{name}))'
6. Union all affected targets
7. Output
Step 2 is the key move. You need two snapshots of the lockfile to diff. git show {base_ref}:uv.lock gives you the base version without checking out the branch. The existing ci.utils.git.git_show helper already does this.
Step 5a matters more than you’d think. PyPI package names are case-insensitive and treat hyphens, underscores, and dots as equivalent. PyYAML and pyyaml are the same package. scikit_learn and scikit-learn are the same package. PEP 503 normalization: lowercase, collapse [-_.]+ to a single hyphen. Without this, you’d miss version bumps because the lockfile says PyYAML but Bazel’s label is @pypi//pyyaml.
Step 5c has a subtlety: removed packages get skipped. If flask was in the old lockfile but not the new one, @pypi//flask doesn’t exist in the current graph. Running rdeps on a nonexistent label errors. So removed packages are reported in the diff output but don’t trigger queries. (If someone removed a dep they were still importing… well, that’ll fail at build time. Not our problem.)
What it doesn’t reinvent
This could have been another 1,600-line tool. It’s not. Because the hard parts already exist:
- Lockfile parsing:
ci.config_parsers.uv_lock.parse_uv_lock– a pydantic model that already knows how to readuv.lockinto structured data. Existed before this script. I just call it. - Git operations:
ci.utils.git.git_show– reads a file from an arbitrary git ref. Existed before this script. - Dependency graph: Bazel’s
rdeps()query – the dep graph we built in the previous post. Existed before this script.
The script adds: PEP 503 normalization, the diffing logic, and the glue that pipes one system’s output into another. That’s it. 264 lines of glue between things that already know everything.
Compare to find_changed: ~1,600 lines that builds its own dependency graph, its own file-level analysis, its own lockfile diffing, its own package mapping. It does version bumps AND dep tracking AND file-level analysis. This script does ONE thing: version bump to affected targets. Separation of concerns.
The CLI
Three output modes, because different consumers want different things:
# Default: affected test targets (pipe to bazel test)
uv run python bazel/tools/detect_version_bumps.py --base_ref origin/main
# //mai_layers/tests:test_attention
# //rocket/src/rocket/tests:test_model
# ...
# Human-readable diff
uv run python bazel/tools/detect_version_bumps.py --output packages
# ~ numpy (1.24.0 -> 1.25.0)
# + pandas (2.1.0)
# - flask (3.0.0)
# Structured for CI
uv run python bazel/tools/detect_version_bumps.py --output json
The targets output is designed to be piped directly into bazel test. The packages output is designed for humans in PR reviews – “here’s what changed in the lockfile and what it means.” The json output is for whatever automation you want to build next.
Uses fire for CLI, because that’s the repo convention. No argparse, no tyro for this one – fire with a single function is the right weight.
Error handling, or: what Bazel does when it doesn’t know
One design decision worth calling out. When bazel query rdeps(//..., @pypi//some_package) fails – maybe the label doesn’t exist, maybe Bazel’s loading phase errors on something unrelated – the script logs the error and skips that package. It doesn’t abort.
This is a deliberate correctness tradeoff. A strict tool would say “I couldn’t determine the impact of the some_package bump, therefore I must fail loud and force you to investigate.” A pragmatic tool says “I found 47 out of 48 bumped packages’ dependents. Here’s what I have. The one I missed is logged.”
In CI, failing the version bump check because one obscure package’s Bazel label is misconfigured would block every PR that touches uv.lock. The pragmatic choice is: report what you can, log what you can’t, let the human decide if the gap matters.
264 vs 1,600
I keep mentioning this ratio because it illustrates something about tool design.
find_changed is 1,600 lines because it does everything. It builds both dependency graphs (package-level from uv.lock, file-level from ruff analyze), traces changes through them, handles lockfile diffing, handles non-Python deps, handles conftest propagation, handles runtime annotations. It’s a monolith that answers every test selection question.
This script is 264 lines because it does one thing. It detects version bumps in the lockfile and maps them to Bazel test targets. It delegates the hard parts – lockfile parsing, git operations, dependency graph traversal – to systems that already exist.
The line count isn’t the point. The architecture is. find_changed is a self-contained system. This script is a composition of existing systems. The lockfile parser doesn’t know about Bazel. Bazel doesn’t know about the lockfile. The script connects them.
When find_changed eventually gets retired, its lockfile diffing logic goes with it. This script survives because it doesn’t own any of the systems it composes. It’s pure glue. Glue is cheap to maintain, cheap to replace, and cheap to understand.
The series gap, closed
Post 1 said the monorepo has two dependency graphs that disagree. Post 2 said the homegrown tool hit its ceiling. Post 3 showed how to get 100% graph coverage with manual stubs. Post 4 was honest about what the graph still doesn’t solve.
This post closes one of those gaps. Version bumps in external dependencies now map to affected Bazel test targets. The script sits alongside Bazel – not inside it, not replacing it – as a bridge between the lockfile’s change detection and the dep graph’s impact analysis.
Bazel still doesn’t care what version of numpy you have. It doesn’t need to. Something outside Bazel cares, and tells Bazel what to retest.
That’s the pattern, if you zoom out. Bazel is excellent at “what depends on what.” It’s blind to “what changed.” Git is excellent at “what changed.” It’s blind to dependencies. The interesting tools live in the gap between the two – taking change signals from one system and impact analysis from the other.
264 lines in the gap. That’s all it took.
This is the fifth post in a series on Bazel migration and monorepo dependency management. Previously: Your Monorepo Has Two Dependency Graphs (and They Disagree), When Homegrown Tools Hit the Ceiling, The Dep Graph Is the Product, What a Full Dep Graph Actually Unlocks.