4.9.3 When to Write Custom Repository Rules
Write a custom repository rule when the dependency boundary itself needs custom materialization logic. If the problem is "download this stable archive and verify these bytes", use the built-in archive path from 4.9.2 Archive Downloads & Integrity. If the problem is "turn a lockfile, host SDK, private object store, or platform-specific tool distribution into Bazel repositories", a custom repo rule may be the right boundary.1
The decision matters because repository rules run during loading, outside target analysis and action sandboxing. They can download, write files, inspect the host, and execute commands through repository_ctx. That power is useful for dependency fetching, but it is also where reproducibility and incrementality are easiest to lose.1,2
Start With The Simpler Boundary
Do not start with repository_rule() just because the dependency is external. Built-in repository rules already cover common shapes: http_archive and http_file for hash-verified downloads, git_repository for cases where an archive is not available, and local repository rules for exposing an existing local tree.3 The first question is whether one of those rules can describe the dependency with attributes rather than code.
The same restraint applies in Bzlmod. 3.1.1 Bzlmod (MODULE.bazel) decides which modules and extension-generated repositories should be visible, but repo rules still materialize the actual directory trees. If a user can write a direct use_repo_rule() call to instantiate one built-in repo, keep it that simple. If you need to aggregate declarations across modules, resolve conflicts, or integrate an ecosystem package manager, that orchestration usually belongs in a module extension that calls repository rules underneath. The extension model itself starts in 4.10.1 Module Extension Fundamentals.4,5
# A direct built-in repo rule is enough when the shape is "one file".
tool = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_file")
tool(
name = "prebuilt_tool_linux_amd64",
urls = ["https://mirror.example.com/tool-linux-amd64"],
sha256 = "...",
)
That is not a custom repository rule yet. It is the baseline a custom rule must beat.
Good Reasons To Write One
The strongest reason is ecosystem translation. A language package manager has its own lockfile, artifact naming, patch model, post-install behavior, and repository layout. The JavaScript npm_translate_lock design, for example, translates package-manager data into fine-grained Bazel repositories, uses Bazel's downloader with integrity hashes from the lockfile, keeps packages lazily fetched, and runs post-install work as Bazel actions rather than as repository-rule work.6 Conda integration at 10X Genomics used a different shape: each Conda package became a separate Bazel repository for parallel fetching, while a central repository rule aggregated packages into the runtime layout Conda expects.7
Another good reason is a dependency source that the built-ins cannot speak to cleanly. rules_gcs exists because gs:// objects, private buckets, and large bucket-style datasets need more than plain http_file. Its gcs_file and gcs_archive rules are drop-in replacements for HTTP rules, but they translate GCS URLs, use Bazel's downloader and repository cache, and integrate with credential helpers instead of hardcoding credentials or shelling out to gsutil.8 A custom repo rule is justified there because the protocol and authentication boundary are the integration, not an implementation detail.
Toolchain and SDK setup is the third common case. A ruleset may need to generate a repository containing toolchain targets, select platform-specific prebuilt tools, or expose a local SDK as Bazel labels. A toolchainization pattern for rules_scala, for example, uses a module extension plus a repository rule to generate a separate toolchain repository, keeping toolchain dependencies encapsulated and letting users register @rules_scala_toolchains//...:all from MODULE.bazel.9 A go_download-style repository rule is the smaller version of the same idea: download a Go distribution, verify its checksum, and generate a BUILD.bazel file describing the extracted toolchain archive.2
The fourth case is controlled bootstrap of a binary package manager or helper. A CIPD integration example first used built-in http_file to fetch the CIPD client, then defined cipd_package as a repository rule that runs the client to materialize a package repository.10 This is a legitimate custom rule shape when the external system has no simple archive URL per package, but it is already in the danger zone: repository_ctx.execute() introduces host-execution, platform, and reproducibility questions that the next items cover in 4.9.5 Reproducibility Concerns, 4.9.6 getenv() vs os.environ Pitfall, and 4.9.7 watch() / watch_tree() File Dependencies.
Bad Reasons To Write One
Do not write a custom repo rule to hide a network fetch inside a build action. A genrule that shells out to wget for external data is the wrong abstraction: Bazel cannot manage it with bazel fetch or bazel vendor, the downloader cache is bypassed, credentials tend to leak into command strings, and even the wget tool itself is undeclared.11 If the build action needs bytes from the network, move the download to a repository rule or built-in download rule, then pass the fetched file to the action as a normal input.12
Do not write one to do work that belongs in actions. The pkg lazy-download example shows the right split: fetch the Node base binary with a repository rule, then run the packaging tool in a network-isolated action with the downloaded file as a declared input.12 The rule authoring boundary is similar for language ecosystems: resolution and BUILD generation can happen at fetch time, but compilation, post-install builds, and expensive transformations should become normal actions whenever possible so caching and remote execution can help.6
Do not write one just to wrap a large external evaluator and hope Bazel will make it incremental. Repository rules can track attributes, watched files, and tracked environment variables, but they cannot see the internal graph of another package manager, CMake configure run, or Nix evaluator. If the integration must run a large external resolver, prefer a checked-in lockfile or precomputed manifest that the repo rule consumes, and keep the full resolver out of routine builds when possible. That anti-pattern is covered directly in 4.9.11 Non-incremental External Evaluation.
A Decision Checklist
Use this checklist before introducing a public custom repository rule:
| Question | If the answer is yes |
|---|---|
Can http_archive, http_file, local_repository, or new_local_repository express it with attributes? | Use the built-in rule and stop. |
| Does the integration need to read declarations from several modules or resolve package-manager conflicts? | Put the orchestration in a module extension. Call repo rules from there.5 |
| Does each generated repository have stable bytes, hashes, lockfile entries, or explicit host inputs? | A custom repo rule can be reasonable. |
Does it need host tools, PATH, environment variables, local config files, or SDK directories? | Treat it as a reproducibility design, not just a fetcher. Continue with 4.9.5 Reproducibility Concerns and 4.9.8 Hermetic Host Tooling. |
| Does it run another build system, compile code, or perform post-install work? | Push that work into normal actions unless there is a narrow bootstrap reason. |
The implementation API in 4.9.4 Repository Rule API is straightforward enough to make custom repo rules feel cheap: an implementation function, attrs, and generated files. The design cost is not the function call. It is making the external world explicit enough that Bazel knows when to refetch, what to cache, and which machine-specific facts are part of the contract.
Write a custom repository rule only when the repository materialization step is genuinely custom: translating a lockfile, adapting a protocol, generating toolchain repositories, or modeling a host/SDK boundary.
If the dependency can be described as stable bytes plus a checksum, use built-in repository rules. If the work is compilation, post-install, or transformation, keep it in normal actions.
Check your understanding · 4 questions
1.When is a custom repository rule justified?
Select one answer
2.Which choices match the article's decision framework before writing a custom repository rule?
Select all that apply
3.Which work should usually stay out of a repository rule and be modeled as normal Bazel actions?
Select all that apply
4.True or false: choosing the right boundary for external dependencies.
Choose True or False for each sentence
http_file can express a single stable downloaded file with a checksum, a custom repo rule is not the starting point.Footnotes
-
Repository Rules — repository rules define external repositories and use
repository_ctxto download, generate files, inspect the host, and control refetching. ↩1 ↩2 -
Writing Bazel rules: repository rules —
go_downloadexample, loading-phase constraints, host-system cautions, and advice to keep repository rules small. ↩1 ↩2 -
Repository Rules — built-in and real-world repository-rule examples including external libraries, C++ toolchain configuration, Go repositories, and Maven artifacts. ↩
-
Bzlmod Migration Guide —
use_repo_rule(), module extensions for more complex logic, package-manager integration, and host toolchain detection patterns. ↩ -
Module extensions — extensions read tags across modules, perform dependency logic, and create repositories by calling repo rules. ↩1 ↩2
-
Using Bazel for JavaScript Projects —
npm_translate_lock, PNPM-in-Starlark architecture, lazy package fetching, downloader integrity hashes, and post-install actions as Bazel actions. ↩1 ↩2 -
Building a Hermetic Python Environment with Conda in Bazel - Adam Azarchs, 10X Genomics — per-package repositories, parallel fetching, central aggregation repository, and lockfile-driven Conda integration. ↩
-
Introducing rules_gcs — GCS-specific repository rules, lazy bucket fetching, credential helpers, and repository-cache integration. ↩
-
Migrating to Bazel Modules (a.k.a. Bzlmod) - Toolchainization — generated toolchain repository pattern for Bzlmod and shared WORKSPACE/module-extension implementation. ↩
-
Bazel.build - Binary package management — custom
cipd_packagerepository rule using a fetched CIPD client andrepository_ctx.execute(). ↩ -
Fetching private data with Repo Rules and MODULE Extensions - Malte Poll, Modus Create —
genrulepluswgetanti-pattern and Bazel downloader/repository-cache replacement. ↩ -
Lazy tool fetching under Bazel — moving runtime downloads to repository rules and passing fetched artifacts into network-isolated build actions. ↩1 ↩2