4.10.6 Hermeticity Considerations

recommended

Module extensions are dependency-setup code with access to the host machine. They run after Bzlmod has resolved the module graph, read tags from every module that uses the extension, and usually create repositories by calling repository rules.1 That makes their hermeticity boundary closer to 4.9.5 Reproducibility Concerns than to an ordinary action: there is no action sandbox around module_ctx.execute(), module_ctx.os, downloads, local file reads, or generated repository contents. The same principle from 2.3.1 Hermeticity still applies: if a host fact can change what repositories the extension creates, Bazel needs to know that fact is an input.

Where Host State Enters

The module_ctx API deliberately overlaps with repository-rule APIs. It can download and extract files, execute commands, generate files, read paths, inspect the host system, watch files, and find programs on PATH.2 Those powers are useful for real integrations: Maven, Go, npm, Cargo, SDK installers, and toolchain setup all need more than bazel_dep() can express. They are also the places where a module extension can accidentally encode "whatever happened to be true on this laptop" into a repository that every downstream target treats as normal build input.

Three APIs deserve special attention during review:

  • module_ctx.execute(...) runs a host command and returns stdout, stderr, and a return code.3 If the command depends on PATH, installed tools, credentials, locale, time, or network state, those facts are now part of the extension's result.
  • module_ctx.os exposes the OS name, architecture, and environment dictionary of the machine running Bazel.4 Reading os.environ is especially sharp: the repository_os docs say that reading this dictionary does not establish an incremental dependency. Use module_ctx.getenv() when the value should invalidate the extension.4
  • module_ctx.path(...), read(...), watch(...), and path methods can observe local files before ordinary build actions exist. A relative module_ctx.path resolves under a temporary working directory for the extension. Labels must point to non-generated files because module extensions run before target outputs exist.5

This is the same class of risk as a repository rule that generates different repos from untracked host files. The difference is scope: a module extension often aggregates tags from the whole module graph first, so one hidden host dependency can affect a shared generated repo used by many modules.

Declare Platform Variance

module_extension() has two booleans for host-platform variance: os_dependent and arch_dependent.6 Set them according to the extension's generated repository set, not according to whether the extension happens to mention platform names in a table.

If an extension creates the same repositories and generated BUILD files on Linux, macOS, and Windows, leave both false. A Go toolchain extension does exactly that: it declares repos for all supported platforms, so Linux, macOS, and Windows users can share the same lockfile metadata.7

go = module_extension(
    implementation = _go_impl,
    tag_classes = {"download": _download_tag},
    os_dependent = False,
    arch_dependent = False,
)

If the extension actually changes its output based on the host OS or CPU, say so:

local_sdk = module_extension(
    implementation = _local_sdk_impl,
    tag_classes = {"sdk": _sdk_tag},
    os_dependent = True,
    arch_dependent = True,
)

That declaration affects how Bazel records extension results. The lockfile docs show a "general" entry when an extension is independent of OS and architecture, and separate entries such as "os:macos" and "os:linux" when the extension depends on those dimensions.8 Without the declaration, a platform-specific extension can look more stable than it really is.

Track Environment And Files

When an environment variable changes extension output, read it through module_ctx.getenv("NAME"). The API records that the extension depends on that variable, so a later value change causes re-evaluation.2 Reading module_ctx.os.environ["NAME"] may return the same string, but it does not give Bazel that dependency edge.4 This is the module-extension version of 4.9.6 getenv() vs os.environ Pitfall.

The same pattern applies to local files and directories. Accessing path.exists or path.is_dir does not watch the path. path.readdir(watch = ...) can watch directory membership, and module_ctx.watch(...) records file or directory changes that should invalidate the extension.5,2 If an extension reads a lockfile, SDK marker file, local config directory, or generated manifest from the source tree, make that observation explicit.

Prefer declarative inputs over host discovery when you can. A tag attribute such as sdk_version = "17.0.10" is easier to review, lock, and reproduce than module_ctx.execute(["java", "-version"]). If discovery is unavoidable, keep the discovered fact small, make the tracked inputs visible, and fail with a clear message when the host does not match the extension's contract.

