3.5.3 Developer Tool Management

recommended

Bazel gives you hermetic toolchains inside actions, but developers still need the same tools — terraform, kubectl, buildifier, gofumpt, language compilers — available from their shell before they type bazel build. Without help, every engineer installs their own version via Homebrew, apt, or a release download, and the team drifts: CI and one developer's laptop end up on different patch versions, and something only breaks on one machine1. This item is about pushing the same hermetic versions Bazel already manages out onto each developer's PATH.

The Problem With bazel run

The obvious answer is bazel run. A project can expose @rules_go//go, @buildifier_prebuilt//:buildifier, or a rules_multitool-provided target, and developers invoke them with bazel run. In practice this fails for two reasons2.

IDEs need a path, not a command. VS Code, IntelliJ, Goland, and every Language Server expect an executable path in a settings file. They cannot accept bazel run //tools:buildifier --. Without a real file on disk, editor-level integration (format on save, go-to-definition, inline linting) quietly stops working.

bazel run changes the working directory. Bazel was designed for targets you wrote yourself and executes them from the exec root, not from the directory the user typed the command in. Running bazel run //tools:terraform -- plan from infrastructure/ fails with Error: No configuration files because Terraform looks for .tf files in the wrong directory. The --run_under workaround historically discarded Bazel's analysis cache, making it a non-option2,3. The underlying bugs are tracked as bazelbuild/bazel#3325 (working directory) and #10782 (--run_under cache discard)2.

Tool-distribution solutions therefore sit next to Bazel: they use Bazel to fetch and build tools, but expose them to developers as plain executables.

rules_multitool: The Fetching Primitive

Before you can distribute a tool, you need Bazel to fetch a versioned copy. For statically-linked binaries, the maintained community ruleset is bazel-contrib/rules_multitool, which reads a JSON lockfile listing a tool's download URL, SHA-256, and platform per binary2,4:

{
  "$schema": "https://raw.githubusercontent.com/bazel-contrib/rules_multitool/main/lockfile.schema.json",
  "terraform": {
    "binaries": [
      {"kind": "archive", "url": "https://releases.hashicorp.com/terraform/1.7.5/terraform_1.7.5_darwin_arm64.zip",
       "sha256": "99c4d4f...", "os": "macos", "cpu": "arm64", "file": "terraform"},
      {"kind": "archive", "url": "https://releases.hashicorp.com/terraform/1.7.5/terraform_1.7.5_linux_amd64.zip",
       "sha256": "3ff056b...", "os": "linux", "cpu": "x86_64", "file": "terraform"}
    ]
  }
}

The lockfile is the contract: every developer who runs bazel run or bazel build gets the same binary, verified by SHA-256, on the right platform. That contract is what lets a team standardise its DevOps tooling (Terraform, kubectl, Helm, Firebase CLI) on a single fetch mechanism instead of cross-platform makefiles that grow brittle across macOS and Linux laptops5, and it removes the "works on my machine" class of reports by replacing per-laptop Homebrew installs with Bazel-fetched, version-pinned binaries6.

rules_multitool is not the only way to fetch a tool — any http_archive, toolchain-provided binary, or in-repo *_binary target works. It just happens to be the ergonomic default for "a Go- or Rust-compiled CLI with a pinned release".

The current Bzlmod surface turns each named hub into stable executable labels backed by OS/CPU-constrained toolchains, and it publishes :cwd and :workspace_root wrappers for the working-directory boundary described above.4 The checked-in lockfile remains the reviewable source of URLs, hashes, archive members, and platform variants. The separately released companion CLI only updates that source.

Keep release discovery in that explicit maintenance step. The companion updater's readme.md documents multitool --lockfile ... update.7 Review and commit the resulting diff before ordinary Bazel invocations consume it. Do not turn “latest GitHub release” discovery into repository evaluation. That separation is the durable workflow—the updater's current GitHub strategy is deliberately narrow, not a general package-manager contract.

Pattern 1: The Wrapper Script

The first public solution for PATH distribution is a symlinked wrapper in tools/2:

#!/bin/sh
# tools/_multitool_run_under_cwd.sh
target="@multitool//tools/$(basename "$0")"
bazel 2>/dev/null build "$target" && \
  exec "$(bazel info execution_root)/$(bazel 2>/dev/null cquery --output=files "$target")" "$@"

