Build & Delivery Internals¶
This page explains how the build and release pipeline works — the stack that turns source into a published wheel and a standalone C library tarball. See Release for the step-by-step checklist.
Overview¶
flowchart TD
src["Source\nnative/ + src/doppler/"]
cmake["CMake\nbuild/"]
so["C extensions\n*.cpython-*.so"]
bin["wfmgen binary\nsrc/doppler/wfm/_bin/wfmgen"]
jb["just-buildit\nPEP 517 backend"]
manylinux["manylinux_2_28\ncontainer (Linux)"]
auditwheel["auditwheel repair\nbundle shared deps"]
wheel["Wheel\ndoppler_dsp-X.Y.Z-*.whl"]
pypi["PyPI\nOIDC trusted publishing"]
ctarball["C library tarball\ndoppler-X.Y.Z-linux.tar.gz"]
src --> cmake --> so --> jb --> manylinux --> auditwheel --> wheel --> pypi
cmake --> bin --> jb
manylinux --> ctarball
CMake layer¶
All build output goes to build/ — never to the repo root.
Key flags¶
| Flag | Default | Purpose |
|---|---|---|
BUILD_PYTHON |
OFF |
Build CPython extensions (make pyext sets it ON) |
DOPPLER_NATIVE |
OFF |
Enable -march=native (make blazing sets it ON) |
CMAKE_BUILD_TYPE |
Release |
Debug / Release / RelWithDebInfo |
Python3_EXECUTABLE |
uv venv python | Must match the interpreter that will import the .so |
PYTHON_EXECUTABLE matters¶
The CPython C extension suffix encodes the Python version and ABI tag
(e.g., cpython-313-x86_64-linux-gnu). If CMake finds a different interpreter
than the one in your active venv, the compiled .so will not be importable.
The Makefile resolves this with:
PYTHON_EXECUTABLE ?= $(shell uv run python -c 'import sys; print(sys.executable)')
PYTHON_EXECUTABLE := $(or $(JUST_BUILDIT_PYTHON),$(PYTHON_EXECUTABLE))
JUST_BUILDIT_PYTHON is set by the wheel backend so wheel builds always
compile against the right version.
Make targets¶
| Target | What it does | When to use |
|---|---|---|
make build |
Configure + build C library only | C-layer work |
make pyext |
Reconfigure with BUILD_PYTHON=ON, build extensions |
Python API work |
make just-build |
PEP 517 hook: pyext + copy src/doppler/ to output |
Wheel backend only |
make blazing |
Clean + Release + -march=native |
Local perf profiling |
Never package a native build
make blazing produces an AVX-512-capable binary tuned to the build host.
The release pipeline gates on an objdump scan that rejects any wheel
containing non-dispatched wide-SIMD instructions. Portability default is
x86-64-v2 (SSE4.2), matching what every released wheel ships.
Build artifacts¶
build/libdoppler.so shared C library
build/libdoppler.a static C library (C++-free, symbol-verified)
src/doppler/**/*.cpython-*.so Python extensions (copied from build/ by pyext)
just-buildit wheel backend¶
pyproject.toml declares just-buildit as the PEP 517 build backend:
[build-system]
requires = ["just_buildit"]
build-backend = "just_buildit"
[tool.just-buildit]
command = "make just-build"
editable_path = "src"
When python -m build --wheel (or uv build) runs:
- just-buildit sets
JUST_BUILDIT_OUTPUT_DIRandJUST_BUILDIT_PYTHON. - It calls
make just-build, which runsmake pyextthen copiessrc/doppler/(including the freshly built.sofiles and theexamples/directory) into the output staging area. - just-buildit packages the staged tree into a wheel and writes it to
dist/.
The --no-group docs flag in UV_SYNC_FLAGS ensures the docs dependency
group is never installed during a wheel build — a docs network blip must
not break packaging.
OIDC trusted publishing¶
PyPI is configured with GitHub's OIDC trusted publisher for the
doppler-dsp/doppler repository. No PYPI_TOKEN secret is needed;
release.yml uses id-token: write permission and the publish-python
job authenticates via a short-lived OIDC token. If PyPI rejects the
upload, check the trusted publisher configuration in the PyPI project
settings, not the repository secrets.
manylinux & auditwheel (Linux wheels)¶
Linux wheels must satisfy PEP 600 symbol-version constraints so they run on
distributions older than the build host. release.yml builds inside the
quay.io/pypa/manylinux_2_28_x86_64 Docker image (glibc 2.28, GCC 12),
which sets the minimum runtime floor.
After the build, auditwheel repair inspects the wheel, bundles any shared
libraries beyond the manylinux allow-list (e.g., libzmq), and retags the
wheel as manylinux_2_28_x86_64.
macOS wheels are built natively on a macos-14 (arm64) GitHub runner using
delocate for the equivalent bundle-and-retag step.
Portability gate¶
Every wheel is scanned for non-dispatched wide-SIMD instructions:
bad=$(objdump -d "$so" | awk '
/vmovaps|vfmadd|vaddps/ && /ymm|zmm/ { print }
' | grep -v '_avx2\|_avx512')
[ -z "$bad" ] || { echo "AVX leak: $bad"; exit 1; }
Runtime-dispatched variants (function names containing _avx2 or _avx512)
are exempted. Anything else that touches ymm/zmm registers fails the
release.
CI pipeline¶
ci.yml runs on every push to main/develop and on all pull requests.
| Job | What it tests | Acts as gate for |
|---|---|---|
build-and-test (Ubuntu 22.04, 24.04, macOS) |
CMake build, CTest, C example smoke tests, C++-free symbol check, static-link smoke | C layer correctness |
python (3.9–3.14) |
make pyext, pytest, --doctest-glob on .pyi stubs, Python example smoke tests |
Python API and bindings |
glibc-228 (Debian 10 buster) |
Imports the built extension | glibc symbol floor (catches GLIBC_2.29+ leaks) |
manifest-drift |
jm status --check |
jm TOML matches generated files |
ci-passed |
Aggregates all required jobs | Required by release.yml verify-ci |
The ci-passed aggregator job is what release.yml polls. It exists so
the release pipeline has a single stable job name to wait on, regardless of
how many individual jobs are added or renamed in the future.
Release pipeline¶
release.yml triggers on a version tag push (v*.*.*). It does not
re-run tests — it trusts that the PR CI gate was green.
| Job | What it does | If it fails |
|---|---|---|
verify-version |
Asserts tag == pyproject.toml == Cargo.toml == CMakeLists.txt |
Run make bump-version VERSION=X.Y.Z consistently across all three files before tagging |
verify-ci |
Polls for ci-passed on the tagged commit SHA |
The tagged commit must have a green CI run; check GitHub Actions on that SHA directly |
build-python |
Builds 6 Python × 2 platform wheels (Linux manylinux + macOS arm64), runs portability gate | Fix any -march=native leak or C compile error; re-tag after fixing |
build-sdist |
Source tarball | Rare; check pyproject.toml [tool.setuptools] include list |
build-c-linux / build-c-macos |
Standalone C library tarballs (lib/ layout, not lib64/) |
CMake install target; check cmake --install output |
smoke-wheel |
Pip-installs the cp312 Linux wheel into a clean venv; runs deploy/validation/wfm_e2e.py from a temp directory |
Python API regression; check what wfm_e2e.py exercises |
publish-python |
Uploads all wheels + sdist to PyPI via OIDC | Check PyPI trusted publisher config; once published, the version cannot be replaced |
github-release |
Creates GitHub Release with auto-generated notes; attaches all wheel + C tarball artifacts | Permissions; check GITHUB_TOKEN has contents: write |
smoke-c |
Downloads the just-published C tarball; runs cmake find_package and pkg-config smoke tests |
CMake install layout or pkg-config .pc file |
Troubleshooting¶
AVX-512 portability failure in build-python¶
Symptom: objdump scan finds ymm/zmm instructions outside
_avx2/_avx512 function names.
Cause: A CMake target or a vendored library was compiled with -march=native
(or a flag like -mavx2 applied globally).
Fix:
grep -r "march=native\|mavx2\|mavx512" native/ CMakeLists.txt
# remove the offending flag, or gate it on DOPPLER_NATIVE
cmake -B build -DDOPPLER_NATIVE=OFF && cmake --build build
GLIBC symbol floor broken (glibc-228 job fails)¶
Symptom: ImportError: GLIBC_2.29 not found inside the Debian 10 container.
Cause: A new syscall wrapper or libc function (e.g., close_range, statx)
was called directly or pulled in by a dependency.
Find the culprit:
Fix: add a shim or avoid the high-version symbol; check what vendored code added the call.
jm version skew (manifest-drift job fails)¶
Symptom: jm status --check reports drift that you cannot reproduce locally.
Cause: Your local jm CLI version differs from the pinned jm_version in
just-makeit.toml.
Fix: always drive doppler with the pinned version:
JM_VERSION=$(grep jm_version just-makeit.toml | grep -oP '"\K[^"]+')
uvx --from "just-makeit==$JM_VERSION" just-makeit status --check
verify-ci times out¶
Symptom: release.yml polls for ci-passed but never finds it.
Cause: The CI run on the tagged commit either never ran, is still in progress,
or failed. verify-ci polls the tagged commit SHA — if CI ran on the PR
branch's HEAD but the merge produced a different SHA, the poll never finds it.
Fix: check https://github.com/doppler-dsp/doppler/actions filtered to the
tagged SHA. If CI failed, fix the issue and re-tag (see below).
Re-running a failed release¶
If release.yml fails before publish-python, it is safe to delete the
tag, fix the issue, and re-tag:
git push origin :refs/tags/vX.Y.Z # delete remote tag
git tag -d vX.Y.Z # delete local tag
# fix the issue, then:
make tag-release VERSION=X.Y.Z # re-tag and push
Do not re-tag after publish
Once publish-python has uploaded to PyPI, the version is immutable.
A re-tag would re-run github-release but not PyPI (which would
reject a duplicate). Publish a patch release instead.
Replicating the wheel build locally¶
To build a Linux manylinux wheel identical to what CI produces:
# Run in the manylinux container (same image CI uses):
docker run --rm -v $PWD:/src -w /src \
quay.io/pypa/manylinux_2_28_x86_64 \
bash -c "
/opt/python/cp312-cp312/bin/pip install uv &&
JUST_BUILDIT_PYTHON=/opt/python/cp312-cp312/bin/python3 \
make just-build JUST_BUILDIT_OUTPUT_DIR=/tmp/out &&
uvx auditwheel repair /tmp/out/*.whl -w dist/
"
# Verify the built wheel:
pip install dist/doppler_dsp-*.whl --force-reinstall
python -c "import doppler; print(doppler.__version__)"
For a quick local wheel without manylinux constraints (dev iteration):