3.8.1 Build a Maintainable Bazel Workspace

recommended

A maintainable Bazel workspace is not one clever BUILD file. It is a sequence of small contracts that agree: the repository pins its Bazel version and modules, developers and CI share named configurations, platform choices are explicit, BUILD-file edits are reviewable, and one broad test command exercises the end state. This walkthrough assembles those contracts in that order.

The project you carry from the first build to the final CI test is maintainer-workspace. Two focused companions make individual stages easier to inspect: build-maintenance provides a deliberately stale dependency and reviewable cleanup plan, while ci-cache-baseline isolates the CI/cache pattern before it is applied to the running project. Their READMEs map finished trees. This article is the incremental path through one end state.

1. Pin the Repository Boundary

Start with two root files. .bazelversion contains the Bazel release that Bazelisk should select:

9.0.0

The finished file is .bazelversion. Beside it, MODULE.bazel names the module and its direct dependencies:

module(
    name = "maintainer-workspace",
    version = "0.1.0",
)

bazel_dep(name = "platforms", version = "1.1.0")
bazel_dep(name = "rules_java", version = "9.0.3")
bazel_dep(name = "rules_shell", version = "0.6.1")
bazel_dep(name = "llvm", version = "0.8.11")

This walkthrough uses the resolved module graph. Dependency resolution, extensions, and lockfile review belong to 3.1.1 Bzlmod (MODULE.bazel) and 3.1.2 Using Extensions. The complete module also configures and registers the LLVM toolchains, then creates a small policy repository through a local extension (lines 11–50).

With the application packages in place, prove the smallest useful end-to-end path before adding shared policy:

bazel build //...
bazel test //app:app_core_test

Captured from the project on Bazel 9.0.0, the build found seven targets and the test passed:

INFO: Found 7 targets...
INFO: Build completed successfully, 34 total actions
//app:app_core_test                                                   PASSED in 1.0s

Executed 1 out of 1 test: 1 test passes.

The action count is a property of this captured run, not a success criterion. The durable checks are the zero exit status, the successful build, and the passing test.

2. Turn Repeated Flags into Named Configs

The first build proves the graph. Now make repeated invocation policy visible in the workspace .bazelrc:

common --color=yes
common --curses=no

common:ci --announce_rc
common:ci --color=no
common:ci --curses=no

build --show_result=5
build --spawn_strategy=sandboxed
build --java_runtime_version=remotejdk_11
build --tool_java_runtime_version=remotejdk_11
build --repo_env=BAZEL_DO_NOT_DETECT_CPP_TOOLCHAIN=1
build --sandbox_default_allow_network=false

build:ci --keep_going
build:ci --repository_cache=~/.cache/bazel-repo
build:ci --disk_cache=~/.cache/bazel-disk
build:ci --noshow_progress
build:ci --show_result=20
build:ci --action_env=CI=true
test:ci --test_output=errors
test:ci --test_summary=short

build:release --compilation_mode=opt

build:linux --platforms=//platforms:linux_x86_64
build:macos --platforms=//platforms:macos_arm64

try-import %workspace%/user.bazelrc

The unqualified build entries are the shared baseline. The common:ci, build:ci, and test:ci groups compose when bazel test --config=ci runs. build:release, build:linux, and build:macos are other opt-in groups. The final try-import leaves a deliberate local escape hatch without making a missing user file an error. The precedence, command inheritance, and import model are explained in 3.2.1 .bazelrc Hierarchy. The command-specific flags are covered by 3.2.4 Command Line Flags and the environment policy by 3.2.6 Hermeticity Settings.

Ask Bazel to show which rc entries it read:

bazel build --announce_rc --config=ci //...

The captured output identifies both the baseline and the named expansion:

INFO: Reading rc options for 'build' from .../maintainer-workspace/.bazelrc:
  'build' options: --show_result=5 --spawn_strategy=sandboxed
  --java_runtime_version=remotejdk_11 --tool_java_runtime_version=remotejdk_11
  --repo_env=BAZEL_DO_NOT_DETECT_CPP_TOOLCHAIN=1
  --sandbox_default_allow_network=false
