View as Markdown

Scopes with Bazel

Compute merge queue scopes from Bazel's dependency graph with bazel-diff so every impacted package is reported.


Bazel knows the entire dependency graph of your monorepo, which makes it a good source of scopes, as long as the query asks which targets a pull request can break rather than which packages hold the files it touched.

bazel-diff answers that question. It hashes every target in the graph at two revisions (over the target’s own content, its rule attributes and its transitive dependencies), then reports the targets whose hash changed. That is the impacted set, and its packages are your scopes.

It is also what other CI tooling reaches for on this question: Trunk’s Bazel action computes its impacted targets with bazel-diff, and Aspect’s Extension Language wraps the same tool as an impacted command. Adding a JVM to a scope detection job is a real cost, and this is the part of that job that is not novel.

Here’s the whole setup running on a Bazel monorepo, from the scope detection step in CI to several packages testing in their own lanes at once:

To use the manual scopes mechanism, configure Mergify to expect scopes from your CI system:

scopes:
source:
manual:
queue_rules:
- name: default
batch_size: 5

Why the impacted set is not the changed set

Section titled Why the impacted set is not the changed set

Scopes are a safety mechanism. Pull requests that share a scope are batched and tested together and never merged in parallel, while pull requests with no scope in common run side by side. The same list usually decides which CI jobs a pull request may skip. A scope list that comes up short is therefore not merely imprecise: it lets the queue parallelize pull requests that conflict, and it lets the jobs gated on those scopes skip work the change really did affect.

Queries built on changed file paths produce exactly that kind of short list. buildfiles() returns the BUILD and .bzl files needed to load the packages the changed files live in, which walks the graph away from the dependents instead of towards them.

Take a workspace where //app/server depends on //lib/util, which depends on //lib/core, and where every BUILD file loads a macro from //tools:

Changebuildfiles(set(…)) returnsImpacted packages
a source file in lib/corelib/core, toolslib/core, lib/util, app/server
an attribute in lib/util/BUILDlib/util, toolslib/util, app/server
the macro in tools/defs.bzltoolsevery package that loads it

Every row names tools, the package that defines the macro, and misses the packages that depend on the change. In a workspace with external dependencies the gap widens: buildfiles() also returns the .bzl files of every ruleset you load, so the scope list fills up with entries like @rules_kotlin//kotlin that mean nothing to your queue.

Reverse-dependency queries such as rdeps() close part of the gap but open another: a pull request that only edits BUILD or .bzl files has no changed target to start from, so the query returns nothing at all.

Detecting Scopes with bazel-diff

Section titled Detecting Scopes with bazel-diff

bazel-diff compares two revisions, so the job that computes scopes needs three things:

  • A JVM. bazel-diff ships as a single bazel-diff_deploy.jar on its releases page.

  • Full git history, so the base commit exists locally. With actions/checkout that means fetch-depth: 0.

  • A working tree it can move. Hashes are generated once per revision, so the job checks out the base, then the head.

The detection itself is three bazel-diff commands, run against the merge-queue-aware $BASE and $HEAD revisions each pipeline below resolves:

Terminal window
curl -fsSL -o /tmp/bazel-diff.jar \
"https://github.com/Tinder/bazel-diff/releases/download/v42.0.0/bazel-diff_deploy.jar"
echo "0170b70fe2f24477ab056c11f5c3240d8b45a1dca5ab73ad252c096679c5500c /tmp/bazel-diff.jar" \
| sha256sum -c - || exit 1
git checkout --quiet --force --end-of-options "$BASE" -- || exit 1
java -jar /tmp/bazel-diff.jar generate-hashes \
--workspacePath "$(pwd)" --includeTargetType /tmp/base-hashes.json
git checkout --quiet --force --end-of-options "$HEAD" -- || exit 1
java -jar /tmp/bazel-diff.jar generate-hashes \
--workspacePath "$(pwd)" --includeTargetType /tmp/head-hashes.json
java -jar /tmp/bazel-diff.jar get-impacted-targets \
--workspacePath "$(pwd)" --targetType Rule \
--startingHashes /tmp/base-hashes.json --finalHashes /tmp/head-hashes.json \
| awk -F: '/^\/\// { print $1 }' | sort -u

