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.
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:
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.
-
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 -
Repair on Linux with
auditwheel. Theauditwheel repairstep bundles every non-system shared object intopackage.libs/and rewritesRPATHentries to$ORIGIN/.libs. PointLD_LIBRARY_PATHat 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/ -
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 asuniversal2:delocate-wheel -w build/wheels/ -v dist/raw/*.whl \ --require-archs x86_64,arm64 \ --check-archs -
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\ -
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 theWHEELtag, 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/ -
Normalize and hash the final artifacts. Compute and record the
RECORDhashes 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
auditwheelbundles 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 restoredbuild/native/from amanylinux_2_28job will contaminate amusllinuxbuild. 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_universal2avoids 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.dband GDAL’sgdal_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-unneededon the staged.sofiles beforeauditwheelcan 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.
Related
- Modern Python Build Tooling & Wheel Configuration — the parent reference tying backends, base images, and registry publishing together.
- Mastering pyproject.toml for spatial wheels — the manifest that drives package discovery and metadata for the artifacts staged here.
- Async build execution and cache strategies — how the aggregated
dist/is produced in parallel and cached by platform tag. - Vendoring PROJ and GDAL vs system libraries — the trade-off that determines what
auditwheelbundles and how large the wheel becomes. - Shared library path resolution — the RPATH and load-path rules that make the repaired artifact relocatable.
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.