How To Make Build Failures Reproducible Before They Become CI Mysteries

Published · Programming

Build failures become expensive when they stop being reproducible.

The first failure is usually just a problem. The third person saying "it only happens in CI" is when the problem starts turning into folklore. Someone reruns the job. Someone else clears a cache. A third person changes an unrelated file and the failure disappears. Two weeks later, the same class of failure comes back with a different error message and nobody can prove whether it is the same bug, a new bug, a flaky test, a dependency drift, or a haunted build runner.

That is the moment where a build system stops being an engineering tool and starts being a rumor mill.

Reproducibility is not only about academic "reproducible builds" where the same source produces byte-for-byte identical artifacts. That is useful, but the everyday CI problem is broader: can a human engineer, a reviewer, or an AI coding agent recreate enough of the failure to investigate it with evidence?

This article is the next step after Making Local CI Commands Boring Enough for Humans and AI Agents and How To Design CI Output That Humans Can Actually Debug. Stable commands and readable output are the front door. Reproducibility is the evidence locker.

Treat The First Failure As Evidence

The worst time to start preserving evidence is after the fifth rerun.

By then, the runner may be gone, the cache state may have changed, dependency metadata may have moved, temporary artifacts may have expired, and the original log has been replaced by a cleaner but less useful failure. The exact failure was not solved. It was overwritten.

For serious build failures, the first job should preserve enough context to answer practical questions:

  • What exact command failed?
  • What commit, branch, and pull request were involved?
  • What environment ran the command?
  • What dependency versions were used?
  • What caches, remote executors, or generated files participated?
  • What artifacts did the failure produce?
  • What focused command should a developer try next?

This does not mean every failed lint job needs a forensic bundle. Scope matters. But if a failure is surprising, intermittent, platform-specific, cache-related, or expensive to rerun, treat the first failure as useful evidence instead of noise to clear.

The habit is simple: capture before you retry.

Print The Exact Command, Not The Idea Of The Command

"Tests failed" is not a reproduction instruction.

Neither is "Bazel failed," "pytest failed," "frontend checks failed," or "the build step broke." Those messages describe a neighborhood. They do not provide an address.

The failure output should show the command that actually ran:

FAILED: test: python unit tests
Command:
  uv run pytest tests/unit/test_parser.py::test_rejects_empty_input -q

If a wrapper command delegated to a more specific command, show both:

Entry point:
  make check

Failed command:
  uv run pytest tests/unit/test_parser.py::test_rejects_empty_input -q

That distinction matters. make check tells the developer how the repository expects validation to be invoked. The focused command tells the developer where to start debugging.

For Bazel, include the target and relevant flags:

Failed target:
  //services/parser:parser_test

Reproduce:
  bazel test //services/parser:parser_test --test_output=errors

For browser tests, include the project, browser, shard, or trace mode when those change behavior:

Reproduce:
  npm run test:e2e -- --project=chromium tests/login.spec.ts

The rule is blunt: if a developer cannot copy a command from the failure and begin narrowing the problem, the CI output is making them reverse-engineer the build.

Capture The Environment That Matters

Reproducibility does not require dumping every environment variable into the log. In fact, doing that is often a security problem. It does require capturing the parts of the environment that can change the result.

Useful context often includes:

  • Operating system and image version.
  • CPU architecture.
  • Container image digest, not just a mutable tag.
  • Language runtime versions.
  • Package manager versions.
  • Build tool versions.
  • Test shard number.
  • Locale and timezone when tests are sensitive to them.
  • Feature flags that affect the build.
  • Important non-secret environment variables.
  • Remote execution or cache endpoint identity.

For example:

Environment:
  runner: ubuntu-24.04
  container: ghcr.io/example/build@sha256:...
  arch: x86_64
  python: 3.12.4
  uv: 0.7.13
  bazel: 8.3.1
  shard: 2/8

Do not print secrets. Do not print tokens. Do not print signed artifact URLs that grant access beyond the intended audience. But do record versions and identities. "It passed locally" is not a useful contrast unless you know what "locally" and "CI" actually were.

Container tags are a common trap. build:latest is convenient until you need to reproduce a failure from yesterday. If the CI job records the image digest, you have a much better chance of recreating the runtime that actually failed.

Save A Reproduction File As An Artifact

The console log is not enough.

Put the reproduction instructions in a predictable artifact, such as:

artifacts/build/repro.txt
artifacts/build/environment.txt
artifacts/build/versions.txt

