4.9.6 getenv() vs os.environ Pitfall

recommended

Repository rules can read the host environment before normal build actions exist. That makes environment variables part of the repository rule's input model, not just a convenience API. The safe path is repository_ctx.getenv("NAME"): it returns the variable value and records that this repository depends on that variable, so a later value change causes the repository to be fetched again.1 Reading repository_ctx.os.environ may return the same string, but it does not create that dependency.2

Same environment string, different invalidation edge
Both reads initially return /opt/sdk-1. Only getenv() tells Bazel when the later change to /opt/sdk-2 invalidates the repository.
First fetch SDK_HOME=/opt/sdk-1
Later command SDK_HOME=/opt/sdk-2
Untracked read
The value is copied into generated files
but no env input is recorded
sdk_home = rctx.os.environ.get("SDK_HOME")
rctx.file("BUILD.bazel", ...)
returns /opt/sdk-1 same string as the good path
NO No tracked edge from SDK_HOME Bazel has no recorded reason to refetch when the variable changes.
After SDK_HOME changes
BUILD.bazel still mentions
/opt/sdk-1

The external repo may stay stale until another input forces evaluation.

Tracked read
The value is copied into generated files
and the env input is recorded
sdk_home = rctx.getenv("SDK_HOME")
rctx.file("BUILD.bazel", ...)
returns /opt/sdk-1 same string as the bad path
OK Tracked edge from SDK_HOME Bazel records the variable as a repository-rule input.
After SDK_HOME changes
BUILD.bazel is regenerated with
/opt/sdk-2

The changed env input invalidates the repo and triggers re-evaluation.

The important difference is the tracked invalidation edge, not the returned string from the environment lookup.

Same Value, Different Incrementality

The bug is easy to miss because both APIs look like ordinary environment access:

def _bad_sdk_repo_impl(rctx):
    sdk_home = rctx.os.environ.get("SDK_HOME")
    rctx.file("BUILD.bazel", _build_file_for(sdk_home))

bad_sdk_repo = repository_rule(
    implementation = _bad_sdk_repo_impl,
)
think

Trace: SDK_HOME changes from /opt/sdk-1 to /opt/sdk-2. You rerun the build, but the generated external repository still points at the old SDK. Why did the environment change without triggering a refetch?

Reveal

The rule read host state, but it did not record that host state as a repository-rule input. After a repository is fetched, Bazel can reuse the generated repository until one of the inputs it knows about changes: the rule's attrs, the Starlark implementation, variables read through repository_ctx.getenv(), watched files, and similar explicit refetch triggers.

repository_ctx.os.environ.get("SDK_HOME") is only a dictionary lookup on the repository rule's process environment. It can return /opt/sdk-1, but it does not add SDK_HOME to the recorded input set. When the shell later changes to /opt/sdk-2, Bazel compares the inputs it recorded, sees no SDK_HOME dependency, and has no reason to rerun the repository rule.

That is a correctness problem, not just a performance quirk: the visible build graph can now reflect stale host state. If an environment value affects generated repository contents, make the value part of the repository rule's tracked input story.

Use getenv() for the value you branch on or write into generated files:

def _sdk_repo_impl(rctx):
    sdk_home = rctx.getenv("SDK_HOME")
    if sdk_home == None:
        fail("SDK_HOME must be set")
    rctx.file("BUILD.bazel", _build_file_for(sdk_home))

sdk_repo = repository_rule(
    implementation = _sdk_repo_impl,
)

The API reference states the incremental contract directly: when building incrementally, a change to the named variable's value causes the repository to be re-fetched.1 The repository_os.environ reference states the inverse just as directly: retrieving a variable from that dictionary does not establish a dependency. Use repository_ctx.getenv or module_ctx.getenv when lookup should be tracked.2

That explicitness is deliberate. If every repository rule implicitly depended on the entire host environment, harmless changes to PWD, TMPDIR, credentials, shell-local variables, or PATH would make external repositories refetch constantly and would make cross-machine reproducibility worse. Bazel therefore makes environment tracking opt-in: use getenv() when the value is part of the repository's meaning, prefer attrs or module configuration for stable user choices, and reserve raw os.environ for diagnostic reads that do not affect generated files.

