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_archivetarballs and other external archives. Avoids re-fetching dependencies on every run. - Bazelisk cache — the downloaded Bazel binary itself, so
.bazelversionbumps are the only moment CI pays the download cost.
Two mechanically equivalent ways to restore and save these between runs:
- A Bazel-aware CI action (
bazel-contrib/setup-bazelon GitHub Actions) points each cache at a stable directory and handles restore/save around the build in one step2. - 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— theoutputRooton Linux, which contains the output base, install base, and server state5. A more surgical setup caches--disk_cacheand--repository_cachepaths 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:
- Remote cache shared across runners — covered in 3.6.5 Simple Remote Cache Setup and in depth in 6.2 Shared Remote Cache.
- Cache strategy — how warm and cold layers interact, where shared-cache trust boundaries belong, and when to take a deliberate cold-path audit — in 3.6.6 Cache Warm/Cold Considerations.
- Runner reuse — stateful vs ephemeral runners and hot pools — in 6.5.9 Reusing CI Runners Safely; advanced JVM snapshotting in 6.6.9 Bottleneck-Driven Optimization.
- Retry policy — distinguishing infrastructure errors from real test failures — in 3.6.3 CI-Specific Flags & Exit Codes and 6.6.5 Classifying Failures Before Retrying and 6.6.6 Build-Service Incident Recovery.
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.
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.
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
3.When does 'bazel test //...' stop scaling and require a more sophisticated approach?
Select one answer
Footnotes
-
Bazelisk — A user-friendly launcher for Bazel — Bazelisk as the
bazelbinary on CI,.bazelversionresolution, and the upgrade-via-pull-request workflow ↩1 ↩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 -
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_cacheon stateful workers ↩1 ↩2 ↩3 ↩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 ↩
-
Continuous Deploys with Bazel & GitHub Actions — minimal
actions/cachesetup pointing at~/.cache/bazelas the "cache the whole output base" fallback pattern ↩ -
Remote Caching —
--disk_cache=<path>, where CI-specific flags live in.bazelrc, and the CI-only-write / developer-read-only pattern ↩1 ↩2 -
setup-bazel repository map — public action inputs, cache-key boundaries, save policy, and implementation escalation routes ↩
-
setup-aspect repository map — Aspect CLI/Bazelisk bootstrap, cache behavior, token handling, and supported-runner boundaries ↩
-
Calling Bazel from scripts —
shutdownat the end of scripted Bazel use and the output-base locking model ↩