Wheels, the sdist and the release workflow#
How the Python distributions of dynG are built, checked and published (PLAN Sections 7.7, 7.9,
8.8 and 10.3; ADR 0011, ADR 0025, and for the CUDA plugins ADRs 0030 to 0032). Nothing in this
page publishes anything by itself: uploads happen only in release.yml, for a release tag
(pushed by the author, or by the AI assistant on the author’s behalf, GOVERNANCE.md,
2026-09-30), and PyPI needs the author’s approval of each pypi* deployment.
What is built#
Distribution |
Contents |
Built by |
|---|---|---|
|
the source of the root |
|
|
|
cibuildwheel from the sdist (CI), |
| dyng_cu12-<version>-cp312-abi3-manylinux_2_28_x86_64.whl, dyng_cu13-... (from 0.2.0) | the CUDA plugins (PLAN 5.4; ADR 0030): dyng_cu<N>/_core.abi3.so (every backend: sequential, OpenMP, CUDA; nanobind domain dyng_cu<N>; the CUDA runtime, libdyng and libstdc++ linked in; SASS for sm_75, 80, 86, 89, 90, 100, 120 and PTX for sm_120), the package dyng_cu<N>/__init__.py (from python/plugin/dyng_plugin), dyng_cu<N>.libs/libgomp-*.so*, the entry point [dyng.backends] cu<N> = dyng_cu<N>, Requires-Dist: dyng==<version>, and the licence files of the CPU wheel plus THIRD_PARTY_LICENSES_CUDA.txt and NVIDIA_CUDA_EULA.txt. Never libcuda (the user’s driver) or libcudart (linked statically) | the core sdist turned into the plugin’s tree by ci/plugin_pyproject.py; cibuildwheel with the pinned CUDA toolkit (CI, ADR 0032), ci/plugin_wheels.sh (locally), see below |
One abi3 wheel serves every CPython from 3.12. The CPU wheel’s extras cu12 and cu13 require
the plugin of exactly its version (pip install "dyng[cu13]"; dynamic metadata, ADR 0030 item
8).
Every distribution passes twine check --strict and ci/wheel_check.py: the size budget of
PLAN 7.7 (a wheel above 90 MB fails; the CPU wheel is about 1.8 MB), the file name and tags
(cp312-abi3, manylinux_2_28_x86_64), the contents listed above and, for the sdist, the files
a source build needs and none of the excluded trees or repository-only files. ci/wheel_check.py --version-info checks that VERSION is a canonical PEP 440 version (release.yml refuses a tag
otherwise).
Licence metadata. Both distributions carry core metadata 2.4 (PEP 639) with
License-Expression: Apache-2.0 AND BSD-3-Clause AND MIT AND GPL-3.0-or-later WITH GCC-exception-3.1
License-File: LICENSE
License-File: NOTICE
License-File: LICENSES/Apache-2.0.txt
License-File: THIRD_PARTY_LICENSES.txt
dynG itself is Apache-2.0; the other terms are the licences of what the wheel contains besides
dynG’s code (THIRD_PARTY_LICENSES.txt): nanobind (BSD-3-Clause) and robin-map (MIT), linked
into _core.abi3.so, and the GCC runtime (libstdc++ and libgcc linked statically, libgomp bundled
by auditwheel; GPL-3.0-or-later WITH GCC-exception-3.1). The expression is the author’s decision of
2026-09-30 (GOVERNANCE.md, approvals log; until then it was Apache-2.0). It comes from the one
[project] table of pyproject.toml (license), so the sdist, whose own files are all
Apache-2.0, carries the same expression as the wheel: for the sdist it is broader than its
files need (it names the licences of the wheels built from it), never narrower; a repackager
of the sources alone (for example the conda-forge recipe from 0.3) can declare Apache-2.0 for
them (THIRD_PARTY_LICENSES.txt says the same). ci/wheel_check.py fails when
pyproject.toml’s license differs from its LICENSE_EXPRESSION, when a wheel’s METADATA or
an sdist’s PKG-INFO has another License-Expression, a legacy License: field, or no
License-File: line for LICENSE, NOTICE or THIRD_PARTY_LICENSES.txt. Changing the
expression (for example when a bundled component changes) is a licensing decision of the author:
change pyproject.toml, LICENSE_EXPRESSION and this page together.
The CUDA plugin wheels (from 0.2.0) contain more, so their expression is longer:
License-Expression: Apache-2.0 AND BSD-3-Clause AND MIT AND GPL-3.0-or-later WITH GCC-exception-3.1 AND Apache-2.0 WITH LLVM-exception AND LicenseRef-NVIDIA-End-User-License-Agreement
with the licence files of the CPU wheel plus THIRD_PARTY_LICENSES_CUDA.txt and
NVIDIA_CUDA_EULA.txt (ADR 0030 item 9). The two added terms are the CUDA C++ Core Libraries’
libcu++ and NVTX (Apache-2.0 WITH LLVM-exception; CUB’s BSD-3-Clause and Thrust’s Apache-2.0 are
already named) and the CUDA runtime, linked statically, under NVIDIA’s CUDA Toolkit EULA
(LicenseRef-NVIDIA-End-User-License-Agreement, the identifier conda-forge’s CUDA packages
use). The author’s decision of 2026-10-06 (GOVERNANCE.md, approvals log). It is
PLUGIN_LICENSE_EXPRESSION in ci/wheel_check.py, which ci/plugin_pyproject.py writes into the
plugin’s rendered pyproject.toml; ci/wheel_check.py refuses a plugin wheel with another
expression (the CPU wheel’s included).
In CI#
Workflow |
Trigger |
What it does |
|---|---|---|
|
pull requests, |
|
|
pull requests that touch the packaging, the library ( |
the sdist; the wheel built from that sdist (its artifact, cibuildwheel’s |
|
a tag |
see below |
ci/python.sh locally installs into the active environment, except when that environment’s
dyng is an editable install of another checkout (the shared dyng-dev environment seen from a
fresh clone or a review worktree): re-pointing it would break the environment once the clone is
deleted, and concurrent runs would race on it, so the script then uses a throwaway venv
build/py-venv of the checkout (--system-site-packages, reusing the environment’s packages).
DYNG_PYTHON_INSTALL=force re-points the environment anyway, =venv always uses the venv, =0
installs nothing.
Locally (no container runtime)#
cibuildwheel’s Linux builds need Docker or Podman, which the development machine does not have
(PLAN 8.8). ci/wheels.sh builds the same distributions without a container:
source scripts/dev_env.sh
# once: the glibc 2.28 toolchain, the wheel tools and a Python 3.13
conda env create -p "$DYNG_SCRATCH/tools/manylinux228-tc" -f ci/wheel-toolchain.yml
python -m venv "$DYNG_SCRATCH/tools/wheeltools"
"$DYNG_SCRATCH/tools/wheeltools/bin/pip" install auditwheel patchelf twine
conda create -p "$DYNG_SCRATCH/tools/py313" -c conda-forge python=3.13
# every time
flock -s "$DYNG_SCRATCH/perf.lock" nice -n 10 ci/wheels.sh # or: ci/check.sh --wheels
It builds the sdist, then the wheel from the sdist with pip wheel, repairs it with
auditwheel repair --plat manylinux_2_28_x86_64 (bundling libgomp), runs twine check and
ci/wheel_check.py, and installs the wheel alone into a fresh venv for each Python version
(3.12 and 3.13): an import from an empty directory, sssp and cycle_count on both host backends,
dyng --version and dyng config, then the whole pytest suite against the installed wheel (a
report of leaked nanobind objects fails the run). The distributions land in
$DYNG_SCRATCH/wheels/<version>/dist.
The differences from the CI build:
CI ( |
locally ( |
|
|---|---|---|
environment |
the manylinux_2_28 image (AlmaLinux 8, glibc 2.28) |
Debian 12 (glibc 2.36) with conda-forge’s GCC 12 against a glibc 2.28 sysroot ( |
compiler |
the image’s gcc-toolset (libstdc++ newer than the system’s through |
GCC 12 with libstdc++ and libgcc linked statically and their symbols hidden ( |
build front end |
|
|
repair |
auditwheel inside the image |
auditwheel from |
tests |
a pytest subset in the built wheel (cibuildwheel), then the whole suite (install-test job) |
the whole suite in fresh venvs |
bundled OpenMP runtime |
the image’s libgomp (GCC 12 toolset) |
the conda toolchain’s libgomp, pinned to GCC 12 ( |
The system GCC alone cannot produce a manylinux_2_28 wheel: linked against glibc 2.36 and the
system libstdc++, auditwheel show reports manylinux_2_34. Two pitfalls found while setting this
up (ADR 0025): a statically linked libstdc++ must have its symbols hidden, or the module’s
references bind to a libstdc++.so.6 another extension (NumPy) loaded first and formatting a
number into a std::ostringstream crashes; and the process-wide default resources are dropped at
interpreter exit, or nanobind reports them as leaked when the module is finalized.
The CUDA plugin wheels#
The plugins dyng-cu12 and dyng-cu13 are built from the same sources as dyng (ADR 0030):
The source tree. Unpack the core sdist (or use a disposable checkout) and run
python3 ci/plugin_pyproject.py --plugin cu13 --project-dir <tree> --cuda-root <toolkit>. It replaces the tree’spyproject.tomlwith the plugin’s, rendered from the root one (--printshows it):name = "dyng-cu13",dependencies = ["dyng==<VERSION>"], the entry point,wheel.packages = ["python/plugin/dyng_cu13"](a copy ofpython/plugin/dyng_pluginit makes in the tree), and the definesDYNG_ENABLE_CUDA=ON,DYNG_ENABLE_OPENMP=ON,DYNG_PYTHON_PLUGIN=cu13,CMAKE_CUDA_RUNTIME_LIBRARY=Static,DYNG_CUDA_ARCHITECTURES=release. It also copies the toolkit’s EULA into the tree asNVIDIA_CUDA_EULA.txt(found as<toolkit>/EULA.txt, a conda prefix’sLICENSE, or through a conda-forge environment’sconda-meta;--cuda-eula <file>otherwise).The wheel. Build that tree with the toolkit of the plugin’s major:
CUDACXX=<toolkit>/bin/nvcc pip wheel --no-deps <tree>(CMake refuses a toolkit of another major, a shared CUDA runtime or a build without OpenMP). The cu12 wheel is built with the latest CUDA 12.x, the cu13 wheel with the latest 13.x (PLAN 7.8).Repair and checks.
auditwheel repair --plat manylinux_2_28_x86_64 --exclude libcuda.so.1,twine check --strict,ci/wheel_check.py(it recognises a plugin wheel by its name).
ci/plugin_wheels.sh does all of it locally, with the toolchain of ci/wheel-toolchain.yml as
host compiler, and then installs each plugin with the core wheel (and nothing else from dynG)
into fresh venvs for Python 3.12 and 3.13: the plugin must be selected and run sssp,
cycle_count and mosp on the CUDA backend (GPU 1) with arrays equal to the sequential backend’s,
a process without a visible device must fall back to dyng._core with a dyng.BackendWarning,
the GPU tests of python/tests must pass (pytest -m gpu, test_cuda.py, with
DYNG_REQUIRE_CUDA=1 so that they cannot pass by skipping; ADR 0031), and the whole suite must
pass with DYNG_CPU_ONLY=1. The fresh venvs hold no PyTorch or CuPy, so the round trips with
them skip there; DYNG_PLUGIN_INTEROP_PYTHON=<python of a venv with torch / cupy> reinstalls the
core and plugin wheels into that venv, runs the GPU tests there too, and uninstalls the plugin
again (on the development machine: $DYNG_SCRATCH/venvs/m6a-interop-3.12, PyTorch 2.14 cu130
and CuPy 14 for CUDA 13; 5.6 GB, so it is made once and not per run):
source scripts/dev_env.sh
# once, besides the tools of the CPU wheel: a CUDA 12.x toolkit for cu12 (none is installed
# system-wide on the development machine; conda-forge's packages work; cuda-cuobjdump is needed
# for the check of the module's SASS and PTX, and the script refuses a toolkit without it)
conda create -p "$DYNG_SCRATCH/tools/cuda-12.9" -c conda-forge --override-channels \
cuda-version=12.9 cuda-nvcc cuda-cudart-dev cuda-cudart-static cuda-cccl cuda-nvtx-dev \
cuda-cuobjdump
# (an environment made before 2026-10-06 without it:
# conda install -p "$DYNG_SCRATCH/tools/cuda-12.9" -c conda-forge --override-channels \
# cuda-version=12.9 cuda-cuobjdump)
# every time: cu13 with /usr/local/cuda-13.1 (the default), cu12 with that toolkit
DYNG_PLUGINS="cu12 cu13" DYNG_CUDA12_ROOT="$DYNG_SCRATCH/tools/cuda-12.9" \
flock -s "$DYNG_SCRATCH/perf.lock" nice -n 10 ci/plugin_wheels.sh
# once: the interop venv for the PyTorch / CuPy round trips (CUDA 13 builds of both)
python3.12 -m venv "$DYNG_SCRATCH/venvs/m6a-interop-3.12"
"$DYNG_SCRATCH/venvs/m6a-interop-3.12/bin/pip" install numpy "pytest>=8" "hypothesis==6.167.1" \
cupy-cuda13x
"$DYNG_SCRATCH/venvs/m6a-interop-3.12/bin/pip" install torch \
--index-url https://download.pytorch.org/whl/cu130
DYNG_PLUGIN_INTEROP_PYTHON="$DYNG_SCRATCH/venvs/m6a-interop-3.12/bin/python" \
flock -s "$DYNG_SCRATCH/perf.lock" nice -n 10 ci/plugin_wheels.sh
The install test also runs the smoke test of the hosted runners (below): with the driver hidden
by ci/without_cuda_driver.sh (a user and mount namespace in which libcuda.so.1 is an empty
file; no root needed), ci/plugin_smoke.py --expect fallback --reason "no CUDA driver" must
pass. The GPU smoke test itself is ci/plugin_smoke.py --expect cuda.
The local GPU gate ci/gpu_local.sh has the same as its step plugin (cu13 and one Python by
default, about four minutes; DYNG_GPU_PLUGIN_WHEELS=<dir> tests wheels built before).
The wheels land in $DYNG_SCRATCH/wheels/<version>-plugins/dist (the core sdist and wheel they
were built with in .../core/dist). DYNG_WHEEL_SKIP_TESTS=1 builds and checks only;
DYNG_PLUGIN_TEST_ONLY=1 tests the wheels built before. The script’s header lists every
setting.
The local builds of 2026-10-06 (M6a): cu13 with CUDA 13.1 (/usr/local/cuda-13.1), cu12 with
CUDA 12.9 (conda-forge’s packages, the recipe above in a fresh prefix, cuda-cuobjdump
included; re-run after the acceptance found the recipe without it); 5.5 MB and 5.7 MB; both
manylinux_2_28_x86_64 (auditwheel reports the module itself consistent with
manylinux_2_17), twine check --strict and ci/wheel_check.py clean; the install tests pass
on Python 3.12 and 3.13 with the RTX A5000 (driver 590.48.01, CUDA 13.1). How these builds
differ from CI’s is the table of the CPU wheel above, plus: the toolkits (CI: NVIDIA’s RHEL 8
packages of the latest 12.x and 13.x installed in the manylinux_2_28 image; locally: the system
CUDA 13.1 and conda-forge’s CUDA 12.9).
The plugins in CI#
wheels.yml builds both plugins from the run’s sdist (ADR 0032):
job
plugin(matrixcu12,cu13): the sdist unpacked intoplugin-src/; the CUDA toolkit’s RPM files downloaded on the runner bypython ci/cuda_toolkit.py download --plugin cu<N> --dest .cuda-rpms(cached between runs withactions/cache, except for tags; every file checked against its SHA-256 and against NVIDIA’s OpenPGP signature, see below); then cibuildwheel withpackage-dir: plugin-srcandconfig-file: ci/cibuildwheel-plugin.toml. In the manylinux_2_28 container,before-all(ci/cibw_plugin.sh) checks the files again (digests; signatures with gpg andrpm -K), installs them withdnf(localpkg_gpgcheck=1), links/usr/local/cuda, checks that nvcc is the pinned release and compiles and links a kernel with the image’s gcc-toolset and the static runtime, and renders the plugin’s tree withci/plugin_pyproject.py --cuda-root /usr/local/cuda; cibuildwheel builds the abi3 wheel once (cp312, four compile jobs; the module linked with-Wl,--exclude-libs,ALL) and repairs it withci/cibw_plugin_repair.sh(auditwheel repair --plat manylinux_2_28_x86_64 --only-plat --exclude libcuda.so.1 --exclude libcuda.so, thenci/wheel_check.py --code-objectswith the toolkit’s cuobjdump: the module must carry exactly the SASS of the toolkit’s release list and the PTX of its last entry, so a lost architecture fails the build).twine check --strictandci/wheel_check.py(90 MB budget and the module’s export list included) check the wheel; artifactwheel-cu<N>-manylinux_2_28_x86_64. About 10 to 20 minutes per plugin.job
plugin-install-test(plugin x Python 3.12, 3.13): the run’s CPU wheel and plugin wheel in a fresh venv (--no-index:dyng==<version>is the run’s core wheel; NumPy and the test tools from PyPI). The runners have no GPU and no driver (checked), so the test is the fallback:ci/plugin_smoke.py --expect fallback --reason "no CUDA driver"(onedyng.BackendWarning,dyng._coreactive, the pluginunusablefor that reason, the plugin’s module importing without a driver and reporting plugin, toolkit, static runtime and architectures, which must be the release list of its toolkit, sssp / cycle_count / mosp on the CPU backends),dyng config, and the whole pytest suite (its GPU tests skip).
The pinned toolkits are ci/cuda_toolkits.toml: per plugin, the toolkit release and every
RPM file of NVIDIA’s RHEL 8 repository the build installs (nvcc, the CUDA runtime with its static
library, CCCL, NVTX, the documentation package with EULA.txt, cuobjdump for the check of the
code objects, and their dependencies inside
NVIDIA’s repository), with SHA-256 and size. The pins make the build reproducible; that the
files are NVIDIA’s is checked separately, every time they are used: each RPM’s header must carry
a valid OpenPGP signature by NVIDIA’s repository key (D42D0685.pub, fetched from the
repository next to the packages and accepted only with the fingerprint
610C7B14E068A878070DA4E99CD0A493D42D0685 pinned in ci/cuda_toolkit.py), and the payload
must match the digest that the signed header records (download and verify --signatures, with
gpg; in the container also rpm -K and dnf’s localpkg_gpgcheck). So a lock taken from a
tampered response would fail at its first use. Now: CUDA 12.9 (nvcc 12.9.86) for cu12, CUDA
13.4 (nvcc 13.4.92) for cu13. When NVIDIA ships a newer 12.x or 13.x (PLAN 7.8: the wheels
are built with the latest of each major), re-pin in a pull request; dependabot does not see the
file:
python3 ci/cuda_toolkit.py lock --plugin cu13 --release 13.5 # reads NVIDIA's repository
python3 ci/cuda_toolkit.py show
python3 -m pytest ci/tests/test_cuda_toolkit.py
The CI toolkit locally. ci/cuda_toolkit.py extract unpacks the pinned files without root
(bsdtar needed, e.g. conda’s libarchive), which builds the plugins locally with exactly the
toolkits of CI:
for p in cu12 cu13; do
python3 ci/cuda_toolkit.py download --plugin $p --dest "$DYNG_SCRATCH/tools/ci-cuda/rpms-$p"
python3 ci/cuda_toolkit.py extract --plugin $p --rpms "$DYNG_SCRATCH/tools/ci-cuda/rpms-$p" \
--dest "$DYNG_SCRATCH/tools/ci-cuda/root-$p"
done
DYNG_PLUGINS="cu12 cu13" \
DYNG_CUDA12_ROOT="$DYNG_SCRATCH/tools/ci-cuda/root-cu12/usr/local/cuda-12.9" \
DYNG_CUDA13_ROOT="$DYNG_SCRATCH/tools/ci-cuda/root-cu13/usr/local/cuda-13.4" \
flock -s "$DYNG_SCRATCH/perf.lock" nice -n 10 ci/plugin_wheels.sh
How the plugins’ CI build differs from ci/plugin_wheels.sh:
CI ( |
locally ( |
|
|---|---|---|
CUDA toolkit |
the pinned RPMs of |
|
host compiler |
the image’s gcc-toolset 14 (cibuildwheel 4.2.1’s manylinux_2_28 image, 2026.09.05-1); the system’s libstdc++ / libgcc, other static libraries hidden ( |
conda-forge’s GCC 12.4 against a glibc 2.28 sysroot, libstdc++ / libgcc static and hidden ( |
checks of the module |
|
the same checks ( |
build front end |
|
|
tests |
the fallback smoke test and the whole suite, no GPU (the release then runs the GPU tests on the CI-built wheels: Release process, steps 8 and 10) |
the GPU smoke test, the fallback with no visible device and with the driver hidden, |
Building a plugin from source (for another toolkit or architecture list) is the same three
steps on a checkout; -Ccmake.define.DYNG_CUDA_ARCHITECTURES=86 (for example) replaces the
release list. A plugin always has the version of the dyng it is installed with.
Releasing#
The release checklist is Release process (PLAN Section 10.3), which says who does each step. For the Python distributions (ADR 0032 for the plugins):
Bump
VERSION(for example0.1.0rc1, then0.1.0),CITATION.cffand the CHANGELOG, in a pull request.Push the tag
v<VERSION>(the author, or the AI assistant on the author’s behalf).release.ymlselects the package: av0.0.xtag equal totools/name_reservation’s version builds the name-reservation package (ADR 0014); every other tag must equalVERSIONand builds the real distributions by callingwheels.yml, from v0.2.0 (release candidates included) with the CUDA plugins (ci/wheel_check.py --release-distributionsnames them). Thecollectjob checks them again, checks that they are exactly the distributions of the release (--release-set), and uploads one artifactdistwith a directory per distribution (--split).Each distribution is uploaded to TestPyPI through its own environment (Trusted Publishing), the CUDA plugins first:
publish-testpypi-pluginsuploadsdyng-cu12throughtestpypi-cu12anddyng-cu13throughtestpypi-cu13, thenpublish-testpypiuploadsdyngthroughtestpypionly if both succeeded (dyng’s extras pin the plugins: adyngon an index without its plugin would makepip install "dyng[cu13]"fall back silently to an olderdyng; ADR 0032 item 6). Pre-releases (a,b,rc,dev) stop here: smoke-test the RC from TestPyPI (Release process, step 8).For a final version, once
dyngreached TestPyPI,publish-pypi-pluginsuploads the plugins to PyPI throughpypi-cu12andpypi-cu13, thenpublish-pypiuploadsdyngthroughpypi; each waits for the author’s approval (three approvals for a release with the plugins: the two plugins in one “Review deployments” dialog, thendyng).
Distribution |
TestPyPI environment |
PyPI environment |
From |
|---|---|---|---|
|
|
|
0.0.1 (the reservation), 0.1.0 |
|
|
|
0.2.0 |
|
|
|
0.2.0 |
The workflow file name release.yml and the six environment names are bound to the trusted
publishers on TestPyPI and PyPI (PyPI refuses two identical pending publishers, so each plugin
has its own pair): never rename them.