Reproducible Builds and Supply-Chain Attestation for Spatial Wheels

A geospatial wheel bundles tens of megabytes of compiled C and C++ that nobody downstream will read, which makes provenance a practical concern rather than a theoretical one: users need to know what went into the artifact and be able to check that the artifact matches. This guide sits under the Modern Python Build Tooling & Wheel Configuration reference and covers byte-reproducible builds, software bills of materials, and the attestations that link a published wheel back to the workflow that produced it. It targets cibuildwheel 3.0+, auditwheel 6.x, PyPI trusted publishing with attestations, GDAL 3.8.x / PROJ 9.3.x, and the release pipeline described in publishing and distributing spatial wheels.

The chain from a commit to a verifiable published wheel A commit and a set of pinned inputs feed a build that is made deterministic by fixing timestamps, paths and ordering. The build produces a wheel, a software bill of materials listing every vendored native library and its version, and a signed attestation naming the workflow and commit. A consumer can then independently rebuild from the same inputs and compare hashes, read the bill of materials to answer a vulnerability question, and verify the attestation to confirm the artifact came from the claimed source. pinned inputs commit · lock · image digest deterministic build fixed time · paths · order the wheel same bytes every time SBOM every vendored library attestation workflow · commit · digest rebuild and compare hashes answer "am I affected by X?" confirm it came from your repo each output answers a different question, and none of the three substitutes for the others

Prerequisites & Environment

  • A build whose inputs are already pinned: base image by digest, native library versions in a lock file, and a committed environment lock as covered in dependency resolution and lockfiles.
  • SOURCE_DATE_EPOCH support in the toolchain — GCC 13, recent binutils, and a build backend that honours it when writing the wheel.
  • An SBOM generator that can read both Python metadata and native libraries; a small script over the wheel’s contents is often more accurate than a generic tool.
  • PyPI trusted publishing already working, per trusted publishing spatial wheels to PyPI.
