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 PY=.venv/bin/python WT=$PWD BASE="$(git describe --tags --abbrev=0 --match 'v[0-9]*' origin/release 2>/dev/null \ || git describe --tags --abbrev=0 --match 'v[0-9]*')" "$PY" scripts/test_gate/run_full_suite.py --wt "$WT" \ --changed-since "$BASE" --list-affected-lanes "$PY" scripts/test_gate/run_full_suite.py --wt "$WT" --py "$PY" \ --changed-since "$BASE" --out impact.jsonl "$PY" scripts/test_gate/gate_verdict.py impact.jsonl \ scripts/test_gate/known_reds_baseline.json --reverify --wt "$WT" --py "$PY" \ --markexpr "not slow" .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.The impact gate runs
smokeplus lanes inferred from files changed since the last release tag. It is the fast release pre-flight. For a broad minor release, shared numerical refactor, or any release without a recent green nightly, also runfull-fastandslow-nightlyfromscripts/test_gate/README.mdbefore tagging.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
release: vX.Y.Zsubject trips the.githooks/commit-msgguard, which refuses the commit unlesspyproject.toml[project] version==X.Y.Zand a## [vX.Y.Z]CHANGELOG header exists, a mechanical check on the two edits above (it does not policeCITATION.cff/docs/citing.md; verify those by eye). Activate the hook once per clone per CONTRIBUTING.md § “Pre-commit hook”.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.
Validate example inputs without committing artifacts
The repository keeps the runnable example inputs, not generated
.out/.system/.molden/.traj/.cubeartifacts. Refresh the editable install first, then smoke-test the canonical inputs in a scratch directory so the release-paper examples still produce clean provenance files:.venv/bin/pip install -e '.[test]' --no-build-isolation tmpdir="$(mktemp -d)" cp examples/molecular/input-h2o-rhf.py "$tmpdir/" (cd "$tmpdir" && VIBEQC_NO_HOSTNAME=1 \ "$OLDPWD/.venv/bin/python" input-h2o-rhf.py) grep -E '^version|^git_sha|^hostname|^wall_seconds' \ "$tmpdir/output-h2o-rhf.system" git status --short -- examples
git statusshould show no generated example artifacts. If a release-paper run needs outputs, store them beside the paper draft or in the external artifact archive, not under the repository checkout.Skipping this step means release-paper example inputs may drift from the install instructions or output provenance expectations.
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 example by copying it outside the checkout and running it there; the generated.outbanner should carry the same release descriptor, and the generated.systemmanifest should redact hostname ifVIBEQC_NO_HOSTNAME=1is set.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 + add the CHANGELOG section. Two edits land in the single
release: vX.Y.Z+1commit:pyproject.toml, bump[project] versiontoX.Y.Z+1. The runtime reports this value (it is read from installed package metadata viaimportlib.metadata.version, seepython/vibeqc/banner.py_compute_version()). Skipping this bump is exactly what shipped v0.11.2 and v0.11.3 self-reporting0.11.1, and the tags are frozen (never force-pushed), so it can’t be undone. Do not skip it.CHANGELOG.md, add the## [vX.Y.Z+1] — YYYY-MM-DDdated section (step 8 below carries this same block back tomain, so it must exist here first).
git commit -am "release: vX.Y.Z+1"
This commit is gated by
.githooks/commit-msg: on anyrelease: vX.Y.Zsubject it refuses the commit unlesspyproject.toml[project] version==X.Y.Zand a## [vX.Y.Z]CHANGELOG header exists. That is the mechanical backstop for the skipped-bump class of error, CI on the erzherzog runner is collect-only, so it cannot gate this. (Activate the hook once per clone per CONTRIBUTING.md § “Pre-commit hook”; reviewed exception:git commit --no-verify.)Run the impact gate 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 PY=.venv/bin/python WT=$PWD BASE=vX.Y.Z "$PY" scripts/test_gate/run_full_suite.py --wt "$WT" \ --changed-since "$BASE" --list-affected-lanes "$PY" scripts/test_gate/run_full_suite.py --wt "$WT" --py "$PY" \ --changed-since "$BASE" --out impact.jsonl "$PY" scripts/test_gate/gate_verdict.py impact.jsonl \ scripts/test_gate/known_reds_baseline.json --reverify --wt "$WT" --py "$PY" \ --markexpr "not slow"
For a patch release, this bounded impact gate is normally the release correctness gate: it runs
smokeplus the lanes touched by the cherry-picks and release-version edits. Runfull-fastorslow-nightlyonly if the patch changes shared numerical machinery, the affected lanes are broad, or no recent nightly covers the base.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, from the hotfix branch, not from main.The hotfix branch carries
version = "X.Y.Z+1"in its tree.origin/releasemay have diverged (e.g. a prior minor’smerge -s oursreconcile commit sits there). To land the hotfix tree on release, not main’s dev-version tree, do the ancestry reconcile while on the hotfix branch:# While on hotfix/X.Y.Z+1 (version = "X.Y.Z+1" in tree): git fetch origin git merge -s ours --no-ff origin/release \ -m "chore: absorb release ancestry into vX.Y.Z+1 hotfix" git push origin HEAD:refs/heads/release # ← fires CI, site updates
Why not
git merge --ff-only vX.Y.Z+1? A ff-only fails iforigin/releaseis not a direct ancestor of the tag commit (this happens whenever a prior minor cut left amerge -s oursreconcile commit onreleasethat is not reachable from the hotfix branch). Themerge -s oursfrom the hotfix side keeps the hotfix tree (version = "X.Y.Z+1") while recordingorigin/releaseas a parent so the push fast-forwards cleanly.The critical direction: the
merge -s oursmust be run from the hotfix branch (keeping the hotfix tree). If run frommaininstead, it keeps main’s dev-version tree and deploysversion = "X.Y.Z+2.devN", the v0.11.1 incident (2026-06-03).After the push, verify CI completes and the live banner reads
Release vX.Y.Z+1(notdev).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 example-input sweep
run or compile every numbered tutorial’s example input in a scratch directory, verify the generated filenames match the docs, and keep the generated
.out/.molden/.cube/.trajartifacts out of the repo. First-time build-out is a one-shot docs 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.