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 ( |
0.2.0 ( |
later releases |
|---|---|---|---|
the checks, the certificate, the version bump, the CHANGELOG, |
the AI assistant, in the release branch and its pull request |
the AI assistant, in the release branches ( |
a maintainer or the AI assistant |
merging the release pull request into |
the AI assistant, through the pull request (the |
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: |
the author (never delegated) |
the author (never delegated): |
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 |
|
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 |
|
maintainer |
3 |
Sanitizers and mutation checks green, each recorded; then the parity certificate written and committed |
the |
maintainer |
4 |
The distributions build and install |
|
maintainer |
5 |
The release-candidate pull request: set |
a pull request |
maintainer |
6 |
|
the same pull request |
maintainer |
7 |
Release candidate: tag |
|
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, |
|
author or maintainer |
9 |
The final-release pull request (The final-release pull request (step 9)): |
a pull request; |
maintainer |
10 |
Final release: tag |
|
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 |
|
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 |
Zenodo’s GitHub integration |
author |
13 |
conda-forge: merge the bot’s feedstock pull request (from 0.4) |
the |
author |
14 |
Announce (Discussions); curate the next “good first issue” backlog; write the milestone retrospective; bump |
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):
selectdecides what the tag builds. Av0.0.xtag equal to the version oftools/name_reservationbuilds the name-reservation package (ADR 0014); every other tag must equalVERSION, which must be a canonical PEP 440 version (ci/wheel_check.py --version-info; a SemVer spelling such as0.1.0-rc.1fails here, before anything is built, instead of producing distributions named0.1.0rc1that no later check expects), whose CHANGELOG section andCITATION.cffmust agree with it (ci/wheel_check.py --release-metadata), and builds the real distributions by callingwheels.yml(the sdist, and the manylinux_2_28 x86_64 abi3 CPU wheel built from that sdist, checked byci/wheel_check.py, install-tested on Python 3.12 and 3.13; from v0.2.0, release candidates included, also the CUDA plugin wheelsdyng-cu12anddyng-cu13built 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, andci/wheel_check.py --release-distributionsnames the distributions of the release with their environments. A tag that matches neither fails.collectchecks the files again (twine check,ci/wheel_check.py), checks that they are exactly the distributionsselectnamed (--release-set), and uploads one artifactdistwith a directory per distribution (--split).publish-testpypi-plugins(from v0.2.0) uploads each CUDA plugin to TestPyPI through Trusted Publishing, in its own environment,testpypi-cu12andtestpypi-cu13; thenpublish-testpypiuploadsdyngin environmenttestpypi, only if every plugin upload succeeded (for v0.0.x and v0.1.x, which have no plugins, it runs alone).For final versions only (no
a,b,rcordevpart) and only afterdyngreached TestPyPI:publish-pypi-pluginsuploads the plugins to PyPI (pypi-cu12,pypi-cu13), thenpublish-pypiuploadsdyng(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#
VERSIONholds the next version with a.dev0suffix 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.ymlreports the Python API’s changes against the base branch (griffe) andci/docs.shthe 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:
VERSIONX.Y.Z;CITATION.cffversion: X.Y.Zanddate-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...mainand[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 thepip install dyngline),SECURITY.md(the status paragraph and the supported-versions row of the release candidates),CONTRIBUTING.mdandSUPPORT.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) anddocs/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 tomaininstead 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-Ukeeps an installed olderdyngthat 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*.mdfiles,python/README.mdanddocs/outsidedocs/developer/,docs/adr/,docs/design/anddocs/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> HEADover the library paths is empty, whichcertify.py writeverifies 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 runparity/certify.py write --version X.Y.Zon the committed final tree. It checks every measured commit against the final commit in the paths of its scope and writes aparity.jsonnamingX.Y.Zand the final commit.VERSION,CHANGELOG.mdandCITATION.cffare 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 withcertify.py checkfirst: every check of thepackagingandreposcopes always (they see the newVERSION; for 0.2: the distributions,ci/check.sh --parity(check-parity),ci/api_check.sh <main>(api-check) andci/gpu_local.sh;certify.py writenames every stale record), and the C++ test suites of thetestsscope (the sanitizer presets,ctest -L mutation) whenREADME.mdor 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.ymlpublishes 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:
Set the date in both files;
python ci/wheel_check.py --release-metadata --release-date <tag day>passes (and--release-date todayon the tag day). Record any decision the author took meanwhile (GOVERNANCE.md) in the same pull request.CHANGELOG.mdandCITATION.cffare in thepackagingscope and in thereposcope (the whole tree butbenchmarks/results/), so after the last change outsidebenchmarks/results/, from fresh clones of that commit, run again: the distributions (Building a release locally, as release.yml would:selectfor 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>andci/gpu_local.sh: in general every check recorded with thepackagingor thereposcope, whichcertify.py writereports as stale otherwise. Record each withparity/certify.py check --version X.Y.Zunder the name it had (for 0.2:distributions,check-parity,api-checkandgpu_local), replacing the old record. Thetestsrecords stand whileREADME.mdand the test paths are unchanged; otherwise the sanitizer presets andctest -L mutationare run again too. The same pull request may add the record of the hostedsanitizers.ymljobs of the release pull request (step 3).parity/certify.py write --version X.Y.Zon the committed tree, which checks every measured commit against it in the paths of its scope; commitbenchmarks/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)).