INFO: Found applicable config definition build:ci in file
.../maintainer-workspace/.bazelrc:
  --keep_going --show_result=20 --action_env=CI=true
INFO: Build completed successfully, 13 total actions

Paths are shortened here, but the options and Bazel diagnostics are unchanged.

3. Make Platform Decisions Part of the Graph

Named platform configs need real platform targets. The project's platforms/BUILD.bazel adds one project-specific constraint dimension and combines it with standard OS and CPU values:

package(default_visibility = ["//visibility:public"])

constraint_setting(name = "libc")

constraint_value(
    name = "glibc",
    constraint_setting = ":libc",
)

# --snip--

platform(
    name = "linux_x86_64",
    constraint_values = [
        ":glibc",
        "@platforms//cpu:x86_64",
        "@platforms//os:linux",
    ],
)

platform(
    name = "macos_arm64",
    constraint_values = [
        ":apple_libc",
        "@platforms//cpu:arm64",
        "@platforms//os:macos",
    ],
)

The target platform is selected by the --platforms flags already hidden behind --config=linux and --config=macos. The vocabulary and selection boundary are in 3.3.3 Constraint Values and 3.3.5 --platforms Flag.

Next, make two different kinds of configuration decision in app/BUILD.bazel. select() chooses a source or argument for a supported configuration. It does not mutate that configuration:

config_setting(
    name = "release_build",
    values = {"compilation_mode": "opt"},
)

java_library(
    name = "app_core",
    srcs = ["MaintainerApp.java"] + select({
        "//platforms:linux_target": ["linux/PlatformProfile.java"],
        "//platforms:macos_target": ["macos/PlatformProfile.java"],
        "//conditions:default": ["generic/PlatformProfile.java"],
    }),
)

# --snip--

java_binary(
    name = "linux_admin_tool",
    srcs = ["LinuxAdminTool.java"],
    main_class = "app.LinuxAdminTool",
    target_compatible_with = ["@platforms//os:linux"],
)

target_compatible_with answers a different question: whether the target may participate in this target platform at all. See 3.3.1 Configurable Attributes (select()) for configurable attributes and 3.3.4 Target Compatibility for compatibility propagation and wildcard behavior.

Before running the commands, predict both outcomes: select() should choose a macOS branch for compatible targets, while a wildcard should skip—not fail on—the incompatible Linux-only target. Making that prediction is the check that the two mechanisms have not blurred together.

Run both sides:

bazel build --config=linux //app:maintainer_app
bazel build --config=macos //services:all

The Linux application builds, while the macOS wildcard build skips the Linux-only bundle and still succeeds:

Target //app:maintainer_app up-to-date:
  bazel-bin/app/maintainer_app
  bazel-bin/app/maintainer_app.jar
INFO: Build completed successfully, 6 total actions

Target //services:linux_admin_bundle was skipped
Target //services:service_probe up-to-date:
  bazel-bin/services/service_probe
INFO: Build completed successfully, 4 total actions

4. Put BUILD Maintenance Around the Graph

The running graph now builds in multiple configurations, but source and BUILD files will continue to change. Practice the maintenance loop in the build-maintenance project to establish a review loop before applying the same gate to the running repository. Its app/BUILD.bazel contains source-derived dependencies, including an intentionally stale //unused:unused_helper edge.

The lightweight project does not pretend to vendor production Gazelle, Buildifier, Buildozer, or unused_deps. Instead its checked-in helpers print the real command plan, and its local quality gate is executable. This keeps the distinction from 3.4.2 Gazelle (BUILD File Generation), 3.4.3 Buildozer, 3.4.4 Code Quality Integration, and 3.4.5 Managing unused_deps explicit.

bazel run //tools:print_gazelle_plan
bazel run //tools:print_format_plan
bazel run //tools:print_buildozer_plan
bazel run //tools:check_build_files
bazel test //...

The Gazelle helper's captured output gives the order of operations:

