Release Checklist¶
Step-by-step process for cutting a doppler release. Run these steps in order — each one is a gate for the next.
1. Confirm main is green and the tree is clean¶
main's required CI already ran the full suite (test-all's equivalent)
plus the docs build on every commit that landed there — step 5's PR merge
is the real correctness gate (see the callout below), and step 7's
verify-ci explicitly does not re-test, only confirms. Re-running
make test-all / make docs locally here just repeats work CI
already did on the exact same tree; skip it unless you have a specific
reason to distrust CI (a suspected environment-specific flake, or extra
confidence before an unusually disruptive release).
git status # nothing uncommitted
git checkout main && git pull # sync to the latest green main
gh pr checks <last-merged-pr> # spot-check CI was actually green, if in doubt
The release is the bump-only fast path
A release PR skips the matrix only when everything else has already landed through its own PR. How changes are shaped and merged so that holds: How We Work.
2. Check examples¶
If any gallery example scripts in src/doppler/examples/ changed since the
last release, regenerate the plots:
git switch -c gallery/vX.Y.Z
make gallery
git add docs/assets/
git commit -m "docs: update gallery plots for vX.Y.Z"
Land it through its own PR before §2b. §2b measures a commit main
already has; a plots commit left on local main is exactly the commit it
must not measure (v0.57.0's set was stamped with one, and that tree never
reached main).
If you added a new plot-generating script, add it to GALLERY_SCRIPTS
in the Makefile before running make gallery.
2a. When freshness asks for work that is not there¶
make release-freshness-check asks, per gallery script, whether the script
moved since the last tag while its own plot under docs/assets/ did not, and
whether a perf-relevant path moved with no snapshot. It cannot see what
moved inside one, so a change confined to a platform this release does not
measure — Windows, for doppler's Linux-measured benchmarks and art — still
reads as stale. Nor can it tell a plot that re-rendered byte-identically from
one nobody rendered: neither leaves a diff, so the identical re-render is
waived in writing too.
Answer it in writing rather than with a re-render that changes nothing:
add release-waivers/v<ver>.md (repo root) with one line per item,
- gallery: path/to/script.py -- why this needs no re-render
- benchmarks: native/inc/header.h -- why the numbers still describe the tree
Each line waives exactly that item, must carry at least the first words of
its reason on the same line as -- (a line without one fails the gate by
name), and is printed
by the gate while it runs, so a skipped check lands in the release log. A
line that matches nothing stale fails the gate, so a waiver cannot outlive
its reason. v0.55.0.md is the worked example: four Windows-only items.
2b. Refresh the benchmarks page¶
The published Benchmarks page is rendered from committed
snapshots under benchmarks/published/v<ver>/ — representative numbers
measured by hand on a real machine, deliberately not from CI (shared
runners aren't hardware-representative). Each release is measured in two
builds so the page shows the from-source upside: portable (the wheel) and
native (-DDOPPLER_NATIVE=ON).
On the same representative machine each release (so the history stays comparable), measure both builds interleaved and publish.
The bench box needs the pinned uv before bench-interleaved
(#1940). Every build and
run in it goes through uv, and a uv other than pyproject.toml's
[tool.uv] required-version refuses with exit 2 before anything is measured.
uv --version must print what make -s print-uv-version does; when it does
not, Adding a Module has the
update for each way uv gets installed.
Measure a commit that is already on origin/main. The set stamps it as
doppler_meta.commit, a reader checks it out to reproduce the numbers, and
make bench-commits-check refuses one main cannot reach (#1322).
bench-interleaved says which commit it measures, and whether origin/main
has it, before and after the run.
Measure from one clone per box, not a clone per release. Each run
leaves its snapshot in the clone, untracked until its PR merges, and
docs/benchmarks.md modified. A clone per release kept every copy: 26
clones, about 10 GB, by 2026-10-14. In one clone, git checkout of the
next release commit refuses while the previous copy is there.
make bench-preflight clears it once origin/main has the same content,
compared the way git status compares (line endings converted). It
refuses, without touching anything, over any result main lacks or holds
differently, one staged and not committed, or a symlink: publish that
through its PR, or remove it.
bench-interleaved runs the same check first.
First put the CPU
in a peak, repeatable state — the published doppler_meta records the governor
either way, but powersave understates the numbers:
sudo cpupower frequency-set -g performance # peak, repeatable; quiesce other load
cd ~/doppler-bench # the box's one clone (any path)
git fetch origin
make bench-preflight # clears the last release's outputs
git checkout --detach origin/main # measure what main has
make bench-interleaved VERSION=X.Y.Z # builds portable + native, runs them
# alternately, keeps the per-bench best
make bench-labels-fix # delete label lines the new set retired
make bench-docs # render docs/benchmarks.md (two columns)
git switch -c bench/vX.Y.Z
git add benchmarks/published docs/benchmarks.md benchmarks/
git commit -m "docs: publish benchmarks for vX.Y.Z (<cpu>)"
make bench-docs refuses until every row the new set publishes has a
label in benchmarks/labels.toml or a line in benchmarks/.labels-exempt
(the label gate, #2372), and each refusal says what to do. Two kinds are
routine at a release, and make bench-labels-fix clears both: a
benchmarks/legacy-units.toml entry whose row records its own unit now
(the first set measured after #2314 recorded every C row's unit, and the fix
deleted the whole file then), and an exempt line whose row the new
set no longer measures -- read the lines it prints, because a row missing
from this one set only (a Python bench whose broker was down) loses its
exempt line too, and needs a label when it comes back. A row the set
publishes for the first time needs
a label written, not an exempt line -- the exempt list only shrinks -- and
it should already have one: a new row is labelled in the PR that adds it,
where the gate holds the label as pending until a release measures the
row. Nothing checks that at PR time yet
(#2390), so this is
where a forgotten or mistyped label first shows. A label the new set was measured with but that names no row it
recorded is reported as dead: a retired row, or a mistyped ID.
The run takes about two hours (2 h 02 min measured 2026-11-05, pinned to the
fastest core class), so over ssh it is launched in the background, and it leaves its outcome where a watcher can wait on it: the log
at build/bench-runs/X.Y.Z.log and, written last, the exit code at
build/bench-runs/X.Y.Z.rc -- on success and on every failure
(#2681). Wait on that
file, with a cap, never on the process: a remote pgrep -f matches the ssh shell that
carries its own pattern, so it never sees the run end -- a failed
release run once read "running" for hours that way. The
cap is for the one case no file can cover: a run killed outright writes
nothing.
ssh <box> 'bash -lc "cd ~/doppler-bench && \
(nohup make bench-interleaved VERSION=X.Y.Z >/dev/null 2>&1 &)"'
run=doppler-bench/build/bench-runs/X.Y.Z # relative to the box's $HOME
for _ in $(seq 90); do # 3 h cap; a run takes about 2 h
ssh <box> "test -f $run.rc" && break
sleep 120
done
# 0 = measured. No .rc after the cap: the run was killed (SIGKILL, OOM,
# reboot) before it could write one -- read where its log stopped.
ssh <box> "cat $run.rc || tail -20 $run.log"
If a set was measured on a branch head instead, it can be restamped after the merge, but only if that exact tree landed on main unchanged (the branch rebase-merged, so the measured commit lands as a commit of its own). A squash-merge folds it into one commit with everything after it, leaves no tree-identical commit, and the restamp refuses:
git fetch origin # the restamp matches against origin/main
make bench-restamp VERSION=X.Y.Z # doppler_meta.commit -> main's tree twin
bench-interleaved builds both flavours in throwaway git worktrees, runs the
suite alternately K times (default 5; K=N to override), and keeps each
benchmark's lowest-mean run — so the %Δ column reflects the real build
difference, not cross-run system drift. The measurement (not the build) is
pinned to the CPU's fastest core class, read from cpuinfo_max_freq: on a
heterogeneous part an unpinned benchmark is bimodal — the same binary measured
3.5 µs on a Zen 5 core and 5.6 µs on a Zen 5c one — and min-of-K only usually
finds the fast mode. The snapshot records the pinned CPUs and the page prints
them. Each snapshot is stamped with the
compiler + flags it read from compile_commands.json, so the page is
self-describing. Skip only if no perf-relevant code changed since the last
release.
main is protected — everything goes through a PR
All changes to main, including the release bump, land via a pull
request whose required CI passed check must be green. Never push
to main directly, and never tag a commit that is not already on a green
main. The release workflow (step 7) runs independently of CI and is not
gated on it — so the PR merge in step 5 is the real gate. The tag only
ever points at a commit those checks already passed.
3. Cut the release branch + bump the version¶
One command does steps 3, 4 and 5's mechanics:
It first asks the tag-time questions that only the tree can answer: whether
benchmarks and the gallery are fresh for this version, and whether the notes
fit (sizes and entry lengths read from the fragments, BEFORE assembly empties
them). Then it branches off origin/main, bumps the five version sites,
promotes the changelog fragments and cuts the section, regenerates the
comparison links, checks the versions and that nothing is left unassembled,
sizes the body release-notes.sh will actually publish, commits, pushes and
opens the PR — so a merged, green release commit passes make ship's own
checks without a surprise at the tag.
Read the sections below for what each part is doing and why — then review the
changelog prose it assembled, which is the one part no command can judge.
To drive it by hand instead (or to stop after the bump):
bump-version updates five files atomically, from one declaration in
scripts/version_sites.py — there is no second list, and version-check
reads the same table:
| File | Field |
|---|---|
pyproject.toml |
version |
ffi/rust/Cargo.toml |
version |
CMakeLists.txt |
project(doppler VERSION …) |
bootstrap.toml |
[project] version |
just-makeit.toml |
[project] version |
(uv.lock is re-synced by the uv lock in the same command, and CHANGELOG.md
is prose you write in step 4 — neither is a probe.)
The last two joined on 2026-08-24, having never been bumped at all:
bootstrap.toml was frozen at 0.3.7 and just-makeit.toml at 0.1.0, the
value it was given in the initial commit. Nothing read either, which is
why nothing noticed — dp_doppler_version() returns DOPPLER_VERSION, stamped
by CMake from PROJECT_VERSION. make version-check now probes all five, so
a missed one is a red gate rather than a number nobody reads.
Pre-releases are refused
make bump-version VERSION=1.2.3rc1 errors. CMake and Cargo reject the
suffix, and standard.mk's version-check requires every site to return
the same string — so a pre-release bump used to produce a tree that could
not pass release.yml's own verify-version step. Supporting them needs a
change in the vendored standard.mk
(just-buildit#30);
doppler has cut zero pre-releases.
Why bootstrap.toml could not be bumped before
Its bytes fed the CI image fingerprint, so moving the version demanded an
image rebuild and a repin — every release, for a string no image layer
reads. The shared CI image's source hash leaves out the [project]
table (#1765), so
the version travels freely and a rebuild still fires on any real change.
See Build Internals.
just-makeit.toml's copy has a second reason to be right:
just-makeit#1141
is that [project] version reaches none of its four generated copies. When
jm closes that, this value starts propagating outward — and 0.1.0 would
have propagated into the three files that were correct.
4. Assemble the changelog¶
Entries are written as fragments under changelog.d/ — one file per
change, which is what stopped CHANGELOG.md conflicting on every rebase — so
## [Unreleased] stays near-empty between releases and the release is where
they get promoted. This step did
not exist in this runbook until 2026-08-28, and its absence was not
theoretical: cutting from the old step 4 alone would have published the 5
entries already sitting under [Unreleased] and left 62 fragments on the
floor.
On the release branch:
make changelog-assemble VERSION=X.Y.Z # promote, then cut the section
make docs-relink # comparison links from the headings
changelog-assemble promotes changelog.d/ into [Unreleased] and STAGES
the promotion (the fragments are deleted but still tracked, so an unstaged
assemble makes the next make lint fail on paths that are gone). With
VERSION= it then renames ## [Unreleased] → ## [X.Y.Z] — YYYY-MM-DD and
opens a fresh empty ## [Unreleased] above it.
That rename was a hand step until 2026-09-16, sitting immediately after a command that had already edited the same file — and a hand step beside an automated one is the half that rots. It refuses to cut a version that already has a section, so a second run cannot open a duplicate.
Writing the fragments is prose and stays prose. Renaming a heading was never prose; reviewing what the fragments say still is, and that review is the reason the release is a PR.
You no longer write the comparison links
## [X.Y.Z] is a markdown reference link, and without a matching
[X.Y.Z]: … definition it renders as the literal text [X.Y.Z]. This
step used to ask for that line by hand and nothing checked, so 0.43.0,
0.43.1 and 0.43.2 all shipped without one and there was no
[unreleased]: definition at all (#996).
scripts/gen_changelog_links.py derives the whole block from the
headings — make docs-relink writes it, and make lint fails on drift.
On its first run it found seven more missing definitions nobody had
noticed, and one link that chained v0.4.1...v0.5.0 straight past 0.4.6.
5. Open the PR and merge it green (the gate)¶
git commit -am "chore: release vX.Y.Z"
git push -u origin HEAD
gh pr create --fill
gh pr merge --rebase # (or --squash) ONLY once `CI passed` is green — never bypass it
The release tag will point at this merged commit, so CI passing here is what makes the release safe.
Check the docs walk by hand. The release PR touches pyproject.toml, so the
Docs walk (candidate) workflow runs on it: every install path the docs name,
walked from a bare container against artifacts built from this tree (both
architectures). It is not in CI passed (#2881),
so a red run does not block the merge. Until it does, confirm both legs are
green before merging (gh pr checks, the two Docs walk, candidate rows);
release.yml runs the published face after the tag, and a failure there is a
shipped release.
The release PR is a version bump alone, so changes classifies it
src=false and the heavy matrix its parent already passed is skipped. The
cheap gates (lint, manifest drift, the CI-image pin) still run. See
CI.
6. Tag merged main¶
git checkout main && git pull
make tag-release VERSION=X.Y.Z # verifies on-main + in-sync, tags, pushes the tag
tag-release pushes only the tag (never main), which triggers the release
workflow. It refuses to run while any changelog.d/ fragment is still
unassembled — step 4 is not optional, and the tag is the irreversible step.
The tag push is irreversible
Pushing the tag starts the release workflow and PyPI uploads begin. Because
PyPI is independent of CI, the safety comes entirely from step 5 — only ever
tag a commit whose CI passed check is already green on main.
7. Release workflow (automatic)¶
The release.yml
workflow runs these jobs in order:
verify-version ── tag == pyproject == Cargo == CMakeLists == bootstrap == just-makeit
│
├── verify-ci ── poll the "CI passed" aggregator on the tagged SHA
│ (the merge to main already ran the full suite — we
│ do NOT re-test here, we confirm it was green)
│
├── build-python ── manylinux_2_28 x86_64 + aarch64 wheels, one docker run per cp3x/arch
├── build-macos ── macOS arm64 wheels, `make wheel` per Python version
├── build-windows ── win_amd64 wheels (clang-cl), `make wheel` per Python version
├── build-sdist ── source distribution (`make sdist`)
├── build-c-linux ── C library tarballs (linux-x86_64 + linux-aarch64, matrixed)
├── build-c-macos ── C library tarball (macos-arm64)
└── build-c-windows ── C library zip (windows-x86_64, clang-cl)
(all seven build-* jobs run in parallel, gated only on verify-version;
every C archive is `make package-c-tarball VERSION=x.y.z`, which a
developer can run before cutting a tag)
smoke-wheel ── install the cp312 wheel (x86_64, aarch64, win_amd64) needs build-python, build-windows
│ + run deploy/validation/wfm_e2e.py
│ (smoke-tests the ARTIFACT, not the source tree)
wheel-tags ── every wheel's name parses as installable by pip needs the three wheel builds
│
▼
publish-python ── PyPI (OIDC trusted publishing, no token needed)
│ needs verify-ci, smoke-wheel, wheel-tags, sdist + every wheel build
├──────────────────────────────┐
▼ ▼
publish-container smoke-pypi ── install doppler-dsp==X.Y.Z FROM PyPI
│ runtime, SDK + downstream and run wfm_e2e.py, per platform x Python
│ images per arch, pushed by
│ digest (the runtime image
│ pip-installs the just-
│ published wheel, no rebuild)
▼
publish-container-merge ── multi-arch :X.Y.Z (+ :latest) tags for each image
│ (ghcr.io/doppler-dsp/doppler, -sdk, -downstream-jm),
│ smoked as a user would pull them
▼
github-release ── GitHub Release + auto-generated notes + wheel/C-archive attachments
│ (also needs the three build-c-* jobs)
├──────────────────────────────┐
▼ ▼
smoke-c smoke-packages
download the published C install THIS arch's published .deb/.rpm
archive (Linux x2, macOS, in several distros (x86_64 + aarch64);
Windows); find_package + find_package + pkg-config from /usr
pkg-config
The release pipeline does not re-run the test suite — the bump PR's merge
to main is the gate, so release.yml only confirms CI was green on the
tagged commit (verify-ci polls the ci-passed aggregator job in ci.yml),
then builds, smoke-tests the built wheel, and publishes. This matches the
canonical release-process skill.
How each wheel is actually built (no cibuildwheel — just-buildit is a
minimal PEP 517 backend driving the whole thing):
- Linux (
build-python) — onedocker runpercp3x× arch insidequay.io/pypa/manylinux_2_28_x86_64or_aarch64(arch picked by the job matrix), installingcmake/pkg-config(fftw/zeromqwere dropped once the FFT was fully vendored and ZMQ was removed — see CHANGELOG) plusnumpy/uv/build/just-buildit, thenpython -m build --no-isolation --wheel. The aarch64 leg runs natively on aubuntu-24.04-armrunner (free for public repos) — no QEMU or cross-toolchain anywhere. - macOS (
build-macos, a separate job) — natively on a macOS arm64 runner.astral-sh/setup-uvselects the Python,make install-depsinstalls the system deps (readsbootstrap.toml, the single source of truth for doppler's system deps), andmake wheel(=uv build --wheel) drives the samejust-builditbackend as the Linux leg — no hand-rolleduv venv/python -m buildbootstrap to drift from CI. The job pinsMACOSX_DEPLOYMENT_TARGET=11.0and_PYTHON_HOST_PLATFORM=macosx-11.0-arm64sodelocatetags the wheelarm64: cmake emits an arm64-only.soon this runner, and without the overridedelocatefails demanding the absentx86_64slice. - Windows (
build-windows) — natively on awindows-latestrunner, the samemake wheelunder the MSVC environment with clang-cl + Ninja (CMAKE_ARGS), the toolchainci.yml's Windows jobs use on every PR. just-buildit's backend (make just-build→ cmake + pyext) assembles the wheel from the build's output dir, detects the platform tag from the.sosuffix, and runsauditwheel repair(Linux, viauvx) /delocate-wheel(macOS) /delvewheel repair(Windows) to bundle shared-lib deps into the wheel — this is why the published wheel needs zero system packages topip install. Each Linux wheel is then scanned for both a leaked-march=native(AVX/AVX-512 on x86_64) and a leaked-mcpu=native(SVE/SVE2 on aarch64) — see Build Internals for both scans.
verify-version checks — it runs make version-check VERSION=<tag>, which
fails immediately if any probed file disagrees with the tag or with the others:
pyproject.tomlffi/rust/Cargo.tomlCMakeLists.txtbootstrap.toml([project] version)just-makeit.toml([project] version)
If it fails, bump the missed file manually, push a fixup commit on main, then re-tag.
A container build that dies on toomanyrequests at a FROM (either
Rate exceeded or Data limit exceeded, ECR Public's anonymous per-IP
limits): re-run the failed jobs. publish-container builds on buildx's
container driver, which pulls inside BuildKit's own container, out of
scripts/stock-pull.sh's reach. So its first step, make stock-registry,
fetches each FROM's manifest through the helper, pulling nothing. That step
retries a rate limit and falls back to STOCK_MIRROR on a quota, and the
builds then pull from whichever registry it needed (#2572). A refusal that begins
between the probe and the build still fails the build. Re-running the
failed jobs probes again. Before that step existed, two releases in a row
lost an image job this way after PyPI had published (#1982).
8. Verify the release¶
make ship already did this — there is nothing to type here, which is the
point. This step used to be a hand-run pip install in a scratch venv, and a
runbook step reports nothing when it is skipped.
Two gates cover it now:
smoke-pypi(release.yml, afterpublish-python) installsdoppler-dsp==X.Y.Zfrom PyPI and runs the fullwfm_e2e.pyagainst it, once per platform wheel (Linux x86_64 and aarch64, win_amd64, macOS arm64) and per supported Python, read frompyproject.toml's classifiers. It installs a wheel only, never the sdist, so a wheel pip would skip fails the leg instead of quietly building from source (#1817). The install retries itself while PyPI's index catches up with the publish (#1394).smoke-wheelinstalls a local file before the upload, so this is the only thing in the release that exercises the index — the C library hadsmoke-cdoing exactly this and the wheel did not (#1347).release-watch.sh(the watch half ofmake ship) confirms PyPI serves the version and thatlatesthas caught up, and that the GitHub Release is published, non-draft, with notes and its assets attached.
Neither is a substitute for a look at the GitHub Release page: the asset check counts tarballs, so confirming that every platform wheel (Linux x86_64, Linux aarch64, win_amd64, macOS arm64) plus the four C library archives (three tarballs and the Windows zip) are present is still worth ten seconds of your eyes.
To re-run the published-wheel check by hand — against any released version,
which is what makes it rehearsable before touching the script.
SMOKE_PYTHON=3.N installs into a venv on that Python, so the wheel tested is
that Python's, as one smoke-pypi leg does:
Version conventions¶
Doppler uses plain Semantic Versioning —
MAJOR.MINOR.PATCH, which read positionally is BREAKING.FEATURE.PATCH:
| Position | Bumps on |
|---|---|
| MAJOR | a backward-incompatible (breaking) API change |
| MINOR | a new, backward-compatible feature / module / API |
| PATCH | a backward-compatible bug fix or small additive tweak |
Stable X.Y.Z releases only — no alpha/beta/rc suffixes. main stays at the
last released version between releases (no post-release dev bump).
Pre-1.0 (where we are)¶
The MAJOR digit stays 0 until we commit to a stable public API and cut
1.0.0, and that has a consequence sharper than it first looks.
SemVer §4 says of 0.y.z: "Anything MAY
change at any time. The public API SHOULD NOT be considered stable." So the
words "backward compatible" in the table above do not apply pre-1.0 — the
qualifier presupposes a stable public API, and there isn't one yet. There is no
such thing as a backward-compatible-or-not 0.y.z release.
Which leaves exactly two questions:
| Increment | Pre-1.0 meaning |
|---|---|
MINOR (Y) |
the release adds functionality |
PATCH (Z) |
the release only fixes bugs |
MAJOR (X) |
unused before 1.0.0 |
Breaking changes do not affect the version. They are not a tiebreaker, not
an escalation, and not a reason to hesitate over the bump: 0.y.z has already
told users the API is unstable. A release that breaks every signature in the
library and adds one feature is a MINOR; one that breaks the same signatures
and only fixes bugs is a PATCH. The whole obligation is to say so in a
### Breaking section of CHANGELOG.md, which github-release publishes
verbatim as the release notes.
Worked examples: 0.6.0 (waveform generator), 0.7.0 (read_iq), 0.8.0 (the
Python composer subsystem), and 0.9.0 (the timing pacing/timestamping
subsystem) are all feature bumps. A bug-fix-only release off 0.8.0 would
have been 0.8.1.
A release can be a feature bump and carry breakage: the one that added
wfm.Reader.header and dp_wfm_kw_check_standard() also changed 38 C kernel
signatures. It took the FEATURE digit because it added functionality — the
breakage went under ### Breaking in the changelog and had no bearing on
which digit moved.
Don't hand-type the version you are about to release
scripts/check_version_strings.py greps hand-owned docs for the literal
version in pyproject.toml and fires at introduction time. Naming the
next version here would pass today and then fail the release bump PR
itself, once pyproject.toml catches up. Describe releases by what they
contained, not by their number — historical numbers (0.8.0 above) are
fine, because they never match the current one.
Authoritative record
CHANGELOG.md in the repository root is the source of truth for what each
version changed.