Release process#

How a dynG release is made (PLAN Sections 10.3 and 10.4). Releases are cut from main, which is always releasable; release/X.Y branches are created only when an older minor release needs a patch. Versions follow SemVer 2.0.0’s meaning from 0.1.0 (PLAN Section 5.9 says what the stability promise covers), spelled in canonical PEP 440 form, which the Python distributions require: 0.1.0, 0.1.0rc1, 0.1.0.dev0, never SemVer’s 0.1.0-rc.1. The single source of the version is the file VERSION; tags are v + VERSION (v0.1.0rc1, v0.1.0), and release.yml refuses a VERSION that is not canonical.

Who does what. Publishing steps belong to the author (the lead maintainer) unless the author delegates one in the approvals log of GOVERNANCE.md. For 0.1.0 the author did so on 2026-09-30, for 0.2.0 on 2026-10-06 (the tags and the GitHub Release) and 2026-10-07 (the author merges the release pull requests):

Step

0.1.0 (v0.1.0rc1, v0.1.0)

0.2.0 (v0.2.0rc1, v0.2.0)

later releases

the checks, the certificate, the version bump, the CHANGELOG, CITATION.cff (steps 1-6, 9)

the AI assistant, in the release branch and its pull request

the AI assistant, in the release branches (release-0.2.0 for 0.2.0rc1, release-0.2.0-final for 0.2.0) and their pull requests

a maintainer or the AI assistant

merging the release pull request into main (merge commit, ADR 0019)

the AI assistant, through the pull request (the main ruleset)

the author, personally; the AI assistant reports when each pull request is green and ready to merge (2026-10-07)

the author

pushing the signed tag (steps 7, 10), which uploads to TestPyPI

the AI assistant, on the author’s behalf

the AI assistant, on the author’s behalf (2026-10-06)

the author

the approval of the PyPI deployments (step 10), which upload to PyPI: pypi for dyng; from 0.2.0 also pypi-cu12 and pypi-cu13 for the CUDA plugins, three approvals

the author (never delegated)

the author (never delegated): pypi-cu12 and pypi-cu13 together, then pypi once both plugins are on PyPI

the author

the GitHub Release (step 11)

the AI assistant, on the author’s behalf

the AI assistant, on the author’s behalf (2026-10-06)

the author

Zenodo, conda-forge (steps 12, 13; checkpoint A4; the documentation is on GitHub Pages, not Read the Docs)

the author (Zenodo is not connected for 0.1.0, so 0.1.0 has no DOI: GOVERNANCE.md, 2026-10-01)

the author (Zenodo is still not connected: 0.2.0 gets a DOI only if the author connects it before step 11)

the author

So the AI assistant can take a release candidate to TestPyPI on its own once the author has merged its pull request, but nothing reaches PyPI before the author approves the deployment in the release.yml run: one deployment per distribution, pypi for dyng and, from 0.2.0, pypi-cu12 and pypi-cu13 for the CUDA plugin wheels dyng-cu12 and dyng-cu13 (ADR 0032).

Checklist#

For each release, copy this list into the release pull request and tick it.

The documentation site is not a step of its own: the deploy job of docs.yml publishes https://dyng-dev.github.io/dyng/ from every push to main. The final-release pull request (step 9) also updates the status blocks of README.md and docs/index.md, which name the latest release; after its merge, check that the deploy job succeeded and that the site shows the release without the development-version banner (docs/conf.py shows the banner while VERSION is a development or pre-release version, with the latest release read from CHANGELOG.md; for a release candidate the banner says that it is on TestPyPI only).

#

Step

Command or place

Who

1

Full parity on the GPU machine: the golden corpora of every ported algorithm on every backend, each replay written to benchmarks/results/<version>/parity-<set>-<backend>.json

ci/check.sh --parity (CPU backends), ci/gpu_local.sh (CUDA: the corpora, the sanitizers); the largest corpora with parity/compare.py ... --json (Benchmark suites and release certificates)

