Fix build determinism issues
A reproducible build produces byte-identical outputs regardless of where it
runs (which directory it's checked out in, which output base, which user). The
IC publishes reproducible artifacts, so any target that bakes a build-time
absolute path, timestamp, or other environment detail into its output is a bug.
The usual culprits are externally-built dependencies — http_archives built
with rules_foreign_cc (autotools/cmake) and Rust crates with build.rs —
because they escape Bazel's normal path/timestamp scrubbing and can embed
$PWD, an install --prefix, __DATE__/__TIME__, a build-script probe
artifact, etc. into their outputs.
All commands run from the repository root (cd "$(git rev-parse --show-toplevel)").
The hunt script
Diagnosis is driven by the upstream hunt reproducibility script. It is not
checked into this repo — get it from its gist, save it
at the repo root as hunt, and chmod +x it. It builds a target twice, each
time in a freshly cloned checkout under an output base nested at a different
depth — so the absolute build path differs between the two runs the same way
it would differ between two machines. It writes each build's
--execution_log_json_file and diffs the actualOutputs (path + content
digest) of every action. Any output whose digest differs between the two runs is
a non-reproducible artifact. A clean run ends with builds 1 - 2: no diff ✓.
usage: ./hunt [--root ROOT] [--startup-options OPTS] [--build-options OPTS] [--runs N] TARGET
--root ROOT dir for the per-run checkouts, output bases and execlogs.
Default: a fresh `mktemp -d`.
--startup-options OPTS extra bazel *startup* options, one space-separated string.
--build-options OPTS extra bazel *build* options, one space-separated string.
--runs N number of builds to compare (default: 2).
TARGET label to build, e.g. //:mkfs.ext4
Always pass --build-options='--config=local'
Run with --build-options='--config=local', which builds without the internal
remote cache — essential, because a cache hit would serve a previously-built
(possibly non-reproducible) artifact and mask the very non-determinism you're
hunting. See the build-without-dfinity-infra skill for what --config=local
does. (--startup-options/--build-options are how you feed bazel any other
flags the build needs.)
Running it
Run hunt inside the dev container so the build environment is the pinned one.
See the run-in-dev-container skill for how to invoke container-run.sh
(including on hosts without podman):
# quick pass/fail (artifacts land in an ephemeral in-container tempdir):
./ci/container/container-run.sh ./hunt --build-options='--config=local' //my:target
# to *diagnose* (step 3 needs the two builds' outputs to survive the container),
# point --root at a path inside the checkout (mounted at its host path) so the artifacts persist on the host:
./ci/container/container-run.sh ./hunt --root "$PWD/,hunt" --build-options='--config=local' //my:target
Gotchas:
hunt does git clone of the repo, so your fix must be committed (to the
current branch) for a hunt run to pick it up. Iterate: commit → hunt → repeat.
- With the default tempdir, the checkouts/output bases live in the container's
/tmp and vanish when the container exits — fine for a verdict, but use
--root inside the checkout when you need to inspect artifacts.
- Bazel marks its output trees read-only. To clean the hunt root between runs:
chmod -R u+w <root> && rm -rf <root>.
- Point
--root at a path on the same filesystem as the repo for faster
(hardlinked) clones.
Procedure
Run hunt --build-options='--config=local' on the failing target and read
the mismatch JSON it prints — a list of {path, digest} for outputs that differed.
Look at the first differing target/output. Non-determinism cascades: one
non-reproducible artifact (a generated source, a static lib, a tool binary)
makes everything that embeds it differ too. Fix the earliest / most upstream
differing artifact first; re-running often makes the downstream diffs vanish.
Map the output path back to the dependency that produces it (e.g.
external/+_repo_rules+<name>/... → the http_archive named <name>;
external/.../<crate>-<version>/... → a crate).
Pin down what differs. The execlog only gives digests. Pull the two
actual artifacts from the persisted output bases ($ROOT/output-base-1/...
vs $ROOT/output-base-2/... — use --root so these survive) and compare them
directly:
cmp "$A" "$B" # confirm they differ
diff <(strings -a "$A"|sort -u) <(strings -a "$B"|sort -u) # build paths / dates
diff <(readelf -SW "$A") <(readelf -SW "$B") # for ELF: which section
Typical findings: an embedded absolute build path (/.../sandbox/.../...), a
__DATE__/__TIME__ string, a build-script probe artifact, or archive
ordering. (Bazel's C toolchain already redacts __DATE__/__TIME__ and
passes -no-canonical-prefixes, so it's almost always an embedded path.)
From the offending string, settle on a one-artifact probe for the bad
pattern — a command that's non-empty on a broken build and empty once fixed,
so you can judge a single build without re-running the full hunt. Pick the
most specific stable marker the leak leaves behind (the sandbox path
component, the exec-root / output-base prefix, .build_tmpdir, a date, the
stray probe filename), e.g.:
strings -a "$A" | grep -n sandbox # expect matches now; none once fixed
Read the dependency's source to find where that string comes from — a
configure-substituted install path, a build.rs writing env!("OUT_DIR")
or a canonicalize()d path into generated code, a hardcoded PREFIX/...
constant, a stray probe file left in OUT_DIR, etc.
Check out the source locally and iterate against it. Get the dependency at
the exact version (same urls/sha256 as in MODULE.bazel, or the crate
source), then point Bazel at your local copy so you can edit and rebuild
without re-uploading a patch each time:
bazel build --config=local --override_repository=<repo_name>=/abs/path/to/src //my:target
(Find <repo_name> with bazel query --output=build <target> or from the
execlog path.) Edit, rebuild, and run the step-3 probe on the freshly-built
artifact as a fast inner-loop check — when it comes back empty the embedded
dependency is gone:
strings -a bazel-bin/.../<artifact> | grep sandbox || echo clean
This single-build probe is a quick check only; it does not replace the
full reproducibility confirmation in step 8. Keep a pristine copy to diff
against for the patch.
Report it upstream. A local patch is a workaround — the real fix belongs
in the dependency, and an upstream fix lets us eventually drop the patch. File
an issue on the dependency's tracker (for a crate or other GitHub project,
https://github.com/<owner>/<repo>/issues/new), including:
- a short explanation of the bug and its impact, e.g. "
build.rs writes the
absolute $OUT_DIR path into the generated foo.rs, so the crate's rlib
differs between builds at different filesystem locations, breaking
reproducible/hermetic builds."
- a minimal reproducible example if the maintainer will likely need one — the
smallest snippet that emits the offending output, or simply "build at two
different paths and
diff the artifacts" (the step-3 probe doubles as the
symptom).
Keep the resulting issue/PR URL; reference it from the patch header below.
Capture the fix as a patch applied at fetch time (don't fork the dep):
http_archive dependency — add a patch (convention:
third_party/<name>_<what>.patch) and reference it from the archive in
MODULE.bazel:
http_archive(
name = "<name>",
...
patches = ["//third_party:<name>_<what>.patch"],
patch_strip = 1,
)
Rust crate — add a patch under bazel/ and a crate annotation in
bazel/rust.MODULE.bazel:
crate.annotation(
crate = "<crate-name>",
patch_args = ["-p1"],
patches = ["@@//bazel:<crate>.patch"],
)
The patch is a git diff (paths a/… b/…); leading # comment lines
describing the fix — and linking the upstream ticket from step 6 — are fine
and conventional here. Prefer the smallest patch
that removes the environment dependency. If the offending artifact is
something nothing downstream consumes (an extra tool the dependency
builds/installs), it's also valid to just stop shipping it (e.g. trim it in a
rules_foreign_cc postfix_script) rather than make it reproducible.
Re-run hunt to confirm builds 1 - 2: no diff ✓. If a new (further
downstream) difference appears, repeat from step 2 — that's the cascade
resolving one layer at a time. Verify the real consumers of the target still
build.
Worked examples
| Dependency |
Kind |
Cause |
Fix |
Commit |
askama (bazel/askama.patch) |
crate (derive macro) |
Path::canonicalize() resolved the sandbox symlink to the repo's real path; the resulting relative path's ..-count depended on the sandbox path depth, so the rlib changed across output-base/nest depths. |
Skip canonicalize() so both paths stay anchored to the sandbox. |
0d9c593299 (#10167) |
libssh2-sys (bazel/libssh2-sys.patch) |
crate (build.rs) |
build.rs generated a pkgconfig file containing absolute build paths. |
Disable that generation. |
737666659c (#3197) |
e2fsprogs / mke2fs (third_party/e2fsprogs_no_external_config.patch) |
http_archive (rules_foreign_cc) |
configure's --prefix is the per-build $BUILD_TMPDIR, so the compiled-in mke2fs.conf path (ROOT_SYSCONFDIR) landed in the binary's .rodata. |
Empty config_fn (use the built-in default profile) + trim the unused, also-non-reproducible extra tools from the install tree. |
cf7fe5147b |
The askama case is the canonical illustration of why hunt varies the nest
depth: the bug only manifests when the build path's depth changes between runs.
1---2name: fix-build-determinism3description: Use this when asked to fix a Bazel build reproducibility / determinism issue — a target whose outputs differ between builds (e.g. across machines, users, or checkout locations), typically because something bakes an absolute build path or a timestamp into an artifact.4---56# Fix build determinism issues78A reproducible build produces byte-identical outputs regardless of *where* it9runs (which directory it's checked out in, which output base, which user). The10IC publishes reproducible artifacts, so any target that bakes a build-time11absolute path, timestamp, or other environment detail into its output is a bug.1213The usual culprits are externally-built dependencies — `http_archive`s built14with `rules_foreign_cc` (autotools/cmake) and Rust crates with `build.rs` —15because they escape Bazel's normal path/timestamp scrubbing and can embed16`$PWD`, an install `--prefix`, `__DATE__`/`__TIME__`, a build-script probe17artifact, etc. into their outputs.1819All commands run from the repository root (`cd "$(git rev-parse --show-toplevel)"`).2021## The `hunt` script2223Diagnosis is driven by the upstream `hunt` reproducibility script. It is **not24checked into this repo** — get it from [its gist](https://gist.github.com/nmattia/dc8a1d4f3bc36c9c0133d15f06acc74e), save it25at the repo root as `hunt`, and `chmod +x` it. It builds a target **twice**, each26time in a *freshly cloned* checkout under an output base nested at a **different27depth** — so the absolute build path differs between the two runs the same way28it would differ between two machines. It writes each build's29`--execution_log_json_file` and diffs the `actualOutputs` (path + content30digest) of every action. Any output whose digest differs between the two runs is31a non-reproducible artifact. A clean run ends with `builds 1 - 2: no diff ✓`.3233```34usage: ./hunt [--root ROOT] [--startup-options OPTS] [--build-options OPTS] [--runs N] TARGET3536 --root ROOT dir for the per-run checkouts, output bases and execlogs.37 Default: a fresh `mktemp -d`.38 --startup-options OPTS extra bazel *startup* options, one space-separated string.39 --build-options OPTS extra bazel *build* options, one space-separated string.40 --runs N number of builds to compare (default: 2).41 TARGET label to build, e.g. //:mkfs.ext442```4344### Always pass `--build-options='--config=local'`4546Run with `--build-options='--config=local'`, which builds **without the internal47remote cache** — essential, because a cache hit would serve a previously-built48(possibly non-reproducible) artifact and *mask* the very non-determinism you're49hunting. See the **build-without-dfinity-infra** skill for what `--config=local`50does. (`--startup-options`/`--build-options` are how you feed bazel any other51flags the build needs.)5253### Running it5455Run `hunt` inside the dev container so the build environment is the pinned one.56See the **run-in-dev-container** skill for how to invoke `container-run.sh`57(including on hosts without podman):5859```sh60# quick pass/fail (artifacts land in an ephemeral in-container tempdir):61./ci/container/container-run.sh ./hunt --build-options='--config=local' //my:target6263# to *diagnose* (step 3 needs the two builds' outputs to survive the container),64# point --root at a path inside the checkout (mounted at its host path) so the artifacts persist on the host:65./ci/container/container-run.sh ./hunt --root "$PWD/,hunt" --build-options='--config=local' //my:target66```6768Gotchas:69- `hunt` does `git clone` of the repo, so **your fix must be committed** (to the70 current branch) for a hunt run to pick it up. Iterate: commit → hunt → repeat.71- With the default tempdir, the checkouts/output bases live in the container's72 `/tmp` and vanish when the container exits — fine for a verdict, but use73 `--root` inside the checkout when you need to inspect artifacts.74- Bazel marks its output trees read-only. To clean the hunt root between runs:75 `chmod -R u+w <root> && rm -rf <root>`.76- Point `--root` at a path on the same filesystem as the repo for faster77 (hardlinked) clones.7879## Procedure80811. **Run `hunt --build-options='--config=local'` on the failing target** and read82 the mismatch JSON it prints — a list of `{path, digest}` for outputs that differed.83842. **Look at the first differing target/output.** Non-determinism cascades: one85 non-reproducible artifact (a generated source, a static lib, a tool binary)86 makes everything that embeds it differ too. Fix the *earliest / most upstream*87 differing artifact first; re-running often makes the downstream diffs vanish.88 Map the output path back to the dependency that produces it (e.g.89 `external/+_repo_rules+<name>/...` → the `http_archive` named `<name>`;90 `external/.../<crate>-<version>/...` → a crate).91923. **Pin down *what* differs.** The execlog only gives digests. Pull the two93 actual artifacts from the persisted output bases (`$ROOT/output-base-1/...`94 vs `$ROOT/output-base-2/...` — use `--root` so these survive) and compare them95 directly:96 ```sh97 cmp "$A" "$B" # confirm they differ98 diff <(strings -a "$A"|sort -u) <(strings -a "$B"|sort -u) # build paths / dates99 diff <(readelf -SW "$A") <(readelf -SW "$B") # for ELF: which section100 ```101 Typical findings: an embedded absolute build path (`/.../sandbox/.../...`), a102 `__DATE__`/`__TIME__` string, a build-script probe artifact, or archive103 ordering. (Bazel's C toolchain already redacts `__DATE__`/`__TIME__` and104 passes `-no-canonical-prefixes`, so it's almost always an embedded path.)105106 From the offending string, settle on a **one-artifact probe** for the bad107 pattern — a command that's non-empty on a broken build and empty once fixed,108 so you can judge a *single* build without re-running the full hunt. Pick the109 most specific stable marker the leak leaves behind (the `sandbox` path110 component, the exec-root / output-base prefix, `.build_tmpdir`, a date, the111 stray probe filename), e.g.:112 ```sh113 strings -a "$A" | grep -n sandbox # expect matches now; none once fixed114 ```1151164. **Read the dependency's source** to find where that string comes from — a117 `configure`-substituted install path, a `build.rs` writing `env!("OUT_DIR")`118 or a `canonicalize()`d path into generated code, a hardcoded `PREFIX/...`119 constant, a stray probe file left in `OUT_DIR`, etc.1201215. **Check out the source locally and iterate against it.** Get the dependency at122 the exact version (same `urls`/`sha256` as in `MODULE.bazel`, or the crate123 source), then point Bazel at your local copy so you can edit and rebuild124 without re-uploading a patch each time:125 ```sh126 bazel build --config=local --override_repository=<repo_name>=/abs/path/to/src //my:target127 ```128 (Find `<repo_name>` with `bazel query --output=build <target>` or from the129 execlog path.) Edit, rebuild, and run the step-3 probe on the freshly-built130 artifact as a fast inner-loop check — when it comes back empty the embedded131 dependency is gone:132 ```sh133 strings -a bazel-bin/.../<artifact> | grep sandbox || echo clean134 ```135 This single-build probe is a quick check only; it does **not** replace the136 full reproducibility confirmation in step 8. Keep a pristine copy to `diff`137 against for the patch.1381396. **Report it upstream.** A local patch is a workaround — the real fix belongs140 in the dependency, and an upstream fix lets us eventually drop the patch. File141 an issue on the dependency's tracker (for a crate or other GitHub project,142 `https://github.com/<owner>/<repo>/issues/new`), including:143 - a short explanation of the bug and its impact, e.g. *"`build.rs` writes the144 absolute `$OUT_DIR` path into the generated `foo.rs`, so the crate's rlib145 differs between builds at different filesystem locations, breaking146 reproducible/hermetic builds."*147 - a minimal reproducible example if the maintainer will likely need one — the148 smallest snippet that emits the offending output, or simply "build at two149 different paths and `diff` the artifacts" (the step-3 probe doubles as the150 symptom).151152 Keep the resulting issue/PR URL; reference it from the patch header below.1531547. **Capture the fix as a patch** applied at fetch time (don't fork the dep):155156 - **`http_archive` dependency** — add a patch (convention:157 `third_party/<name>_<what>.patch`) and reference it from the archive in158 `MODULE.bazel`:159 ```python160 http_archive(161 name = "<name>",162 ...163 patches = ["//third_party:<name>_<what>.patch"],164 patch_strip = 1,165 )166 ```167168 - **Rust crate** — add a patch under `bazel/` and a crate annotation in169 `bazel/rust.MODULE.bazel`:170 ```python171 crate.annotation(172 crate = "<crate-name>",173 patch_args = ["-p1"],174 patches = ["@@//bazel:<crate>.patch"],175 )176 ```177178 The patch is a `git diff` (paths `a/…` `b/…`); leading `#` comment lines179 describing the fix — and linking the upstream ticket from step 6 — are fine180 and conventional here. Prefer the smallest patch181 that removes the environment dependency. If the offending artifact is182 something nothing downstream consumes (an extra tool the dependency183 builds/installs), it's also valid to just stop shipping it (e.g. trim it in a184 `rules_foreign_cc` `postfix_script`) rather than make it reproducible.1851868. **Re-run `hunt` to confirm** `builds 1 - 2: no diff ✓`. If a *new* (further187 downstream) difference appears, repeat from step 2 — that's the cascade188 resolving one layer at a time. Verify the real consumers of the target still189 build.190191## Worked examples192193| Dependency | Kind | Cause | Fix | Commit |194| --- | --- | --- | --- | --- |195| **askama** (`bazel/askama.patch`) | crate (derive macro) | `Path::canonicalize()` resolved the sandbox symlink to the repo's real path; the resulting relative path's `..`-count depended on the sandbox path *depth*, so the rlib changed across output-base/nest depths. | Skip `canonicalize()` so both paths stay anchored to the sandbox. | `0d9c593299` (#10167) |196| **libssh2-sys** (`bazel/libssh2-sys.patch`) | crate (`build.rs`) | `build.rs` generated a pkgconfig file containing absolute build paths. | Disable that generation. | `737666659c` (#3197) |197| **e2fsprogs / mke2fs** (`third_party/e2fsprogs_no_external_config.patch`) | `http_archive` (rules_foreign_cc) | `configure`'s `--prefix` is the per-build `$BUILD_TMPDIR`, so the compiled-in `mke2fs.conf` path (`ROOT_SYSCONFDIR`) landed in the binary's `.rodata`. | Empty `config_fn` (use the built-in default profile) + trim the unused, also-non-reproducible extra tools from the install tree. | `cf7fe5147b` |198199The askama case is the canonical illustration of *why* `hunt` varies the nest200depth: the bug only manifests when the build path's depth changes between runs.