Build Artifact Structuring and Packaging

Structuring build artifacts is the step that turns a freshly compiled geospatial extension into a relocatable, registry-ready wheel — and it is where most CI pipelines silently break. This topic sits inside Modern Python Build Tooling & Wheel Configuration, the parent reference for compiling and distributing geospatial Python packages, and focuses narrowly on what happens after the compiler runs: how compiled outputs are laid out, how native dependencies are vendored, and how the resulting .whl is tagged and validated before it reaches PyPI or an internal index. The packages that motivate it — rasterio, pyproj, fiona, shapely — each bundle a stack of C/C++/Fortran shared objects (GDAL, PROJ, GEOS, SQLite) whose runtime linking is fragile. This page targets auditwheel 6.x, delocate 0.11+, delvewheel 1.x, wheel 0.43+, and twine 5.x, and shows how to produce artifacts that resolve their shared libraries deterministically on every target platform.

Build-to-publish pipeline for a geospatial wheel A vertical pipeline. Compiled outputs in three tiers — lib for pure-Python modules, ext for .so/.pyd/.dylib extensions, and native for vendored GDAL, PROJ and GEOS binaries — are staged into the canonical wheel layout to produce a raw quarantined .whl. The raw wheel is repaired per platform: auditwheel on Linux, delocate on macOS and delvewheel on Windows each vendor the dependent libraries into a .libs directory and rewrite RPATH to a relative origin. The repaired wheel is re-tagged so the WHEEL tag names the glibc floor, passes the twine check structural and metadata gate, is verified by an isolated install whose imports resolve against the vendored copies, and is finally ingested into PyPI or an internal index. lib/ pure-Python modules ext/ .so · .pyd · .dylib native/ vendored GDAL · PROJ · GEOS Stage into canonical wheel layout build/ tree → raw .whl in dist/raw/ (quarantine) Repair per platform vendor libs into .libs/ · rewrite RPATH → $ORIGIN auditwheel · Linux delocate · macOS delvewheel · Win Re-tag platform wheel WHEEL tag names the glibc floor — never to mask symbols twine check structural + metadata gate before upload Isolated install verify import resolves vendored libs, not the host Registry ingest — PyPI / internal index

Prerequisites & Environment

Repair tooling rewrites binary load paths, so it must run on or against the same platform floor the wheel claims. Pin every participant before staging a single file — a mismatch between the build image and the repair tool is the root cause of most “works on CI, fails on install” reports. The glibc floor in particular must match the manylinux_2_28 Docker base images that anchor your wheel tags, and the vendored library revisions should come from the locked toolchain described in environment isolation with pixi and conda.

Component Pinned version Why it matters for artifact structuring
auditwheel 6.1.0 Linux repair + RPATH rewriting; newer policy file defines manylinux_2_28 symbol set
delocate 0.11.0 macOS install-name rewriting and arch-slice checks
delvewheel 1.6.0 Windows DLL vendoring and mangling
wheel 0.43.0 wheel unpack/pack for tag inspection and re-tagging
twine 5.1.0 Structural and metadata validation gate before upload
Build image quay.io/pypa/manylinux_2_28_x86_64 glibc floor is baked into the platform tag the artifact claims
patchelf 0.18.0 Backing tool auditwheel shells out to for ELF RPATH edits

Export the reproducibility floor in the job environment so every artifact the pipeline emits is bit-stable across reruns:

export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)
export PYTHONHASHSEED=0
export LC_ALL=C.UTF-8
export TZ=UTC

Pinning SOURCE_DATE_EPOCH to the commit timestamp (not date +%s) is what makes the wheel’s internal RECORD and file mtimes reproducible — a wall-clock value reintroduces nondeterminism on every rebuild and defeats content-addressed caching downstream.

Core Configuration

Predictable artifacts start with a canonical on-disk layout. Geospatial packages must strictly separate pure-Python modules, compiled extension binaries, and vendored native libraries so that downstream validation and the repair tools can reason about each tier independently:

build/
├── lib/                  # Pure Python modules (importable)
├── ext/                  # Compiled .so / .pyd / .dylib extensions
├── native/               # Vendored GDAL, PROJ, GEOS, SQLite binaries
├── metadata/             # .dist-info, RECORD, WHEEL, entry_points
└── wheels/               # Final .whl outputs per platform tag

Route build output explicitly and disable in-tree builds so nothing leaks from the source checkout into the artifact:

python -m build --wheel --outdir build/wheels/ --no-isolation

Configure the build backend to map directories deterministically. With setuptools, turn off implicit package discovery so test fixtures and benchmark data never end up inside the wheel — the metadata generation rules these tables drive are covered in depth under mastering pyproject.toml for spatial wheels:

# pyproject.toml
[tool.setuptools]
include-package-data = false

[tool.setuptools.packages.find]
where = ["src"]
exclude = ["tests*", "benchmarks*", "docs*"]