# The starting question: do two builds of the same commit produce the same bytes?
sha256sum dist-run1/*.whl dist-run2/*.whl

Core Configuration

Byte-reproducibility fails for a small, well-understood set of reasons, and each has a standard remedy. The wheel format itself is a zip archive, so timestamps and entry ordering are recorded in the file; the compiled objects inside carry build paths and, on some toolchains, build identifiers.

# The four settings that account for most non-determinism
export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)     # fixed timestamps
export PYTHONHASHSEED=0                                  # stable dict ordering in build scripts
export CFLAGS="$CFLAGS -ffile-prefix-map=$PWD=."         # strip absolute build paths
export LC_ALL=C.UTF-8                                    # stable sort order for globs
Source of drift What it changes Remedy
Build timestamp zip entry times, __pycache__ SOURCE_DATE_EPOCH from the commit
Absolute build path debug info, __FILE__ strings -ffile-prefix-map
File ordering zip entry order, archive member order sort explicitly; LC_ALL=C
Parallel link order symbol table ordering deterministic archives (ar D), fixed job order
Toolchain version everything pin the base image by digest
Locale glob order, message strings LC_ALL=C.UTF-8

The last two rows are the ones specific to spatial builds. A GDAL compile pulls in thousands of source files, and a floating base-image tag changes the compiler underneath them without any visible change in your repository — which produces a different binary from the same commit and defeats the whole exercise. Pinning by digest is a prerequisite for reproducibility, not an optimisation.

Step-by-Step Implementation

  1. Make the timestamp a function of the commit rather than of the clock, and export it before anything runs:

    export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)
    
  2. Strip build paths from the compiled objects, so a build in /home/runner/work and one in /build produce identical bytes:

    export CFLAGS="-ffile-prefix-map=$(pwd)=. -g0"
    export CXXFLAGS="$CFLAGS"
    
  3. Normalise the archive after the wheel is built, sorting entries and zeroing timestamps if the backend has not already:

    pipx run reproducible-wheel dist/*.whl   # or a short zip-rewrite script
    
  4. Emit the SBOM alongside the wheel, generated from what is actually inside the archive rather than from the manifest — the manifest does not know about libtiff.

  5. Publish with attestations enabled so the index records the workflow identity that produced each file.

Building the Bill of Materials From the Artifact

An SBOM assembled from pyproject.toml describes the Python dependency graph and misses the entire native payload, which is exactly the part a consumer cannot inspect. The useful version is generated from the built wheel: unpack it, enumerate the bundled shared objects, and record each one’s name, version and source.

What a manifest-derived SBOM misses in a vendored spatial wheel Two inventories side by side. The manifest-derived inventory lists the Python dependencies only: numpy and the package itself. The artifact-derived inventory lists those plus every bundled native library — libgdal, libproj, libgeos, libtiff, libsqlite3, libcurl, libwebp and zlib — each with its version and the source it was built from. The native entries are the ones a vulnerability question is actually about. from the manifest mypkg 1.4.0 numpy >=1.23 two entries — and neither of them is the code that parses an untrusted file answers "which Python packages?" cannot answer "am I affected by a libtiff advisory?" from the artifact mypkg 1.4.0 · numpy >=1.23 libgdal 3.8.4 libproj 9.3.1 · proj.db schema 1.3 libgeos 3.12.1 libtiff 4.6.0 · libwebp 1.3.2 libsqlite3 3.45.1 · libcurl 8.6.0 zlib 1.3.1 answers the question people actually ask when an advisory is published generate it from the wheel's own contents, publish it beside the wheel, and record it in the release notes

Producing that inventory is a short script rather than a tool adoption. Unpack the wheel, list the .so files the repair step bundled, and read each library’s version from the soname and from the strings the library itself records.

# A minimal artifact-derived inventory
unzip -o dist/*.whl -d /tmp/w >/dev/null
for so in /tmp/w/*.libs/*.so*; do
  name=$(basename "$so" | sed -E 's/-[0-9a-f]{6,}//; s/\.so.*//')
  ver=$(strings "$so" | grep -oE "$name [0-9]+\.[0-9]+\.[0-9]+" | head -1)
  printf '%s\t%s\t%s\n' "$name" "${ver:-unknown}" "$(sha256sum "$so" | cut -c1-16)"
done | sort | tee dist/sbom.tsv

Whatever format you publish it in, the properties that matter are the same: it is derived from the artifact, it names versions rather than ranges, and it ships where a user can find it — beside the wheel, in the release notes, and ideally readable from the installed package at runtime.

Attestation: Linking the Artifact to Its Origin

Reproducibility lets someone rebuild and compare; attestation lets them verify without rebuilding. The mechanism is the same trusted-publishing identity that already authorises the upload: the workflow signs a statement binding the artifact’s digest to the repository, the workflow file and the commit, and the index records it.

What an attestation binds together, and what it does not claim An attestation binds the artifact digest to the repository, the workflow file, the commit and the build time, all signed by the CI provider's identity. It does not claim that the code is correct, that the vendored libraries are free of vulnerabilities, or that the build was reproducible; those are separate properties established by tests, the bill of materials and a rebuild comparison respectively. what it binds sha256 of this exact wheel file the repository that built it the workflow file and its ref the commit the build ran from the identity that signed it verifiable by anyone, without a rebuild what it does not claim that the code is correct — that is what tests are for that the libraries are unaffected — that is what the SBOM is for that the build is reproducible — that is what a rebuild proves a signature is provenance, not quality

Enabling it is close to free once trusted publishing is in place: the publishing action generates and uploads attestations by default in recent versions, and the requirement is simply that the release job be the small, privileged job described in the publishing chapter. The value appears later, when a user or a downstream distribution asks whether the file on the index really came from your repository — a question that has no good answer without it.

Verification

