Release process¶
Audience: anyone (human or AI agent) committing to vibe-qc, plus users who want to pin a calculation to an exact build. If you only want to install and run, see
installation.md.READ THIS BEFORE TOUCHING
release/ORv*TAGS. TaggingvX.Y.Zand advancing thereleasebranch are exclusively the release chat’s job. Dev chats request inclusion in a patch via aPatch-candidate:commit-message trailer — seeCLAUDE.md§ 13 and § “How candidates are flagged” below. Direct pushes torelease, directvX.Y.Ztag creation, and MRs targetingreleaseare blocked by GitLab branch + tag protection (since 2026-05-15) and would violate the FF-only-from-tag invariant that keeps build-banner provenance trustworthy.
Branch model¶
vibe-qc has two long-lived branches with strict roles. All development chats and contributors must respect this split — it’s the mechanism that lets us match a calculation log to the exact code that produced it, and it’s how we keep public users on a stable surface while we iterate.
Branch |
Purpose |
Push policy |
|---|---|---|
|
Active development. Every feature, bugfix, refactor lands here first. CI must pass. Daily commits expected. |
Maintainers + agents, after CI green. |
|
Public-facing snapshot. Advertised install instructions and any binary distribution we ever publish pull from |
Fast-forward only, and only from a tagged commit on |
Any other branch is a topic branch — short-lived, owned by a single
contributor or chat session, gets squashed or rebased into main once
its work lands.
The branch model has been in effect since the v0.4.0 cutover on
2026-04-27. Before that, release did not exist and the docs site
tracked main directly so readers wouldn’t be stuck at a months-
stale v0.1.0 snapshot while v0.2 / v0.3 / v0.4-dev features landed.
That deviation ended when v0.4.0 was tagged and release was
created at the same SHA — the site has rendered release ever
since.
What’s the user-visible difference?¶
Every vibe-qc run prints a banner that identifies the build:
╔═══════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════╗
║ Release v0.1.0 — Quantum chemistry for molecules and solids ║
║ © Michael F. Peintinger · MPL 2.0 · https://vibe-qc.com ║
║ linked: libint 2.13.1 · libxc 7.0.0 · spglib 2.7.0 · libecpint 1.0.7 (vendored, MAX_L=5) · fftw3 3.3.10 · blas Accelerate ║
╚═══════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════╝
vs. a development build:
╔═══════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════╗
║ dev 0.1.0 (main @ abc1234) — Quantum chemistry for molecules and solids ║
║ © Michael F. Peintinger · MPL 2.0 · https://vibe-qc.com ║
║ linked: libint 2.13.1 · libxc 7.0.0 · spglib 2.7.0 · libecpint 1.0.7 (vendored, MAX_L=5) · fftw3 3.3.10 · blas Accelerate ║
╚═══════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════╝
When the user installs the optional [dispersion] extra (which
pulls in the dftd3 and dftd4 PyPI wheels, both of which
bundle binary libraries that vibe-qc loads at runtime), the banner
gains a second linkage line:
║ linked: libint 2.13.1 · libxc 7.0.0 · spglib 2.7.0 · libecpint 1.0.7 (vendored, MAX_L=5) · fftw3 3.3.10 · blas Accelerate ║
║ dispersion: dftd3 1.4.0 · dftd4 4.2.0 ║
The dispersion: line is hidden when neither package is
installed, so the default banner stays compact for users who don’t
need D3 / D4.
The first form appears only when:
The current commit is exactly tagged (e.g.
v0.1.0).The working tree is clean (no uncommitted changes).
Any other state — branch, dirty tree, detached HEAD, untagged commit —
shows the dev form, including branch name, short SHA, and a dirty
flag if there are uncommitted changes.
Cutting a release — checklist¶
Maintainers only. Run from a clean checkout of main with everything
committed and pushed.
Pre-flight
git fetch origin git checkout main git pull --ff-only .venv/bin/python -m pytest tests/ # 100 % pass .venv/bin/python -c "from vibeqc.banner import build_info; print(build_info())"
The
build_info()output should showdirty: False. If anything else is wrong, fix it onmainfirst.Bump the version + promote
[Unreleased]→[vX.Y.Z]Two edits land in one
release: vX.Y.Zcommit:pyproject.toml— bumpversiontoX.Y.Z(the banner reads this).CHANGELOG.md— promote the existing## [Unreleased]block to a dated## [vX.Y.Z] — YYYY-MM-DD — *Codename*section, then open a fresh empty## [Unreleased]at the top with a forward-looking note pointing at the next minor’s scope. The codename comes frompython/vibeqc/banner.py’sRELEASE_CODENAMESdict, which needs the new entry added in the same commit (per the standing CLAUDE.md § 6 coupled-fix pattern).CITATION.cff+docs/citing.md— bump theversion:/date-released:/ APA + BibTeX entries to match.
git commit -am "release: vX.Y.Z" git push origin main
The CHANGELOG promotion lives on main from this commit on. The tag in step 4 immortalises it. Skipping the promotion is the bug that caused v0.9.0 → v0.10.2 to leave main’s CHANGELOG five releases behind — don’t skip it.
Regenerate the bundled reference outputs
The artefacts under docs/_static/examples/ carry the runtime banner from the exact build that produced them. Regenerate against the just-bumped version so banners read
Release vX.Y.Zinstead ofdev X.Y.devN:.venv/bin/python scripts/regenerate_doc_examples.py git diff --stat docs/_static/examples/ git add docs/_static/examples/ git commit -m "docs: regenerate reference outputs for vX.Y.Z" git push origin main
The script refuses dirty trees by default — that’s the protection against shipping
Release vX.Y.Z, dirtybanners in published artefacts. If a job fails or its declared outputs don’t match what the script produced, fix the entry in scripts/doc_examples.toml and re-run. Total wall-clock for the full sweep is ~15 min on a recent laptop (most of it the H₂O trimer optimization and the NH₃ NEB).Skipping this step is the most common release-time mistake — you’ll ship outputs whose banner reads the previous dev SHA, which is misleading to anyone reading them as a reference for the new release. See DOC1 in the v0.5.0 roadmap section for the design rationale.
Tag the release commit on
maingit tag -a vX.Y.Z -m "vibe-qc X.Y.Z" git push origin vX.Y.Z
Fast-forward
releaseto that taggit checkout release # or git checkout -b release if first time git merge --ff-only vX.Y.Z git push origin release
If git refuses the fast-forward, stop.
releasehas somehow diverged frommain’s tag. Investigate before doing anything destructive.Verify on a clean checkout
cd /tmp && rm -rf vibeqc-rel-check git clone -b release https://gitlab.peintinger.com/mpei/vibeqc.git vibeqc-rel-check cd vibeqc-rel-check ./scripts/setup_native_deps.sh python3 -m venv .venv && .venv/bin/pip install -e '.[test]' .venv/bin/python -c "from vibeqc.banner import print_banner; print_banner()"
Banner should read
Release vX.Y.Z. Spot-check one of the bundled reference outputs too — the banner indocs/_static/examples/h2o-rhf/output-h2o-rhf.outshould match (modulo git SHA differences for that specific commit) and definitely should NOT readdev X.Y.devN.Post-release
Update the changelog with the release notes (sourced from the project-root
CHANGELOG.md).Bump the next
0.X.Y+1.dev0version onmainso subsequent dev builds are visibly post-release.The vibe-qc.com docs site auto-deploys via GitLab CI on every push to
release(the.gitlab-ci.ymlfilter isonly: [release]since v0.4.0). The push ofreleasein step 4 already triggered a deploy; the next pipeline at https://gitlab.peintinger.com/mpei/vibeqc/-/pipelines should show green within ~3 minutes and the live site banner readsRelease vX.Y.Z.
Cutting a patch release (vX.Y.Z+1)¶
The minor-release flow above goes forward from current main. A
patch release (vX.Y.Z+1) is the opposite: it cherry-picks
hotfixes onto the previous tag, leaving main’s in-flight
development out of the public surface. Use this for security fixes,
install-script bugs, doc errata, and anything else that’s “we need
this in users’ hands now” but doesn’t justify dragging the rest of
main along.
How candidates are flagged¶
Dev chats flag patch-target commits with a Patch-candidate:
trailer in the commit body (see CLAUDE.md § 13).
The release chat scans main for trailered commits when cutting
each patch:
# Trailered commits since the last 0.7 patch:
git log v0.7.<last>..origin/main \
--pretty='format:%H %s%n%(trailers:key=Patch-candidate,valueonly,unfold)' \
| awk '/^[a-f0-9]{40}/ {h=$0; next}
/v0\.7\.x|v0\.7\.\*/ {print h}'
# Plus git-notes-attached flags (for commits already pushed):
git fetch origin 'refs/notes/*:refs/notes/*'
git log v0.7.<last>..origin/main --notes=commits \
--format='%H %N' \
| grep "Patch-candidate.*v0\.7\.x"
The release chat curates the union (some candidates may be deferred or rejected — feature-shaped commits, conflicts, etc.) and proceeds to step 1 below with the resulting list.
Patch-release steps¶
Identify the fixes to backport. Each one should be a single commit on
main, ideally with a clear self-contained subject line, so cherry-picking is straightforward.Branch off the previous tag (NOT off
release, to keep the workflow uniform regardless of which tag is currently published):git checkout -b hotfix/X.Y.Z+1 vX.Y.Z
Cherry-pick each fix. Resolve any minor conflicts (usually none for self-contained fixes):
git cherry-pick <sha-on-main>
Bump the patch version in
pyproject.tomlto vX.Y.Z+1 and commit:git commit -am "release: vX.Y.Z+1"
Run the test suite to confirm the cherry-picks didn’t silently break anything:
./scripts/setup_native_deps.sh .venv/bin/pip install -e '.[test]' --force-reinstall --no-deps .venv/bin/python -m pytest tests/
Tag and push:
git tag -a vX.Y.Z+1 -m "vibe-qc X.Y.Z+1" git push origin vX.Y.Z+1
Fast-forward
release:git checkout release git merge --ff-only vX.Y.Z+1 git push origin release # ← fires CI, site updates
Carry the new
[vX.Y.Z+1]dated section back tomain. The hotfix branch’srelease: vX.Y.Z+1commit added the## [vX.Y.Z+1]block toCHANGELOG.md. That block now needs to land onmaintoo — otherwisemain’s CHANGELOG silently diverges fromreleaseat every patch cut and accumulates into the recurring “main left half-finished CHANGELOG” drift. (v0.9.0 → v0.10.2 was a five-release reconciliation on 2026-05-29 specifically because this step was missing from the playbook. Don’t skip it.)The mechanical recipe — single tiny commit on main:
git checkout main git fetch origin --quiet && git pull --rebase origin main # Open the tag's CHANGELOG and main's CHANGELOG side-by-side; # copy the new `## [vX.Y.Z+1]` block (header + body, up to but # not including the previous `## [vX.Y.Z]`) and prepend it # above the current top dated section on main. # # Or with git plumbing: git show vX.Y.Z+1:CHANGELOG.md \ | awk '/^## \[vX\.Y\.Z\+1\]/{p=1} p; /^## \[vX\.Y\.Z\]/{exit}' \ > /tmp/new-section.md # ... then prepend /tmp/new-section.md above the top dated # section in CHANGELOG.md (above any existing `## [Unreleased]` # block is wrong — keep [Unreleased] at top, dated sections below # in descending version order). git diff CHANGELOG.md # sanity check git commit -am "docs(changelog): land [vX.Y.Z+1] dated section on main" git push origin HEAD:main
Update the rendered changelog if it carries manually-authored content (typically a one-line auto-include from project-root
CHANGELOG.md— no extra edit needed).Delete the hotfix branch (it served its purpose; the tag immortalises the state):
git branch -D hotfix/X.Y.Z+1
Cherry-pick anything new from the hotfix back to main if the fix didn’t originate there. (For our typical case — fix lands on main first, then gets backported — main already has it; nothing extra to do.)
The patch-release workflow does not touch the
0.X.Y+1.dev0 version on main — main is post-vX.(Y+1).0.dev0
and that’s correct. Only release and the hotfix tag move; the
main version stays put. The only main edit in step 8 is the
CHANGELOG dated-section carry-back; everything else on main is
unaffected.
Running calculations against a specific build¶
Common case: testing a fix from a topic branch, comparing accuracy across builds, or reproducing an old result. The banner is the source of truth, so:
# 1. Clone or check out the exact ref you want to test.
git fetch origin
git checkout release # or v0.1.0, or some-topic-branch
# 2. Rebuild the native deps and the python package against that tree.
./scripts/setup_native_deps.sh
.venv/bin/pip install -e '.[test]' --force-reinstall --no-deps
# 3. Confirm the banner shows what you expect.
.venv/bin/python -c "from vibeqc.banner import print_banner; print_banner()"
# 4. Run your calculation. Every persisted SCF log carries the same
# banner at the top, so the result is unambiguously paired to the build.
If a calculation gives a wrong answer, always include the banner in the bug report. The branch+SHA pinpoint the exact source state; the linked-library line pinpoints the native ABI.
Documentation cadence¶
Docs lag code if no-one is paid to keep them in sync. vibe-qc has no-one paid for anything, so we use a two-tier audit cadence to bound the drift:
Per-tag mechanical sprint (~3 hours, every release)¶
Triggered by: any vibe-qc tag (v0.X.0, v0.X.Y, …), executed
on tag day after engineering pushes the tag.
The canonical playbook for v0.8.0 is
docs/release_v0_8_0_prep.md — pre-
staged copy + checklist + wall-clock estimate. Pattern to
copy + adapt for each future release:
Tag-day kickoff:
cp docs/release_v0_8_0_prep.md docs/release_vX_Y_Z_prep.md. ~5 min.Clear the v0.8.0-specific pre-staged copy; refresh against the new release’s deliverables. ~25 min.
Execute the playbook’s tag-day checklist:
CHANGELOG promotion + reset (~10 min)
Homepage admonitions swap (~15 min)
Landing-page rework if the release warrants it (~25 min)
Roadmap “shipped” sweep (~30 min)
pyproject.toml+CITATION.cff+docs/citing.mdversion bumps (~5 min)README.mdheadline-feature paragraph refresh (~10 min)Paper-input package refresh (vibeqc-article repo; ~30 min — only relevant when a release paper is in flight)
Banner / linked-library surface refresh (~10 min)
Verify deploy + cross-link audit (~30 min)
Total per release: ~3 hours of focused docs work. The pre-staged copy in the prep doc means it’s mechanical, not creative — no decisions on tag day.
Per-quarter deep audit (~6–8 hours, every 3 months OR every 3 minor releases — whichever comes first)¶
Catches drift the per-tag playbook misses. Six items:
Tutorial parity re-audit — walk every ❌ / 🟡 / ✅ row in
docs/roadmap.md§ ORCA tutorials, § CRYSTAL tutorials, § ASE workflow tutorials. Flip statuses against shipped capability. Many ❌ / 🟡 → ✅ as features land.Stale-link audit — Sphinx
-W --keep-goingplussphinx-build -b linkcheck. Fix or remove dead refs.Stale-API audit — every documented API signature (function names, parameter types, return shapes) cross- checked against the live code. Small grep + AST script can automate the catching; fixing is manual.
docs/features.mdregen — capability matrix re-validated against current shipped features. New rows added; obsolete rows removed.User-guide page coverage audit — anything new shipped without a dedicated
docs/user_guide/page? File the gaps; pace the writing.Cross-link audit — every “post-merge placeholder” or
TODO: linkreference resolved into a real cross-ref.DOC1-full — tutorial-wide bundled reference outputs sweep — refresh
.out/.molden/.cube/.trajartefacts underdocs/_static/examples/<name>/for every numbered tutorial, with the 30-line excerpts + download links each tutorial carries. Demoted to this per-quarter slot from a tagged minor (was the v0.11.0 DOC1-full milestone) — a docs sprint should not gate a tag. First-time build-out is a one-shot ~5-day task with its own owner; the per-quarter pass is a refresh against the latest tagged build (~half-day, included in the budget below).
Total per quarter: ~6–8 hours.
Lightweight ongoing (every session)¶
Two reflexes for the docs chat / docs-aware contributors:
New bug discovered → homepage admonition update same session. Don’t queue. The warning admonition on
docs/index.mdis the front-line defence against users hitting silent wrong answers.Behaviour change in a CHANGELOG entry → user-guide cross-reference same session. A CHANGELOG line about e.g.
b3lypresolving to libxc id 475 instead of 402 is worthless ifdocs/user_guide/functionals.mddoesn’t surface the change to readers who don’t read CHANGELOGs.
Drop-box convention¶
Each dev chat contributing to a release writes a status file
to .release-status/<version>/<chat-id>.md (gitignored,
local-only). The release chat collates these into the merge
sequence; the docs chat absorbs them into homepage / CHANGELOG
/ roadmap / user-guide updates. The drop-box is deleted
post-tag (Phase E of the release-chat runbook), so the docs
chat is responsible for extracting any quotes / numbers /
SHAs into permanent docs/ before deletion.
Per-tag deletion is intentional: the drop-box is a working artefact, not a permanent record. Permanent record lives in CHANGELOG.md, the release-prep doc, the homepage admonition, the roadmap, and the user-guide pages.
CI hooks¶
What’s wired today (.gitlab-ci.yml at repo root):
C++/Python build + test (
build-testjob) runs on every push tomainand on every merge request. It builds the vendored numerical libraries viascripts/build_*.sh(libint / libxc / spglib / libecpint / FFTW3), builds the vibe-qc C++ extension, runs animport vibeqcsmoke check, and runspytest tests/ -m "not slow". This is the build-break gate: a commit that leaves the C++ uncompilable (a declared-but-undefined function, a syntax slip) fails CI here instead of landing silently onmain.third_party/is cached between runs, keyed on the version-pinned build scripts, so only the first run pays the full (~30 min) libint build. Containerpython:3.13-slim+ apt-installed toolchain; runnererzherzog-docker(tagdocker). The job surfaces status on the pipeline page; to make it a hard merge gate, enable Settings → Repository → Protected branches → “Pipelines must succeed”.Docs build + deploy runs on every push to
release. Container:python:3.13-slimfor build,alpine:latestfor deploy. Runner:erzherzog-docker(gitlab/gitlab-runner in Docker on the same host as the GitLab CE instance, tagsdocker/sphinx). Deploy uses an rsync-over-SSH push toweb18@vibe-qc.com:/web/, with the ed25519 deploy key stored as the masked, protected$DEPLOY_SSH_KEY_B64CI variable. The deploy host’s SSH host keys are pinned indeploy/known_hosts.deploy(committed); the pre-v0.6.46-erassh-keyscanat job time was trust-on-first-use and got replaced after security audit #6 (2026-05-24). The pinned file carries verification + rotation instructions in its header. Build logs surface in GitLab’s pipeline UI.
What’s planned, not yet wired:
releaseshould be marked as a protected branch on the GitLab project (Settings → Repository → Protected branches): only fast-forward from a tag allowed (push rule). Belt-and-suspenders protection on top of the human-process discipline in this document.A nightly job re-runs the full test suite (including the
slow-marked parity / integration tests, which the per-MRbuild-testjob skips to stay bounded) againstrelease, to catch silent breakage from environment drift (e.g. a newer compiler rejecting our C++).A manual job tags + pushes the release branch given a tag name. Useful for one-click cutting of a release; today the workflow is the manual step-1-through-6 checklist above.
Why not just use main for everything?¶
We’re a small project, so the temptation is real. The reason
release exists anyway is that the docs site at
https://vibe-qc.com pulls from release, and the
.gitlab-ci.yml filter is only: [release] for the docs-deploy
pipeline. Keeping release separate means casual visitors see
stable docs and stable install instructions blessed by a tagged
release, even as we tear apart main for a refactor.