Each tool gets a symlink pointing at that script: tools/terraform -> _multitool_run_under_cwd.sh. When the user runs ./tools/terraform plan, the script resolves basename $0 to terraform, asks Bazel to build the matching target, asks bazel cquery where the output lives, and execs it in the current working directory — sidestepping the bazel run cwd bug2.

The pattern is simple and dependency-free, but it has two costs. Users have to type ./tools/terraform instead of terraform, which trains different muscle memory than their CI scripts or runbooks. And editors still can't locate the binary unless the project documents a fixed path manually.

Pattern 2: bazel_env.bzl + direnv

bazel_env.bzl solves the ergonomic gap by putting the tools directly on PATH1,8. The setup has three moving parts:

  1. A bazel_env rule maps tool names to Bazel targets. The build produces a populated bin/ directory at a stable, platform-independent output path (thanks to a platform-changing Bazel transition — see 4.7 Build Settings & Transitions).
  2. A .envrc file in the workspace root runs direnv_layout_dir and adds the bin/ directory to PATH.
  3. direnv — a small shell plugin developers install once — loads .envrc automatically whenever the developer cds into the workspace and unloads it when they leave. It refuses to load a new .envrc until the user runs direnv allow, so adding tools to your PATH is explicit1.

After bazel run //tools:bazel_env once, every tool listed in the rule is available as terraform, buildifier, go, node, pnpm, kubectl, and so on — using the command names developers already know1. Running bazel clean does not leave the shell in a confusing state: direnv shows a clear reload message1:

% bazel clean
direnv: loading ~/Projects/silo/.envrc
direnv: ERROR[bazel_env.bzl]: Run 'bazel run //tools:bazel_env' to regenerate
        bazel-out/bazel_env-opt/bin/tools/bazel_env/bin
direnv: export ~PATH

