4.9.11 Non-incremental External Evaluation
recommendedRepository rules are a tempting place to run another build or package manager: call Nix, CMake, pkg-config, or an ecosystem resolver, then generate a Bazel repository from the result. The trap is that Bazel can only track the repository rule's declared inputs and final repository contents. The external evaluator's own dependency graph stays opaque, so every refetch can become a full re-evaluation instead of a small incremental update.
repository_ctx.execute().
The Boundary Bazel Can See
A repository rule runs while Bazel is materializing external repositories, before ordinary rule analysis has registered actions. Its repository_ctx can download archives, execute host commands, read files, and write generated BUILD.bazel files, but it cannot create normal build actions or depend on action outputs.1 That placement is useful for fetching source code and generating a small repository, but it is a poor fit for work that has its own large, dynamic graph.
The repository API gives Bazel some explicit dependency hooks. repository_ctx.getenv() records an environment variable dependency, watch() and watch_tree() record file dependencies, and hash-verified downloads can reuse the repository cache before touching the network.2 Those hooks let Bazel decide when the repository should be refetched. They do not let Bazel peer inside a Nix evaluator, a CMake configure run, or any other external engine that the repository rule launches with repository_ctx.execute().
That makes this article a sibling of 4.9.5 Reproducibility Concerns, not a duplicate. Reproducibility asks "will the same inputs produce the same repo?" This anti-pattern asks "when something changes, how much work must be repeated before Bazel can even start normal analysis?"
What the Anti-Pattern Looks Like
The shape is usually small in Starlark and large outside it:
def _package_repo_impl(rctx):
result = rctx.execute([
"external-evaluator",
"--lockfile",
str(rctx.path(rctx.attr.lockfile)),
])
# Parse the evaluator output and generate BUILD.bazel files.
rctx.file("BUILD.bazel", render_build_file(result.stdout))
There is nothing inherently forbidden about repository_ctx.execute(). The official remote-execution guidance treats host command execution as one of the operations to audit because it runs locally and can depend on host state.3 The same practical point holds at the rule-authoring level: repository rules can run commands and touch the host filesystem, but that power is harder to make portable and reproducible than ordinary build actions.1
The performance problem appears when the command is not a tiny probe but a second evaluator with its own world model. Bazel sees "this repository rule ran and produced files." The external tool sees "load my own graph from scratch." The result is a coarse invalidation boundary: a small change to an evaluator input can force the whole external evaluation again.
The Nix Case Study
A rules_nixpkgs deployment on Canva's codebase measured a 13-second "cache hit" path caused by Nix evaluation overhead: Nix evaluation invalidated on changes to .nix files or derivation sources, and that evaluation was repeated for each nixpkgs_package repository.4 Bazel's own Starlark evaluation is different — incremental and reused between invocations while the Bazel server and analysis state remain warm.4 For how Skyframe keeps Bazel-side evaluation incremental, see 2.4.1 Skyframe & Incrementality.
The attraction of the integration is clear: Nixpkgs gives access to compiler toolchains and system libraries, while Bazel gives fine-grained incremental builds for project source code.5 The problem is not "Nix is bad". It is putting too much Nix evaluation inside the repository-rule boundary and expecting Bazel to make it incremental.
The same cost appears at a larger scale. In a rules_nixpkgs + remote execution deployment, a single Nix evaluation was about one second, but 300 packages made a no-op path about 300 seconds before optimization.6 Large external-repository file trees compounded it: Bazel could restart repository evaluation as new files were discovered, so the integration was changed to pass content-addressed Nix store path strings instead of making Bazel scan entire trees.6
The same boundary shows up from another angle: lengthy Nix evaluations ran on third-party changes, analysis blocked while Bazel waited for single dependencies to materialize, and remote execution required elaborate setup because the build still needed the Nix daemon and Nix store paths.7 The mitigation was to generate Bazel metadata ahead of time, commit those files, and hardcode specific Nix store paths so Nix could fetch directly from the binary cache instead of re-evaluating the package expression during routine builds.7
Why Remote Execution Does Not Save It
Repository rules do not become remote actions just because the rest of the build uses remote execution. Repo rules still run locally today, even with remote execution enabled: an http_archive is downloaded and extracted locally, then its source files are uploaded as action inputs when needed.8 Possible future designs would reimagine repository rules as build rules, but that raises deeper questions such as whether query would need to run actions.8
For this anti-pattern, that means a remote build cluster can speed up C++ compiles, Java compiles, tests, and other normal actions in 6.3 Remote Execution Infrastructure, but it will not remove a slow local repository-rule evaluator. The operational point is direct: --repository_cache avoids re-downloading toolchains and libraries, but it is a downloader cache for repository-rule inputs, not a cache of the resulting external repository. Fresh runners still execute repository rules before actions can be sent remote.9 If every clean CI worker has to run the external resolver before analysis can proceed, remote execution starts after the damage is already done.
Prefer Smaller, Bazel-Native Boundaries
The better design is usually to move work out of the repository rule or make its output more stable:
- Prefer content-addressed archives with generated or checked-in
BUILD.bazelfiles when the dependency has stable source artifacts. A Nix-providedwoff2dependency, for example, can be replaced with anhttp_archiveplus a Bazel build file, with Brotli coming from the Bazel Central Registry.4 - Keep repository rules as materializers, not compilers. The general pattern is that repository rules should do minimal version resolution and BUILD generation, while compilation stays in build-time actions where caching and remote execution apply.10
- If an external resolver is unavoidable, run it deliberately as a lockfile update or release-prep step, then let the repository rule consume the precomputed result. That keeps routine builds from paying resolver cost just to discover that nothing relevant changed.
- If you must integrate Nix-like package output, centralize evaluation and pass stable, content-addressed identifiers rather than large mutable file trees. The large-scale
rules_nixpkgs+ remote execution deployment above did exactly this — Nix store paths and build-plan hashes let Bazel compare strings instead of rediscovering every file.6
The same warning applies to CMake or other external evaluators, but the mapped sources for this item give concrete evidence mainly for Nix. If a repository rule shells out to cmake just to discover installed libraries or generate a whole project model, treat it as the same shape: Bazel can track the command's declared inputs, but it cannot reuse CMake's internal reasoning as Skyframe nodes.
Use repository rules to fetch, verify, and describe external repositories. Avoid using them as a hidden build system driver. Once a repository rule delegates to a large external evaluator, Bazel's incremental model stops at the process boundary, and every refetch risks paying the evaluator's full startup and graph cost again.
Check your understanding · 3 questions
1.Which facts make a large external evaluator a poor fit for a repository rule?
Select all that apply
2.What did the Nix case studies show about rules_nixpkgs-style integration?
Select one answer
3.True or false: designing around non-incremental external evaluation
Choose True or False for each sentence
--repository_cache caches the fully materialized external repository, so fresh CI runners can skip repository rule execution.BUILD.bazel files usually make a cleaner Bazel boundary than shelling out to a full evaluator.Footnotes
-
Writing Bazel rules: repository rules — repository rules use
repository_ctxduring loading, can execute commands, and cannot create normal actions. ↩1 ↩2 -
repository_ctx —
download(),download_and_extract(),getenv(),watch(), andwatch_tree()dependency hooks, plusrepo_metadata()reproducibility metadata. ↩ -
Finding Non-Hermetic Behavior in WORKSPACE Rules — repository-context operations such as
execute,os,which, and host-path symlinks run locally and should be audited for non-hermetic host dependence. ↩ -
Nix and Bazel: The Odd Couple of Build Tools - Jesse Schalken, Canva —
rules_nixpkgsevaluation overhead, 13-second cache-hit path, per-nixpkgs_packagerepetition, and migration to Bazel-native package builds. ↩1 ↩2 ↩3 -
Nix + Bazel = fully reproducible, incremental builds — original motivation for using Nixpkgs for toolchains/system libraries and Bazel for fine-grained incremental project builds. ↩
-
Remote Execution with Rules_nixpkgs: Design and Deployment - Guillaume Maudoux, Modus Create — 300 package evaluations, content-addressed store-path strategy, and avoiding large external-repository file-tree scanning. ↩1 ↩2 ↩3
-
Nix and Bazel: A New Hope - Artem Leshchev, Avride — rules_nixpkgs speed, analysis blocking, Nix daemon, and store-path mitigation details. ↩1 ↩2
-
Frequently asked questions — repo rules still run locally with remote execution. Future remote-repo-rule ideas would require architectural changes. ↩1 ↩2
-
Estimating the effort to build a Bazel CI/CD — repository cache as downloader cache and fresh-runner repository-rule re-execution before remote actions. ↩
-
Cross-compilation in Rust — repository rules should do minimal version resolution and BUILD generation, while compilation belongs in normal build actions. ↩