3.6.1 Basic CI Recipe

A Bazel CI pipeline can run with only Bazelisk on PATH and bazel test //..., but the basic operational recipe in this article also persists caches across runs. That avoids paying every cold execution cost again without introducing a cache service. Add worker models, sharding, remote-cache tuning, or affected-target calculation only after measuring a bottleneck.

The Three Ingredients

1. Bazelisk handles the Bazel version

Installing Bazel inside a CI step is a distraction. The supported pattern is to install Bazelisk as the bazel binary and let it read .bazelversion from the repository1. On a cold runner Bazelisk fetches the exact version the repo demands. Once the cache layers below are in place, later runs reuse the same launcher instead of paying that download cost again. CI and local developers execute the same Bazel — no drift, no install step, no "works on my laptop" version mismatches.

Bazelisk as a launcher was already introduced in 0.1.1 Bazelisk & .bazelversion. In CI you get its second benefit for free: upgrading Bazel becomes a normal pull request that bumps .bazelversion, which CI picks up on the next run1.

On GitHub Actions, the de-facto tool is bazel-contrib/setup-bazel. It installs Bazelisk, reads .bazelversion, and wires up caching in a single step2:

- uses: bazel-contrib/setup-bazel@0.19.0
  with:
    bazelisk-cache: true
    disk-cache: ${{ github.workflow }}
    repository-cache: true

On Buildkite there is no single "official" Bazel action. The common pattern is to bake Bazelisk into the agent image so the pipeline body can simply call bazel — the same two-line shape Bazel's own CI uses as its starting point3.

2. Cache persistence across runs is the main performance factor

Bazel on an ephemeral CI runner starts cold every time: no in-memory Skyframe graph, no warm JIT, no populated output tree4. The cold run walks the full analysis graph and re-executes every action. This is the single largest source of "why is CI so much slower than my laptop?" — unpacked in 3.6.2 Bazel Server Lifecycle in CI.

The fix is to persist Bazel's on-disk caches across runs. Three matter for a basic recipe2:

  • Disk cache — action results and output artifacts, keyed by action digest. The biggest wall-clock win. Covered in depth in 3.6.4 Disk Cache in CI.
  • Repository cache — downloaded http_archive tarballs and other external archives. Avoids re-fetching dependencies on every run.
  • Bazelisk cache — the downloaded Bazel binary itself, so .bazelversion bumps are the only moment CI pays the download cost.

Two mechanically equivalent ways to restore and save these between runs:

  1. A Bazel-aware CI action (bazel-contrib/setup-bazel on GitHub Actions) points each cache at a stable directory and handles restore/save around the build in one step2.
  2. A generic cache plugin (GitHub Actions actions/cache, Buildkite cache plugin) restores and saves a directory you specify. The simplest target is Bazel's user cache directory at ~/.cache/bazel — the outputRoot on Linux, which contains the output base, install base, and server state5. A more surgical setup caches --disk_cache and --repository_cache paths separately6.

Approach (1) is more portable and faster because it only caches the directories that matter. Approach (2) is a reasonable starting point on platforms without a dedicated Bazel action.

Either way, the underlying caching mechanism is the one described in 2.4 Caching & Incrementality — CI just persists it across otherwise-ephemeral machines.

3. bazel test //... is the command

On small-to-medium repos, a single bazel test //... is all you need3. Bazel analyzes the graph, skips unchanged actions, runs the tests whose inputs changed, and exits. --build_tests_only is a useful add-on when the repo has non-test targets that only exist for deployment or packaging and shouldn't be built during testing3.

The wildcard stops scaling only when analysis itself becomes the bottleneck — repositories so large that loading the full graph on each run is too expensive. At that point you reach for affected-target calculation (bazel query, bazel-diff), whose production service contract belongs in 6.5.1 Affected-Target Service Contract. It is not a problem to solve on day one.

The Standard Pipeline

Regardless of platform, the shape is the same:

checkout → restore caches → bazel test //... → save caches

A concrete GitHub Actions skeleton — this is roughly what the bazel-materials repo itself uses:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: bazel-contrib/setup-bazel@0.19.0
        with:
          bazelisk-cache: true
          disk-cache: ${{ github.workflow }}
          repository-cache: true

      - run: bazel test //...

setup-bazel handles restore and save around the build, so the pipeline body stays three lines2. The ci-cache-baseline workflow is a runnable version of the same skeleton, with setup-bazel additionally pinned to a specific Bazelisk version and bash tools/ci_bazel.sh //... instead of the inline bazel test. The exit-code-preserving body of that wrapper is the part worth copying — it captures bazel test's status around set -e so the CI platform records the real Bazel exit code rather than the echo that follows. A Buildkite pipeline is equally compact: install Bazelisk as part of the agent image, run bazel test //..., let the cache plugin persist the --disk_cache directory between jobs3.