# 1. Two independent builds of the same commit agree byte for byte
git clean -xdf && SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct) python -m build --wheel -o out-a
git clean -xdf && SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct) python -m build --wheel -o out-b
sha256sum out-a/*.whl out-b/*.whl | awk '{print $1}' | sort -u | wc -l
# expected: 1
# 2. No absolute build path survives in the compiled objects
unzip -o dist/*.whl -d /tmp/w >/dev/null
strings /tmp/w/*.so /tmp/w/*.libs/*.so* | grep -c "$(pwd)"
# expected: 0
# 3. The published file's attestation resolves to this repository
pipx run pypi-attestations inspect dist/*.whl
# expected: the repository, workflow and commit you expect

Optimization & Edge Cases

  • Perfect reproducibility is a goal, not a gate — at first. Start by making the Python layer reproducible and the native layer version-pinned; chasing the last byte of a GDAL build is a long project, and version-pinning already answers most questions people ask.
  • Debug information is the usual last obstacle. It encodes paths, and sometimes compiler working directories that -ffile-prefix-map misses. Stripping the release wheel removes the problem and shrinks the artifact, which is a benefit either way.
  • Timestamps hide in more places than the zip. Compiled Python bytecode, generated headers and any file written by a build script can carry them. SOURCE_DATE_EPOCH covers the well-behaved tools; a final normalising pass over the archive covers the rest.
  • Record the SBOM inside the wheel as well as beside it. A file in .dist-info survives installation, so a user diagnosing behaviour on a machine can read what they actually have without going back to the index.
  • Rebuild verification belongs in a scheduled job. Running it on every commit doubles build time for a signal that changes rarely; running it weekly catches a toolchain change within days.

Troubleshooting

Two builds differ only in the zip entry timestamps. SOURCE_DATE_EPOCH was set after the backend started, or the backend does not honour it. Set it in the job environment rather than in a build step, and normalise the archive afterwards as a belt-and-braces measure.

The diff is inside libgdal.so. Almost always a build path in debug information or a different compiler. Compare strings output between the two builds and look for a path or a version banner; if it is the compiler, the base image moved.

The attestation is missing on the published file. The release job did not have the identity permission, or an older publishing action was used. Both are visible in the job log, and neither affects the wheel itself — republishing the same version is not possible, so fix it for the next release.

The SBOM lists a library the wheel does not contain. The generator read the build environment rather than the artifact. Generate from the unpacked wheel; that is the whole point of the exercise.

Frequently Asked Questions

Is byte-reproducibility worth the effort for a wheel nobody will rebuild?

The rebuild is not the only benefit. The work of making a build reproducible is mostly the work of removing hidden inputs — floating base images, ambient environment variables, machine-specific paths — and those are the same hidden inputs that make a release fail mysteriously six months later. The determinism is the visible outcome; the hygiene is the real one.

Does an SBOM have to be in a standard format?

For internal use, no: a sorted list of names, versions and hashes answers the question. For users who feed SBOMs into scanning tools, a standard format is more useful, and converting a good inventory into one is mechanical. What matters is that the inventory is derived from the artifact rather than the manifest.

What should I do when an advisory affects a library I vendor?

Rebuild against the patched version and publish, then tell people. Because your users cannot patch the library themselves, the release is the fix, and the release notes are the notification. Having the version inventory to hand turns “are we affected?” into a one-minute answer instead of an afternoon.

Can I attest a wheel built outside CI?

Not usefully. The value of an attestation comes from the identity being one nobody can assume — a CI provider’s, bound to a repository and workflow. A signature from a local build attests only that someone with the key built something, which is a much weaker claim and one users cannot check against anything.

Where should the inventory live inside the wheel?

In the package directory as an ordinary data file, and optionally also in .dist-info. The package directory is the version that survives every installation method and can be read at runtime by a user diagnosing behaviour on their own machine, which is the case that matters most in practice.

Hidden Inputs Are the Real Subject

The framing that makes this work tractable is to stop thinking about determinism and start thinking about inputs. A build is reproducible when every input is declared; it is irreproducible when something the build depends on is not written down anywhere. Chasing byte-identity without doing that inventory produces frustration, while doing the inventory produces reproducibility as a side effect — along with a build that behaves predictably for entirely unrelated reasons.

For a spatial wheel, the inputs divide into four groups, and most projects have declared exactly one of them.

The source is almost always declared: a commit, a tag, a checkout. This is the group everyone starts with and the group that causes the fewest surprises.

The environment is partially declared. A lock file pins the Python packages; a Dockerfile names a base image, frequently by a floating tag rather than a digest. The compiler, the C library and the linker come from that image, and a project that pins its Python dependencies to the hash while referring to manylinux_2_28:latest has pinned the small half of its environment and left the large half free.

The native sources are usually pinned by version and rarely by hash. “GDAL 3.8.4” is a version; the tarball it names can be re-rolled upstream, and a mirror can serve something different. Recording the download URL and its checksum alongside the version costs one line per library and closes the gap.

The ambient state is almost never declared: environment variables inherited from the runner, the working directory path, the locale, the wall clock, the machine’s core count where a build is parallelism-sensitive. This is the group that makes reproducibility feel mysterious, because none of it appears in any file a maintainer reads.

# Declaring the fourth group explicitly, in one place
env -i \
  PATH=/usr/local/bin:/usr/bin:/bin \
  HOME=/tmp/build \
  LC_ALL=C.UTF-8 \
  SOURCE_DATE_EPOCH="$(git log -1 --pretty=%ct)" \
  PYTHONHASHSEED=0 \
  TZ=UTC \
  ./ci/build-native.sh

Running the build under env -i with an explicit list is a blunt instrument and an extremely effective one: anything the build silently depended on fails immediately and visibly, at which point it can be added to the list deliberately. Teams that do this once usually find two or three dependencies nobody knew about, and those are exactly the things that would have made a release irreproducible six months later.

Making Provenance Useful to the People Who Need It

The outputs described here are only valuable if someone can find and use them, and the audiences are more varied than “security teams”.

A user hit by an advisory wants to know whether the wheel they installed contains the affected library, and at which version. They will not unpack the wheel; they will look at the release notes, and failing that they will open an issue. Putting the vendored versions in the release notes and in a runtime-readable module answers the question before it is asked, and a short template — the same four lines every release — makes it a habit rather than an effort.

A downstream packager — someone building a conda recipe, a distribution package, or an internal mirror — wants to know what the wheel bundles so they can decide whether to use it or rebuild against system libraries. For them the artifact-derived inventory is the document that matters, and publishing it as a file beside the wheel saves both sides a conversation.

An auditor or a procurement process wants the chain: this file came from that commit, built by that workflow. The attestation answers it mechanically, and the fact that it is verifiable without contacting you is the entire point.

Your future self wants to know why release 2.4.1 behaves differently from 2.4.0 when the diff is two lines of Python. The answer is nearly always in the inputs rather than the source — a base image that moved, a GDAL patch release, a compiler bump — and the only way to see it is to have recorded them.

# Runtime-readable provenance, ten lines that save a great deal of correspondence
from importlib.metadata import distribution

def provenance():
    """Return what this installation actually contains."""
    import json
    with distribution("geo-core").locate_file("geo_core/_provenance.json").open() as fh:
        return json.load(fh)

# {"package": "2.4.1", "gdal": "3.8.4", "proj": "9.3.1", "geos": "3.12.1",
#  "libtiff": "4.6.0", "commit": "9f3c1ab", "built": "2026-02-11T09:14:00Z"}

None of this requires adopting a framework. A JSON file generated from the built wheel, copied into the package before it is zipped, plus four lines in the release notes and attestations enabled on the publish step, covers every audience above. The work is small; the reason it is often skipped is that its value only becomes visible at the moment something goes wrong, which is exactly when it is too late to add.

Further Reading

  • The reproducible-builds project documentation on SOURCE_DATE_EPOCH and archive normalisation.