A few details worth knowing:

  • --targetType Rule keeps rules and drops the source and generated files that share their packages. --includeTargetType on generate-hashes is what records the type, so the two flags go together.

  • The awk turns each impacted label into its package label, so //lib/core:core becomes //lib/core and a target in the root package becomes //. It also drops labels from external repositories, which are not yours to queue on.

  • Scoping on packages rather than targets is a choice, and yours to change. get-impacted-targets reports target labels and has no package output of its own, so the reduction has to happen somewhere. Doing it here keeps scope names short and stable, at the price of serializing two pull requests that impact different targets in the same package. Drop the -F: and print the whole label instead if you want that finer grain, keeping the /^\/\// filter so external labels stay out.

  • git checkout --force is deliberate: bazel writes to MODULE.bazel.lock, and a plain checkout refuses to move over the modified file. Run this on a CI checkout, not a working tree you care about.

  • --end-of-options and the trailing -- pin $BASE and $HEAD as revisions, so git reads them neither as options nor as paths that happen to exist. || exit 1 then makes a rejected value stop the job: without it the checkout fails, hashing continues against whatever revision is still checked out, and the pull request gets a confident scope list computed from the wrong tree.

  • Both revisions are hashed in the same directory, so the Bazel server and its analysis cache stay warm and the second generate-hashes costs a fraction of the first.

The block is long next to the buildfiles() query it replaces, and almost all of the extra length is the download, its verification and the second checkout rather than the query itself. bazel-diff ships a bazel-diff-example.sh that wraps the same sequence, but running it means cloning the bazel-diff repository into your job, which trades a pinned and verified artifact for a moving branch. Spelling the steps out is the price of not doing that. Running bazel-diff as a service removes most of them.

bazel-diff is a Bazel module, so a workspace already on Bzlmod can declare it instead of downloading anything:

bazel_dep(name = "bazel-diff", version = "42.0.0")
Terminal window
bazel run @bazel-diff//cli:bazel-diff -- generate-hashes --workspacePath "$(pwd)" hashes.json

That is the install bazel-diff recommends, and it is the better one for running the tool by hand. The pipelines below use the released JAR regardless, for one reason: they check out two revisions of your workspace, and bazel run @bazel-diff//... resolves against whichever MODULE.bazel is checked out at the time. At $BASE that is the file as it stood then, which may pin a different version or not declare bazel-diff at all. That covers every base older than the commit adding the dependency, including the pull request that adds it. A JAR fetched once, outside the workspace, is the same tool at both revisions, which is how bazel-diff’s own two-revision examples invoke it.

The pipelines below download a JAR over the network and run it with the same privileges as the rest of the job, so the first command names exactly what runs: the URL points at a version instead of latest, and sha256sum -c checks the bytes behind it. Recompute the digest when you bump the version:

Terminal window
sha256sum bazel-diff_deploy.jar # shasum -a 256 on macOS

The release also ships SLSA provenance beside the JAR, a stronger claim than a digest: it says the artifact came out of the project’s own release workflow, not just that its bytes match something someone published. The GitHub CLI checks it and exits non-zero if the provenance does not hold:

Terminal window
curl -fsSL -o /tmp/bazel-diff.jar.intoto.jsonl \
"https://github.com/Tinder/bazel-diff/releases/download/v42.0.0/bazel-diff_deploy.jar.intoto.jsonl"
gh attestation verify /tmp/bazel-diff.jar \
--bundle /tmp/bazel-diff.jar.intoto.jsonl \
--repo Tinder/bazel-diff --signer-repo bazel-contrib/.github

--signer-repo is needed because bazel-diff releases through the shared bazel-contrib/.github workflow, so the signing identity is that workflow rather than the bazel-diff repository itself.