Because the output path is stable, editor configuration becomes a one-liner. The buildifier setup guide in .vscode/settings.json is a typical example9 — the tools/ segment in the middle just mirrors the package of the bazel_env target (here //tools:bazel_env. A root-level //:bazel_env would omit it):

"bazel.buildifierExecutable": "./bazel-out/bazel_env-opt/bin/tools/bazel_env/bin/buildifier",
"bazel.buildifierFixOnFormat": true

The same projection is usable without direnv: the supported CI route is bazel run //:bazel_env print-path >> "$GITHUB_PATH".10 The repository's examples/ workspace tests both projected tools and stable locations, so a PATH problem can be reduced to the generated environment before debugging an IDE or shell integration.

Four Categories of Tools bazel_env Accepts

The bazel_env rule is deliberately schema-flexible. Any target can appear in its tool map, which lets a repo mix four categories8:

  1. In-repo binaries. Point bazel_env at a go_binary, py_binary, or custom tool you wrote. Build-tools-from-source discipline becomes ergonomic — a Go program you wrote this week is as easy to run as Terraform.
  2. Language toolchains. Reference a toolchain's make variable such as $(NODE_PATH), $(JAVA), $(CARGO), or $(RUSTFMT) and bazel_env stages the correct binary. Developers get the same node / java / cargo version Bazel uses in actions, matching the <a class="cross-ref" href="/book/3~3~3" title="3.3.3 Constraint Values"><span class="cross-ref-id">3.3.3</span> Constraint Values</a>-driven resolution.
  3. rules_multitool entries. Load the exported TOOLS dict from the ruleset (load("@multitool//:tools.bzl", MULTITOOL_TOOLS = "TOOLS")) and either pick entries by name ("terraform": MULTITOOL_TOOLS["terraform"]) or merge the whole dict into bazel_env's tool map.
  4. Package-manager scripts. Binaries published under a package manager's bin entry — NPM bin fields, Python console scripts, Go go tool entries — can be surfaced through their respective rulesets (rules_js, rules_python, and so on).

Since the rule accepts plain targets, projects can choose a single shared dependency closure or keep "prod" and "tools" trees separate. bazel_env takes no position on that argument1.

Trade-offs and Operational Notes

The two patterns are not equivalent, and neither is universally correct.

Pattern 1 (wrapper script) is simpler: no new tooling, no shell plugin, and tool invocation stays explicitly project-scoped (./tools/terraform). It fits small teams, single-language repos, and environments where installing direnv is a policy problem.

Pattern 2 (bazel_env + direnv) is seamless: once the one-time direnv install is done, developers experience the tools as if they were system-installed. The cost is a hard dependency on direnv and an eager-fetch characteristic — bazel_env currently fetches every tool in the map on first run, even those a given developer will never use1. A common workaround is multiple bazel_env targets with per-subdirectory .envrc files, so a java/ subtree doesn't have to download the Rust toolchain just to compile Java8. The recommended use is to preserve the developer workflows that existing Makefiles or README install instructions already established11.

For IDEs, either pattern solves the path problem differently: wrappers need an editor that can run a shell command on save. bazel_env provides a real path that any settings file accepts12. This connects directly with 3.5.1 IDE Support, which covers the editor integration side of the same problem.

Both patterns share a subtlety with 3.2.8 tools/bazel Wrapper: they all sit outside Bazel's output boundary and exist to bridge Bazel's hermetic world to the developer's shell. tools/bazel is a Bazelisk-level hook for the bazel command itself. The wrappers and bazel_env here are about other tools the developer runs alongside Bazel. The shared insight is that a hermetic build system doesn't automatically give you a hermetic developer environment — you have to project the pinned versions outwards. The maintainer-workspace tools/bazel is a runnable sample of that Bazelisk hook: it asserts BAZEL_REAL is set, flips a CI-mode env var when CI=true, then execs the real Bazel.

key takeaway

Developer tool management is how you extend Bazel's 2.3 Hermeticity & Sandboxing guarantees beyond bazel run and into every developer's shell. Use rules_multitool (or any repository rule) to fetch pinned binaries, then pick one distribution pattern: a tools/<name> wrapper script for minimal dependencies, or bazel_env.bzl + direnv for a seamless PATH-based experience that also gives IDEs a stable file path. Either way, the hermetic version Bazel uses in actions is the same one every developer gets in their terminal.

Check your understanding · 3 questions

1.Why can't 'bazel run //tools:terraform' be used as a drop-in replacement for a plain 'terraform' command?

Select one answer

2.Match each developer tool distribution approach to its defining characteristic:

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

Answers
rules_multitool lockfile
tools/ wrapper script
bazel_env.bzl + direnv

3.True or false about bazel_env.bzl:

Choose True or False for each sentence

bazel_env.bzl currently fetches all listed tools lazily, only when a specific tool is first invoked.
A stable output path from bazel_env.bzl can be used in IDE settings files like .vscode/settings.json.
bazel_env.bzl can surface language toolchain binaries (like node, java, cargo) alongside external CLI tools.
0 of 3 answered

Footnotes

  1. Device management: tools on your developers PATH — Alex Eagle's write-up of bazel_env.bzl + direnv as Aspect's new default, including the clean-and-regenerate flow and the non-lazy fetch trade-off. 1 2 3 4 5 6 7

  2. Running local tools installed by Bazel — original wrapper-script pattern, rules_multitool lockfile schema, and the bazel#3325 / bazel#10782 working-directory bugs. 1 2 3 4 5 6

  3. Developer Tooling in Monorepos with bazel_env — feat. Fabian Meumertzheim--run_under analysis-cache regression history and why stable-path symlinks were the eventual fix.

  4. rules_multitool repository map — the examples/module/ hub flow and generated hub_repo_tool_template/ show platform-resolved tools plus :cwd and :workspace_root wrappers. 1 2

  5. Running a Start-up on Bazel — Prasanna Swaminathan, Ergattarules_multitool adopted for Terraform, kubectl, Helm, and Firebase CLI after cross-platform makefiles became brittle.

  6. Bazel Hermetic Toolchain and Tooling Migration — Tinder replacing Homebrew with Bazel-fetched versioned binaries to remove "works on my machine" support load.

  7. multitool — rules_multitool lockfile updater — the updater's readme.md defines its public maintenance command and deliberately narrow GitHub release strategy

  8. Developer Tooling in Monorepos with bazel_env — feat. Fabian Meumertzheim — four tool categories bazel_env supports, stable output path via platform transition, and multiple per-directory environments. 1 2 3

  9. Starlark linter: Buildifier — recommended .vscode/settings.json keys bazel.buildifierExecutable / bazel.buildifierFixOnFormat pointing at the bazel_env stable path.

  10. bazel_env.bzl — Bazel-managed developer environments — the upstream CI workflow documents print-path, and examples/bazel_env_test.sh exercises the projected environment.

  11. Integrate Dev Workflows — Workshop — "Getting tools on the developer's path" section. bazel_env + direnv to preserve existing workflows without asterisked README install instructions.

  12. Review of State of the Art Solutions for IDE Support and Developer Tooling in Monorepos — Zipline's production recipe using bazel_env to expose tools and language servers (including Starpls for Starlark) on PATH so IDE extensions can find them.