That file should be short enough to read and structured enough to paste into a debugging thread. A useful repro.txt might look like this:

Failure:
  test: python unit tests

Commit:
  4f2c9ab

Entry point:
  make check

Focused command:
  uv run pytest tests/unit/test_parser.py::test_rejects_empty_input -q

Artifacts:
  junit: artifacts/junit/python-unit.xml
  log: artifacts/logs/python-unit.log

Notes:
  Failed on shard 2/8 in container ghcr.io/example/build@sha256:...

This is not bureaucracy. It is an affordance for the next person.

It also helps AI coding agents. An agent can parse a small reproduction file far more reliably than a noisy CI log. The file gives it the contract, the failure, and the next command without inviting it to hallucinate from unrelated warnings.

Preserve The Raw Logs, But Do Not Make Them The Interface

Raw logs matter. Keep them.

But do not confuse preservation with usability. A 20,000-line log may contain the evidence, but it is a terrible first diagnostic surface. Store the raw log as an artifact and put a small summary in front of it.

The useful layering looks like this:

  • Console summary for the common reader.
  • repro.txt for the next debugging move.
  • Machine-readable reports for tools.
  • Full raw logs for deep inspection.
  • Build-system-specific diagnostics when needed.

For tests, that usually means JUnit XML plus full test logs. For browser tests, screenshots and traces are often more valuable than another screen of console output. For Bazel and other build systems, execution logs, build event protocol files, or remote cache diagnostics may be the difference between guessing and knowing.

The goal is to let the reader start small and go deeper only when the evidence requires it.

Make Dependency State Visible

Many "CI mysteries" are dependency mysteries wearing a fake mustache.

The source code did not change, but a package was republished, a lockfile was ignored, a base image moved, a system package upgraded, a remote cache entry was poisoned, or a tool downloaded something at runtime that nobody pinned.

You do not need to turn every repository into a hermetic build fortress overnight. You do need to make dependency drift visible.

Practical steps:

  • Commit lockfiles and make CI use them.
  • Prefer immutable container image digests for important jobs.
  • Record language runtime and package manager versions.
  • Avoid installing unpinned global tools in CI.
  • Capture dependency resolution output when failures look suspicious.
  • Fail when generated dependency files are out of sync.
  • Keep cache keys visible in job output.

For Python, that might mean preserving the resolved package list:

uv pip freeze > artifacts/build/python-packages.txt

For Node, it might mean recording:

node --version > artifacts/build/node-version.txt
npm --version >> artifacts/build/node-version.txt

For system packages, it may mean saving the base image digest or package manifest instead of pretending the runner label is specific enough.

The point is not to collect trivia. The point is to know whether the build ran against the same inputs when you try to reproduce it.

Name Caches And Remote Execution Clearly

Caches are wonderful until they are invisible.

A build that depends on local caches, remote caches, dependency caches, Docker layer caches, compiler caches, test caches, or remote execution needs to make those systems visible enough for debugging.

At minimum, record:

  • Which caches were enabled.
  • Cache key or namespace.
  • Whether the job restored a cache.
  • Whether the job saved a cache.
  • Remote execution platform when applicable.
  • Whether a failing action ran locally or remotely.

For Bazel-style workflows, this is especially important. A failure might be caused by the source, the action environment, the remote execution platform, the cache contents, or a mismatch between local and remote execution. If the failure output collapses all of that into "build failed," the team is going to waste time.

Useful output does not have to be fancy:

Remote cache:
  enabled: true
  instance: ci-linux-x86_64
  read: true
  write: false

Remote execution:
  enabled: true
  platform: ubuntu-24.04-x86_64

When cache state is part of the failure theory, provide a documented way to run without the cache. Not as a permanent solution, but as a diagnostic move:

Diagnostic rerun:
  bazel test //services/parser:parser_test --noremote_accept_cached

If disabling the cache makes the failure disappear, you have learned something. If it does not, you have learned something else. Either outcome is better than rerunning and hoping.

Distinguish Minimal Repro From Full Validation

A minimal reproduction is not the same thing as proof that the change is safe.

This is a distinction worth making explicit because engineers and agents both get tempted by the smaller command that passes.

The focused command is for diagnosis:

uv run pytest tests/unit/test_parser.py::test_rejects_empty_input -q

The full validation command is for confidence:

make ci

