5.2.7 Query in CI Pipelines
recommendedSelective CI is safe only when target determination is treated as a correctness decision. A Git diff tells you which paths changed. It does not tell you which Bazel targets must be built or tested. The missing step is to translate paths into graph nodes, compute their consumers in every supported configuration, and fall back conservatively whenever that translation is incomplete.
Define the contract before writing the query
For a pull request from revision B to revision H, a target-determination job
needs four explicit inputs:
- the exact base and head revisions.
- the target universe that CI promises to protect.
- the configurations that CI supports.
- the change classes that invalidate more than ordinary source dependencies.
Its output is a set of target labels, plus enough metadata to reproduce the
decision: revisions, universe, configuration flags, tool version, and fallback
reason. A later job can filter that set to tests or release targets. This keeps
the question “what is affected?” separate from “which CI work should run?” A
real dynamic-pipeline implementation follows the same separation: Git and
query compute candidate packages and reverse dependencies, then an external
program emits the CI steps.1
reverse impact
reverse impact
The universe is part of the safety claim. In
bazel query 'rdeps(//products/..., //lib:pricing)'
the answer can contain consumers under //products/.... It says nothing about
consumers outside that first argument. A narrow universe can be a useful product
boundary, but it must not be described as repository-wide impact. This is the
same reverse-edge model introduced in 2.1.6 Reverse Dependencies & Change Impact and developed
for interactive use in 5.2.1 bazel query — Static Graph Analysis.
Map paths to targets before taking reverse dependencies
The tempting rule “put every changed file into rdeps()” works only for files
that still exist in the queried revision and are represented by source-file
targets in the loaded graph. It is not a general path-to-target mapping.
Classify the diff first:
| Change class | Safe starting point |
|---|---|
| Existing source file | Find the target or targets that declare the file, then compute their reverse dependencies. |
BUILD or BUILD.bazel | Seed all relevant targets declared by that package, because attributes, visibility, target names, and dependency edges may have changed. |
.bzl loaded from repository packages | Find BUILD and .bzl load dependants, then seed targets from the affected packages. A target-graph rdeps() alone does not traverse the load graph. |
.bzl used during repository or module resolution | Fail closed to the protected universe unless the selector explicitly models the repositories and graph inputs it can change. This includes code loaded by WORKSPACE, WORKSPACE.bzlmod, module extensions, and repository rules. |
| Deleted or renamed path | Inspect both revisions. The head graph cannot name a deleted target or recover an edge removed by the change. Treat a rename as deletion plus addition unless equivalence is proved. |
| Workspace, module, lockfile, rc, platform, or toolchain policy | Use a documented broad-impact rule, commonly the full protected universe, unless the target-determination tool models that input explicitly. |
Pinterest's query-based collector used distinct handling for workspace files, BUILD files, extension files, deletions, and ordinary source files. For deleted files it walked upward to candidate BUILD files because the deleted node was no longer available to query. For extension changes it used Sky Query to find load dependants before applying target-graph analysis.2
rbuildfiles() in 5.2.6 Sky Query Mode supplies that missing reverse-load view for
BUILD and .bzl paths. It still returns package-definition evidence, not the
final configured target set. Convert the affected BUILD files into package
targets, then continue the impact calculation. This technique applies to .bzl
loads in repository packages. It is not a safe default for Starlark evaluated
during repository resolution: that code can change which external repositories
or packages exist before the protected target graph can even be loaded. Unless
those effects are explicitly modeled, select the full protected universe.
The base graph matters for every structural change. Suppose a patch deletes
//legacy:codec and removes it from //app:server. Querying only H finds
neither the old target nor the old reverse edge. A robust procedure computes
candidates from both B and H, unions their labels, and reconciles labels
that no longer exist before invoking the head build. This also covers target
renames and package moves without pretending that Git's rename heuristic proves
Bazel-level equivalence.
The
query-ci-base-head-union snippet
makes that union concrete without pretending to be a production selector. In
its base fixture, //legacy:codec owns the changed path
and //app:server consumes it.
The head fixture retains //app:server but deletes the file, owner target, and
edge. The checked-in
selector script
derives an exact file label and package scope from the changed path, excluding
an unrelated codec.txt, then runs bounded rdeps(//..., seed) queries in both
graphs and asserts their union. Running tools/assert_selector.sh produces:
changed path: legacy/codec.txt
base owners:
//legacy:codec
base reverse impact:
//app:server
//legacy:codec
head owners: <none>
head reverse impact: <none>
base/head union:
//app:server
//legacy:codec
The head-only result is empty, but the union preserves //app:server. A real
selector must then reconcile deleted labels before scheduling work. Graph
storage, CI integration, and production-scale policy remain in
6.5.1 Affected-Target Service Contract.
Choose query or cquery per configuration policy
Ordinary query sees every dependency written in a select(). That makes it an
over-approximation of any one configured build: it may schedule targets from
branches that the current configuration does not select. cquery runs analysis
and follows the resolved configured graph, so it can remove those false
positives.3
That precision is conditional, not absolute. A cquery result is precise only
for its top-level context and flags. Running it once for Linux does not protect
Windows, a release build, another CPU, or a different feature flag. Define the
CI configuration matrix first and evaluate every supported row:
affected = union(
impact(base, head, --config=linux),
impact(base, head, --config=windows),
impact(base, head, --config=release),
)
Use the same top-level roots that the corresponding build or test job uses.
cquery --universe_scope determines that configured context, and configured
targets may appear more than once when transitions create multiple
configurations.3 5.2.2 bazel cquery — Configured Graph covers configuration identities and scope in
depth.
Decide: Is one cquery run always safer than one query run for affected-test selection?
Reveal
No. One cquery run is more precise for the configuration it analyzes, but it
can omit consumers that exist only in another supported configuration. An
ordinary query may overbuild because it includes all select() branches. A
configuration matrix of cquery runs can safely reduce that set only when the
matrix and top-level contexts match CI's actual support promise.
This trade-off has an operational cost. Official documentation notes that
cquery evaluates configured targets and therefore takes more time and memory
than query.3 BazelDiff users likewise report supporting both graph modes,
with a substantial performance cost for cquery. Their unconfigured mode uses
explicit seed paths for rare toolchain, Starlark, and .bazelrc changes that
must invalidate the whole selection.4 The decision rule is therefore:
- use
querywhen conservative over-selection across configurations is acceptable and broad-impact inputs are handled explicitly. - use a matrix of
cqueryevaluations when configuration-only edges materially affect correctness or the saved work justifies analysis cost. - never call one configured result “exact” without naming its configuration and top-level universe.
Diff graph meaning, not command output
Saving the labels printed by a query at B and H and subtracting the two sets
detects targets that appeared or disappeared. It does not detect an
unchanged label whose sources, attributes, rule implementation, toolchain, or
transitive dependencies changed. Reverse dependencies seeded from known changed
nodes cover some of those cases, but only after path mapping and non-source
inputs have been handled correctly.
Hash-based tools solve a broader problem by fingerprinting target definitions
and propagating dependency hashes, then comparing the two revision graphs. That
is the central BazelDiff model described by its users.4 It is not equivalent
to git diff plus one rdeps() call, and its exact safety boundary depends on
which rule attributes, external repositories, configurations, toolchains, and
seed paths the chosen version includes. Full BazelDiff mechanics, integration,
and distance filtering continue in 6.5.1 Affected-Target Service Contract.
Treat saved graph data as a cache, not as authority. Its key must include at least the source revision, graph mode, universe, configuration flags, Bazel and target-determination tool versions, and inputs that influence repository or module resolution. Reusing a result under a different key is underbuilding, not an optimization.
Make uncertainty fail closed
The dangerous failure mode is a green pipeline that quietly selected too little. Define broad fallbacks for conditions such as:
- either revision cannot be checked out or queried.
- a changed path cannot be mapped under the documented rules.
- a BUILD or
.bzlload graph fails to load. - the configuration matrix is incomplete.
- an external-dependency, module, toolchain, or CI-policy input changed without precise modeling.
- cached graph metadata does not match the current selection key.
“Broad” means the protected universe, not necessarily every target in a multi-product repository. Record the reason so teams can improve the model rather than silently weakening it.
Roll out selection in shadow mode first: compute the proposed set while still running the established full CI scope, then compare missed failures and target sets over representative changes. BazelDiff practitioners recommend this evidence-building stage before trusting target selection to skip work.4 Periodically run the broad scope as a control, and monitor fallback rate, empty selections, selection latency, selected-target counts, and any failure found only by the control job.
Do not let target determination replace other correctness mechanisms. Tests can only catch behavior they exercise, undeclared dependencies can make the Bazel graph incomplete, and changes outside the modeled graph need policy. Selective CI is a conservative projection of a declared support contract—not proof that unselected software cannot be affected.
A safe query-driven CI pipeline compares both base and head, translates each
change class into graph seeds, computes reverse impact inside an explicit
universe, and unions results across every supported configuration. query
usually over-selects unresolved branches. cquery is more precise only for the
specific configured contexts it analyzes.
Treat BUILD, .bzl, deletion, rename, module, rc, platform, and toolchain
changes as first-class cases. Key cached results by every graph-shaping input,
fall back to the protected universe when evidence is incomplete, and validate
the selector in shadow mode before allowing it to skip CI work. Use BazelDiff or
another graph-diff tool only within its documented modeling boundary.
Check your understanding · 4 questions
1.Match each changed path to its conservative starting policy:
Drag each answer onto the matching prompt, or click an answer and then click a prompt
2.A patch deletes //legacy:codec and removes //app:server's dependency on it. Why should target determination inspect both base and head?
Select one answer
3.Which query policies can safely satisfy a declared multi-configuration CI contract?
Select all that apply
4.What should a selector do when a changed path cannot be mapped or one supported configuration fails to load?
Select one answer
Footnotes
-
Fully dynamic pipelines with Bazel and Buildkite — separating Git path detection, Bazel reverse-dependency queries, and runtime pipeline generation ↩
-
Designing a language Agnostic CI using Bazel queries — change-type classification for workspace, BUILD, extension, deleted, and source files ↩
-
Configurable Query (cquery) — configured-target precision, analysis cost, configurations, and universe scope ↩1 ↩2 ↩3
-
Precision CI at Scale: Target-Aware Workflows with Bazel Diff — graph hashing, query/cquery trade-off, seed paths, caching, and shadow-mode rollout ↩1 ↩2 ↩3