[tool.setuptools.package-data]
"mypackage.ext" = ["*.so", "*.pyd"]
"mypackage.native" = ["*.so", "*.dylib", "*.dll"]

For CMake-driven spatial packages, the [tool.scikit-build] table gives stricter control over staging and wheel assembly; the backend that consumes it is documented under integrating CMake with scikit-build-core. Either way, a repaired wheel is just a ZIP archive with a predictable internal layout, and every later stage depends on that layout being stable:

Internal layout of a repaired geospatial wheel A repaired wheel is a ZIP archive named package-1.0-cp311-manylinux_2_28_x86_64.whl. It branches into three top-level entries: package/, holding the Python modules and .so extensions; package.libs/, holding the vendored GDAL, PROJ and GEOS shared objects copied in by the repair tool; and package-1.0.dist-info/, the metadata directory. The dist-info directory in turn contains METADATA, WHEEL, RECORD and entry_points.txt. package-1.0-cp311-…-manylinux_2_28_x86_64.whl ZIP archive — predictable internal layout package/ Python modules + .so extensions package.libs/ vendored GDAL · PROJ · GEOS .so package-1.0.dist-info/ metadata directory dist-info contents METADATA WHEEL · RECORD entry_points.txt

The second configuration object is the runtime search path embedded at link time. Relocatable artifacts require relative load paths — $ORIGIN for Linux ELF, @loader_path/@rpath for macOS Mach-O, and directory-relative DLL resolution on Windows. When building through the scikit-build-core backend, set these CMake variables so the linker never bakes in an absolute path:

# CMakeLists.txt
set(CMAKE_BUILD_WITH_INSTALL_RPATH ON)
set(CMAKE_INSTALL_RPATH "$ORIGIN/../native")
set(CMAKE_INSTALL_RPATH_USE_LINK_PATH ON)
set(CMAKE_MACOSX_RPATH ON)

Step-by-Step Implementation