maintainer

2

The performance gates against the originals (manual, under the exclusive lock; CUDA at locked clocks, the default-clock readings recorded, ADRs 0018 and 0021): every benchmark suite of the release’s algorithms, run from a clean checkout of the release commit

parity/bench_suite.py run benchmarks/paper/<suite>.yaml --version <version> (Benchmark suites and release certificates): the suite summaries and the compacted records in benchmarks/results/<version>/

maintainer

3

Sanitizers and mutation checks green, each recorded; then the parity certificate written and committed

the asan / tsan / tsan-openmp presets (ci/sanitizers.sh; from 0.2.0 also the hosted sanitizers.yml jobs of the release pull request’s head, which exist only once the pull request is open: recorded with parity/certify.py check --name sanitizers-hosted --scope tests --commit <the head> --result passed --evidence <the job logs> in a later commit of that pull request or in the tag-day pull request, A tag day later than the final-release pull request; a final-release pull request whose certificate is carried over records the release candidate’s hosted jobs with --scope library when README.md or a test path changed since that head, because their library is still the release’s while the tests scope is not, and the local presets of the tests scope run again on the final tree), compute-sanitizer in ci/gpu_local.sh, the CTests *.mutation.* and parity/mutate.py run (the mutations that need the golden corpus), each recorded with parity/certify.py check (certify.py requires the local ones by release series, REQUIRED_CHECKS, Benchmark suites and release certificates); then parity/certify.py write --version <version> writes benchmarks/results/<version>/parity.json and the tables of its README.md. It fails if any part failed or is missing (from 0.2.0 the summaries of the sssp, cycle_count and mosp suites are required, each with a gated reading of every gated metric; a suite summary must cover its whole suite, with verified inputs; every committed fixture set of cpp/tests/data needs its tests passed in a check), or if a part was measured on other code than the release commit’s in the paths of its scope; the one exception is a library difference confined to the generated algorithm metadata, accepted for the gates, the replays and the golden mutations (never for a test-suite check) with a passed parity/certify.py equivalence record (Benchmark suites and release certificates, “The release certificate”)

maintainer

4

The distributions build and install

ci/check.sh --wheels locally; from 0.2.0 also the CUDA plugins on the GPU machine, ci/plugin_wheels.sh with DYNG_PLUGINS="cu12 cu13" (the GPU tests on GPU 1; Wheels, the sdist and the release workflow); wheels.yml (manual run, with the plugins) on hosted runners, green on the release pull request’s head commit before the author merges it (it is not a required check, and it is the only build of the published setup: manylinux_2_28, gcc-toolset, the pinned toolkits; the run is linked in the pull request). Before that, the CUDA toolkits of ci/cuda_toolkits.toml are the latest 12.x and 13.x (re-pin with ci/cuda_toolkit.py lock, Wheels, the sdist and the release workflow)

maintainer

5

The release-candidate pull request: set VERSION to X.Y.ZrcN (for example 0.1.0rc1; release.yml requires the tag to equal VERSION); move the CHANGELOG’s Unreleased entries under ## [X.Y.ZrcN] - <date> (the draft summary of the release is kept at the top while it is prepared)

a pull request

maintainer

6

version: X.Y.ZrcN and date-released in CITATION.cff

the same pull request

maintainer

7

Release candidate: tag vX.Y.ZrcN (equal to VERSION) on main after the merge; release.yml builds the sdist and the wheels through wheels.yml and uploads them to TestPyPI (environment testpypi; from 0.2.0 also testpypi-cu12 / testpypi-cu13 for the plugins, which go first, dyng after them); pre-releases stop there. Before pushing: build the distributions locally as release.yml would (Building a release locally, as release.yml would), and check that the CHANGELOG heading’s date and CITATION.cff’s date-released are the day of the tag (UTC); if the merge fell on a later day, fix both in a small pull request first (the tagged files cannot be changed afterwards)

