4.11.2 Toolchainization

recommended

Toolchainization 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 expose ToolchainInfo,
  • 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.bazel registers the hub repo's toolchain targets with register_toolchains(...).

This keeps 4.6.3 Toolchain Resolution deterministic and order-driven, but moves the boring setup choices into the ruleset.

Toolchainization keeps registration light and the heavy repo lazy
Consumers register one tiny hub. Each toolchain candidate points to a platform implementation, and resolution needs only the selected one.
MODULE.bazel
Consumer declares setup
imports and registers the generated hub
tc = use_extension(...)
tc.download(version = "1.2")
use_repo(tc, "mylang_toolchains")
register_toolchains(
"@mylang_toolchains//:all",
)
Extension
Extension creates repos
graph-wide policy
module_ctx.modules

reads requested versions once

repository rules

write the hub and per-platform repos

Hub repo lists toolchain() candidates · cheap to resolve
:macos_arm64_toolchain not chosen
:linux_x86_64_toolchain matched; value points to @mylang_linux_x86_64
:linux_arm64_toolchain not chosen
Implementation repos resolution makes only the selected label necessary
cold @mylang_macos_arm64

declared label, no fetch

fetched @mylang_linux_x86_64

downloaded for the resolved toolchain

cold @mylang_linux_arm64

declared label, no fetch

The generated hub repo owns the toolchain() targets. A candidate's implementation label becomes necessary only when resolution selects it, so only the matched implementation repo is fetched.

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.

  1. The extension exposes tag classes such as download(version = "..."), scala_deps.scala(), or scala_deps.scalatest().
  2. The implementation reads tags from module_ctx.modules and applies the ruleset's selection policy.
  3. It calls repository rules to create SDK or tool repos for supported platforms.
  4. It calls a second repository rule that writes the hub repo's BUILD.bazel file with toolchain() declarations.
  5. 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.

key takeaway

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

The hub repo may contain small wrapper targets needed to expose ToolchainInfo.
Heavy SDK downloads should happen just because the hub repo is registered.
dev_dependency = True can keep ruleset-development setup from affecting downstream release users.
A root module can no longer override a ruleset's default toolchain registration.
0 of 3 answered

Footnotes

  1. Writing Bazel rules: module extensions — "Toolchainization" section: initial go_download + direct registration setup and its three problems. 1 2

  2. Deploying Rules — "Registering toolchains" guidance to split registered toolchain targets from heavier <LANG>_toolchain implementation repos.

  3. Migrating to Bazel Modules (a.k.a. Bzlmod) - Toolchainization — "The magic of Toolchainization" four-step pattern.

  4. 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 in MODULE.bazel.

  5. Migrating to Bazel Modules (a.k.a. Bzlmod) - Toolchainization — dynamic @rules_scala_toolchains//...:all registration and empty top-level BUILD behavior.

  6. Writing Bazel rules: module extensions — Go extension implementation: read tags, pick highest version, download manifest, create platform repos, create go_toolchains.

  7. 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.

  8. Sponsored Session: Writing Bazel Rules - Instructor: Jay Conrod — workshop "Toolchainization Pattern": heavy platform repos vs tiny toolchains-only repo.

  9. Migrating to Bazel Modules (a.k.a. Bzlmod) - Toolchainization — root module toolchain registration priority and dev_dependency behavior for extension usages and registrations. 1 2

  10. 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

  11. Migrating to Bazel Modules (a.k.a. Bzlmod) - Maintaining Compatibility, Part 1 — shared scala_toolchains / scala_deps API shape and equivalent WORKSPACE/Bzlmod behavior.

  12. Never Compile Protoc Again — historical protoc-from-source cost, host C++ sensitivity, and cross-platform recompilation pain.

  13. Never Compile Protoc Againtoolchains_protoc and toolchain resolution decouple "where protoc comes from" from rules that need protoc.

  14. 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