Build the artifact in stages: compile, stage into the canonical layout, repair per platform, then re-tag. Each step emits a runnable command and a concrete output the next step consumes.

  1. Produce the raw wheel into a quarantine directory. Keep the unrepaired wheel separate from the publishable output so a half-repaired artifact can never be uploaded by accident:

    python -m build --wheel --outdir dist/raw/ --no-isolation
    
  2. Repair on Linux with auditwheel. The auditwheel repair step bundles every non-system shared object into package.libs/ and rewrites RPATH entries to $ORIGIN/.libs. Point LD_LIBRARY_PATH at your vendored prefix so it pulls the pinned GDAL/PROJ rather than whatever the runner ships:

    LD_LIBRARY_PATH=/opt/gdal/lib:/opt/proj/lib \
    auditwheel repair dist/raw/*.whl \
      --plat manylinux_2_28_x86_64 \
      --wheel-dir build/wheels/
    
  3. Repair on macOS with delocate. Rewrite install names, copy dependent dylibs into the wheel, and assert both architecture slices are present so an Intel-only build never ships as universal2:

    delocate-wheel -w build/wheels/ -v dist/raw/*.whl \
      --require-archs x86_64,arm64 \
      --check-archs
    
  4. Repair on Windows with delvewheel. Vendor the DLLs and mangle their names to avoid load-order collisions with other geospatial wheels in the same environment. Avoid bundling MSVCRT or any system CRT — rely on the installed Visual C++ runtime instead:

    delvewheel repair dist/raw/*.whl ^
      --add-path C:\vcpkg\installed\x64-windows\bin ^
      --wheel-dir build\wheels\
    
  5. Re-tag only when the platform tag is wrong, not the binary. If a wheel built clean but was tagged linux_x86_64, unpack, edit the WHEEL tag, and repack rather than rebuilding — but never re-tag a wheel whose symbols actually exceed the target floor (that is a recompile, not a rename):

    wheel unpack dist/raw/mypackage-1.0-py3-none-any.whl -d build/retag/
    # edit build/retag/*/mypackage-1.0.dist-info/WHEEL  -> Tag: cp311-cp311-manylinux_2_28_x86_64
    wheel pack build/retag/mypackage-1.0 -d build/wheels/
    
  6. Normalize and hash the final artifacts. Compute and record the RECORD hashes the registry will verify, and optionally sign for internal distribution:

    sha256sum build/wheels/*.whl > build/wheels/SHA256SUMS
    python -m sigstore sign build/wheels/*.whl   # optional, internal trust chains
    

Verification

Confirm each artifact is internally consistent before trusting a green checkmark. The platform tag must name the glibc floor, the bundled libraries must include the geospatial stack, and the extension must import against the vendored copies — not the host.

Inspect the repaired wheel’s tag and bundled libraries on Linux:

auditwheel show build/wheels/rasterio-1.3.9-cp311-cp311-manylinux_2_28_x86_64.whl
# rasterio-...-manylinux_2_28_x86_64.whl is consistent with the
# following platform tag: "manylinux_2_28_x86_64".
# The following external shared libraries are required: libgdal.so.34, libproj.so.25, ...

List the dylib dependencies on macOS and confirm both slices are present:

delocate-listdeps --all build/wheels/*.whl
# /usr/lib/libSystem.B.dylib
# @loader_path/.dylibs/libgdal.34.dylib
lipo -archs build/wheels/*.whl/rasterio/_io*.so   # x86_64 arm64

Prove the wheel installs and imports in an isolated environment, with shared-library lookups resolving against the vendored copies — the RPATH mechanics behind this are detailed under shared library path resolution:

python -m venv /tmp/validate-env
/tmp/validate-env/bin/pip install --no-deps build/wheels/*.whl
/tmp/validate-env/bin/python -c "import rasterio; print(rasterio.gdal_version())"
# 3.8.4
ldd $(/tmp/validate-env/bin/python -c "import rasterio, os; print(os.path.dirname(rasterio.__file__))")/_io*.so | grep gdal
# libgdal.so.34 => .../rasterio.libs/libgdal-....so.34

Run the structural gate last; twine check rejects malformed metadata before it can poison an index:

twine check build/wheels/*.whl
# Checking build/wheels/rasterio-1.3.9-cp311-cp311-manylinux_2_28_x86_64.whl: PASSED

Optimization & Edge Cases

Once the artifact builds clean, most remaining wins come from controlling what gets vendored and how the output is cached.

  • Wheel size is a vendoring decision. Every shared object auditwheel bundles inflates the artifact; a naive PROJ/GDAL vendor can push a wheel past 100 MB. Deduplicate transitively shared libraries and weigh the trade-off described in vendoring PROJ and GDAL vs system libraries — and see exactly why vendoring PROJ causes wheel bloat before you ship.
  • Cache artifacts, never build/ across ABI targets. A restored build/native/ from a manylinux_2_28 job will contaminate a musllinux build. Key the wheel cache on platform tag and architecture; the full invalidation strategy lives in async build execution and cache strategies.
  • musl vs glibc need separate package.libs/. A musllinux artifact must vendor musl-linked copies of GDAL/PROJ; you cannot reuse glibc-built shared objects. The decision criteria are laid out in manylinux2014 vs musllinux for spatial libs.
  • macosx_11_0_universal2 avoids Rosetta. A single universal2 artifact runs natively on Intel and Apple Silicon, but doubles the vendored payload; ship two thin wheels instead when binary size dominates download cost.
  • Data files survive ABI repair but not metadata pruning. proj.db and GDAL’s gdal_data/ are not shared objects, so the repair tools ignore them entirely — they must be declared as package data, or the wheel imports cleanly and then fails at first reprojection.
  • Strip debug symbols before repair. Running strip --strip-unneeded on the staged .so files before auditwheel can halve artifact size; do it before, not after, so the RPATH edits land on the stripped binary.

Troubleshooting

auditwheel: error: cannot repair "...-linux_x86_64.whl" to "manylinux_2_28_x86_64" ABI because of the presence of too-recent versioned symbols The extension was linked against a newer glibc than the target tag permits — almost always a build that ran outside the manylinux_2_28 image. Rebuild inside the pinned base image rather than re-tagging; the symbol floor is part of the C-API vs CPython ABI compatibility contract and cannot be renamed away.

ImportError: libgdal.so.34: cannot open shared object file: No such file or directory The artifact was published without running a repair pass, or the raw dist/raw/*.whl was uploaded instead of the repaired one. Confirm auditwheel show lists libgdal under bundled libraries and that build/wheels/ — not the quarantine directory — is what reaches the registry.

OSError: PROJ: proj_create_from_database: Cannot find proj.db The shared library was vendored but the data files were not. Add the proj.db directory to the wheel’s package data or set PROJ_DATA at runtime; this is a packaging gap that survives an otherwise-clean repair, covered in managing shared library paths in manylinux.

delocate.libsana.DelocationError: Some missing architectures in wheel: arm64 The compiler produced an x86_64-only object but the wheel was tagged universal2. Rebuild with ARCHFLAGS="-arch x86_64 -arch arm64" or drop the universal tag and publish a thin macosx_11_0_x86_64 artifact; --check-archs is doing its job by refusing to mislabel the binary.

InvalidDistribution: Metadata is missing required fields: Name, Version (from twine check) The dist-info was assembled by hand or by a re-tag that dropped fields. Regenerate the wheel through python -m build so METADATA is produced from pyproject.toml, then re-run the gate.


Further reading: the PEP 600 specification for the authoritative glibc-to-manylinux tag mapping, and the auditwheel documentation for the policy files that define each platform’s allowed symbol set.