python ci/wheel_check.py --release-metadata --release-date today; git tag -s -m "dynG X.Y.ZrcN" vX.Y.ZrcN && git push origin vX.Y.ZrcN

author (0.1.0rc1, 0.2.0rc1: the AI assistant on the author’s behalf)

8

Smoke-install the RC from TestPyPI in a clean venv on a CPU machine and on the GPU machine (CPU backends): import, dyng.show_config(), sssp, cycle_count and (from 0.2.0, stable) mosp (dyng.mosp.compute and update) on both host backends, dyng --version; from 0.2.0 also both CUDA plugins as CI built them, each in its own fresh venv on the GPU machine: dyng-cu12 and dyng-cu13 from TestPyPI alone (--no-deps, NumPy from PyPI first: the plugin names are not on PyPI before the final release, so pip must not look for them there), ci/plugin_smoke.py --expect cuda and pytest -m gpu on GPU 1 (The GPU tests of the published plugin wheels (steps 8 and 10); the GPU tests of step 4 ran on locally built wheels, linked with another host compiler)

pip install -i https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ dyng==X.Y.ZrcN; with a plugin pip install "numpy>=1.26" then pip install -i https://test.pypi.org/simple/ --no-deps dyng==X.Y.ZrcN dyng-cu13==X.Y.ZrcN (and dyng-cu12)

author or maintainer

9

The final-release pull request (The final-release pull request (step 9)): VERSION X.Y.Z; the CHANGELOG section renamed ## [X.Y.Z] - <date> with its link references; version: X.Y.Z and date-released in CITATION.cff; the texts the release candidate wrote for its own state (the status lines, the install lines, SECURITY.md); and the certificate under benchmarks/results/X.Y.Z/ (the release candidate’s carried over, or measured again). Without a release candidate, steps 5-6 do this directly and 7-8 are skipped

a pull request; python ci/wheel_check.py --release-metadata; parity/certify.py write --version X.Y.Z

maintainer

10

Final release: tag vX.Y.Z (equal to VERSION) on main after that merge; release.yml uploads to TestPyPI, then waits for the approval of the pypi environment (from 0.2.0: of pypi-cu12 and pypi-cu13, then, once both plugins are on PyPI, of pypi, one per distribution) and uploads the same files to PyPI. Before approving pypi-cu12 and pypi-cu13, the GPU tests of both plugin wheels of this run’s dist artifact pass on the GPU machine (The GPU tests of the published plugin wheels (steps 8 and 10); the final tag’s wheels are new builds, not the RC’s). Before pushing, as in step 7: the release date is the day of the tag (A tag day later than the final-release pull request when that day is later than the final-release pull request’s), and the author’s open decisions due before the release are taken (GOVERNANCE.md, “Open decisions”; for 0.1.0 these were O3, the NOTICE wording, confirmed as drafted on 2026-10-01, and the licence expression, to which the author raised no objection by the tag day, the approval of the pypi deployment being the final confirmation: both in the approvals log)

python ci/wheel_check.py --release-metadata --release-date today; git tag -s -m "dynG X.Y.Z" vX.Y.Z && git push origin vX.Y.Z; approve the deployment in the Actions run (Review deployments → tick every pending environment → Approve and deploy; from 0.2.0 twice: pypi-cu12 and pypi-cu13 together, then pypi once the plugins are on PyPI)

the tag: author (0.1.0, 0.2.0: the AI assistant on the author’s behalf); the approval: author

11

The GitHub Release from the CHANGELOG section, with the distributions of the release.yml run attached (a release candidate may get a GitHub pre-release, --prerelease)