gazelle -build_file_name=BUILD.bazel ./...

git diff -- app/BUILD.bazel logging/BUILD.bazel message/BUILD.bazel
bazel run //tools:print_buildozer_plan

The Buildozer helper then prints the reviewable cleanup, including the stale edge:

buildozer 'add deps //logging:audit_log' //app:app_lib
buildozer 'replace deps //message:legacy_message //message:message' //app:app_lib
buildozer 'remove deps //unused:unused_helper' //app:app_lib
buildozer 'print rule' //app:app_lib

Finally the executable checks passed:

BUILD file style check passed for 6 files
//app:maintenance_app_test                                            PASSED in 1.0s

Executed 1 out of 1 test: 1 test passes.

The workflow is the contract: regenerate source-derived structure, format it, review structured cleanup, then build and test. A repository with real tool targets should substitute those pinned targets for the print-only helpers. The exercise uses a separate fixture because the main project's BUILD files are the clean end state. The operational lesson returns with you: generated edits are inputs to review, and the broad test remains the acceptance gate.

5. Carry the Same Contract into CI and Caches

The final stage moves the running project's broad test into a clean CI entry point. The focused ci-cache-baseline project separates this pattern for inspection. Now apply it to the main project's .bazelrc, which defines stable log settings and two persistent cache paths:

common:ci --announce_rc
common:ci --color=no
common:ci --curses=no

build:ci --repository_cache=~/.cache/bazel-repo
build:ci --disk_cache=~/.cache/bazel-disk
build:ci --keep_going
build:ci --noshow_progress
build:ci --show_result=20

test:ci --test_output=errors
test:ci --test_summary=short

The repository cache stores downloaded external artifacts. The disk cache stores reusable action results and blobs. Bazelisk's downloaded Bazel binary is a third cache. Their roles and limitations belong to 3.6.4 Disk Cache in CI, 3.6.5 Simple Remote Cache Setup, and 3.6.6 Cache Warm/Cold Considerations.

The main project's GitHub Actions workflow checks out the repository, asks setup-bazel to restore those three layers, runs the small tools/ci_bazel.sh wrapper, and shuts the server down before cache save. The general pipeline and server-lifecycle reasoning are in 3.6.1 Basic CI Recipe and 3.6.2 Bazel Server Lifecycle in CI.

The wrapper preserves Bazel's exit code, but the command at its center remains the same command a maintainer can run locally:

bazel test --config=ci //...

Captured from maintainer-workspace on Bazel 9.0.0:

Target //app:macos_admin_tool was skipped
//app:app_core_test                                          PASSED in 0.1s

Executed 1 out of 1 test: 1 test passes.

That is the end-state acceptance command. It uses the checked-in Bazel version, the checked-in named config, every package under the workspace, and the same tests the CI workflow runs. The skipped macOS-only target is also evidence that the compatibility policy from step 3 remains active in the final graph.

key takeaway

A maintainable workspace grows as one chain of verified contracts: pin Bazel and modules, prove the first build and test, centralize shared flags as named configs, model platform choices in the graph, keep BUILD-file rewrites outside the graph and reviewable, then make CI run bazel test --config=ci //... with explicit cache layers. Each stage stays independently inspectable, but the final command proves they work together.

Check your understanding · 3 questions

1.Why does the walkthrough prove a small build and test before adding shared configuration, platforms, and CI?

Select one answer

2.Match each repository mechanism to the decision it records:

Drag each answer onto the matching prompt, or click an answer and then click a prompt

Answers
.bazelversion and MODULE.bazel
.bazelrc named configs
select()
target_compatible_with

3.True or false about the final maintenance and CI workflow:

Choose True or False for each sentence

Generated BUILD-file edits should be formatted, reviewed, and followed by a broad build or test gate.
CI should reproduce repository policy with the same checked-in named config that maintainers can invoke locally.
A successful warm-cache run is sufficient evidence that the clean CI path works.
The repository cache and disk cache store the same kind of data, so persisting either one is equivalent.
0 of 3 answered