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. Test evidence is produced locally before landing (ordinary main pushes create no CI pipeline; see § CI hooks). Daily commits expected.

Maintainers + agents, after the affected suites pass locally.

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
    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" --impact-mode release --list-affected-lanes
    "$PY" scripts/test_gate/run_full_suite.py --wt "$WT" --py "$PY" \
        --tier T1 --out blocking.jsonl
    "$PY" scripts/test_gate/gate_verdict.py blocking.jsonl \
        scripts/test_gate/known_reds_baseline.json --wt "$WT" --py "$PY"
    "$PY" -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.

    The blocking gate is the T1 tier, which is exactly what the build-test job runs on a tag or a release-candidate/* branch. Running the same invocation locally is deliberate: the pre-flight and CI then cannot disagree about what “green” means.

    Do not use --profile release-core --profile-mode blocking here. That selection pulls in tests/test_binding_sanity.py, which is tier T2, and gate_verdict.py refuses a blocking input containing any T2 record (blocking verdict accepts only T0/T1 records; found T2). The command aborts even though every selected test passes, so it reads as a broken release rather than a mis-specified gate. This bit the v0.15.62 and v0.15.63 cuts; both used the tier form above instead.

    --list-affected-lanes stays informational. It names the area lanes this release touched, and the owning dev chat runs the calculation-heavy ones on touch (see AGENTS.md: run affected lanes, not the full inventory). AICCM/CCM/SECCM are experimental and are not release, advisory-profile, or automatic impact tests. For a broad minor release, shared numerical refactor, or any release without a recent green nightly, also run full-fast and slow-nightly from scripts/test_gate/README.md before tagging.

    The lane manifest records method_maturity, lane_class, linked global bug/gate items, target release, and required_full_calculation for every lane. Pytest and CI are change-safety evidence only: scientific_acceptance is false for every lane. Method or paper acceptance still requires the full production-shaped matched-reference calculation selected by the agentic-loop validation policy.

    Reconcile release ancestry now, before anything is tagged. origin/release may sit on a commit that is not an ancestor of main: a prior cut’s post-tag “absorb” merge, or a hotfix tag that never landed on main as a branch. Check, and if so absorb that ancestry into main first:

    git merge-base --is-ancestor origin/release HEAD \
        || git merge -s ours --no-ff origin/release \
               -m "chore: absorb release ancestry pre-vX.Y.Z"
    git push origin main
    

    The -s ours merge keeps main’s tree byte-identical (empty diff); it only records origin/release as a second parent. Because it runs before the tag, the tag in step 4 lands on a commit whose ancestry already contains release, step 5’s git merge --ff-only vX.Y.Z succeeds, and release HEAD ends up exactly on the tag, with the reconcile in its history rather than on top of it.

    That exactness is a published contract, not cosmetics: vq admin update vibeqc-release <host> --tag vX.Y.Z verifies the deployed checkout with git describe --exact-match --tags HEAD after pulling release, and aborts when release HEAD is even one commit past the tag. The v0.15.57 cut ran the reconcile after tagging instead, so every venv host in the 2026-07-24 fleet rollout had to fall back to --expected-sha <release-HEAD> (see vibe-queue/docs/fleet_update_runbook.md § Known gaps). Never absorb after the tag again.

    When origin/release is already an ancestor of main (the normal steady state once this pre-tag ordering has run at least once, and no hotfix intervened), the guard above is a no-op and no absorb commit exists at all.

  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 release: vX.Y.Z subject trips the .githooks/commit-msg guard, which refuses the commit unless pyproject.toml [project] version == X.Y.Z and a ## [vX.Y.Z] CHANGELOG header exists, a mechanical check on the two edits above (it does not police CITATION.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.

  3. Validate example inputs without committing artifacts

    The repository keeps the runnable example inputs, not generated .out / .system / .molden / .traj / .cube artifacts. 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 status should 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.

  4. Prove the tree with a release candidate, then tag it

    CI is release-only: build-test and test-vq do not run on ordinary main pushes, so a green main pipeline is not evidence that the release tree builds. Prove the exact release commit first, and tag only after that pipeline is green:

    git push origin HEAD:refs/heads/release-candidate/vX.Y.Z
    # Wait for green. build-test runs the T1 gate on release-candidate/*,
    # and test-vq / vibe-view-test run unconditionally on that ref.
    glab api "projects/19/pipelines?ref=release-candidate/vX.Y.Z&per_page=1"
    
    git tag -a vX.Y.Z -m "vibe-qc X.Y.Z"
    git push origin vX.Y.Z
    git push origin --delete "release-candidate/vX.Y.Z"   # once tagged
    

    A tag is immutable, so this ordering is the only thing preventing a broken tree from consuming a version number. v0.15.62 was tagged before its gate had ever executed and spent a tag on a red pipeline; v0.15.63 proved the commit on release-candidate/v0.15.63 first and its tag was green on arrival.

  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. The usual cause is a skipped step-1 ancestry reconcile (an old absorb merge or hotfix tag sits on release outside the new tag’s ancestry). Do not “fix” it by merging origin/release on top of the tag: that post-tag absorb leaves release HEAD one commit past the tag and breaks the fleet’s --tag verification (the v0.15.57 failure mode). The tag is already pushed and immutable, so recover like this: perform the post-tag absorb once as a legacy fallback, roll the fleet with --expected-sha <release-HEAD> for this release only, and let the next cut’s step-1 reconcile restore the contract. Any other divergence: 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 example by copying it outside the checkout and running it there; the generated .out banner should carry the same release descriptor, and the generated .system manifest should redact hostname if VIBEQC_NO_HOSTNAME=1 is set.

  7. Post-release

    • Wait for the tag’s release-gate pipeline to go fully green (a docs-only release pipeline is not build evidence). This is the ONE CI run of the release: it executes every component gate, build-test (clean native build + blocking T0+T1), test-vq, ruff-vq, vibe-view-test, docs-build, ci-path-policy, on the exact release tree. Then generate and commit the fleet release report:

      TAG=vX.Y.Z
      python3 vibe-queue/scripts/make_release_report.py \
        --tag "$TAG" \
        --generated-at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
        --output "vibe-queue/releases/$TAG.json"
      git add "vibe-queue/releases/$TAG.json"
      git commit -m "chore(release): record fleet report for $TAG"
      git push origin HEAD:main
      

      The generator pins all four components (release, dev, vq, vibe-view) to the release commit and requires each component’s gating job to have succeeded at that exact SHA in the release-gate pipeline. Ordinary main pipelines no longer exist and are never acceptable evidence. It exits non-zero if any pin lacks evidence. Do not commit a rejected or hand-edited report. Once this report is on main, the operator can run vq admin rollout-latest without supplying a version, tag, or SHA:

      commits → release cut → CI exactly once on the release tree →
      accepted report on main → vq admin rollout-latest → fleet converged
      
    • 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 documentation at https://vibe-qc.com/docs/ auto-deploys via GitLab CI on every push to release (the docs-deploy job, see § CI hooks). The fast-forward of release in step 5 already triggered it; the pipeline at https://gitlab.peintinger.com/mpei/vibeqc/-/pipelines should go green and the docs should report vX.Y.Z. The marketing site at the domain root is a separate deploy that tracks main, so cutting a release does not change it.

    • Verify the live site once the deploy is green:

      curl -sI https://vibe-qc.com/docs/ | head -1            # 200, docs
      curl -sI https://vibe-qc.com/ | head -1                 # 200, marketing site
      curl -sI https://vibe-qc.com/quickstart.html | head -2  # 301 to /docs/quickstart.html
      

      A 404 on the last one means the server-side .htaccess legacy redirects are missing (see § CI hooks). The 2026-07-17 launch shipped without them and every old doc URL 404’d until they were applied by hand.

Sibling modules at vibe-qc release time

The vX.Y.Z tag versions vibe-qc only. The sibling modules in this repo, vibe-view/, vibe-queue/ (vq), and vibe-basis/, are versioned independently, but their releases ride the same tree: whatever is at the tag is what ships. The release chat owns making sure landed sibling work does not stay unversioned (standing decision, 2026-07-23; before this, sibling bumps were dev-chat-owned and routinely didn’t happen).

At every vibe-qc tag, for each sibling module:

  1. Find the sibling’s last version-bump commit (git log --oneline -S '__version__' -- <module>/) and diff the module since then: git log --oneline <bump>..HEAD -- <module>/.

  2. If user-visible work landed (the root CHANGELOG.md [Unreleased] entries name the module in their headings, e.g. “Fixed: vibe-view …”), bump the sibling’s version in the same tree you tag:

    • version carries a .dev0 marker → finalize it (2.1.0.dev02.1.0);

    • no .dev0 marker → bump patch for fixes, minor for features. Promote the module’s [Unreleased] entries under a version heading when you promote vibe-qc’s. Docs-only or refactor-only sibling ranges get no bump, same rule as vibe-qc itself.

  3. Update every version site; they drift when bumped singly:

    Module

    Version sites

    vibe-view

    vibe-view/pyproject.toml · vibe-view/src/vibeview/__init__.py (__version__ + codename comment) · vibe-view/tests/test_offscreen.py (pins __version__) · vibe-view/electron/package.json ("version", drives the desktop auto-updater)

    vq

    vibe-queue/pyproject.toml · vibe-queue/src/vq/__init__.py

    vibe-basis

    vibe-basis/pyproject.toml · vibe-basis/src/vibe_basis/__init__.py (both pinned together by vibe-basis/tests/test_versioning.py; scheme in vibe-basis/VERSIONING.md)

  4. What the bump does not include: publishing the vibe-view desktop update feed. electron-updater only offers an update when the feed’s version is higher than the running app’s, so the package.json bump is the precondition, but the feed publish itself is a separate, maintainer-run deploy (vibe-view/electron/PUBLISHING.md). Note in the release report whether a feed publish is now pending.

  5. Report each sibling’s version (bumped or not, and why) in the release notes, so the fleet can be verified against something.

The dev chat that lands sibling work still may bump proactively (and seeds the next .dev0 when starting a new cycle); the release chat’s job is the backstop audit so nothing ships version-stale.

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 + add the CHANGELOG section. Two edits land in the single release: vX.Y.Z+1 commit:

    • pyproject.toml, bump [project] version to X.Y.Z+1. The runtime reports this value (it is read from installed package metadata via importlib.metadata.version, see python/vibeqc/banner.py _compute_version()). Skipping this bump is exactly what shipped v0.11.2 and v0.11.3 self-reporting 0.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-DD dated section (step 8 below carries this same block back to main, so it must exist here first).

    git commit -am "release: vX.Y.Z+1"
    

    This commit is gated by .githooks/commit-msg: on any release: vX.Y.Z subject it refuses the commit unless pyproject.toml [project] version == X.Y.Z and a ## [vX.Y.Z] CHANGELOG header exists. That is the mechanical backstop for the skipped-bump class of error: ordinary main pushes run no CI, so nothing else gates this. (Activate the hook once per clone per CONTRIBUTING.md § “Pre-commit hook”; reviewed exception: git commit --no-verify.)

  5. 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" --impact-mode release --list-affected-lanes
    "$PY" scripts/test_gate/run_full_suite.py --wt "$WT" --py "$PY" \
        --tier T1 --out blocking.jsonl
    "$PY" scripts/test_gate/gate_verdict.py blocking.jsonl \
        scripts/test_gate/known_reds_baseline.json --wt "$WT" --py "$PY"
    

    The same rule as the minor flow applies: the blocking gate is the T1 tier, matching build-test on a tag or release-candidate/* branch. The --profile release-core --profile-mode blocking form aborts on a T2 record and must not be used here either.

    For a patch release, T1 plus this bounded impact listing is normally the release correctness gate: it checks cheap shipped features plus cheap release-impact lanes touched by the cherry-picks and release-version edits. Calculation-heavy area lanes can be run by the owning dev chat when the patch changes that scientific area, but they are not default patch-release blockers. AICCM/CCM/SECCM stay out of release and advisory profiles while experimental. Run full-fast or slow-nightly only if the patch changes shared numerical machinery or no recent nightly covers the base.

    Do not promote a method or paper value from this pytest result. The manifest’s required_full_calculation field names the full matched calculation evidence that the agentic loop must run before promotion.

  6. Reconcile release ancestry on the hotfix branch, before tagging.

    The hotfix branch carries version = "X.Y.Z+1" in its tree. origin/release may have diverged (e.g. a prior cut’s merge -s ours reconcile 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-base --is-ancestor origin/release HEAD \
        || git merge -s ours --no-ff origin/release \
               -m "chore: absorb release ancestry into vX.Y.Z+1 hotfix"
    

    Why not a plain ff later? A ff-only push to release fails if origin/release is not an ancestor of the hotfix tip (this happens whenever a prior cut left a reconcile commit on release that is not reachable from the hotfix branch). The merge -s ours from the hotfix side keeps the hotfix tree (version = "X.Y.Z+1") while recording origin/release as a parent so the push in step 7 fast-forwards cleanly.

    The critical direction: the merge -s ours must be run from the hotfix branch (keeping the hotfix tree). If run from main instead, it keeps main’s dev-version tree and deploys version = "X.Y.Z+2.devN", the v0.11.1 incident (2026-06-03).

    The critical order: reconcile before the tag, so the tag in step 7 lands on the reconcile merge itself and release HEAD ends up exactly on the tag. Tagging first and absorbing after (the pre-v0.15.58 pattern) leaves release HEAD one commit past the tag, which aborts vq admin update ... --tag vX.Y.Z+1 on every venv host (git describe --exact-match --tags HEAD is its verification): the v0.15.57 fleet-rollout failure.

  7. Tag the reconciled tip, push the tag, fast-forward release.

    git tag -a vX.Y.Z+1 -m "vibe-qc X.Y.Z+1"
    git push origin vX.Y.Z+1
    git push origin HEAD:refs/heads/release   # ← fires CI, site updates
    

    After the push, verify CI completes, the live banner reads Release vX.Y.Z+1 (not dev), and git describe --exact-match --tags origin/release prints vX.Y.Z+1.

  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 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 / .traj artifacts 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.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

CI policy (maintainer decision, 2026-07-26): CI runs exactly once per release, on the exact release tree. Ordinary pushes to main create no pipeline at all: the workflow: rules in .gitlab-ci.yml refuse to create one. Day-to-day test evidence is produced locally before landing (the scripts/test_gate/ release/impact profile for producer work, python -m pytest tests inside vibe-queue/ for queue work, the vibe-view suite for viewer work). The whole deployment contract is:

commits → release cut → CI exactly once on the release tree →
if green, generate + commit the accepted release report →
vq admin rollout-latest → deployment complete

What’s wired today (.gitlab-ci.yml at repo root):

  • The release-gate pipeline is the only pipeline that runs test gates. It is created for a version tag vX.Y.Z or a release-candidate/* branch, and it runs EVERY component gate exactly once on the exact release tree:

    • build-test: builds the vendored numerical libraries via scripts/build_*.sh (libint / libxc / spglib / libecpint / FFTW3), builds the vibe-qc C++ extension in a unique empty build directory with ccache disabled (release evidence is always a clean native build), runs an import vibeqc smoke check (the T0 build-break gate), then executes the T1 production sentinel through the isolated gate runner. third_party/*/install stays cached between runs, keyed on the version-pinned dependency scripts, so only the first compatible run pays the full libint build.

    • test-vq + ruff-vq: the full pure-Python vq suite and lint; neither installs nor compiles vibe-qc.

    • vibe-view-test: the full viewer suite, headless, without the native vibe-qc package.

    • docs-build: the Sphinx site builds.

    • ci-path-policy: the policy guard (below).

    The fleet release report (vibe-queue/scripts/make_release_report.py) consumes this one pipeline’s per-job evidence at the exact release SHA; nothing else is acceptable evidence, and there are no ordinary main pipelines to fall back to. Container python:3.13-slim-bookworm + apt-installed toolchain; runner erzherzog-docker (tag docker).

  • CI path-policy guard (ci-path-policy) runs in every pipeline that exists. It compares the real GitLab rules:changes anchors with the tested classifier (scripts/ci/path_policy.py) and prints representative selections, including the main context, whose correct selection is now the empty set. A CI-policy edit cannot silently re-widen job selection.

  • Merge-request pipelines keep the historical path-gated component checks (build-test for native paths, test-vq/ruff-vq for vibe-queue/**, vibe-view-test for vibe-view/**, docs-build for docs paths) for the rare MR-based contribution. They are not part of the release evidence chain.

  • Website & docs deploys. vibe-qc.com serves the Astro marketing site (website/) at the root and the Sphinx docs under /docs/. All three deploy jobs rsync-over-SSH to web18@vibe-qc.com with the masked, protected $DEPLOY_SSH_KEY_B64 key; the host keys are pinned in deploy/known_hosts.deploy (committed), which replaced the pre-v0.6.46 trust-on-first-use ssh-keyscan after security audit #6 (2026-05-24). Each --delete-after prunes within its own subtree only:

    • website-deploy → the root. Runs on every main push that changes website/: the one main trigger that still creates a pipeline, and it carries deploy jobs only, never test gates. Decoupled from releases so marketing copy and fixes ship on demand; needs: [] means it’s gated only by its own npm run build. A forced redeploy without a website/ change goes through a manually started pipeline (Run pipeline on main).

    • website-staging/web/preview/ (vibe-qc.com/preview/, noindex). Same main + website/-changed trigger; a pre-prod mirror.

    • docs-deploy/web/docs/. Runs automatically on release: public docs are the released version (conf.py’s is_dev logic and the homepage “renders the release branch” admonition depend on this). Publishing latest main docs on demand goes through a manually started pipeline (Run pipeline on main, then play docs-deploy); use it deliberately, since a main build shows the dev version and the Experimental section.

  • Legacy doc-URL redirects are a manual, server-side step. CI deploys deliberately exclude .htaccess. The rules in deploy/htaccess-redirects.conf (old root /quickstart.html/docs/…) live in /web/.htaccess on the server, applied by hand (done 2026-07-17) and persistent across deploys. Re-apply only if the URL structure changes again. After any release that moves doc URLs, verify curl -sI https://vibe-qc.com/quickstart.html returns 301 → /docs/…, not 404. The 2026-07-17 launch shipped without the rules and every legacy doc URL 404’d until they were applied.

  • Runner history. Before path-sensitive CI (2026-07-17), build-test ran on every main push and a queue-only commit spent 1404.7 seconds in it, including 1302.3 seconds rebuilding the editable C++ extension. Path gating removed the job from non-native commits; the 2026-07-26 release-only policy then removed ordinary main pipelines entirely; the erzherzog runner now works only at releases, for MRs, and for deploys. concurrent = 3 remains set on the erzherzog-docker runner so deploy jobs run in parallel with build-test. GitLab runs in Docker on erzherzog; the runner config lives on the host at /docker/gitlab-runner/config/config.toml (bind-mounted into the gitlab-runner container at /etc/gitlab-runner), not in the host’s /etc. Apply config changes with a graceful reload, docker kill -s HUP gitlab-runner, which re-reads the file without aborting running jobs; a container restart would kill them.

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 the documentation: the Sphinx docs at https://vibe-qc.com/docs/ deploy from release (the docs-deploy job; see CI hooks above), so casual visitors see stable docs and install instructions blessed by a tagged release, even as we tear apart main for a refactor. The marketing site at the root is different: it carries no version-specific contract, so it deploys straight from main (website-deploy). Docs are versioned; the marketing shell is evergreen.