gh release create vX.Y.Z --title "dynG X.Y.Z" --notes-file <the CHANGELOG section> dist/*

author (0.1.0, 0.2.0: the AI assistant on the author’s behalf)

12

The Zenodo DOI of the release (once Zenodo is connected, checkpoint A4), then the DOI in CITATION.cff and the README. Zenodo archives only the GitHub Releases published after it is switched on: for a DOI of this release, the author switches it on before step 11; otherwise the release can only be uploaded to Zenodo by hand (Repository settings (lead maintainer), section 14). 0.1.0: Zenodo is not connected, so 0.1.0 has no DOI (the author, 2026-10-01); it may be connected later (A4), and the releases published after that get DOIs

Zenodo’s GitHub integration

author

13

conda-forge: merge the bot’s feedstock pull request (from 0.4)

the dyng-feedstock repository

author

14

Announce (Discussions); curate the next “good first issue” backlog; write the milestone retrospective; bump VERSION to the next .dev0

a pull request

maintainer

The CBST trace goldens (from 0.3.0, M8a). parity/certify.py requires, for every CBST trace set of parity/goldens.toml (kind cbst_trace), a replay of the dynG store under the set’s original preset and under dyng_default; a set without them fails the certificate. Step 1 writes them: DYNG_GPU_RESULTS=benchmarks/results/<version> ci/gpu_local.sh (the cbst step) writes parity-cbst_trace_<set>-device-<preset>.json for the small sets, and the large sets are replayed with parity/compare.py cbst_trace --store device [--policy dyng_default] --set <set> --certificate benchmarks/results/<version> (each checks its documented divergences against parity/cbst_documented.toml).

The hypergraph container’s replays (from 0.3.0, M8b). For the CBST trace sets with hypergraph batches (HYPERGRAPH_ID_REPLAYS of parity/certify.py: ESCHER-GPU’s test, coauth50k and syn1m, MOSP_ESCHER’s test and dblp50k_hyperedge) a 0.3 certificate also needs the replay of their batch records through dyng::hypergraph with the originals’ ids and rows on sequential, openmp and cuda, and, for ESCHER-GPU’s sets, the line graph against A’s H2H on cuda. ci/gpu_local.sh (the hypergraph step) writes the CUDA records of the small sets; the rest are parity/compare.py hypergraph_ids --backend <backend> [--threads 8] [--line-graph --mode by_id] --set <set> --certificate benchmarks/results/<version> (a few minutes per set and backend).

The release workflow#

release.yml runs on a tag v* (the release tags ruleset lets only repository administrators create one: the author, or the AI assistant on the author’s behalf where the approvals log says so):

  1. select decides what the tag builds. A v0.0.x tag equal to the version of tools/name_reservation builds the name-reservation package (ADR 0014); every other tag must equal VERSION, which must be a canonical PEP 440 version (ci/wheel_check.py --version-info; a SemVer spelling such as 0.1.0-rc.1 fails here, before anything is built, instead of producing distributions named 0.1.0rc1 that no later check expects), whose CHANGELOG section and CITATION.cff must agree with it (ci/wheel_check.py --release-metadata), and builds the real distributions by calling wheels.yml (the sdist, and the manylinux_2_28 x86_64 abi3 CPU wheel built from that sdist, checked by ci/wheel_check.py, install-tested on Python 3.12 and 3.13; from v0.2.0, release candidates included, also the CUDA plugin wheels dyng-cu12 and dyng-cu13 built from the same sdist with the pinned CUDA toolkits and install-tested without a GPU, ADR 0032). Whether the version is a pre-release comes from the same parse, and ci/wheel_check.py --release-distributions names the distributions of the release with their environments. A tag that matches neither fails.

  2. collect checks the files again (twine check, ci/wheel_check.py), checks that they are exactly the distributions select named (--release-set), and uploads one artifact dist with a directory per distribution (--split).

  3. publish-testpypi-plugins (from v0.2.0) uploads each CUDA plugin to TestPyPI through Trusted Publishing, in its own environment, testpypi-cu12 and testpypi-cu13; then publish-testpypi uploads dyng in environment testpypi, only if every plugin upload succeeded (for v0.0.x and v0.1.x, which have no plugins, it runs alone).

  4. For final versions only (no a, b, rc or dev part) and only after dyng reached TestPyPI: publish-pypi-plugins uploads the plugins to PyPI (pypi-cu12, pypi-cu13), then publish-pypi uploads dyng (pypi), only if every plugin upload succeeded. Each waits for the required reviewer of its environment (the author).

Why the plugins go first. dyng’s extras pin the plugins exactly (cu13 = ["dyng-cu13==X.Y.Z"]). If dyng X.Y.Z were on an index without dyng-cu13 X.Y.Z, pip install "dyng[cu13]" would not fail: pip backtracks to the newest dyng whose extra it can satisfy, or to an older dyng without the extra, warns that the extra is not provided, and installs a CPU package. With the plugins first, a failed or rejected plugin upload leaves dyng X.Y.Z unpublished on that index, and a plugin without its dyng is harmless (it requires dyng==X.Y.Z, so pip cannot install it yet).

If a publish job fails. Re-run the failed jobs of the same release.yml run (Re-run failed jobs: only the failed legs run again, with the same artifact; the TestPyPI uploads skip the files already there); their approvals are asked again. Do not push the tag again.

The release candidate 0.2.0rc1 showed how a plugin’s first upload fails (run 37871452302, Repository settings (lead maintainer), section 11): a project that does not exist yet is created only by a pending publisher with the project name dyng-cu<N> and the plugin’s own environment (testpypi-cu<N> on TestPyPI, pypi-cu<N> on PyPI), workflow release.yml, repository dyng-dev/dyng. A publisher whose environment differs fails with invalid-publisher (no publisher matches the token’s claims); a publisher added to the existing project dyng (or a pending one with another project name) cannot create dyng-cu<N> and fails with “400 Non-user identities cannot create new projects”. Fix the publisher on the index (the author’s account), then Re-run failed jobs of the same run: attempt 3 of 0.2.0rc1’s run uploaded exactly the files the first attempt built (the builds did not run again; the hashes on TestPyPI equal the run’s dist artifact, Retrospective: R021 (the final-release pull request for 0.2.0)). Before the final release’s tag, check the pending publishers on PyPI against the environments of release.yml in the same way. If a plugin can never be uploaded for this version (for example a file the index refuses), dyng X.Y.Z stays unpublished: fix the cause and release the next patch version. Should dyng X.Y.Z ever be on PyPI without a plugin of the same version (an upload by hand), yank it on PyPI until the plugin is there.

The file name release.yml and the environment names testpypi, pypi, testpypi-cu12, pypi-cu12, testpypi-cu13 and pypi-cu13 are bound to the trusted publishers on TestPyPI and PyPI (one pair per project: PyPI refuses two identical pending publishers): never rename them. v0.0.x and v0.1.x tags publish dyng alone, through testpypi and pypi, as before. Wheels, the sdist and the release workflow describes the wheel builds and the local build of this machine; Reserving dyng on PyPI (checkpoint A3) the 0.0.1 reservation.

Versioning rules#

  • VERSION holds the next version with a .dev0 suffix between releases (0.1.0.dev0); the CMake package version, the Python package version and the documentation’s version all come from it.

  • Before 1.0, a minor release may break the stable API, with a CHANGELOG “Changed” or “Removed” entry and a migration note; patch releases only fix bugs. api-check.yml reports the Python API’s changes against the base branch (griffe) and ci/docs.sh the C++ API’s against the committed baseline.

  • Output stability: generator streams are bit-exact within a major version; results change only for documented bug fixes.

The final-release pull request (step 9)#

The release candidate’s pull request wrote some texts for the state “the release candidate is on TestPyPI, the release is not on PyPI yet”; the final-release pull request brings each to the release’s state:

  • VERSION X.Y.Z; CITATION.cff version: X.Y.Z and date-released (the planned tag day);

  • CHANGELOG.md: ## [X.Y.ZrcN] - <date> renamed ## [X.Y.Z] - <date> (or a new ## [X.Y.Z] section above the release candidate’s, with what changed since), the empty ## [Unreleased] kept above it, and the link references: [Unreleased]: .../compare/vX.Y.Z...main and [X.Y.Z]: .../compare/<previous tag>...vX.Y.Z; the summary’s install line (pip install dyng, no longer TestPyPI);

  • the status lines, each written by the release candidate for “X.Y.ZrcN on TestPyPI, X.Y.Z not released”: README.md (the “Alpha: X.Y release candidate” block, the “Until X.Y.Z is released” install lines and the TestPyPI commands), docs/index.md (the same block and install lines), docs/getting_started/index.md (“until X.Y.Z is released, in the release candidate … on TestPyPI”), docs/getting_started/install.md (the “Until X.Y.Z is on PyPI” lines and their TestPyPI commands, the latest release of the pip install dyng line), SECURITY.md (the status paragraph and the supported-versions row of the release candidates), CONTRIBUTING.md and SUPPORT.md (the “X.Y.ZrcN, the release candidate” wording), docs/citing.md (the DOI sentence: which releases have a DOI, step 12), docs/roadmap.md (“Where we are”: it opens with the release, the earlier releases after it) and docs/developer/plan.md (the status line);

  • the pages that describe the release’s new content as unreleased (documentation.md: “new in X.Y” or “not released yet” on a page newer than the latest release): the maturity lines of the algorithm pages (docs/algorithms/*.md), the tutorials (docs/tutorials/, for 0.2.0 the sentence that sent readers to main instead of the previous release), and any other user-facing page; “not released yet” goes, “new in X.Y” (or “since X.Y.Z”) stays;

  • the upgrade line wherever the docs install the package (README.md, install.md, the CHANGELOG summary, which becomes the GitHub Release’s text) when the release adds extras or something an earlier release lacks: pip install "dyng[cu13]" without -U keeps an installed older dyng that has no such extra, and pip only warns;

  • what is left: grep -rn "X.Y.ZrcN\|release candidate\|not released\|new in X.Y", and a grep for the previous release’s version (wording such as “not the 0.1.0 release”), over the user-facing files (README.md, the top-level *.md files, python/README.md and docs/ outside docs/developer/, docs/adr/, docs/design/ and docs/history/); every hit left must be history that stays true after the release;

  • the certificate (benchmarks/results/X.Y.Z/), one of:

    • carried over, when the library did not change since the release candidate’s measured commits (git diff <measured> HEAD over the library paths is empty, which certify.py write verifies for every part in the paths of its scope): copy the release candidate’s directory, cp -r benchmarks/results/X.Y.ZrcN benchmarks/results/X.Y.Z, and run parity/certify.py write --version X.Y.Z on the committed final tree. It checks every measured commit against the final commit in the paths of its scope and writes a parity.json naming X.Y.Z and the final commit. VERSION, CHANGELOG.md and CITATION.cff are outside the library scope, so the gates, the replays and the golden mutations carry over; the checks whose scope the final pull request touches are run again on the final tree and recorded with certify.py check first: every check of the packaging and repo scopes always (they see the new VERSION; for 0.2: the distributions, ci/check.sh --parity (check-parity), ci/api_check.sh <main> (api-check) and ci/gpu_local.sh; certify.py write names every stale record), and the C++ test suites of the tests scope (the sanitizer presets, ctest -L mutation) when README.md or another test path changed, whose C++ quickstart is a test;

    • measured again, when code changed: steps 1-3 for X.Y.Z. Commit the directory with the release; release.yml publishes the version the certificate names.

python ci/wheel_check.py --release-metadata (also run by the select job of release.yml and by ci/tests) checks VERSION, the CHANGELOG section and its links and CITATION.cff against each other; the status texts are read by the reviewer of the pull request.

A tag day later than the final-release pull request#

The CHANGELOG heading’s date and CITATION.cff’s date-released must be the day the tag is pushed (UTC): python ci/wheel_check.py --release-metadata --release-date today fails on any other day, and the tagged files cannot be changed afterwards. When the tag falls on a later day than the one the final-release pull request wrote (0.1.0: written 2026-10-01, tagged 2026-10-02, Retrospective: R012 (the tag day of 0.1.0)), fix both in a small pull request before the tag, and bring the certificate to its tree:

  1. Set the date in both files; python ci/wheel_check.py --release-metadata --release-date <tag day> passes (and --release-date today on the tag day). Record any decision the author took meanwhile (GOVERNANCE.md) in the same pull request.

  2. CHANGELOG.md and CITATION.cff are in the packaging scope and in the repo scope (the whole tree but benchmarks/results/), so after the last change outside benchmarks/results/, from fresh clones of that commit, run again: the distributions (Building a release locally, as release.yml would: select for the tag, ci/wheels.sh, twine check --strict, ci/wheel_check.py --platform manylinux_2_28_x86_64 --require-libgomp, fresh 3.12 and 3.13 venvs with the wheel’s test subset and the README quickstart; from 0.2 the plugin wheels too), ci/check.sh --parity, ci/api_check.sh <main as pushed> and ci/gpu_local.sh: in general every check recorded with the packaging or the repo scope, which certify.py write reports as stale otherwise. Record each with parity/certify.py check --version X.Y.Z under the name it had (for 0.2: distributions, check-parity, api-check and gpu_local), replacing the old record. The tests records stand while README.md and the test paths are unchanged; otherwise the sanitizer presets and ctest -L mutation are run again too. The same pull request may add the record of the hosted sanitizers.yml jobs of the release pull request (step 3).

  3. parity/certify.py write --version X.Y.Z on the committed tree, which checks every measured commit against it in the paths of its scope; commit benchmarks/results/X.Y.Z/.

The GPU tests of the published plugin wheels (steps 8 and 10)#

The hosted runners have no GPU, so the plugin wheels that release.yml uploads (built by cibuildwheel in the manylinux_2_28 image with gcc-toolset and the pinned toolkits) are tested on a GPU only here, before the author approves their PyPI environments. Both plugins are tested, each in its own fresh venvs (Python 3.12 and 3.13), on GPU 1 of the development machine (its CUDA 13.1 driver runs the cu12 wheel too): the GPU smoke test, the fallback without a device and without a driver, pytest -m gpu and the suite with DYNG_CPU_ONLY=1, as ci/plugin_wheels.sh runs them on local builds. From a checkout of the tag:

source scripts/dev_env.sh
v=$(cat VERSION); run=RUN_ID  # the id of the release.yml run
d="$DYNG_SCRATCH/release-$v"
gh run download "$run" --repo dyng-dev/dyng --name dist --dir "$d/artifact"  # what is uploaded
mkdir -p "$d/plugins/dist" && cp "$d"/artifact/dyng-cu1*/*.whl "$d/plugins/dist/"
DYNG_PLUGIN_TEST_ONLY=1 DYNG_PLUGINS="cu12 cu13" DYNG_CORE_DIST="$d/artifact/dyng" \
    DYNG_PLUGIN_OUT="$d/plugins" \
    flock -s "$DYNG_SCRATCH/perf.lock" nice -n 10 ci/plugin_wheels.sh