When a failure is fixed, the engineer should usually run both the focused test and the relevant broader validation. The focused command proves the observed failure moved. The broader command catches adjacent damage.

That layering belongs in the CI output and in the repository's local command interface. A good failure summary can say:

Debug with:
  uv run pytest tests/unit/test_parser.py::test_rejects_empty_input -q

Before merging, run:
  make check

That small bit of guidance prevents a common review smell: "I fixed the one test" when the actual change needs a larger safety pass.

Make Flakiness Reproducible Too

Flaky tests are often treated as inherently unreproducible. That is too generous.

Some flakes are genuinely timing-sensitive or environment-sensitive, but many can still preserve useful context:

  • Random seed.
  • Test order.
  • Retry count.
  • Shard number.
  • Worker ID.
  • Browser and viewport.
  • Service startup logs.
  • Port allocation.
  • Timeouts and timing information.
  • System load when available.

If a test runner supports random seeds, print the seed. If a test failed only on retry, say that. If a browser test failed in WebKit but not Chromium, do not bury that detail.

For example:

Flaky failure context:
  test: tests/search.spec.ts::search results update
  browser: webkit
  shard: 3/6
  retry: 1
  seed: 184029
  trace: artifacts/playwright/search-results-update.zip

That does not guarantee an immediate reproduction. It does turn "random flake" into a narrower investigation.

Also be careful with automatic retries. Retries are useful for reducing false red builds, but they can destroy evidence if the first failure is not captured. If a retry passes, preserve the first failure's logs and mark the job as flaky or unstable according to your team's policy. Silent retries teach the team to ignore a problem that may be growing.

Give Humans A Failure Template

When a build failure escapes CI and turns into a debugging thread, give people a template. Not a giant incident form. Just a small structure that keeps the conversation evidence-based.

For example:

Build failure:
  Link:
  Commit:
  Command:
  Failing target/test:
  First seen:
  Repro locally:
  Artifacts:
  Suspected class:
  Next step:

That template changes the conversation. Instead of "CI is broken again," the team starts with a command, a target, and artifacts. The difference is not ceremony. It is engineering hygiene.

This is especially useful when a senior engineer gets pulled in. A good failure report lets them spend their first five minutes thinking instead of asking for the obvious links.

Avoid Reproduction Theater

There is a failure mode on the other side: collecting a beautiful bundle of irrelevant data while nobody can reproduce the actual problem.

Reproduction artifacts should stay practical. Do not preserve every file just because storage is cheap. Do not dump secrets. Do not create a 300-step procedure that nobody will run. Do not pretend a local laptop can reproduce a production-like distributed system when it cannot.

Be honest about the boundary:

  • Locally reproducible.
  • Reproducible in a container.
  • Reproducible only on the CI runner image.
  • Reproducible only with remote execution.
  • Not currently reproducible, but evidence preserved.

That last category is fine. It is much better than pretending. A failure can be not-yet-reproducible and still well captured.

A Practical Build Failure Repro Checklist

For a serious CI or build failure, preserve this:

  • The entry-point command.
  • The focused failing command or target.
  • Commit, branch, pull request, and runner link.
  • OS, architecture, container image digest, and tool versions.
  • Relevant non-secret environment settings.
  • Dependency lockfile status and resolved package versions when useful.
  • Cache and remote execution state.
  • Test seed, shard, retry, and worker details for flaky failures.
  • JUnit, traces, screenshots, logs, and build-system diagnostics.
  • A short repro.txt artifact with the next commands to try.

That may look like a lot, but most of it can be automated once and reused forever. The first version can be simple. Start by printing commands, capturing versions, publishing artifacts, and writing the reproduction file.

Then improve it every time a failure makes you say, "I wish we had captured that."

The Payoff Is Better Engineering Judgment

Reproducible build failures do not merely make CI prettier. They change how a team thinks.

They reduce superstition. They make cache bugs diagnosable. They help new engineers learn the system. They let reviewers ask sharper questions. They give AI coding agents a smaller, more reliable surface to work with. They turn expensive debugging threads from archaeology into engineering.

The standard is not perfection. The standard is this: when a build fails, the next person should have enough evidence to make progress without guessing.

If you want a practical place to start, pick one important CI job this week and make it produce three things:

  • The exact failing command.
  • A short reproduction artifact.
  • Predictable links to the full logs and reports.

That alone will make the next failure less mysterious.

For more engineering craft and CI/CD debugging articles, visit Slaptijack.

Slaptijack's Koding Kraken