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.
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:
Configuring Manual Scopes
Section titled Configuring Manual ScopesTo use the manual scopes mechanism, configure Mergify to expect scopes from your CI system:
scopes: source: manual:
queue_rules: - name: default batch_size: 5Why the impacted set is not the changed set
Section titled Why the impacted set is not the changed setScopes 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:
| Change | buildfiles(set(…)) returns | Impacted packages |
|---|---|---|
a source file in lib/core | lib/core, tools | lib/core, lib/util, app/server |
an attribute in lib/util/BUILD | lib/util, tools | lib/util, app/server |
the macro in tools/defs.bzl | tools | every 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-diffbazel-diff compares two revisions, so the job that computes scopes needs three things:
-
A JVM. bazel-diff ships as a single
bazel-diff_deploy.jaron its releases page. -
Full git history, so the base commit exists locally. With
actions/checkoutthat meansfetch-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:
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 1java -jar /tmp/bazel-diff.jar generate-hashes \ --workspacePath "$(pwd)" --includeTargetType /tmp/base-hashes.json
git checkout --quiet --force --end-of-options "$HEAD" -- || exit 1java -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 -uA few details worth knowing:
-
--targetType Rulekeeps rules and drops the source and generated files that share their packages.--includeTargetTypeongenerate-hashesis what records the type, so the two flags go together. -
The
awkturns each impacted label into its package label, so//lib/core:corebecomes//lib/coreand a target in the root package becomes//. It also drops labels from external repositories, which are not yours to queue on. -
git checkout --forceis deliberate:bazelwrites toMODULE.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-optionsand the trailing--pin$BASEand$HEADas revisions, so git reads them neither as options nor as paths that happen to exist.|| exit 1then 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-hashescosts a fraction of the first.
Installing bazel-diff
Section titled Installing bazel-diffbazel-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")bazel run @bazel-diff//cli:bazel-diff -- generate-hashes --workspacePath "$(pwd)" hashes.jsonThat 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.
Verifying the download
Section titled Verifying the downloadThe 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:
sha256sum bazel-diff_deploy.jar # shasum -a 256 on macOSThe 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:
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.
GitHub Actions
Section titled GitHub Actionsname: Detect Scopeson: 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 }}Buildkite
Section titled BuildkiteUsing 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}"Any CI (Mergify CLI)
Section titled Any CI (Mergify CLI)
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:
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 1java -jar /tmp/bazel-diff.jar generate-hashes \--workspacePath "$(pwd)" --includeTargetType /tmp/base-hashes.json
git checkout --quiet --force --end-of-options "$HEAD" -- || exit 1java -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.jsonReducing the cost
Section titled Reducing the costTwo 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_requestevents the base is usually the same commit across many pull requests. Storingbase-hashes.jsonunder a cache key derived from the base SHA removes one checkout and one hash from most runs. -
Scope the content hashing.
generate-hashes --modified-filepathsreads 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. -
Run bazel-diff as a service. For very large repositories,
bazel-diff servekeeps a long-lived process that caches hashes per commit SHA and answers “what changed between these two revisions” over HTTP. It is experimental, and documented in the bazel-diff README.
Changes the dependency graph cannot see
Section titled Changes the dependency graph cannot seeSome 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 wantbazel-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?
Thanks for your feedback!