RFA-697 · Case file with fixtures · Case 669 of 694 · Cargo rebuild evidence
A build.rs Environment Input Needs rerun-if-env-changed
Once a build script narrows Cargo's change detection, every ambient input that affects output needs an explicit rerun contract. Track global variables with rerun-if-env-changed and prove it with two builds sharing one target directory.
- Reviewed
- Rust
- Rust 1.98.1, Cargo 1.98.1, edition 2024
- Targets
- all Cargo targets
- Profiles
- dev, release, test
Direct answer
What this Rust failure means
- Why it happens
- The build script narrowed Cargo's change detection without declaring the environment input through cargo::rerun-if-env-changed, so Cargo reused its old output.
- First discriminating check
- Build twice in the same target directory with different input values, then add rerun-if-env-changed and assert that the embedded output changes on the second build.
A build script can read an environment variable correctly and still produce the wrong output on the next build. The problem is not reading the value. It is telling Cargo when that value makes cached build output stale.
The failing project reads RFA_BUILD_MODE, writes it into a Rust file under OUT_DIR, and includes that constant in the binary. The evidence runner builds with alpha, then builds again in the same target directory with beta.
The second binary still prints alpha. Cargo did not rerun the script because the script declared only build.rs as its change input.
Build scripts have a cache contract
Cargo does not understand arbitrary reads performed by a program. If build.rs opens a configuration file, checks a tool version, reads an environment variable, queries Git, or probes a native SDK, Cargo needs a stable description of which changes invalidate its output.
Without any rerun-if instruction, Cargo uses a broad package scan. Once the script prints at least one narrow change instruction, that broad default is replaced by the declared set. This is where a well-meant optimization can create stale generated code.
The Cargo change-detection documentation is the source I return to. I treat every input read as one line in a dependency ledger, not as an implementation detail.
Track the variable that actually changes output
The repaired project adds:
println!("cargo::rerun-if-env-changed=RFA_BUILD_MODE");
The specific instruction asks Cargo to rerun when the named environment value changes between Cargo invocations. The same two-build sequence now prints alpha and then beta.
I emit this line even when the variable is currently absent. Presence, absence, and a changed value are different inputs. Hiding the instruction inside if let Ok(value) would fail to track the transition from missing to present.
I also avoid tracking variables that do not affect output. Every declared input creates rebuild work. The goal is an exact dependency set, not the longest list possible.
Cargo-provided variables are a different category
The instruction is intended for global environment inputs such as a compiler path, SDK selection, or an explicit product mode. Variables such as TARGET are supplied and tracked by Cargo's own build model. Cargo's documentation says not to use rerun-if-env-changed for its build-script variables.
This distinction prevents a script from restating only part of Cargo's target logic. I read TARGET, HOST, and CARGO_CFG_* at run time, but I reserve custom rerun declarations for external inputs Cargo cannot infer.
For Rust source, env! and option_env! have their own automatic rebuild tracking. That does not cover a build script calling std::env::var, because Cargo cannot inspect arbitrary executable behaviour.
A clean build cannot detect this bug
Deleting target before every CI job makes the failing project appear correct. The script always runs after a clean build and captures the current value.
The bug exists only across reuse. This is why the Atlas fixture deliberately shares one target directory for two builds. It tests the cache invalidation contract rather than only the generated source.
I keep both kinds of CI:
- clean builds prove all outputs can be created from declared source inputs;
- warm rebuild tests prove relevant changes invalidate exactly the right work;
- no-change rebuilds prove the script does not run for unrelated edits.
For important generated configuration, the binary or library test asserts the embedded value. Merely seeing “Compiling package” in logs is weaker because it does not prove which generated artifact reached the final crate.
Ambient discovery can damage reproducibility
An environment variable may point at a developer's local SDK, contain a timestamp, identify a Git checkout, or leak a machine path into output. Correct reruns do not make such builds reproducible.
I ask whether the input should exist at all. A manifest feature, checked-in configuration, lockfile, or command-line contract may be more reviewable. If ambient discovery is required, I record the value category without logging secrets and make failures explicit.
I never embed credentials through generated Rust. Build outputs, compiler diagnostics, caches, and binaries can all retain them.
My invalidation review
For each build script I list:
- every file, directory, environment value, tool, and target fact it reads;
- which of those inputs can change its emitted instructions or generated bytes;
- the
rerun-if-changedorrerun-if-env-changedrule owning that change; - inputs Cargo already tracks itself;
- a warm two-build test for each important transition;
- a no-change test guarding against unnecessary work;
- whether absolute paths, timestamps, or secrets enter output.
The core principle is cache honesty. A build script is a function only if I describe its inputs. rerun-if-env-changed turns an ambient variable into one declared edge in Cargo's build graph, and the shared-target two-build fixture proves that the edge really works.