Build systems · advanced
Same commit produces different output: identify non-hermetic inputs
A build system returns different artifacts for the same commit hash, indicating a non-hermetic build. This guide walks through identifying uncontrolled inputs (time, environment, network, generated files, dependency resolution) that leak into build outputs, and provides evidence-based steps to prove and remediate the variance.
The symptoms
- •Cache hash for a given commit changes between two otherwise identical local builds with no source edits.
- •Remote CI artifact bytes differ from a local artifact produced from the exact same commit SHA.
- •Reproducible-build checksum (e.g., diffoscope, sha256 of tarball) mismatches across reruns while inputs remain constant.
- •Build logs contain absolute paths, hostnames, user names, timestamps, or locale-specific formatting embedded in compiled output.
- •Dependency lockfile resolves to different versions across machines or across successive runs despite a pinned lockfile.
- •Generated code or assets (protobuf, OpenAPI, GraphQL, codegen) emits different bytes on each run due to non-deterministic ordering.
Likely causes
- •Wall-clock or timezone leakage: SOURCE_DATE_EPOCH not set, or build tools embed __DATE__, __TIME__, build timestamps, or filesystem mtimes into artifacts.
- •Unpinned or floating dependency versions (e.g., npm semver ranges, Maven SNAPSHOTs, Go pseudo-versions, Cargo git refs) that resolve differently across runs.
- •Environment leakage: PATH, LANG/LC_ALL, USER, HOSTNAME, HOME, or shell environment variables are baked into compiled output or generated files.
- •Network or registry non-determinism: package fetched from a remote registry without an integrity hash, or a CDN-served asset that changes between fetches.
- •Non-deterministic file ordering: directory walks, glob expansion, or tar/zip steps that emit entries in filesystem order rather than sorted order.
- •Filesystem metadata leakage: timestamps, UIDs, GIDs, or permission bits carried into archives or compiled into resources.
First ten minutes
- 01Confirm the variance is real by capturing sha256 of two consecutive outputs of the same commit and computing a diff; this rules out caching-miss misdiagnosis.
- 02Inspect the build tool's hash-input manifest (Turborepo's hashing inputs, Bazel's --show-inputs, Nix derivation hash) to see which files it claims affect the hash.
- 03Capture the full environment of one build with `env -i` plus the documented required vars, then compare against a second build's environment to detect drift.
- 04Sort and compare the contents of any generated source files (e.g., *.pb.go, generated.ts, *_generated.rs) byte-for-byte to localize the diff to a specific generator stage.
- 05Check lockfiles for resolution timestamps, registry URLs, or platform-specific resolution fields that may indicate lockfile non-determinism.
- 06Disable cache and run the build twice in succession; if outputs still differ, the variance is from inputs, not from a stale cache.
Evidence to collect
- •sha256 sums of the final artifact from two runs of the same commit, plus byte-level diff of differing regions.
- •Hash-input manifest from the build tool listing every file path and env var that contributed to the cache key.
- •Sorted list of environment variables in both builds, with values, to detect any drift in PATH, locale, or user-specific vars.
- •Resolved dependency versions from the lockfile plus the registry URL and any integrity/SHA fields.
- •Generator tool version and configuration (proto compiler, openapi-generator, schema codegen) used in each run.
- •Order of files emitted by any glob, find, or tar step, comparing sorted versus actual output order.
Where to look
- •Build tool configuration boundary: the inputs declared to the cache key (Turborepo inputs in turbo.json, Bazel --host_action_env, Nx task inputs).
- •Source generator boundary: any code generator invoked during pre-build, including its version, flags, and schema inputs.
- •Dependency resolution boundary: lockfile parser output, package manager cache, and registry integrity verification.
- •Filesystem walk boundary: glob/find/tar invocations whose order is not explicitly sorted.
- •Process environment boundary: env vars read at build time, especially those passed through to compilers or codegen tools.
- •Output archive boundary: any post-build packaging step (tar, zip, jar, deb) that may embed filesystem metadata.
Diagnostic steps
- 01Run the build twice with the cache disabled and capture sha256 of both artifacts; equal hashes rule out environmental drift as the source of variance.
- 02Extract and sort the build tool's hash inputs (e.g., `turbo run build --summarize` output) to identify which file paths are hashed but are not in version control.
- 03Bisect generated versus compiled regions of the differing artifact: if the diff is confined to a generator's output, isolate the generator; if it spans the binary, suspect env or path leakage.
- 04Re-run the build with `SOURCE_DATE_EPOCH` set to a fixed integer and compare hashes; unchanged variance means timestamps are not the source.
- 05Re-run with all locale and user environment unset (`LC_ALL=C`, `USER=builder`, `HOME=/tmp/builder`) and compare hashes to test for env-var leakage.
- 06Inspect lockfile resolution metadata; if it contains floating ranges, registry fetch timestamps, or platform-specific fields, dependency drift is a candidate.
- 07Sort the input list to any archive or codegen step explicitly and verify the output bytes stabilize; non-deterministic ordering is confirmed if sorting fixes the diff.
Common mistakes
- •Assuming the build is hermetic because cache hits return the same artifact, without verifying that cache misses also produce identical artifacts.
- •Hashing only the final binary while ignoring intermediate artifacts, generator outputs, or source maps that may embed non-deterministic content.
- •Trusting lockfiles as deterministic without checking for floating versions, registry-specific resolution metadata, or platform-conditional resolution.
- •Diffing artifact names rather than contents, which can mask variance introduced by time or hostname embedded in metadata.
- •Resetting only PATH or LANG while leaving other locale, timezone, or user-specific variables uncontrolled.
- •Reproducing on the same machine in the same shell, which masks env-var leakage that appears across different CI runners or developers.
Safe fixes
- •Condition: diff is localized to timestamps or embedded dates. Fix: export `SOURCE_DATE_EPOCH=<fixed_unix_seconds>` and configure generators to honor it; re-run and confirm hash stabilizes.
- •Condition: lockfile shows floating ranges or unconstrained refs. Fix: regenerate the lockfile with `--frozen-lockfile` semantics and pin every transitive to an exact integrity hash; verify with a clean cache.
- •Condition: hash-input manifest includes env vars not in version control. Fix: declare only the inputs the task truly depends on (per Turborepo's inputs configuration) and remove env vars that do not affect output.
- •Condition: file ordering causes archive or codegen variance. Fix: pipe directory walks through `LC_ALL=C sort` before feeding the next stage; confirm archive entries are emitted in sorted order.
- •Condition: locale-dependent formatting appears in output. Fix: set `LC_ALL=C` and `LANG=C` for the build process; verify generated strings are byte-identical across locales.
- •Condition: package fetched from network without integrity check. Fix: add an integrity/SRI hash to the fetch step or vendor the dependency; verify offline rebuild produces the same hash.
Prove the fix
- 01Build the same commit twice in isolated environments with the declared inputs fixed and confirm the final artifact hashes and manifests match.
- 02Compare the remaining artifact differences byte by byte and verify that timestamps, paths, dependency resolutions, and generated-file ordering no longer vary.
- 03Run the build in CI and locally from the same lockfile revision, then record the matching output hash as the regression baseline.
Prevention and next steps
- •Declare explicit, minimal inputs for every cacheable task and review them on PRs so accidental inputs (logs, timestamps, env) cannot enter the hash.
- •Pin all dependencies to exact versions with integrity hashes; reject builds that resolve floating ranges in CI.
- •Standardize the build environment via a hermetic image or container with fixed locale, timezone, and PATH; require local builds to match.
- •Adopt reproducibility assertions in CI: run each release build twice, compare sha256, and fail the pipeline on mismatch.
- •Add a pre-commit or CI step that diffs generated source files across two runs to catch non-determinism before merge.
Safe commands and checks
sha256sum <artifact_path> diff -q <artifact_run_1> <artifact_run_2> && echo IDENTICAL || echo DIFFERENT LC_ALL=C sort <unsorted_file_list> > <sorted_file_list> env -i HOME=<build_home> PATH=<build_path> LC_ALL=C SOURCE_DATE_EPOCH=<fixed_unix_seconds> <build_command> tar -tf <archive> | LC_ALL=C sort > <archive_sorted_manifest>