Bazel builds are hermetic and incremental: every action declares its inputs and outputs explicitly, so Bazel knows exactly which targets need rebuilding and which are safe to cache. In a large monorepo, this means CI runs that skip 95% of work because nothing in the relevant dependency graph changed. Claude Code writes BUILD files, custom rules, and Bazel query commands — and explains why a target is being rebuilt when it shouldn’t be.
CLAUDE.md for Bazel Projects
## Build System: Bazel
- Version: 7.x (via Bazelisk — always use `bazel` which resolves to the right version)
- `.bazelversion` file pins the version — never upgrade without team discussion
- Remote cache: `--remote_cache=grpcs://buildcache.internal:443`
- All new code needs BUILD files — never run `go build` or `npm run build` directly
- Target naming: `//path/to/package:target_name` (target = last path segment when equal to directory)
- Test targets always end in `_test`: `go_test`, `py_test`, `jest_test`
- `bazel build //...` builds everything; `bazel test //...` runs all tests
- Avoid `glob()` for source files — prefer explicit lists for reproducibility
Go Targets
# services/order-api/BUILD
load("@rules_go//go:def.bzl", "go_binary", "go_library", "go_test")
load("@rules_oci//oci:defs.bzl", "oci_image", "oci_push")
load("@rules_pkg//pkg:tar.bzl", "pkg_tar")
go_library(
name = "order_api_lib",
srcs = [
"main.go",
"server.go",
"handlers.go",
],
importpath = "github.com/myorg/order-api",
visibility = ["//visibility:private"],
deps = [
"//internal/db",
"//internal/events",
"@com_github_go_chi_chi_v5//:chi",
"@com_github_rs_zerolog//:zerolog",
],
)
go_binary(
name = "order_api",
embed = [":order_api_lib"],
visibility = ["//visibility:public"],
pure = "on", # CGO disabled — required for distroless container
static = "on",
)
go_test(
name = "order_api_test",
size = "small", # Must complete in 60s
srcs = ["handlers_test.go"],
embed = [":order_api_lib"],
deps = [
"//internal/testutil",
"@com_github_stretchr_testify//assert",
],
)
# Container image
pkg_tar(
name = "binary_tar",
srcs = [":order_api"],
)
oci_image(
name = "image",
base = "@distroless_base",
tars = [":binary_tar"],
entrypoint = ["/order_api"],
)
oci_push(
name = "push",
image = ":image",
repository = "gcr.io/myorg/order-api",
)
# WORKSPACE or MODULE.bazel — declaring external Go dependencies
# With bzlmod (Bazel 6+):
# MODULE.bazel
bazel_dep(name = "rules_go", version = "0.46.0")
bazel_dep(name = "gazelle", version = "0.35.0")
go_sdk = use_extension("@rules_go//go:extensions.bzl", "go_sdk")
go_sdk.download(version = "1.22.0")
go_deps = use_extension("@gazelle//:extensions.bzl", "go_deps")
go_deps.from_file(go_mod = "//:go.mod")
use_repo(go_deps,
"com_github_go_chi_chi_v5",
"com_github_rs_zerolog",
)
TypeScript/Node Targets
# frontend/BUILD
load("@aspect_rules_js//js:defs.bzl", "js_library", "js_binary")
load("@aspect_rules_ts//ts:defs.bzl", "ts_project")
load("@aspect_rules_jest//jest:defs.bzl", "jest_test")
ts_project(
name = "app_ts",
srcs = glob(["src/**/*.ts", "src/**/*.tsx"]),
declaration = True,
tsconfig = "//:tsconfig.json",
deps = [
"//:node_modules/@types/react",
"//:node_modules/react",
"//:node_modules/react-dom",
],
)
jest_test(
name = "app_test",
srcs = glob(["src/**/*.test.ts", "src/**/*.test.tsx"]),
data = [
":app_ts",
"//:node_modules/jest",
"//:node_modules/@testing-library/react",
],
config = "jest.config.js",
)
Gazelle: Auto-Generating BUILD Files
We have 50 Go packages. Generate BUILD files for all of them.
Gazelle reads Go source files and generates BUILD files:
# From the repo root — generates/updates BUILD files for all Go packages
bazel run //:gazelle
# Update Go module dependencies in BUILD files
bazel run //:gazelle -- update-repos -from_file=go.mod -to_macro=deps.bzl%go_deps
# Fix a single package
bazel run //:gazelle -- update //services/order-api/...
# BUILD file at repo root — Gazelle configuration
load("@gazelle//:def.bzl", "gazelle", "gazelle_binary")
# gazelle:prefix github.com/myorg
# gazelle:go_naming_convention import_alias
gazelle(
name = "gazelle",
gazelle = ":gazelle_binary",
)
gazelle_binary(
name = "gazelle_binary",
languages = [
"@bazel_gazelle//language/go",
"@bazel_gazelle//language/proto",
],
)
Remote Caching
Set up remote caching so CI shares build artifacts across PRs.
# .bazelrc — tiered cache configuration
# Local execution, remote cache
build --remote_cache=grpcs://buildcache.internal:443
build --remote_header=x-cache-token=<TOKEN>
build --remote_timeout=60
# Don't upload build artifacts from local dev (read-only for devs)
build --noremote_upload_local_results
# CI uploads results
build:ci --remote_upload_local_results
# Remote execution (if available)
build:rbe --remote_executor=grpcs://remotebuild.internal:443
build:rbe --remote_cache=grpcs://buildcache.internal:443
build:rbe --jobs=200 # RBE can parallelize heavily
# Disk cache (local fallback)
build --disk_cache=~/.cache/bazel-disk
# Stamp version info into binaries (use with version-stamped releases)
build:release --workspace_status_command=./scripts/workspace-status.sh
# .github/workflows/build.yml
- name: Configure Bazel remote cache
run: |
echo 'build:ci --remote_cache=${{ secrets.BAZEL_REMOTE_CACHE_URL }}' >> .bazelrc.user
echo 'build:ci --remote_header=Authorization=Bearer ${{ secrets.CACHE_TOKEN }}' >> .bazelrc.user
echo 'build:ci --remote_upload_local_results' >> .bazelrc.user
- name: Build and test affected targets
run: |
# Only build/test what changed vs main
bazel build --config=ci $(./scripts/affected-targets.sh)
bazel test --config=ci $(./scripts/affected-targets.sh) --test_output=errors
#!/bin/bash
# scripts/affected-targets.sh — find targets affected by changed files
CHANGED_FILES=$(git diff --name-only origin/main...HEAD)
# Convert changed files to Bazel package paths
CHANGED_PACKAGES=$(echo "$CHANGED_FILES" | xargs -I {} dirname {} | sort -u | sed 's|^|//|' | sed 's|$|:all|')
# Use bazel query to find all transitive rdependents
bazel query "rdeps(//..., set($CHANGED_PACKAGES))" --output=label 2>/dev/null
Bazel Query
Which targets depend on our //internal/db package?
# All targets that directly or transitively depend on //internal/db
bazel query "rdeps(//..., //internal/db)"
# Only direct dependents
bazel query "attr(deps, //internal/db, //...)"
# What does a target depend on? (full transitive closure)
bazel query "deps(//services/order-api)"
# What's the dependency path between two targets?
bazel query "somepath(//services/checkout-api, //internal/db)"
# Find all Go test targets in the repo
bazel query "kind(go_test, //...)"
# Find targets that haven't been built or tested recently
# (combine with build events to track staleness)
# Visualize the dependency graph for a package
bazel query "deps(//services/order-api)" --output=graph | dot -Tsvg > deps.svg
Custom Rules
We need a custom Bazel rule that runs our code generator
and makes the output available as a Go library.
# tools/protoc-gen-custom/defs.bzl — custom rule
def _proto_gen_impl(ctx):
# Collect proto files from all proto_library deps
proto_files = []
for dep in ctx.attr.proto:
proto_files += dep[ProtoInfo].direct_sources
# Output directory
out_dir = ctx.actions.declare_directory(ctx.label.name + "_generated")
# Run the code generator
ctx.actions.run(
inputs = proto_files + ctx.files.plugin,
outputs = [out_dir],
executable = ctx.executable.plugin,
arguments = [
"--proto_path=.",
"--custom_out=" + out_dir.path,
] + [f.path for f in proto_files],
mnemonic = "ProtoGenCustom",
progress_message = "Generating custom code for %s" % ctx.label,
)
return [
DefaultInfo(files = depset([out_dir])),
]
proto_gen_custom = rule(
implementation = _proto_gen_impl,
attrs = {
"proto": attr.label_list(
providers = [ProtoInfo],
doc = "proto_library targets to generate from",
),
"plugin": attr.label(
executable = True,
cfg = "exec", # Run the plugin for the execution platform, not target
doc = "The code generator binary",
),
},
doc = "Generate custom code from proto files",
)
# Usage in BUILD file
load("//tools/protoc-gen-custom:defs.bzl", "proto_gen_custom")
load("@rules_go//go:def.bzl", "go_library")
proto_library(
name = "order_proto",
srcs = ["order.proto"],
)
proto_gen_custom(
name = "order_gen",
proto = [":order_proto"],
plugin = "//tools/protoc-gen-custom:plugin",
)
go_library(
name = "order_gen_lib",
srcs = [":order_gen"],
importpath = "github.com/myorg/gen/order",
)
Debugging Build Failures
My target is rebuilding every time even though nothing changed.
# Find out WHY Bazel is rebuilding — execution log
bazel build //services/order-api --execution_log_json_file=/tmp/exec.log
# Parse the log to find non-deterministic actions
cat /tmp/exec.log | jq '.[] | select(.runner == "local") | {mnemonic, target: .targetLabel}' | head -20
# Check if inputs are stable
bazel build //services/order-api --verbose_failures
# Inspect the action graph
bazel aquery //services/order-api --output=text 2>/dev/null | head -50
# Common causes of unnecessary rebuilds:
# 1. timestamp-stamped outputs (use --nostamp for local dev)
# 2. glob() matching generated files
# 3. environment variable leaking into actions (use --action_env to allowlist)
# 4. Non-hermetic tools writing to unexpected paths
# Check what files an action reads/writes (sandbox inspection)
bazel build //services/order-api --sandbox_debug 2>&1 | grep "ERROR\|WARNING" | head -20
For the monorepo tooling that complements Bazel for code sharing decisions, see the platform engineering guide. For the Docker/OCI image builds that oci_push targets produce, the supply chain security guide covers image signing with Sigstore. The Claude Skills 360 bundle includes build system skill sets covering Bazel rules, remote caching, and monorepo query patterns. Start with the free tier to try BUILD file generation.