hea-bench has one version number and a single, hands-off release
pipeline, and since 2.0.6 the pipeline drives itself: pushing to
main is releasing. This document explains where the version lives,
what a push triggers, the overrides, and the one-time setup.
The single source of truth is __version__ in
src/hea_bench/__init__.py. Everything else
either derives from it automatically or is stamped from it:
| Surface | How it gets the version |
|---|---|
pyproject.toml (PyPI) |
Derived. dynamic = ["version"]; hatchling reads __version__. |
src-tauri/tauri.conf.json (desktop) |
Derived. No version key, so Tauri inherits src-tauri/Cargo.toml. |
| CLI, MCP server, tests | Derived. They from . import __version__. |
src-tauri/Cargo.toml |
Stamped. |
server.json (MCP registry, twice) |
Stamped. |
CITATION.cff (version + date-released) |
Stamped. |
llms.txt (prose + BibTeX) |
Stamped. |
web/index.html (VERSION + VERSION_DATE) |
Stamped. |
Stamping and verification are handled by one zero-dependency script,
tools/version.py:
python tools/version.py --check # fail if any file disagrees (runs in CI)
python tools/version.py --set 2.1.0 # bump the canonical value + restamp + datesCI runs --check on every push and pull request, so the files can never
silently drift apart.
The two append-only histories — the ## [x.y.z] section in
CHANGELOG.md and the top row of the VERSION_HISTORY array
in web/index.html — are written at release time by
tools/autorelease.py.
Any push to main that changes a shippable surface — src/**, web/**,
src-tauri/**, server.json, or pyproject.toml — triggers
.github/workflows/auto-release.yml,
which with no further input:
- runs
tools/autorelease.py --prepare: bumps the patch version (or honors a pre-bumped tree, see below), stamps every surface, promotes the## [Unreleased]changelog section (or synthesizes notes from the pushed commit subjects), and prepends the webVERSION_HISTORYentry; - commits
Release vX.Y.Z, pushes it, and pushes an annotatedvX.Y.Ztag; - dispatches
release.ymlon that tag (the full pipeline below) andpages.ymlonmain(site redeploy), explicitly, because pushes made with the workflow token never trigger other workflows on their own; - then verifies: the run goes red if PyPI, the MCP registry or the GitHub Release fail, or if the live site is not serving the new version within 15 minutes. It does not wait for the desktop exe (see below).
So the day-to-day release procedure is, in full:
git commit -am "Fix the thing"
git pushControls, all optional:
- Skip a release: put
[no-release]anywhere in the head commit message of the push. The changes ride along in the next release. - Minor or major bump: run
python tools/version.py --set X.Y.0and include that in your push. The bot detects the pre-bumped tree and releases exactly that version instead of a patch bump. - Better release notes: write them under
## [Unreleased]inCHANGELOG.mdbefore pushing; the bot promotes them verbatim. Otherwise the commit subjects since the last tag become the notes. - Docs, CI, tests, tools, manuscript: pushes touching only those paths never release.
Whether cut by the bot or by hand, a vX.Y.Z tag drives
.github/workflows/release.yml, which runs
with no further input:
- gate — the full pytest 3.10–3.12 matrix, the JS parity test, and the version-consistency check on the tagged commit.
- pypi — build the sdist and wheel and publish to PyPI over OIDC Trusted Publishing (no token).
- mcp — wait until PyPI serves the new version, then publish
server.jsonto the Model Context Protocol registry via GitHub OIDC. - release — create the GitHub Release with the changelog section as its notes and the sdist/wheel attached. Publishing the Release fires the GitHub↔Zenodo integration, which archives the tag and mints the new version DOI under the concept DOI.
- desktop — build the portable
HEA-Bench.exeand attach it to the release.
The bot stands down when the head commit message starts with Release v, so
a manual release never collides with an automatic one:
python tools/autorelease.py --prepare --notes "One-line what's-new text"
git commit -am "Release vX.Y.Z" # X.Y.Z = the version the script printed
git tag -a vX.Y.Z -m "Release vX.Y.Z" # ANNOTATED tag (-a), see below
git push origin main vX.Y.Z # push the tag BY NAME
git ls-remote --tags origin vX.Y.Z # confirm it is really on the remoteTwo traps this recipe avoids, learned the hard way:
- Never rely on
git push --follow-tags. It only pushes annotated tags; a plaingit tag vX.Y.Zmakes a lightweight tag, which--follow-tagssilently skips — the branch pushes, the tag stays local, no release fires, and every surface silently stays on the old version. Always push the tag by name and confirm withls-remote. - A bare commit push is not a release. Without a tag (or the bot), only the Pages site redeploys; PyPI, the MCP registry, the desktop exe, the GitHub Release, the Zenodo DOI, and the version badge all stay put.
The Windows executable is a 15-20 minute Rust build on a Windows runner,
and it is the only surface that takes longer than a coffee. So, as of
2026-08-18, verify-release in auto-release.yml goes green as soon as
the pypi, mcp and release jobs succeed, and the Auto release run
is green while the exe is still compiling. Agents and humans stop
watching at that point.
A failure in desktop-build or desktop-attach would otherwise be
silent, so release.yml has a desktop-failed job that runs only when
one of those two actually failed (not when they were skipped because
an upstream surface failed first, which the watch already reports). It
opens an issue titled "Desktop exe failed to ship for vX.Y.Z", labelled
desktop-build, that mentions and assigns the owner; GitHub delivers
both as email. If an open desktop-build issue already exists it
comments there instead, so a flaky runner cannot fan out into a pile of
issues. The site's download link shows its update-in-progress notice
until an exe is attached.
To recover, fix the cause on main, then re-fire only the release
pipeline for that tag and close the issue once the exe is attached:
gh workflow run release.yml --ref vX.Y.ZIf a release run dies after the tag exists (runner eviction, a cancelled
run, an outage), nothing is lost and nothing needs reverting: the tag and
the stamped release commit are already on main, and every pipeline step
is idempotent (PyPI publishing skips already-uploaded files). Re-fire it:
gh workflow run release.yml --ref vX.Y.Z # the four surfaces + Release + Zenodo
gh workflow run pages.yml --ref main # the site, if it is stale tooThe verify-release and verify-live jobs already do this once
automatically when they see a cancelled (as opposed to failed) run.
Every failed release cycle to date, with its root cause and the guard that now exists against it. This table is maintained deliberately: a release process that fails a quarter of the time is a process problem, not bad luck, and each row below was a check that either could have run before the push and did not, or lived only inside an external publisher.
| Date | Release | What failed | Root cause | Guard now in place |
|---|---|---|---|---|
| 2026-07-06 | v2.0.3, v2.0.4 | manual tag flow | lightweight tag + --follow-tags silently pushed no tag; bare commit push released nothing |
manual flow retired; push = release automation, and the manual recipe above pushes tags by name and verifies with ls-remote |
| 2026-07-09 | landing-page push | every gate job cancelled | runner eviction, not a code fault | verify jobs distinguish cancelled from failed and re-dispatch once |
| 2026-07-23 | v2.1.0 | release-gate ruff | new descriptors missing from __all__; lint never ran locally before the push |
tools/preflight.py runs the exact gate lint pre-push |
| 2026-07-24 | app-icon push | release-gate ruff | unpinned ruff minor release changed the rules mid-week | ruff pinned to the 0.15 series in pyproject.toml |
| 2026-08-10 | v2.2.0 | benchmark-freeze gate | benchmark code changed without regenerating the frozen digests; the local suite silently skipped those tests because no corpus was built | tools/preflight.py reports every locally skipped test and names the benchmark-freeze gate as unverified when the corpus is absent |
| 2026-08-11 | v2.4.0 watch | verify-release went red on a green release | one transient GitHub API timeout failed the whole watch | watcher treats unreadable status as "still running" and polls again |
| 2026-08-16 | v2.5.0 | mcp publish, HTTP 422 |
server.json description was 199 characters; the MCP registry caps it at 100, and nothing anywhere validated registry constraints |
tools/preflight.py --metadata encodes the registry limits and runs in four places: the pre-push hook, by hand locally, in CI on every push, and in the release bot before it stamps or tags |
| 2026-08-17 | no red run; found by inspection | the live site never showed a favicon in Google results | the icon was a data: URI, which Google cannot crawl, and no gate has ever opened a file under web/ that is not code |
tools/preflight.py --metadata checks the icon set, the 1200x630 social card, the manifest and the JSON-LD, and rejects a data: URI or a root-absolute icon path |
The defense has four layers, in firing order:
- The pre-push hook (
tools/git-hooks/pre-push, enabled by the same one-per-clonegit config core.hooksPath tools/git-hooksas the commit-msg hook) detects shippable paths (src/**,web/**,src-tauri/**,server.json,pyproject.toml) in the pushed range and refuses the push until the preflight passes. Nobody — human or agent — has to remember anything; forgetting is not possible, and--no-verifyis never an acceptable answer to a red preflight. Non-shippable pushes (docs, tools, tests, CI) pass through silently. python tools/preflight.pyby hand, while iterating on a shippable change. It runs the publish-metadata checks,version.py --check, the exact release-gate ruff invocation, and the full pytest suite, then names any release gate it could NOT verify locally (missing corpus, missing Node) so pushing anyway is a knowing choice rather than an accident.- CI on every push and pull request runs
tools/preflight.py --metadata, so externally enforced constraints are checked long before a release exists. - The release bot runs the same metadata check before it stamps, commits, or tags, so invalid metadata fails the run cleanly instead of half-releasing (tag cut, PyPI published, registry rejected — the v2.5.0 shape).
The ratchet rule. When a release fails for a reason this table does
not list, the fix is not complete until the same change teaches
tools/preflight.py (or the CI gate) to catch that reason before the
next push, and adds the row here. Checks are only added, never removed.
These are configured once, in the GitHub and PyPI web consoles. Until they are
done, the first tagged release will fail at the pypi job.
- PyPI Trusted Publisher. On the existing
hea-bench PyPI project → Manage →
Publishing, add a GitHub publisher:
- Owner:
dfieser - Repository:
hea-bench - Workflow filename:
release.yml - Environment name:
pypi
- Owner:
- GitHub environment. Repo Settings → Environments → create one named
exactly
pypi. Leave it with no required reviewers so releases stay hands-off. (It exists only to scope the OIDC token to release publishing.) - Retire the old token. After the first successful OIDC publish, delete
the
PYPI_API_TOKENrepository secret and revoke that token on PyPI. The pipeline no longer uses it.
The GitHub↔Zenodo integration is already enabled and bound to the concept DOI
10.5281/zenodo.20346287, which
always resolves to the latest release and is the DOI to cite. Publishing a
GitHub Release is the only trigger it needs.
Never disconnect, re-enable, toggle, rename-and-relink, or drive this integration through the Zenodo REST API. Any of those can mint a new concept DOI and permanently fork the citation lineage. DOIs cannot be merged or deleted.
.zenodo.json pins the authors, ORCIDs, affiliation, license,
and keywords that each new release records, so the archive does not depend on
whatever Zenodo would otherwise scrape. Keep its author list in step with
CITATION.cff. CITATION.cff itself intentionally records only the concept
DOI, so it never needs a per-release DOI edit.