Here's the question worth asking about any release you've hashed, signed or timestamped: if someone challenged it, could you hand them the exact bytes that went into the hash?
I don't mean "the same release" or "the same source at the same commit". I mean the same bytes. A hash doesn't care what the file means. It only checks bytes. And a proof that some hash existed at some point is worth nothing if you can't produce a file that hashes to it.
So it's worth working through properly.
The obvious answer
The obvious answer goes like this. My build is deterministic enough. Same commit, same toolchain, same output. If I ever need the artifact again I'll check out the tag and rebuild.
Let's test that with the most boring packaging step there is.
# build, pack, hash
make dist
tar -czf release.tar.gz dist/
sha256sum release.tar.gz
# later: same commit, rebuild, pack, hash
make clean && make dist
tar -czf release.tar.gz dist/
sha256sum release.tar.gz
Compare the two hashes. On a plain GNU tar setup, expect them to be different, even though nothing in your code changed.
Why? The tarball isn't only your files. It's your files plus metadata about them.
Where the extra bytes come from
The tar format stores more than file contents. Every entry gets:
- the file's modification time, which your rebuild just reset
- owner and group, by name and by numeric id, which depend on who ran the build
- permission bits, which depend on the umask of whoever ran it
- an order, which is whatever order the filesystem handed the entries back in
Then gzip wraps the whole thing, and the gzip header has its own slot for a timestamp and an original filename.
None of that is your code, and all of it is in the bytes you hashed. So "I'll just rebuild it" fails, and not because anything is wrong with your project. The thing you timestamped was the archive, and the archive recorded the moment it was built.
This is the step I think matters most. The proof can be perfectly valid. The chain record is fine, the hash is fine, the verification passes for anyone holding the original file. And you still can't use it, because the original file is gone and you can't regenerate it.
Fix one: make the packaging deterministic
You can pin every one of those metadata fields. With GNU tar and gzip:
export SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)
tar --sort=name \
--mtime="@${SOURCE_DATE_EPOCH}" \
--owner=0 --group=0 --numeric-owner \
--mode='u+rwX,go+rX,go-w' \
-cf - dist/ \
| gzip -n > release.tar.gz
sha256sum release.tar.gz
Here's what each flag pins:
--sort=name fixes entry order so the filesystem doesn't get a say. --mtime stamps every entry with the commit time instead of the build time. SOURCE_DATE_EPOCH is the reproducible-builds convention for this, and some tools read it on their own. --owner, --group and --numeric-owner remove the "who built it" leak. --mode normalizes permissions so umask stops mattering. gzip -n drops the name and timestamp from the gzip header.
Now try the rebuild test again. If the hashes still differ, the drift is coming from inside dist/. Maybe a generated file embeds a build time, or a bundler orders things differently between runs. That's a real reproducibility bug and you want to know about it anyway.
Honestly though, this only covers the packaging step. Every tool upstream of tar gets a vote too. Getting a whole pipeline to reproduce byte for byte is real work, and you might not finish it.
Fix two: hash the contents instead of the container
Another option is to stop timestamping the archive at all. Timestamp a manifest of what's inside it:
cd dist
find . -type f -print0 \
| LC_ALL=C sort -z \
| xargs -0 sha256sum > ../MANIFEST.sha256
cd ..
sha256sum MANIFEST.sha256
The manifest hash is the one you anchor. Now mtimes, owners and archive format stop mattering. You can repackage as zip next year and the manifest still checks out, as long as the file contents match.
LC_ALL=C matters there. Without it, sort order follows your locale, and a teammate with a different locale gets a different manifest from identical files. Yeah. It's that kind of problem the whole way down.
What this costs you: the manifest only covers what you put in it. Permissions aren't in it, empty directories aren't in it, and symlinks get followed or skipped depending on how you wrote the find. If any of that matters for your artifact, it has to go in the manifest explicitly, or you've timestamped less than you think.
Fix three: just keep the bytes
The least clever option is often the right one. Whatever you hash, store that exact file somewhere it won't get rebuilt over. Store the archive itself, not a recipe for making it.
Reproducibility is a nice property. But "I can probably regenerate it" is a weaker position than "here it is".
Where this lands for the tool I run
I built ProofLedger, and it's fully on the wrong side of this problem on purpose. It hashes the bytes with SHA-256, anchors the hash to Polygon and Bitcoin, and returns a proof. There's no file-type pipeline. It doesn't know a tarball from a JPEG and doesn't try to. The file stays on your machine and only the hash leaves.
I took that tradeoff deliberately, and it means none of the above is something it can do for you. If you anchor a tarball with a build timestamp baked into its header, you've anchored that exact tarball, and the proof will verify cleanly against that file and nothing else. Deciding which bytes are the canonical ones has to happen before anything gets hashed, and it's your job.
The verification side is where it's simple. Anyone holding the file can hash it and look it up:
H=$(sha256sum release.tar.gz | cut -d' ' -f1)
curl "https://proofledger.io/api/v1/verify?hash=${H}"
That endpoint is public and needs no auth. There's also a verify-proof package on PyPI that does the check locally and offline. Both of them answer one narrow question: does this hash have a record? Neither one can tell you the file you're holding is the right one. That part is up to whoever produced it.
The test to run today
Pick an artifact you'd actually need to defend later. Last week's release tarball, a dataset snapshot, an exported log bundle, whatever. Then:
- Write down its SHA-256.
- Delete your local copy. Seriously, move it out of reach.
- Using only what's in your repo and your CI config, produce it again.
- Hash the result and compare.
If it matches, good, your hash points at something you can actually show someone. If it doesn't, find out where your stored copy lives, or whether one exists anywhere at all. And it's better to answer that now than when someone asks.
ProofLedger lives at proofledger.io if you want to see the other side of it.
Disclosure: this article was drafted by an AI agent I built and run, from facts I supplied about my own project.
Top comments (0)