The setup-bazel repository map keeps the three persistent costs separate. Its README.md documents independent Bazelisk, disk, and repository-cache inputs plus cache-save: false for restore-without-write pull requests.7 Repositories that standardize on Aspect CLI can instead follow the setup-aspect map: its security contract exchanges the long-lived API token inside the action step and leaves a short-lived credential for later tasks. That is a GitHub Actions integration choice, not a general Bazel authentication mechanism.8

Where CI-specific flags belong

Anything worth running in CI but not locally — --disk_cache=/some/path, --remote_cache=..., retry and log-shaping flags — belongs behind a named config in the workspace .bazelrc, activated with --config=ci6. This is the named-config pattern from 3.2.1 .bazelrc Hierarchy:

build:ci --disk_cache=/tmp/bazel-disk-cache
build:ci --keep_going
build:ci --color=no --curses=no

The CI step then runs bazel test --config=ci //.... Developers never have to care. They see the same commands either way. The specific flags worth putting behind --config=ci, and the exit codes your CI script needs to interpret, are covered in 3.6.3 CI-Specific Flags & Exit Codes.

For setups that need CI-vs-local logic below the level of .bazelrc — for example detecting CI=true and rewriting mirror URLs before Bazel starts — a tools/bazel wrapper is the right hook, with trade-offs covered in 3.2.8 tools/bazel Wrapper.

What "Basic" Leaves Out

This recipe is deliberately minimal. It skips:

None of these is required to get a working Bazel CI pipeline. All of them become obvious once the basic recipe is green and you can measure where time is actually going.

key takeaway

The basic Bazel CI recipe is short: install Bazelisk, cache the disk / repository / Bazelisk directories between runs, and call bazel test //.... Put CI-only flags behind build:ci in .bazelrc. The rest of this section (3.6.2–3.6.6) fills in why a cold runner is slow, which flags to set, how each cache layer behaves, and when to graduate from disk to remote cache.

extra

bazel shutdown at the end of a CI step

On a fully ephemeral runner the container dies immediately after the job, so stopping the Bazel server is mostly cosmetic. When the runner is stateful, or when a cache-save step runs after Bazel in the same job, bazel shutdown releases the server's file lock on the output base so the subsequent cache-save step sees a quiescent tree9. If you persist the whole user cache (~/.cache/bazel) and skip shutdown, the cache save can race the running server and upload an inconsistent snapshot. More on server lifetime in 3.6.2 Bazel Server Lifecycle in CI.

Check your understanding · 3 questions

1.What are the three foundational ingredients for a working Bazel CI pipeline?

Select one answer

2.True or false about basic CI setup:

Choose True or False for each sentence

bazel-contrib/setup-bazel on GitHub Actions handles cache restore and save automatically, keeping the pipeline body to just a few lines.
CI-specific flags like --disk_cache and --color=no should be set inline in the CI step command rather than in .bazelrc.
bazel shutdown should be called at the end of a CI step when the runner is stateful, to avoid cache-save races.

3.When does 'bazel test //...' stop scaling and require a more sophisticated approach?

Select one answer

0 of 3 answered

Footnotes

  1. Bazelisk — A user-friendly launcher for Bazel — Bazelisk as the bazel binary on CI, .bazelversion resolution, and the upgrade-via-pull-request workflow 1 2

  2. Sponsored Session: Integrate Dev Workflows — "Cold Start Problem" cache-layer table: Bazelisk binary via setup-bazel, disk cache, repository cache 1 2 3 4

  3. How Bazel built its CI system on top of Buildkite — the two-line starter pipeline (bazel build //src:bazel + bazel test --build_tests_only //...) and --disk_cache on stateful workers 1 2 3 4

  4. The Many Caches of Bazel — why a freshly-started Bazel process has no in-memory Skyframe cache and which layers are persistent vs ephemeral

  5. Continuous Deploys with Bazel & GitHub Actions — minimal actions/cache setup pointing at ~/.cache/bazel as the "cache the whole output base" fallback pattern

  6. Remote Caching--disk_cache=<path>, where CI-specific flags live in .bazelrc, and the CI-only-write / developer-read-only pattern 1 2

  7. setup-bazel repository map — public action inputs, cache-key boundaries, save policy, and implementation escalation routes

  8. setup-aspect repository map — Aspect CLI/Bazelisk bootstrap, cache behavior, token handling, and supported-runner boundaries

  9. Calling Bazel from scriptsshutdown at the end of scripted Bazel use and the output-base locking model