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/ OR v* TAGS. Tagging vX.Y.Z and advancing the release branch are exclusively the release chat’s job. Dev chats request inclusion in a patch via a Patch-candidate: commit-message trailer — see CLAUDE.md § 13 and § “How candidates are flagged” below. Direct pushes to release, direct vX.Y.Z tag creation, and MRs targeting release are 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

main

Active development. Every feature, bugfix, refactor lands here first. CI must pass. Daily commits expected.

Maintainers + agents, after CI green.

release

Public-facing snapshot. Advertised install instructions and any binary distribution we ever publish pull from release.

Fast-forward only, and only from a tagged commit on main.

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:

  1. The current commit is exactly tagged (e.g. v0.1.0).

  2. 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.

  1. 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 show dirty: False. If anything else is wrong, fix it on main first.

  2. Bump the version + promote [Unreleased][vX.Y.Z]

    Two edits land in one release: vX.Y.Z commit:

    • pyproject.toml — bump version to X.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 from python/vibeqc/banner.py’s RELEASE_CODENAMES dict, 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 the version: / 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.

  3. 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.Z instead of dev 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, dirty banners 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.

  4. Tag the release commit on main

    git tag -a vX.Y.Z -m "vibe-qc X.Y.Z"
    git push origin vX.Y.Z
    
  5. Fast-forward release to that tag

    git 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. release has somehow diverged from main’s tag. Investigate before doing anything destructive.

  6. 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 in docs/_static/examples/h2o-rhf/output-h2o-rhf.out should match (modulo git SHA differences for that specific commit) and definitely should NOT read dev X.Y.devN.

  7. Post-release

    • Update the changelog with the release notes (sourced from the project-root CHANGELOG.md).

    • Bump the next 0.X.Y+1.dev0 version on main so 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.yml filter is only: [release] since v0.4.0). The push of release in 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 reads Release 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

  1. 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.

  2. 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
    
  3. Cherry-pick each fix. Resolve any minor conflicts (usually none for self-contained fixes):

    git cherry-pick <sha-on-main>
    
  4. Bump the patch version in pyproject.toml to vX.Y.Z+1 and commit:

    git commit -am "release: vX.Y.Z+1"
    
  5. 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/
    
  6. Tag and push:

    git tag -a vX.Y.Z+1 -m "vibe-qc X.Y.Z+1"
    git push origin vX.Y.Z+1
    
  7. Fast-forward release:

    git checkout release
    git merge --ff-only vX.Y.Z+1
    git push origin release          # ← fires CI, site updates
    
  8. Carry the new [vX.Y.Z+1] dated section back to main. The hotfix branch’s release: vX.Y.Z+1 commit added the ## [vX.Y.Z+1] block to CHANGELOG.md. That block now needs to land on main too — otherwise main’s CHANGELOG silently diverges from release at 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
    
  9. 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).

  10. Delete the hotfix branch (it served its purpose; the tag immortalises the state):

    git branch -D hotfix/X.Y.Z+1
    
  11. 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:

  1. Tag-day kickoff: cp docs/release_v0_8_0_prep.md docs/release_vX_Y_Z_prep.md. ~5 min.

  2. Clear the v0.8.0-specific pre-staged copy; refresh against the new release’s deliverables. ~25 min.

  3. 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.md version bumps (~5 min)

    • README.md headline-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:

  1. 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.

  2. Stale-link audit — Sphinx -W --keep-going plus sphinx-build -b linkcheck. Fix or remove dead refs.

  3. 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.

  4. docs/features.md regen — capability matrix re-validated against current shipped features. New rows added; obsolete rows removed.

  5. User-guide page coverage audit — anything new shipped without a dedicated docs/user_guide/ page? File the gaps; pace the writing.

  6. Cross-link audit — every “post-merge placeholder” or TODO: link reference resolved into a real cross-ref.

  7. DOC1-full — tutorial-wide bundled reference outputs sweep — refresh .out / .molden / .cube / .traj artefacts under docs/_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.md is 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. b3lyp resolving to libxc id 475 instead of 402 is worthless if docs/user_guide/functionals.md doesn’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-test job) runs on every push to main and on every merge request. It builds the vendored numerical libraries via scripts/build_*.sh (libint / libxc / spglib / libecpint / FFTW3), builds the vibe-qc C++ extension, runs an import vibeqc smoke check, and runs pytest 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 on main. 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. Container python:3.13-slim + apt-installed toolchain; runner erzherzog-docker (tag docker). 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-slim for build, alpine:latest for deploy. Runner: erzherzog-docker (gitlab/gitlab-runner in Docker on the same host as the GitLab CE instance, tags docker/sphinx). Deploy uses an rsync-over-SSH push to web18@vibe-qc.com:/web/, with the ed25519 deploy key stored as the masked, protected $DEPLOY_SSH_KEY_B64 CI variable. The deploy host’s SSH host keys are pinned in deploy/known_hosts.deploy (committed); the pre-v0.6.46-era ssh-keyscan at 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:

  • release should 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-MR build-test job skips to stay bounded) against release, 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.