For a release candidate (step 8) the same files are on TestPyPI, and the install from the index is checked too, per plugin in a fresh venv:

# NumPy and the test tools from PyPI, then dyng and the plugin from TestPyPI alone (--no-deps:
# the plugin names are not on PyPI before the final release, so pip must not look there)
python3.12 -m venv /tmp/rc-cu12 && /tmp/rc-cu12/bin/pip install "numpy>=1.26" "pytest>=8" \
    "hypothesis==6.167.1" && /tmp/rc-cu12/bin/pip install -i https://test.pypi.org/simple/ \
    --no-deps "dyng==$v" "dyng-cu12==$v"
(cd /tmp && CUDA_VISIBLE_DEVICES=1 /tmp/rc-cu12/bin/python "$OLDPWD/ci/plugin_smoke.py" \
    --plugin cu12 --expect cuda --version "$v")
CUDA_VISIBLE_DEVICES=1 DYNG_REQUIRE_CUDA=1 /tmp/rc-cu12/bin/python -m pytest -q -m gpu python/tests
# the same for cu13

A failure stops the release: the PyPI environments are not approved (the plugins go first, so dyng stays unpublished too, ADR 0032 item 6), and the fix goes into a new release candidate.

Building a release locally, as release.yml would#

Before a tag is pushed, the distributions of that tag are built and tested on the development machine with the steps of release.yml (no tag is created; for 0.1.0rc1 this was done in R010, Retrospective: R010 (release preparation for 0.1.0rc1)):

