3.8.1 Build a Maintainable Bazel Workspace
recommendedA 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.
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
3.True or false about the final maintenance and CI workflow:
Choose True or False for each sentence