Why Repository Rules Need This Explicitness

Repository rules run during loading when Bazel needs the repository.3 Their repository_ctx can download files, execute host commands, read and write files in the external repository, and inspect the host system.4 Those powers are useful for dependency fetching, but they sit outside the execution sandbox that catches undeclared action inputs in 2.3.2 Sandboxing.

That is why this pitfall is part of the reproducibility model in 4.9.5 Reproducibility Concerns. A repo rule that silently reads ambient environment state can generate different repository contents on two machines while presenting the same apparent dependency declaration to the rest of the build. Repository rules are Bazel's reproducibility blind spot for exactly this reason: they can depend on shell environment, filesystem state, and network calls unless the rule author deliberately models those dependencies.5

The same thinking applies to less obvious environment reads. If a repository rule discovers a tool through PATH, chooses behavior based on CI, or passes selected variables into repository_ctx.execute(), treat those values as inputs. The important design question is not "can Starlark see this value?" but "will Bazel know when this value changes?"

What About environ On repository_rule()?

Older repository rules often declare environment dependencies on the rule definition:

sdk_repo = repository_rule(
    implementation = _sdk_repo_impl,
    environ = ["SDK_HOME"],
)

That form tells Bazel the repository depends on those variables, but current API docs mark environ as deprecated and say to migrate to repository_ctx.getenv instead.6 For new code, prefer local getenv() calls at the point where the value is needed. They keep the dependency close to the branch or generated file that uses it, and they avoid a long top-level allowlist that drifts away from the implementation.

For existing code, environ = [...] is still a useful clue during review: it means the rule author knew the repository should be invalidated by environment changes. The cleanup is to replace broad declarations and untracked os.environ reads with tracked getenv() calls, then remove stale entries once the implementation no longer relies on them.

Review Pattern

When reviewing a repository rule, scan environment access before reading the rest of the implementation:

  • rctx.getenv("NAME") is the normal tracked form.
  • rctx.os.environ[...] or .get(...) is only acceptable for diagnostic or truly non-input reads. If the value affects generated repository contents, replace it.
  • repository_rule(environ = [...]) is historical API. Do not introduce it in new code.
  • Host-tool discovery and repository_ctx.execute() calls should make their relevant environment explicit, because they can otherwise smuggle PATH, credentials, or CI-only state into generated repository contents.

The adjacent file-dependency version of the same rule-authoring discipline is 4.9.7 watch() / watch_tree() File Dependencies: if a repo rule reads host files, declare those file dependencies too.

key takeaway

Environment variables are repository-rule inputs when they affect the generated external repository. Read them with repository_ctx.getenv() so Bazel can refetch the repository when they change.

Treat repository_ctx.os.environ as an untracked escape hatch. It may give you the value, but it does not give Bazel the dependency edge needed for correct incremental behavior.

Check your understanding · 3 questions

1.Why should a repository rule use repository_ctx.getenv("SDK_HOME") when SDK_HOME affects generated repository contents?

Select one answer

2.True or false: environment access in repository rules.

Choose True or False for each sentence

repository_ctx.os.environ can expose environment variables without creating a tracked repository-rule dependency.
A repository rule implementation runs as an ordinary sandboxed build action.
If an environment variable affects generated BUILD files, the rule should make that dependency visible to Bazel.
repository_rule(environ = [...]) is the preferred API for new repository-rule code.

3.Which review findings should raise concern in a repository rule?

Select all that apply

0 of 3 answered

Footnotes

  1. repository_ctxgetenv() return value and incremental re-fetch contract for changed environment variables. 1 2

  2. repository_osenviron dictionary note: reading it does not establish a repository-rule or module-extension dependency. 1 2

  3. .bzl filesrepository_rule() implementation functions receive repository_ctx for repository materialization.

  4. Writing Bazel rules: repository rules — repository rules use repository_ctx, can access the host system, and need extra care around host-dependent state.

  5. Determinism in a Non-Hermetic World - Albert Lloveras, Canva — repository rules can depend on shell environment, filesystem, and network calls. Environment reads must be tracked to avoid stale repository results.

  6. .bzl filesrepository_rule(environ = ...) is deprecated and points users to repository_ctx.getenv.