4.11.2 Toolchainization
recommendedToolchainization is the ruleset publishing pattern that turns "please download and register this compiler or SDK" into a small, repeatable module API. The ruleset still defines the toolchain_type, concrete ToolchainInfo, and toolchain() targets from 4.6.2 Defining, Registering & Accessing Toolchains. Toolchainization decides how those targets are packaged, generated, and registered so users do not hand-wire every platform variant themselves.
The pattern belongs here, after 4.11.1 Ruleset Layout & Public Entry Points, because it is a public setup contract. A published ruleset can have excellent rule implementations and still be painful if every consumer must call a stack of setup macros in the right order, list every platform archive, and understand the ruleset's private toolchain dependencies.
The Problem It Solves
The naive SDK setup puts the heavy implementation and the registered toolchain() target in the same external repository. The naive Go setup starts that way: the root module calls go_download once per platform, then registers @go_darwin_arm64//:toolchain and @go_linux_amd64//:toolchain directly.1 That works, but it has three ruleset-author problems: users must know which platforms to list, multiple modules can register conflicting versions, and Bazel may need to materialize every registered SDK repo just to read its toolchain constraints.1
Official ruleset guidance calls out the same optimization from the publishing side: Bazel must analyze registered toolchain targets during resolution, but it does not need to analyze everything behind the toolchain.toolchain attribute. If registering toolchains would otherwise force expensive repository computation, split the repository that contains lightweight toolchain() targets from the repository that contains the heavier <LANG>_toolchain implementations.2
That split is the heart of toolchainization:
- a hub repo contains generated
toolchain()declarations and any lightweight wrapper targets needed to exposeToolchainInfo, - one or more artifact repos contain downloaded SDKs, compilers, runtimes, or prebuilt tools,
- a module extension chooses which repos to create from tags across the module graph,
MODULE.bazelregisters the hub repo's toolchain targets withregister_toolchains(...).
This keeps 4.6.3 Toolchain Resolution deterministic and order-driven, but moves the boring setup choices into the ruleset.
reads requested versions once
write the hub and per-platform repos
declared label, no fetch
downloaded for the resolved toolchain
declared label, no fetch
The Hub Repo Shape
The canonical hub-repo shape works like this: the ruleset provides a module extension, instantiates all toolchain dependency repos from that extension, brings the generated toolchain repo into scope with use_repo(), then passes the hub repo's targets to register_toolchains().3
bazel_dep(name = "rules_scala", version = "7.0.0")
scala_config = use_extension(
"@rules_scala//scala/extensions:config.bzl",
"scala_config",
)
scala_config.settings(scala_version = "2.13.16")
scala_deps = use_extension(
"@rules_scala//scala/extensions:deps.bzl",
"scala_deps",
)
scala_deps.scala()
scala_deps.scalatest()
use_repo(scala_deps, "rules_scala_toolchains")
register_toolchains("@rules_scala_toolchains//...:all")
The Scala names are incidental to the pattern. @rules_scala_toolchains is a generated repository whose packages contain the lightweight targets Bazel needs for toolchain resolution. The extension also creates the repositories those toolchains depend on, but those internal artifact repos live in the extension's repository scope, not in the user's public setup surface.4
The target pattern is deliberate. register_toolchains("@rules_scala_toolchains//...:all") recursively finds toolchain() targets in whatever packages the generated repo contains. Non-toolchain targets are ignored, and the call can still succeed when the generated repo is empty.5 That lets the hub repo be dynamic. If a user enables ScalaTest but not Scalafmt, the extension can generate only the packages relevant to that configuration.
Generation Flow
The moving parts are the hub-repo design pattern from 4.10.4 Extension Design Patterns, built from repository rules in 4.9.1 Repository Rule Fundamentals and module-extension mechanics in 4.10.1 Module Extension Fundamentals. This article applies that pattern as a published ruleset setup API.
- The extension exposes tag classes such as
download(version = "..."),scala_deps.scala(), orscala_deps.scalatest(). - The implementation reads tags from
module_ctx.modulesand applies the ruleset's selection policy. - It calls repository rules to create SDK or tool repos for supported platforms.
- It calls a second repository rule that writes the hub repo's
BUILD.bazelfile withtoolchain()declarations. - The module registers the hub repo target pattern.
A Go module extension is a compact worked example. It gathers every go.download tag from selected modules, picks the highest requested Go version, downloads a manifest from go.dev, instantiates go_download repos for supported platform archives, then creates a go_toolchains repo containing the toolchain() declarations.6
go = use_extension("//:go.bzl", "go")
go.download("1.25.0")
use_repo(go, "go_toolchains")
register_toolchains("@go_toolchains//:all")
After that, a downstream module only needs to ask for a Go version through the extension tag. It does not need to repeat the hub repo registration, list every platform URL, or know the SHA-256 values for the SDK archives.7
Why The Hub Must Be Light
Toolchain resolution needs to inspect candidate toolchain() targets before it knows which implementation will run. If those targets live in the same repo that downloads a 3 GB SDK matrix, registration can turn a simple build into a cold-start fetch storm. The problem statement is simple: if toolchains are declared inside repository rules that perform the SDK downloads, Bazel may evaluate those repos just to read constraints. The fix is one tiny toolchains-only repo plus platform-specific implementation repos fetched only after resolution selects them.8
The hub repo therefore should contain only enough BUILD structure for Bazel to resolve:
go_toolchain(
name = "linux_amd64_impl",
builder = "@go_linux_amd64//:builder",
tools = ["@go_linux_amd64//:tools"],
stdlib = "@go_linux_amd64//:stdlib",
)
toolchain(
name = "linux_amd64",
exec_compatible_with = [
"@platforms//os:linux",
"@platforms//cpu:x86_64",
],
target_compatible_with = [
"@platforms//os:linux",
"@platforms//cpu:x86_64",
],
toolchain = ":linux_amd64_impl",
toolchain_type = "@rules_go_simple//:toolchain_type",
)
The lightweight go_toolchain wrapper can live in the hub repo while its attributes point at files from the platform-specific download repo. The hub still should not perform the expensive work itself. Its job is to describe candidates cheaply so 4.6.3 Toolchain Resolution can decide which candidate belongs in the configured target graph.
Root-Only Overrides And dev_dependency
Toolchainization does not remove user control. The root module and command line still have higher priority in toolchain registration order, so a consuming workspace can register custom toolchains ahead of a ruleset's defaults when it needs to override selection.9
dev_dependency is the other half of the boundary. Under Bzlmod, some extension usages and toolchain registrations should apply only when the ruleset repository itself is the root module: test-only toolchains, source-build fallbacks, local development helpers, and "build my own release tools" dependencies. Both use_extension(..., dev_dependency = True) and register_toolchains(..., dev_dependency = True) are the root-module-only knobs for this.9 The same idea applies from the artifact side: release users should download prebuilt tools, while source-archive users may need the language toolchain and libraries required to build those tools from source.10
For a published ruleset, treat this as part of API design:
- Release consumers get a small
bazel_dep(...)plus the ordinary extension tags. - The ruleset's own development mode can keep source-build toolchains and extra dependencies.
- BCR metadata or release patches can mark build-from-source module dependencies as development-only when the released archive already contains prebuilt tool metadata.10 That is a module metadata concern. See 4.11.3 Module Metadata & Version Semantics.
Sharing WORKSPACE And Bzlmod Implementations
Many rulesets still need a migration window where WORKSPACE users and Bzlmod users both work. Toolchainization should not fork the setup logic into two unrelated systems. The cleaner shape is a shared implementation function or macro that creates the same repos, with a thin Bzlmod extension wrapper on one side and a legacy WORKSPACE macro on the other.
That shape works as follows: legacy WORKSPACE calls a shared toolchain-setup macro such as scala_toolchains(...) directly, while a module extension translates tag values and then calls the same underlying toolchain setup. The benefit is that equivalent configuration values produce no behavioral difference between WORKSPACE and Bzlmod builds.11
That also explains why toolchainization is not "just write a module extension." The extension is the Bzlmod entry point. The reusable unit is the repository generation contract: names, generated packages, toolchain labels, dependency repos, and compatibility behavior.
A Real Case Study: protoc
The protoc migration shows why this pattern matters beyond tidy setup files. Historically, Bazel proto users often compiled protoc from source even though Protobuf releases publish compiler binaries. That caused slow local builds, host C++ toolchain sensitivity, and repeated work when caches or execution platforms changed.12 Aspect's now-archived toolchains_protoc project was an important bridge: it packaged those binaries behind the proto toolchain interface while upstream support was incomplete.13
The upstream solution arrived in stages. Protobuf 33.4 introduced an official prebuilt toolchain as an opt-in with --@protobuf//bazel/toolchains:prefer_prebuilt_protoc. Bazel 7 and 8 also need --incompatible_enable_proto_toolchain_resolution. Bazel 9 enables that resolution path by default. Protobuf 34 then made the prebuilt compiler the default. Its canonical switch is --@protobuf//bazel/flags:prefer_prebuilt_protoc, with the earlier bazel/toolchains label retained as an alias. Setting it to false disables those official prebuilt candidates. Under the stock registrations the source-built toolchain is then the fallback.14
For ordinary application proto targets, the mature shape is to keep standard proto_library and language rules, let Bazel resolve the compiler for the execution platform, and consume the toolchain maintained by the tool's owner. The official prebuilt candidates cover Protobuf's standard release platforms. On an unsupported execution platform the stock registrations fall back to the source-built compiler, so a project that forbids source builds must constrain its platforms or register another compatible toolchain.14
Published rulesets have an additional distribution question: should every consumer inherit the ruleset's proto schema, language runtime, and language-toolchain policy? The mini-ruleset answers no for its internal worker protocol. It ships officially generated Python binding code with a pinned runtime wheel, while Protobuf 34.1 remains a maintainer-only dependency. A small regeneration rule resolves the upstream prebuilt compiler. The fail-closed check rejects any Protobuf CppCompile action before it regenerates and diffs the checked-in file. Poison per_file_copt settings in the example's .bazelrc also make an accidental source edge fail on its first compile action. This avoids registering a private Python language toolchain into consumer builds and confines the one upstream-private toolchain label to maintainer tooling.
Be precise about the knobs. Setting prefer_prebuilt_protoc to false disables the official prebuilt candidates. With Protobuf's stock registrations Bazel then selects its source-built fallback. A patched compiler additionally requires a custom proto toolchain. Prebuilt selection removes the compiler build, but does not reduce schema-generation action count or make every language runtime and plugin prebuilt. For an internal protocol shipped by a reusable ruleset, generated code plus a release runtime can remove those consumer-side edges too.
Toolchainization packages a ruleset's compiler or SDK setup as generated toolchain repositories. Keep the registered hub repo small, put heavy downloads in implementation repos, generate both from a module extension, and expose only the stable setup surface users need.
The pattern builds on toolchain resolution, repository rules, and module extensions. Its value is ruleset ergonomics: users register a toolchain family, not a pile of platform archives and private repos.
When the tool owner publishes a supported prebuilt toolchain, consume it and preserve the standard rule surface. Keep source compilation as a conscious fallback or opt-out rather than the accidental default. For a ruleset's private schema, it can instead be correct to ship generated bindings and a pinned runtime, provided maintainers regenerate them with that upstream toolchain and enforce drift without leaking language-toolchain policy to consumers.
Check your understanding · 3 questions
1.What is the main purpose of the hub repo in a toolchainized ruleset?
Select one answer
2.Which pieces belong in the toolchainization pattern?
Select all that apply
3.True or false: toolchainization boundaries.
Choose True or False for each sentence
ToolchainInfo.dev_dependency = True can keep ruleset-development setup from affecting downstream release users.Footnotes
-
Writing Bazel rules: module extensions — "Toolchainization" section: initial
go_download+ direct registration setup and its three problems. ↩1 ↩2 -
Deploying Rules — "Registering toolchains" guidance to split registered
toolchaintargets from heavier<LANG>_toolchainimplementation repos. ↩ -
Migrating to Bazel Modules (a.k.a. Bzlmod) - Toolchainization — "The magic of Toolchainization" four-step pattern. ↩
-
Migrating to Bazel Modules (a.k.a. Bzlmod) - Module Extensions — WORKSPACE vs MODULE.bazel table: toolchain dependency repos can remain inside an extension namespace.
register_toolchains()belongs inMODULE.bazel. ↩ -
Migrating to Bazel Modules (a.k.a. Bzlmod) - Toolchainization — dynamic
@rules_scala_toolchains//...:allregistration and empty top-level BUILD behavior. ↩ -
Writing Bazel rules: module extensions — Go extension implementation: read tags, pick highest version, download manifest, create platform repos, create
go_toolchains. ↩ -
Writing Bazel Rules: Module Extensions — Go SDK example: downstream module asks for a version while the extension centralizes platform repos and the shared toolchain declaration repo. ↩
-
Sponsored Session: Writing Bazel Rules - Instructor: Jay Conrod — workshop "Toolchainization Pattern": heavy platform repos vs tiny toolchains-only repo. ↩
-
Migrating to Bazel Modules (a.k.a. Bzlmod) - Toolchainization — root module toolchain registration priority and
dev_dependencybehavior for extension usages and registrations. ↩1 ↩2 -
Releasing Bazel rulesets that publish tools — release users download prebuilt tools while source-archive users may need build-from-source dependencies. BCR patching converts Go dependencies to
dev_dependency. ↩1 ↩2 -
Migrating to Bazel Modules (a.k.a. Bzlmod) - Maintaining Compatibility, Part 1 — shared
scala_toolchains/scala_depsAPI shape and equivalent WORKSPACE/Bzlmod behavior. ↩ -
Never Compile Protoc Again — historical protoc-from-source cost, host C++ sensitivity, and cross-platform recompilation pain. ↩
-
Never Compile Protoc Again —
toolchains_protocand toolchain resolution decouple "where protoc comes from" from rules that need protoc. ↩ -
Pre-built protoc binaries for Bazel — official Protobuf 33.4 opt-in, Bazel 9 resolution default, Protobuf 34 prebuilt default, configuration labels, and source-build opt-out. ↩1 ↩2