Reproducing a release build from a tag Jump to heading
An attestation says a build produced an artefact. Reproducing that build independently and getting the identical artefact proves it, without trusting the builder, the signature or the transparency log. Full bit-for-bit reproducibility is genuinely hard, and the useful news is that most of the distance is covered by a handful of pins β and that even partial reproducibility catches the failure modes that matter. This recipe gets as far as your toolchain allows, within build provenance and attestations.
When to use this approach Jump to heading
- You want a check on the build system rather than a check by it.
- A customer or auditor asks whether releases are reproducible.
- An artefact behaves unexpectedly and you suspect the build rather than the source.
- You are hardening a release pipeline and want a measurable property.
- If your builds embed a timestamp you cannot control, expect partial results β which are still worth having.
Step 1 β Pin every input the build consumes Jump to heading
Reproducibility is mostly a pinning exercise.
# Base image by digest, never by tag
FROM node:20.11.1-bookworm-slim@sha256:4f2a1b3c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a
# Dependencies from the lockfile, with no lifecycle scripts
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts # Actions by commit id, not by tag
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 # Verification: nothing in the build references a mutable tag
grep -nE 'FROM [^@]+$|uses: [^@]+@v' Dockerfile .github/workflows/release.yml || echo "all inputs pinned" Step 2 β Remove the common sources of nondeterminism Jump to heading
# A fixed timestamp derived from the commit, not from the clock
export SOURCE_DATE_EPOCH="$(git log -1 --format=%ct "$TAG")" # Deterministic archives: fixed ordering, fixed timestamps, no owner
tar --sort=name --mtime="@${SOURCE_DATE_EPOCH}" \
--owner=0 --group=0 --numeric-owner \
-cf dist.tar dist/ # A fixed locale, so sorting is stable
export LC_ALL=C TZ=UTC # Verification: two local builds produce the same archive hash
sh build.sh && sha256sum dist.tar
sh build.sh && sha256sum dist.tar SOURCE_DATE_EPOCH is honoured by a surprising amount of tooling, and setting it from the commit date rather than the build time is what makes a rebuild months later produce the same bytes.
Step 3 β Rebuild from the tag, in a clean environment Jump to heading
git clone --branch v2.8.0 --depth 1 https://github.com/acme/app /tmp/rebuild
cd /tmp/rebuild && git verify-tag v2.8.0 export SOURCE_DATE_EPOCH="$(git log -1 --format=%ct v2.8.0)" LC_ALL=C TZ=UTC
docker build --no-cache -t rebuild:local . # Compare against the published artefact
crane digest ghcr.io/acme/app:v2.8.0
docker inspect --format='{{.Id}}' rebuild:local # Verification: the image config, which is what differs most often
crane config ghcr.io/acme/app:v2.8.0 | jq -S . > /tmp/published.json
docker inspect rebuild:local | jq -S '.[0].Config' > /tmp/local.json
diff /tmp/published.json /tmp/local.json | head -20 Step 4 β Compare at the level you can actually reach Jump to heading
Full digest equality is the goal; partial comparisons are useful long before you get there.
# Level 1: the filesystem contents, ignoring metadata
crane export ghcr.io/acme/app@"$DIGEST" - | tar -tvf - | awk '{print $NF}' | sort > /tmp/a.txt
docker save rebuild:local | tar -xO --wildcards '*/layer.tar' | tar -tvf - | awk '{print $NF}' | sort > /tmp/b.txt
diff /tmp/a.txt /tmp/b.txt | head # Level 2: the hash of each file, which catches content differences
crane export ghcr.io/acme/app@"$DIGEST" - | tar -xO | sha256sum # Verification: record which level you reached, honestly
echo "v2.8.0: file contents identical; layer digests differ (timestamps)" >> reproducibility.log SAFETY WARNING β a rebuild that does not match is not automatically a security finding, and treating it as one wastes the mechanism. Timestamps, build paths and tool versions produce differences constantly. What matters is whether the file contents differ: identical contents with different metadata is a reproducibility gap, while different contents from the same source is the thing worth investigating immediately.
Step 5 β Automate it, and record the trend Jump to heading
A reproducibility check that runs once is a curiosity; run per release it is a property.
# .github/workflows/verify-reproducible.yml
name: verify-reproducible
on:
schedule: [{ cron: '0 3 * * 1' }]
jobs:
rebuild:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { ref: ${{ github.event.inputs.tag || 'v2.8.0' }}, fetch-depth: 0 }
- run: |
export SOURCE_DATE_EPOCH="$(git log -1 --format=%ct)" LC_ALL=C TZ=UTC
docker build --no-cache -t rebuild:ci .
crane export "ghcr.io/acme/app:${GITHUB_REF_NAME}" - | tar -xO | sha256sum > /tmp/published.sha
docker save rebuild:ci | tar -xO --wildcards '*/layer.tar' | tar -xO | sha256sum > /tmp/rebuilt.sha
diff /tmp/published.sha /tmp/rebuilt.sha # Verification: the job's result is recorded per release
tail -5 reproducibility.log Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Is full reproducibility realistic? Jump to heading
For some stacks yes, for others not yet, and partial reproducibility is worth having regardless. Reaching βidentical file contents, differing metadataβ catches a compromised builder, a substituted dependency and an unexpected input β which is most of what the exercise is for. Chasing the last few bytes has sharply diminishing returns.
What if the rebuild differs and we cannot explain it? Jump to heading
Narrow it: compare file lists, then file hashes, then the differing fileβs content. Almost every unexplained difference resolves to a timestamp, a path or a tool version once you look at the actual bytes. If the difference is in compiled output from identical source, that is the case worth escalating.
Does this replace signing? Jump to heading
No β they answer different questions. Signing says who produced an artefact; reproduction says the artefact corresponds to the source. An attacker who compromises the builder can sign perfectly and cannot make a rebuild match, which is exactly why having both is stronger than having either.
Related Jump to heading
- Build Provenance & Attestations β the parent topic and what provenance does not prove.
- Pinning GitHub Actions to a Commit SHA β one of the pins this depends on.
- Publishing a Signed SBOM With Each Release β the inventory that a reproduction confirms.