Open source · Build tooling
SOURCE_DATE_EPOCH: why a build timestamp is a reproducibility bug
If two builds of the same source produce different bytes, you cannot tell a compiler difference from a supply-chain compromise. Usually the culprit is a clock.
A reproducible build is one where the same source, built the same way, produces byte-identical output every time. The point is not tidiness. It is that independent parties can rebuild an artifact and compare hashes, which turns "trust the publisher" into "check the publisher". If the output differs on every run, that check is unavailable, and a genuine tampering event looks exactly like a normal rebuild.
The most common thing standing in the way is a timestamp.
The convention
The
reproducible-builds project
settled on a small, boring answer:
SOURCE_DATE_EPOCH, an environment variable holding a UNIX timestamp
in seconds. A build tool that would otherwise call the system clock reads that
variable instead. The value normally comes from the last source commit, so it is
derived from the input rather than from when the build happened.
What makes it work is that it is a convention rather than a feature. It costs one environment-variable read per tool, it is opt-in, and a tool that does not implement it is not broken — it is just not reproducible. Debian, Arch, Nix, setuptools, GNU tar and many others honour it, which is why it is the right thing to reach for rather than inventing a per-tool flag.
A concrete case: timestamps in an SBOM
In July 2026 I opened a pull request against cyclonedx-python, the tool that generates CycloneDX software bills of materials from Python projects. It is open and unmerged as of August 2026 (PR #1084), so treat everything below as a description of an approach rather than as shipped behaviour.
The tool already had an --output-reproducible flag. Its strategy
was to drop the timestamp entirely, along with the randomly generated serial
number. That works, in the sense that the output stops changing. But it also
throws away real information: an SBOM with no timestamp cannot answer "when was
this dependency set captured?", which is one of the main things a consumer wants
from it.
SOURCE_DATE_EPOCH offers a better trade. Instead of no
timestamp, emit a deterministic one. The behaviour table ends up being four
rows:
- flag off, variable anything → current time, unchanged;
- flag on, variable unset or empty → timestamp omitted, unchanged;
- flag on, variable invalid → timestamp omitted, plus a warning log line;
- flag on, variable valid → that instant, in UTC.
The variable is only ever read behind the flag, so nothing changes for anyone who has not opted in. That constraint is worth adopting as a habit: a reproducibility feature that alters output for people who did not ask for it is a regression wearing a nice hat.
Defining "valid" is most of the work
The implementation is one small method returning an optional datetime, and almost all of its lines are about edge cases:
- Whitespace is stripped before parsing, because shell interpolation leaves it behind constantly.
- Empty or whitespace-only is treated as unset, not invalid. Build systems that conditionally export the variable produce an empty string routinely, and warning about it would train people to ignore the warning.
- Negative values are rejected. A timestamp before the epoch is not a build date; it is a bug upstream.
- Anything else logs a warning and falls back to omitting the timestamp. The failure mode matters: a typo in CI should never silently write a wrong date into an artifact that downstream tooling treats as evidence. Degrade to "no claim", never to "false claim".
- The serial number stays random. A serial number is supposed to be unique per document, so deriving it from a timestamp would be the wrong kind of determinism.
Parsing is int() followed by
datetime.fromtimestamp(seconds, tz=timezone.utc), with
ValueError, OverflowError and OSError all
caught. The last two are easy to forget and both are reachable from a large
enough integer.
The trap: reading an environment variable makes tests environment-dependent
This is the part I did not anticipate, and it is the reason I think the change is worth writing about at all.
The moment the code reads SOURCE_DATE_EPOCH, the test suite
starts depending on whether that variable happens to be set on the machine
running it. Every snapshot test in the project passes
--output-reproducible, and the test harness invokes the CLI
in-process rather than as a subprocess, so the ambient environment leaks
straight in. A developer — or a distribution build, where the variable is
routinely exported — would suddenly get timestamps in snapshots that are
compared byte-for-byte against stored fixtures.
I confirmed it rather than assuming it: with the variable exported and no guard in place, one integration module failed 240 of 283 tests. There is something fitting about a reproducibility feature making the build non-reproducible.
The fix is to pop that one variable inside a scoped environment patch in the test helper, leaving the rest of the environment untouched. Narrow beats broad here — clearing the whole environment would hide unrelated problems.
I first tried something else and backed it out, which is worth recording
because it looks obviously correct: setting SOURCE_DATE_EPOCH= in
the tox configuration. An empty value makes setuptools fail while building test
fixtures, with ValueError: invalid literal for int() with base 10: ''.
The convention says empty means unset; not every consumer implements it that
way.
The general lesson
Determinism is a property of the whole pipeline, not of one function. Clocks are the obvious source of nondeterminism, but the same reasoning applies to dictionary and set iteration order, filesystem readdir order, absolute paths baked into output, locale-dependent formatting, and hostnames. Each one is individually trivial and collectively the reason "it builds differently on my machine" is so hard to chase down.
If you want to check your own project, the cheapest possible experiment is:
build twice into different directories, and diff -r them. Whatever
comes back is your list.
Related reading here: writing a secret-detection rule for gitleaks, another open pull request in the same supply-chain tooling area, and Docker for CS coursework, which covers the layer-caching side of build determinism.