name: Detect Scopes
on:
pull_request:
jobs:
detect-scopes:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
with:
# The queue-aware base commit must exist locally to be checked out.
fetch-depth: 0
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- name: Get git refs
id: refs
uses: Mergifyio/gha-mergify-ci@v25
with:
action: scopes-git-refs
- name: Get scopes
id: scopes
env:
HEAD: ${{ steps.refs.outputs.head }}
BASE: ${{ steps.refs.outputs.base }}
run: |
curl -fsSL -o /tmp/bazel-diff.jar \
"https://github.com/Tinder/bazel-diff/releases/download/v42.0.0/bazel-diff_deploy.jar"
echo "0170b70fe2f24477ab056c11f5c3240d8b45a1dca5ab73ad252c096679c5500c /tmp/bazel-diff.jar" \
| sha256sum -c - || exit 1
git checkout --quiet --force --end-of-options "$BASE" -- || exit 1
java -jar /tmp/bazel-diff.jar generate-hashes \
--workspacePath "$(pwd)" --includeTargetType /tmp/base-hashes.json
git checkout --quiet --force --end-of-options "$HEAD" -- || exit 1
java -jar /tmp/bazel-diff.jar generate-hashes \
--workspacePath "$(pwd)" --includeTargetType /tmp/head-hashes.json
scopes=$(java -jar /tmp/bazel-diff.jar get-impacted-targets \
--workspacePath "$(pwd)" --targetType Rule \
--startingHashes /tmp/base-hashes.json --finalHashes /tmp/head-hashes.json \
| awk -F: '/^\/\// { print $1 }' | sort -u | paste -sd, -)
echo "scopes=$scopes" >> "$GITHUB_OUTPUT"
- name: Scopes upload
uses: Mergifyio/gha-mergify-ci@v25
with:
action: scopes-upload
token: ${{ secrets.MERGIFY_TOKEN }}
scopes: ${{ steps.scopes.outputs.scopes }}

Using the mergifyio/mergify-ci Buildkite plugin, a first step resolves the merge-queue-aware base and head SHAs and exposes them as meta-data, a second computes the impacted packages with bazel-diff into the mergify-ci.scopes meta-data, and a third uploads them. The upload runs in its own step because the plugin replaces the step’s command, so a step that both detects and uploads never runs its detection:

steps:
- label: ":mag: Get git refs"
key: git-refs
plugins:
- mergifyio/mergify-ci#v7:
action: scopes-git-refs
- label: ":mag: Detect scopes"
key: detect-scopes
depends_on: git-refs
command: |
BASE=$(buildkite-agent meta-data get "mergify-ci.base")
HEAD=$(buildkite-agent meta-data get "mergify-ci.head")
curl -fsSL -o /tmp/bazel-diff.jar \
"https://github.com/Tinder/bazel-diff/releases/download/v42.0.0/bazel-diff_deploy.jar"
echo "0170b70fe2f24477ab056c11f5c3240d8b45a1dca5ab73ad252c096679c5500c /tmp/bazel-diff.jar" \
| sha256sum -c - || exit 1
git checkout --quiet --force --end-of-options "$$BASE" -- || exit 1
java -jar /tmp/bazel-diff.jar generate-hashes \
--workspacePath "$(pwd)" --includeTargetType /tmp/base-hashes.json
git checkout --quiet --force --end-of-options "$$HEAD" -- || exit 1
java -jar /tmp/bazel-diff.jar generate-hashes \
--workspacePath "$(pwd)" --includeTargetType /tmp/head-hashes.json
SCOPES=$(java -jar /tmp/bazel-diff.jar get-impacted-targets \
--workspacePath "$(pwd)" --targetType Rule \
--startingHashes /tmp/base-hashes.json --finalHashes /tmp/head-hashes.json \
| awk -F: '/^\/\// { print $$1 }' | sort -u | paste -sd, -)
buildkite-agent meta-data set "mergify-ci.scopes" "$$SCOPES"
- label: ":mag: Upload scopes"
depends_on: detect-scopes
plugins:
- mergifyio/mergify-ci#v7:
action: scopes-upload
token: "${MERGIFY_TOKEN}"

Install the Mergify CLI in your pipeline and export MERGIFY_TOKEN. Use mergify ci git-refs to get the merge-queue-aware base and head SHAs and mergify ci scopes-send to upload the detected scopes:

Terminal window
REFS=$(mergify ci git-refs --format json)
BASE=$(printf '%s' "$REFS" | jq -r '.base')
HEAD=$(printf '%s' "$REFS" | jq -r '.head')
curl -fsSL -o /tmp/bazel-diff.jar \
"https://github.com/Tinder/bazel-diff/releases/download/v42.0.0/bazel-diff_deploy.jar"
echo "0170b70fe2f24477ab056c11f5c3240d8b45a1dca5ab73ad252c096679c5500c /tmp/bazel-diff.jar" \
| sha256sum -c - || exit 1
git checkout --quiet --force --end-of-options "$BASE" -- || exit 1
java -jar /tmp/bazel-diff.jar generate-hashes \
--workspacePath "$(pwd)" --includeTargetType /tmp/base-hashes.json
git checkout --quiet --force --end-of-options "$HEAD" -- || exit 1
java -jar /tmp/bazel-diff.jar generate-hashes \
--workspacePath "$(pwd)" --includeTargetType /tmp/head-hashes.json
java -jar /tmp/bazel-diff.jar get-impacted-targets \
--workspacePath "$(pwd)" --targetType Rule \
--startingHashes /tmp/base-hashes.json --finalHashes /tmp/head-hashes.json \
| awk -F: '/^\/\// { print $1 }' | sort -u \
| jq -R -s '{scopes: split("\n") | map(select(length > 0))}' > scopes.json
mergify ci scopes-send --scopes-json scopes.json

Two generate-hashes runs cost roughly two bazel query passes over the graph. On most repositories that is a small job, but there are ways to trim it:

  • Cache the base hashes. On pull_request events the base is usually the same commit across many pull requests. Storing base-hashes.json under a cache key derived from the base SHA removes one checkout and one hash from most runs.

  • Scope the content hashing. generate-hashes --modified-filepaths reads only the files listed in a changed-file list instead of every source file. The list must be a superset of what actually changed, or the targets behind a missing file are skipped, which is why the flag is still marked experimental.

Running bazel-diff as a service

Section titled Running bazel-diff as a service

For a workspace large enough that generate-hashes dominates the job, bazel-diff serve removes those runs rather than trimming them: a long-lived process holds its own clone of the workspace, caches the hashes it generates per commit SHA, and answers impacted-target queries over HTTP.

Terminal window
java -jar bazel-diff_deploy.jar serve \
--workspacePath /path/to/workspace-clone \
--cacheDir /var/cache/bazel-diff \
--port 8080

The CI step then drops the JVM, the second checkout and both hashing runs:

Terminal window
curl -fsSL --get "http://bazel-diff.internal:8080/impacted_targets" \
--data-urlencode "from=$BASE" --data-urlencode "to=$HEAD" \
-o /tmp/impacted.json || exit 1
jq -r '.impactedTargets[]' /tmp/impacted.json | awk -F: '/^\/\// { print $1 }' | sort -u

The response is written to a file and the pipeline reads it back, so that a failed request stops the job. Piping curl straight into jq would hide it: an instance that answers 503 because it is still fetching leaves curl failing while jq succeeds on empty input, and the pull request goes up with no scopes at all.

What you take on in exchange is a service to run: a host, a clone to keep fetched, and a /health endpoint to route on, since an instance reports unhealthy until its first fetch finishes. The query service is experimental, and documented in the bazel-diff README.

Changes the dependency graph cannot see

Section titled Changes the dependency graph cannot see

Some changes invalidate everything while leaving no trace in the graph: a .bazelrc edit, a CI image bump, a change to a toolchain that lives outside the workspace. Two mechanisms cover them.

On the Bazel side, list those files in a file passed to generate-hashes --seed-filepaths. Their content is mixed into every target hash, so editing one of them marks the whole graph impacted.

On the Mergify side, mark the pull request as a barrier instead of enumerating scopes. With gha-mergify-ci, set all_scopes: true on the scopes-upload action; with the CLI, pass --all to mergify ci scopes-send. The queue then serializes around that pull request.

When bazel-diff is more than you want

Section titled When bazel-diff is more than you want

bazel-diff costs a JVM and a second checkout. If that is more than the job is worth, prefer file-pattern scopes over a dependency-graph query you cannot trust: patterns are coarse, but they need no CI job at all and they never quietly claim a change is smaller than it is.

Was this page helpful?