ADR 0032: The CUDA plugin wheels in CI and in the release workflow#
Status: Accepted under delegation (2026-10-06; GOVERNANCE.md, “Delegation of technical ADRs”). It records how PLAN Sections 7.7 (the plugins’ row of the distributions table, the “wheel vs parity build” row), 7.8 and 8.8 (
wheels.yml,release.yml) are implemented in M6a, step “ci-release”, and where the implementation refines the plan’s sketch. It changes no rule the author approved: the gates and their measurement protocol, the parity rules, the licence and the licence expression, the names, the publishing steps and the environments (the author’s and the orchestrator’s:testpypi/pypifordyng,testpypi-cu12/pypi-cu12andtestpypi-cu13/pypi-cu13for the plugins) are taken as given, and nothing is published by it.Date: 2026-10-06
Deciders: the AI assistant, on the author’s behalf (M6a, step “ci-release”)
Context#
ADR 0030 builds the plugin wheels dyng-cu12 and dyng-cu13 from the core sdist with a
rendered pyproject.toml; ADR 0031 lets dyng choose and fall back. Locally,
ci/plugin_wheels.sh builds and tests them on the development machine. PLAN 7.7 asks for the
plugins to be built by cibuildwheel in the manylinux_2_28 image “with the CUDA toolkit installed
in before-all from NVIDIA’s RHEL8 repository”, PLAN 7.8 for the latest 12.x and the latest
13.x, PLAN 8.8 for wheels.yml to build and install-test them and for release.yml to publish
them through Trusted Publishing, with a 90 MB size check. GitHub’s hosted runners have no GPU and
no NVIDIA driver. Release 0.2.0 is the first with the plugins (PLAN Appendix F); the projects
dyng-cu12 and dyng-cu13 have pending trusted publishers bound to release.yml and to their
own environments (PyPI refuses two identical pending publishers, so they cannot share
testpypi / pypi with dyng).
Decision#
wheels.ymlbuilds the plugins from the sdist of the same run (jobsplugin, matrixcu12/cu13, andplugin-install-test), next to the unchanged jobs of the CPU wheel. The job unpacks the core sdist, and cibuildwheel builds it with the configuration fileci/cibuildwheel-plugin.toml(the CPU wheel keeps[tool.cibuildwheel]ofpyproject.toml):build = cp312-manylinux_x86_64(one abi3 wheel), the manylinux_2_28 image that cibuildwheel pins,before-all = ci/cibw_plugin.sh, the plugin passed in asDYNG_PLUGIN(environment-pass),CUDACXX=/usr/local/cuda/bin/nvcc, four parallel compile jobs (the runners have four cores), the module linked with-Wl,--exclude-libs,ALL(as in the local build: neither libdyng’s nor the static CUDA runtime’s symbols are exported), and the repair of ADR 0030 item 6 (auditwheel repair --plat manylinux_2_28_x86_64 --exclude libcuda.so.1 --exclude libcuda.so) inci/cibw_plugin_repair.sh, which then checks the module’s code objects with the toolkit’s cuobjdump (ci/wheel_check.py --code-objects: exactly the SASS of the toolkit’s release list and the PTX of its last entry, PLAN 7.7; the lists inci/wheel_check.pyare tied tocmake/cuda_architectures.cmakeby its self-test).twine check --strictandci/wheel_check.py(the 90 MB budget, tags, contents, entry point,Requires-Dist: dyng==<version>, licence files, no libcuda / libcudart needed or bundled, an export list ofPyInit__coreand std / nanobind / type_info symbols only) run on each wheel; the artifact iswheel-cu<N>-manylinux_2_28_x86_64. Pull requests that touch what the distributions are built from build the plugins too: the packaging, the library (cpp/**, added after the M6a review: the published plugin configuration, nvcc with the image’s gcc-toolset, the static runtime and the module in plugin mode, is built by no other workflow, so a library change that breaks it would otherwise first fail at the release tag) and the licence files the wheels ship (they are not required checks);workflow_callandworkflow_dispatchtake an inputplugins(default true).The toolkits are pinned file by file.
ci/cuda_toolkits.toml, generated byci/cuda_toolkit.py lock --plugin cu<N> --release X.Yfrom the metadata of NVIDIA’s RHEL 8 repository (repodata/primary.xml), pins for each plugin the toolkit release and every RPM of the minimal set the build needs, with its SHA-256 and size:cuda-nvcc,cuda-cudart-devel(withlibcudart_static.a; it pulls CCCL),cuda-nvtx(NVTX 3, the profiler’s ranges),cuda-documentation(it carriesEULA.txt, which the wheel ships, ADR 0030 item 9),cuda-cuobjdump(the check of the code objects) and what they require from NVIDIA’s repository; what they require from outside it (gcc-c++,/sbin/ldconfig,/bin/sh) is listed asexternaland comes from the image’s repositories. Pinned now: CUDA 12.9 (nvcc 12.9.86, 12 files, 183 MB) forcu12and CUDA 13.4 (nvcc 13.4.92, 14 files, 140 MB) forcu13, the latest of each major in the repository on 2026-10-06 (PLAN 7.8). The runner downloads the files on the host (ci/cuda_toolkit.py download: every digest checked, aSHA256SUMSwritten) into.cuda-rpms/, which cibuildwheel copies into the container with the project;before-allchecks the digests again (sha256sum --checkandci/cuda_toolkit.py verify) and installs exactly those files withdnf. The signatures are checked too (added after the M6a review): the SHA-256 pins come from the repository’s metadata over HTTPS and prove only that CI installs the bytes that were locked, while installing from NVIDIA’s repository, as the plan has it, would have checked NVIDIA’s signatures (gpgcheck=1). Sodownloadalso fetches the repository keyD42D0685.pub, accepts it only with the fingerprint pinned inci/cuda_toolkit.py(610C7B14E068A878070DA4E99CD0A493D42D0685), and checks with gpg that every RPM’s header carries a valid signature by it and that the payload matches the digest the signed header records;before-allrepeats that check (verify --signatures), imports the key into the container’s rpm database, requiresrpm -Kto report the signatures OK, and installs withdnf --setopt=localpkg_gpgcheck=1. This refines PLAN 7.7’s “installed from NVIDIA’s RHEL8 repository”: the packages are NVIDIA’s RHEL 8 packages, but the build no longer depends on what the repository serves on the day (a re-pin is a reviewed change of the lock file), and the download can be cached, which a container step cannot. Dependabot does not see the lock: re-pinning when NVIDIA ships a new toolkit is a maintainer’s step (docs/developer/wheels.md).Cache.
actions/cachekeeps.cuda-rpms/between runs, keyed by the plugin and the lock file’s hash; it is not used for tag runs (a release downloads afresh), and a restored file is used only if its digest is the pinned one (the cache cannot change what is built). The build itself is not cached (no ccache in the container): about 10 to 20 minutes per plugin on the four-core runners, from the local timing.before-allchecks the toolkit before the build (ci/cibw_plugin.sh): nvcc’s release is the pinned one and of the plugin’s major,EULA.txtis present, and a kernel compiles and links with the image’s host compiler (gcc-toolset 14 in cibuildwheel 4.2.1’s manylinux_2_28 image, 2026.09.05-1) and the static runtime, so an unsupported host compiler fails in one line rather than in the middle of the build. It then renders the plugin’s tree withci/plugin_pyproject.py --cuda-root /usr/local/cuda(a link to/usr/local/cuda-X.Y).No GPU in CI: a fallback smoke test and the whole suite.
plugin-install-test(plugin x Python 3.12 / 3.13) installs the CPU wheel and the plugin wheel of the run into a fresh venv (--no-index:dyng==<version>comes from the run’s core wheel, which is not on PyPI yet; NumPy and the test tools from PyPI), checks that the runner has no driver, then runsci/plugin_smoke.py --expect fallback --reason "no CUDA driver": exactly onedyng.BackendWarning,dyng._coreactive, the plugin reportedunusablewith that reason, the plugin’s own module importing without a driver and reporting its build (plugin, toolkit, static runtime, architectures: the release list of its toolkit, in both modes),Resources.cuda()refused, and sssp / cycle_count / mosp equal on the OpenMP and sequential backends; then the whole pytest suite (its GPU tests skip). The same script with--expect cudais the GPU smoke test ofci/plugin_wheels.sh(which now also runs the CI smoke test, with the driver hidden byci/without_cuda_driver.sh: a user and mount namespace in whichlibcuda.so.1is an empty file). The GPU tests of the plugins stay on the author’s machine (ci/plugin_wheels.sh,ci/gpu_local.sh’s stepplugin), as PLAN 8.8 has it for every GPU test.release.ymlpublishes each distribution through its own environments.selectnames the distributions of the tag (ci/wheel_check.py --release-distributions, a JSON list of name, file prefix and environment suffix):dyngalone for the name reservation (v0.0.x) and for v0.1.x, and from 0.2.0, release candidates included (PLUGINS_SINCE = (0, 2)), alsodyng-cu12anddyng-cu13; it callswheels.ymlwithpluginsset accordingly.collectchecks the files again and that they are exactly the named distributions of the version (--release-set: the core’s sdist and wheel, one wheel per plugin, no plugin sdist), and sorts them into one directory per distribution of the artifactdist(--split). Each distribution is uploaded from its directory in its own environment, the plugins beforedyngon each index:publish-testpypi-plugins(a matrix over the plugins, environmentstestpypi-cu12,testpypi-cu13), thenpublish-testpypi(dyng,testpypi) only if every plugin upload succeeded; for final versions only and only afterdyngreached TestPyPI,publish-pypi-plugins(pypi-cu12,pypi-cu13), thenpublish-pypi(dyng,pypi) only if both plugins reached PyPI. Eachpypi*environment needs the author’s approval: three approvals for a final release with the plugins (the two plugins in one “Review deployments” dialog, thendyng). The order matters becausedyng’s extras pin the plugins exactly: adyngon the index without its plugin would makepip install "dyng[cu13]"backtrack to an olderdyngwithout the extra and install the CPU package with only a warning, and a failed or rejected plugin upload (likely enough on the first upload through a new pending publisher) would leave it so. With the plugins first, such a failure leavesdyngunpublished instead, and a plugin alone is harmless (it requiresdyng==<version>).docs/developer/release.mdsays how to recover from a failed leg. Theselectstep’s script is tested for every kind of tag byci/tests/test_release_select.py. For v0.0.x and v0.1.x nothing changes but the artifact’s layout (dist/dyng/): the same files go to the same environments (the plugin jobs are skipped). (Revised after the M6a review: the first version published the three distributions as one unordered matrix.)The informational “wheel vs parity build” row (PLAN 7.7) is measured by
parity/wheel_vs_parity.py: the same Python driver, in two interpreters, one with the core and plugin wheels, one importingdyngfrom an overlay whose module is theparity-cudapreset’s library (sharedlibdyng.so,-O3 -lineinfo,sm_86, shared CUDA runtime) with the bindings; the workload of the gates (sssp: roadNet-CA, K = 3, the suite’s 50K unsafe batch throughdyng.updateasdyng-compat-mospdoes, the stagesssp.enact_fusedper objective; cycle_count: DD, k = 4, the stagecycle_count.count, and an update of 25K + 25K changes drawn by the script); the exclusive perf lock, GPU 0, clocks locked (boost, the ClockLock ofparity/perf_ab.py), alternating rounds, medians. It is recorded inparity/results/M6a-wheel-vs-parity.jsonand in the M6a retrospective, never gated.
Consequences#
One run of
wheels.ymlbuilds and tests the sdist, the CPU wheel and both plugins from one source archive; a release publishes them together, each under its own trusted publisher.The CI toolkits are reproducible and reviewable; a new CUDA toolkit needs a re-pin (and the cu12 / cu13 wheels follow PLAN 7.8’s “latest 12.x / 13.x” only through it). The locally built wheels of
ci/plugin_wheels.shdiffer from the CI wheels in the host compiler (conda’s GCC 12.4 with a glibc 2.28 sysroot, libstdc++ and libgcc linked statically, against the image’s gcc-toolset 14) and, unlessDYNG_CUDA<N>_ROOTpoints at the pinned toolkit unpacked byci/cuda_toolkit.py extract, in the toolkit (/usr/local/cuda-13.1and conda-forge’s 12.9 on the development machine);docs/developer/wheels.mdlists both.The new environments
testpypi-cu12,pypi-cu12,testpypi-cu13,pypi-cu13need the protection rules ofpypi/testpypi(required reviewer onpypi-*, tagsv*only, no administrator bypass): an account-level setting of the author, listed indocs/developer/repository_settings.mdsection 11.No new third-party action:
actions/cache(by GitHub) is already used and SHA-pinned.The CI build itself was not run on GitHub in this step (no push): the configuration was checked by actionlint, zizmor,
ci/github_meta_check.py --verify-pinsand cibuildwheel’s own option parsing, the pinned toolkits were unpacked and used for local builds of both plugins, once with the usual GCC 12.4 toolchain and once with conda-forge’s GCC 14 against the glibc 2.28 sysroot (the image’s compiler major), all of which passed the GPU tests on GPU 1 and the fallback smoke test with the driver hidden. The first pull request run is the first real test of the container part (dnf, the image’s gcc-toolset, cibuildwheel’s isolated build).