reproducible Is A Promise

Returning module_ctx.extension_metadata(reproducible = True) tells Bazel that the extension always creates the same repositories for the same inputs, and therefore its generated repo specs do not need to be stored in MODULE.bazel.lock.2,8 This is useful for lockfile size and merge conflicts, but it is not a cache performance hint to sprinkle on every extension.

Use reproducible = True only when the extension's result is determined by tracked inputs: tags, .bzl code, checked-in files, declared environment reads, watched local files, and downloads guarded by checksums. The official module-extension best practices state that reproducible extensions can still be cached across server restarts, so marking an extension reproducible does not make long-running resolution inherently slower.1

The opposite case is just as important. A Go toolchain extension downloads a live version manifest from go.dev to learn URLs and SHA-256 sums, then returns reproducible = False. That forces Bazel to record the resolved URLs and hashes in the lockfile so a later upstream change becomes visible instead of silently changing repository contents.7

For extensions that fetch effectively immutable data but cannot verify it with a checksum at first read, facts can help turn the discovered value into a tracked lockfile input.2 Keep the rule of thumb here: reproducible = True needs tracked inputs. The API shape and schema cautions belong in 4.10.3 Facts (State Persistence).

Review Pattern

When reviewing a module extension, scan for host-observing APIs before you read the rest of the algorithm:

  • Does every execute() call have a declared reason, stable arguments, and explicit environment?
  • Does every environment-dependent branch use module_ctx.getenv() rather than module_ctx.os.environ?
  • Does every local file or directory read have a matching watch, read(watch = ...), or readdir(watch = ...) story?
  • Do os_dependent and arch_dependent match the repositories the extension creates on different hosts?
  • Is reproducible = True backed by tracked inputs, checksums, or persisted facts rather than optimism?

bazel mod deps is a useful development command because it forces evaluation of module extensions that might otherwise remain lazy until one of their generated repos is referenced.1 Use it to catch obvious extension failures, then review the input model with the same seriousness you apply to repository rules and production actions.

key takeaway

Module extensions are powerful because they run before the normal build graph can solve dependency setup. That also means they sit outside ordinary action sandboxing.

Make host dependence explicit: track environment variables with module_ctx.getenv(), watch local files you read, declare OS and architecture variance on module_extension(), and reserve reproducible = True for extensions whose generated repos are truly determined by tracked inputs.

Check your understanding · 3 questions

1.Which module extension operations should trigger a hermeticity review?

Select all that apply

2.When should os_dependent or arch_dependent be set on module_extension()?

Select one answer

3.True or false: reproducibility metadata for module extensions

Choose True or False for each sentence

extension_metadata(reproducible = True) means the extension promises the same generated repos for the same tracked inputs.
A reproducible extension must always be written to MODULE.bazel.lock so Bazel can replay it.
If an extension reads a live manifest without a checksum, marking it non-reproducible can make the resolved data visible in the lockfile.
module_ctx.os.environ is equivalent to module_ctx.getenv() for invalidation tracking.
0 of 3 answered

Footnotes

  1. Module extensions — extension lifecycle, lazy evaluation, repo-rule-like capabilities, and reproducibility best practices. 1 2 3

  2. module_ctx — module extension API for downloads, execution, file reads, getenv, watch, path, facts, and extension_metadata. 1 2 3 4 5

  3. exec_result — return structure for module_ctx.execute() / repository_ctx.execute().

  4. repository_os — OS name, architecture, environment dictionary, and warning that environ reads do not establish dependencies. 1 2 3

  5. path — path observation and watching behavior for existence, directory checks, and readdir(watch = ...). 1 2

  6. .bzl filesmodule_extension() parameters, including os_dependent and arch_dependent.

  7. Writing Bazel Rules: Module Extensions — Go toolchain extension example, os_dependent / arch_dependent, and non-reproducible manifest handling. 1 2

  8. Bazel Lockfile — module extension lockfile entries, OS/architecture splitting, and reproducible = True lockfile behavior. 1 2