---
title: Scopes with Bazel
description: 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](https://github.com/Tinder/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](https://github.com/trunk-io/bazel-action) computes its impacted targets with
bazel-diff, and Aspect's Extension Language wraps the same tool as an
[`impacted` command](https://github.com/aspect-extensions/impacted). 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:

<Youtube video="oJmidhu0AIA" title="Parallel merge queues for Bazel monorepos" />

## Configuring Manual Scopes

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

```yaml
scopes:
  source:
    manual:

queue_rules:
  - name: default
    batch_size: 5
```

## Why the impacted set is not the changed set

[Scopes](/merge-queue/scopes) are a safety mechanism. Pull requests that share a scope are
[batched](/merge-queue/batches) 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.

:::caution
  If your pipeline derives scopes from changed file paths, it is under-reporting. Move it to
  impacted targets before relying on scopes to keep conflicting pull requests apart.
:::

## 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](https://github.com/Tinder/bazel-diff/releases).

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

```bash
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](#running-bazel-diff-as-a-service) removes most of them.

:::note
  On a Bzlmod-only workspace, `generate-hashes` logs `no such package 'external'` while it queries
  the legacy `//external` package. Hashing continues and the result is unaffected; pass
  `--excludeExternalTargets` to silence it.
:::

### Installing bazel-diff

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

```bazel
bazel_dep(name = "bazel-diff", version = "42.0.0")
```

```bash
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.

### Verifying the download

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:

```bash
sha256sum bazel-diff_deploy.jar   # shasum -a 256 on macOS
```

The release also ships [SLSA](https://slsa.dev/) 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](https://cli.github.com/) checks it and
exits non-zero if the provenance does not hold:

```bash
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.

:::note
  `sha256sum` comes from GNU coreutils and is not installed on macOS. On a macOS agent, use
  `shasum -a 256 -c -` in place of `sha256sum -c -`.
:::

### GitHub Actions

```yaml
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@@@GHA_MERGIFY_CI_VERSION@@
        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@@@GHA_MERGIFY_CI_VERSION@@
        with:
          action: scopes-upload
          token: ${{ secrets.MERGIFY_TOKEN }}
          scopes: ${{ steps.scopes.outputs.scopes }}
```

### Buildkite

Using the
[`mergifyio/mergify-ci`](https://github.com/Mergifyio/mergify-ci-buildkite-plugin)
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:

```yaml
steps:
  - label: ":mag: Get git refs"
    key: git-refs
    plugins:
      - mergifyio/mergify-ci#@@BUILDKITE_PLUGIN_VERSION@@:
          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#@@BUILDKITE_PLUGIN_VERSION@@:
          action: scopes-upload
          token: "${MERGIFY_TOKEN}"
```

### Any CI (Mergify CLI)

<ScopesDetection
  command={String.raw`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`}
/>

## Reducing the cost

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

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.

```bash
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:

```bash
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](https://github.com/Tinder/bazel-diff#query-service-experimental).

## 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](/merge-queue/scopes#declaring-a-pull-request-impacts-every-scope) 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

bazel-diff costs a JVM and a second checkout. If that is more than the job is worth, prefer
[file-pattern scopes](/merge-queue/scopes/file-patterns) 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.
