5.2.7 Query in CI Pipelines

recommended

Selective 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:

  1. the exact base and head revisions.
  2. the target universe that CI promises to protect.
  3. the configurations that CI supports.
  4. 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

A Git diff is not enough to decide which CI work is safe to skip
Map each change to evidence, compare base and head with the chosen graph mode, and fail closed when evidence is incomplete.
1 · GIVE EACH CHANGE CLASS ITS OWN SAFE ROUTE
Change class → evidence route
Existing source file Find all declaring targetsThen compute their reverse impact inside the protected universe.
BUILD or BUILD.bazel Seed relevant package targetsThen compute their reverse impact inside the protected universe.
Package-loaded .bzl Find reverse-load dependants, then affected package targetsOnly then compute target-graph reverse impact.
Deleted or renamed path Collect evidence from both base and headThe head graph cannot recover a removed target or edge.
Module or repository resolution, rc, platform, or toolchain policy Apply a documented broad-impact ruleIf these effects are not modeled, select the full protected universe.
2 · COMPARE BASE AND HEAD IN THE CHOSEN GRAPH MODE
query · conservative unconfigured graph
Base
reverse impact
Head
reverse impact
Union both revisions. Unresolved select() branches may over-select work across configurations.
cquery · configured graph for every supported context
CI context
Base
Head
Linux
impact
impact
Windows
impact
impact
Release configuration
impact
impact
A configured result is precise only for the top-level context and flags it analyzed.
3 · MAKE A CONSERVATIVE CI DECISION
Evidence is complete
Union all results required by the chosen graph mode
Any evidence is missing
Select the full protected universe
Union complete evidence. Fail closed when classification, loading, revision comparison, configuration coverage, or cache metadata is incomplete.

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 classSafe starting point
Existing source fileFind the target or targets that declare the file, then compute their reverse dependencies.
BUILD or BUILD.bazelSeed all relevant targets declared by that package, because attributes, visibility, target names, and dependency edges may have changed.
.bzl loaded from repository packagesFind 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 resolutionFail 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 pathInspect 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 policyUse 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.

think

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 query when conservative over-selection across configurations is acceptable and broad-impact inputs are handled explicitly.
  • use a matrix of cquery evaluations 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 .bzl load 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.

key takeaway

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

Answers
Existing source file
BUILD or BUILD.bazel
Deleted or renamed path
Repository-resolution .bzl file

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

0 of 4 answered

Footnotes

  1. Fully dynamic pipelines with Bazel and Buildkite — separating Git path detection, Bazel reverse-dependency queries, and runtime pipeline generation

  2. Designing a language Agnostic CI using Bazel queries — change-type classification for workspace, BUILD, extension, deleted, and source files

  3. Configurable Query (cquery) — configured-target precision, analysis cost, configurations, and universe scope 1 2 3

  4. 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