4.9.8 Hermetic Host Tooling
extraRepository rules sit at the awkward boundary between Bazel's declared build graph and the machine that is running Bazel. During loading they can download files, write repository contents, inspect the filesystem, and execute host programs outside ordinary action sandboxing.1 Hermetic host tooling extends 2.3 Hermeticity & Sandboxing to that fetch-time boundary by making every unavoidable host interaction explicit enough that a future refetch is understandable: what executable ran, which environment variables mattered, which files were inspected, and which generated BUILD targets now represent the result.
This is a refinement of 4.9.5 Reproducibility Concerns, not a replacement for it. If a repository rule only fetches a hash-verified archive, prefer that. If a tool can run as a normal action, prefer an executable attr, cfg = "exec", or a toolchain. The patterns in this article are for the cases that remain: bootstrapping a repository, discovering a system installation, wrapping a local SDK, or running a small helper before the action graph exists.
rules_sh makes the choice observable. Its stable
sh/posix.bzl
discovers POSIX commands from the host and the README explicitly calls that
setup non-hermetic. The separate
sh/experimental/posix_hermetic.bzl
is a preview alternative backed by an explicit sh_binaries bundle.2
A toolchain label improves access and substitution, but it is the bundle's
inputs—not the label-shaped API—that determine whether two machines receive the
same tools.
Treat Host Discovery as a Last-Mile Adapter
A repository rule implementation receives repository_ctx, and that object exposes APIs that are intentionally more powerful than rule implementation APIs: download(), download_and_extract(), execute(), file(), template(), which(), getenv(), watch(), and watch_tree() all live in the fetch-time world.3 That power is useful, but it removes the guardrails you get from action inputs and sandboxing.
The safest shape is a thin adapter:
def _sdk_repo_impl(rctx):
tool = rctx.path(rctx.attr._helper)
result = rctx.execute(
[tool, "--print-build-file"],
environment = {"PATH": ""},
timeout = 60,
)
if result.return_code:
fail(result.stderr)
rctx.file("BUILD.bazel", result.stdout)
sdk_repo = repository_rule(
implementation = _sdk_repo_impl,
attrs = {
"_helper": attr.label(
default = "//tools/repo:sdk_probe",
allow_single_file = True,
),
},
)
The helper is a declared label, not a random PATH lookup. The environment is deliberately narrow. The repository rule turns the helper's answer into repository contents, then normal build rules consume those contents as labels. This mirrors the action contract in 4.4.3 Action Execution Contract: tools should be explicit dependencies, not ambient shell state.
There is one important limitation: repository-rule label attrs can point at source files and files from already-available repositories, but not generated outputs from the normal build graph. The repo must exist before Bazel can analyze targets that would build such outputs. That is why repository helpers are usually prebuilt, checked in, downloaded by another repository rule, or provided by a bootstrap module rather than built by the workspace they are about to configure.4
Track the Environment You Read
The most common hidden dependency is PATH. Consider a repository rule that reads one explicit variable and one implicit variable through which(): the code appears reasonable, but Bazel will not know to refetch the repository when those values change unless the rule records the dependency.5
If executable discovery is truly unavoidable, pair that discovery with repository_ctx.getenv("PATH") or another explicit input that records how the executable was selected. The API says a getenv() value change causes the repository to be refetched. repository_ctx.os.environ does not establish that dependency.3,6 4.9.6 getenv() vs os.environ Pitfall explains the full getenv() / os.environ distinction.
Tracking PATH does not make the discovered tool hermetic. It only makes the selection visible to Bazel. If the generated repository also depends on files under the discovered installation, declare those separately with watch() / watch_tree() or generate labels that expose the actual files. File watching is the filesystem counterpart to getenv() and is covered directly in 4.9.7 watch() / watch_tree() File Dependencies.
Wrap Local Filesystems Honestly
Sometimes the dependency is not a downloadable archive: it is an installed SDK, an NFS mount, a shared vendor tree, or a local checkout. local_repository makes a local directory that already contains Bazel files available as a repository. new_local_repository does the same for a directory without Bazel files by supplying generated BUILD contents.7
That is not the same as making the dependency portable. It is a way to give Bazel a label-shaped model for something local:
new_local_repository(
name = "vendor_sdk",
path = "/opt/vendor/sdk",
build_file_content = """
filegroup(
name = "headers",
srcs = glob(["include/**/*.h"]),
visibility = ["//visibility:public"],
)
""",
)
The win is honesty. Downstream rules now depend on @vendor_sdk//:headers instead of reaching into /opt/vendor/sdk from an action or shell script. The cost is also explicit: the repository is machine-dependent, and its correctness depends on how well the repository rule declares the files and directories whose changes should cause refetching.
Model Dynamically Linked Tools, Not Just the Binary
Host tools are rarely one file. A dynamically linked executable depends on an interpreter and shared libraries that may live outside the executable's directory. One approach uses lddtree /bin/ls -a to discover the runtime shared-object tree, then generates BUILD data so a sh_binary has explicit data dependencies on libraries such as libc.so.6, libtinfo, and the dynamic loader.8
The teaching point is not that every repository rule should run lddtree. The point is that “use /bin/bash” is not a complete dependency model. If a repository rule discovers a host binary and then generated targets rely on that binary later, the model needs to include the binary's runtime closure or it still depends on the machine in a way Bazel cannot see.
This pattern is most useful during migrations and in environments where tools are not under your control. It eliminates “worked on my machine” behavior, makes static graph questions such as which targets still use a given shared library answerable, and lets host dependencies be cleaned up incrementally.8 If you can replace the host tool with a proper toolchain, do that first.
Use Portable Helpers for Small Bootstrap Tools
Actually Portable Executables (APE), built with Cosmopolitan, attack a narrower problem: simple Unix-like helper tools whose platform differences would otherwise leak into repository rules. The @ape module provides hermetic, runnable targets for tools such as sed, python, bash, and ls, giving consistency across Linux, macOS, Windows, and BSD variants from a single binary format.9
That matters in repository rules because fetch-time code may need a prebuilt executable. For example, a hermetic zstd helper can be exported and passed into a repository rule that needs to decompress a tar.zst archive, avoiding a system zstd lookup.9
Use this pattern for small bootstrap utilities, not as an excuse to move real build work into fetching. The general best practice is clear: repository rules should do as little as possible, such as version resolution and BUILD generation, while compilation stays in build actions so remote caching and execution can help.10
Hermetic host tooling does not mean pretending the host is absent. It means every unavoidable host dependency leaves a trace Bazel can reason about: an attr, a label, a watched path, a tracked environment variable, generated BUILD targets, or a documented local-repository boundary.
If a host interaction cannot be named that way, it is probably too implicit for a repository rule.
Check your understanding · 3 questions
1.What is the safest shape for a helper used by a repository rule?
Select one answer
2.Which dependencies should be made explicit when a repository rule discovers or wraps host tooling?
Select all that apply
3.True or false: host-tooling patterns in repository rules.
Choose True or False for each sentence
repository_ctx.getenv() makes a discovered host tool fully hermetic.new_local_repository can expose a non-Bazel local directory by supplying generated BUILD contents.Footnotes
-
Repository Rules — repo rules materialize external repositories during loading and may use non-hermetic functions such as finding or executing binaries. ↩
-
rules_sh — POSIX shell toolchains for Bazel — public PATH-discovery contract, preview explicit-tool-bundle alternative, and focused integration tests. ↩
-
repository_ctx — repository rule API for executing commands, environment tracking, file generation, downloads, path watching, and
which(). ↩1 ↩2 -
.bzl files —
repository_rule()parameters and private label attrs. Repo rules cannot depend on generated artifacts from normal build actions. ↩ -
Determinism in a Non-Hermetic World - Albert Lloveras, Canva — repository rules as a reproducibility blind spot, the
which()/PATHtracking problem, and environment narrowing trade-offs. ↩ -
repository_os —
os.environaccess does not establish an incremental dependency. Usegetenv()instead. ↩ -
local repository rules —
local_repositoryvsnew_local_repositoryand generated BUILD file content for local directories. ↩ -
Perfect Sandboxing in Bazel - Rahul Butani, Intel —
lddtree-based shared-object discovery, generated BUILD data, and host dependency modeling patterns. ↩1 ↩2 -
Consistent Hermetic Tooling with Actually Portable Executables - Matt Clarkson, Arm — APE/Cosmopolitan helper binaries and exporting hermetic tools for repository-rule use. ↩1 ↩2
-
Bazel and Rust: Optimizing Builds and Cross-Compilation with Daniel Wagner-Hall — repository rules should keep minimal responsibility and leave compilation to normal build actions. ↩