source scripts/dev_env.sh
v=$(cat VERSION)  # the tag about to be pushed is v$v
# the select job, for that tag: its run block, extracted from release.yml
python -c 'import yaml; wf = yaml.safe_load(open(".github/workflows/release.yml")); print(next(
    s["run"] for s in wf["jobs"]["select"]["steps"] if s.get("id") == "select"))' > /tmp/select.sh
GITHUB_REF_NAME="v$v" GITHUB_OUTPUT=/tmp/select.out bash /tmp/select.sh && cat /tmp/select.out
# the package job (wheels.yml): the sdist, the wheel from the sdist, wheel_check, fresh venvs
# (into $DYNG_SCRATCH/wheels/$v)
flock -s "$DYNG_SCRATCH/perf.lock" nice -n 10 ci/wheels.sh
# from 0.2.0: the plugins from that sdist (CI's toolkits: docs/developer/wheels.md), GPU tests
# (into $DYNG_SCRATCH/wheels/$v-plugins)
DYNG_PLUGINS="cu12 cu13" DYNG_CORE_DIST="$DYNG_SCRATCH/wheels/$v/dist" \
    flock -s "$DYNG_SCRATCH/perf.lock" nice -n 10 ci/plugin_wheels.sh
# the collect job (from 0.2.0: the core files and the plugin wheels together)
twine check --strict "$DYNG_SCRATCH/wheels/$v"/dist/* "$DYNG_SCRATCH/wheels/$v-plugins"/dist/*
python ci/wheel_check.py "$DYNG_SCRATCH/wheels/$v"/dist/* \
    "$DYNG_SCRATCH/wheels/$v-plugins"/dist/* \
    --platform manylinux_2_28_x86_64 --require-libgomp --release-set

select must print kind=package, version=$v and plugins=true (from 0.2.0), with prerelease=true for a release candidate (v0.2.0rc1) and prerelease=false for a final release (v0.2.0), the three distributions dyng, dyng-cu12 and dyng-cu13 with their environment suffixes, and the plugins’ matrix plugin_distributions (for a 0.1.x tag: plugins=false, dyng alone and an empty plugin matrix; ci/tests/test_release_select.py checks every kind of tag), and fail for a tag that differs from VERSION or when VERSION, CHANGELOG.md and CITATION.cff disagree (ci/wheel_check.py --release-metadata; a release date other than today is a warning there, and an error with --release-date today before the tag). Then install the wheel alone into fresh Python 3.12 and 3.13 venvs and run the wheel’s test subset and the README quickstart from an empty directory.

The readiness list of 0.1.0, with what is done and what waits for the author, is in the M5 retrospective (Retrospective: M5 (the Python package, the CLI and the 0.1 documentation)), the R010 retrospective (Retrospective: R010 (release preparation for 0.1.0rc1)) and the R011 retrospective, the final-release record (Retrospective: R011 (the final-release pull request for 0.1.0)), and the R012 retrospective, the tag day (Retrospective: R012 (the tag day of 0.1.0)). Those of 0.2.0 are in the R020 retrospective, the release candidate (Retrospective: R020 (release preparation for 0.2.0rc1)), and the R021 retrospective, its smoke test from TestPyPI and the final-release pull request (Retrospective: R021 (the final-release pull request for 0.2.0)).