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. Test evidence is produced locally before landing (ordinary |
Maintainers + agents, after the affected suites pass locally. |
|
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" --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 showdirty: False. If anything else is wrong, fix it onmainfirst.The blocking gate is the T1 tier, which is exactly what the
build-testjob runs on a tag or arelease-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 blockinghere. That selection pulls intests/test_binding_sanity.py, which is tier T2, andgate_verdict.pyrefuses 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-lanesstays 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 runfull-fastandslow-nightlyfromscripts/test_gate/README.mdbefore tagging.The lane manifest records
method_maturity,lane_class, linked global bug/gate items, target release, andrequired_full_calculationfor every lane. Pytest and CI are change-safety evidence only:scientific_acceptanceis 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/releasemay sit on a commit that is not an ancestor ofmain: a prior cut’s post-tag “absorb” merge, or a hotfix tag that never landed onmainas a branch. Check, and if so absorb that ancestry intomainfirst: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 oursmerge keepsmain’s tree byte-identical (empty diff); it only recordsorigin/releaseas a second parent. Because it runs before the tag, the tag in step 4 lands on a commit whose ancestry already containsrelease, step 5’sgit merge --ff-only vX.Y.Zsucceeds, andreleaseHEAD 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.Zverifies the deployed checkout withgit describe --exact-match --tags HEADafter pullingrelease, and aborts whenreleaseHEAD 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>(seevibe-queue/docs/fleet_update_runbook.md§ Known gaps). Never absorb after the tag again.When
origin/releaseis already an ancestor ofmain(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.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.
Prove the tree with a release candidate, then tag it
CI is release-only:
build-testandtest-vqdo not run on ordinarymainpushes, so a greenmainpipeline 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.63first and its tag was green on arrival.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. The usual cause is a skipped step-1 ancestry reconcile (an old absorb merge or hotfix tag sits on
releaseoutside the new tag’s ancestry). Do not “fix” it by mergingorigin/releaseon top of the tag: that post-tag absorb leavesreleaseHEAD one commit past the tag and breaks the fleet’s--tagverification (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.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
Wait for the tag’s release-gate pipeline to go fully green (a docs-only
releasepipeline 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
mainpipelines 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 onmain, the operator can runvq admin rollout-latestwithout 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.dev0version onmainso 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(thedocs-deployjob, see § CI hooks). The fast-forward ofreleasein step 5 already triggered it; the pipeline at https://gitlab.peintinger.com/mpei/vibeqc/-/pipelines should go green and the docs should reportvX.Y.Z. The marketing site at the domain root is a separate deploy that tracksmain, 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
.htaccesslegacy 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:
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>/.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
.dev0marker → finalize it (2.1.0.dev0→2.1.0);no
.dev0marker → 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.
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__.pyvibe-basis
vibe-basis/pyproject.toml·vibe-basis/src/vibe_basis/__init__.py(both pinned together byvibe-basis/tests/test_versioning.py; scheme invibe-basis/VERSIONING.md)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.jsonbump 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.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¶
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: ordinarymainpushes 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.)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-teston a tag orrelease-candidate/*branch. The--profile release-core --profile-mode blockingform 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-fastorslow-nightlyonly 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_calculationfield names the full matched calculation evidence that the agentic loop must run before promotion.Reconcile release ancestry on the hotfix branch, before tagging.
The hotfix branch carries
version = "X.Y.Z+1"in its tree.origin/releasemay have diverged (e.g. a prior cut’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-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
releasefails iforigin/releaseis not an ancestor of the hotfix tip (this happens whenever a prior cut left a reconcile 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 in step 7 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).The critical order: reconcile before the tag, so the tag in step 7 lands on the reconcile merge itself and
releaseHEAD ends up exactly on the tag. Tagging first and absorbing after (the pre-v0.15.58 pattern) leavesreleaseHEAD one commit past the tag, which abortsvq admin update ... --tag vX.Y.Z+1on every venv host (git describe --exact-match --tags HEADis its verification): the v0.15.57 fleet-rollout failure.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(notdev), andgit describe --exact-match --tags origin/releaseprintsvX.Y.Z+1.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¶
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.Zor arelease-candidate/*branch, and it runs EVERY component gate exactly once on the exact release tree:build-test: builds the vendored numerical libraries viascripts/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 animport vibeqcsmoke check (the T0 build-break gate), then executes the T1 production sentinel through the isolated gate runner.third_party/*/installstays 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 ordinarymainpipelines to fall back to. Containerpython:3.13-slim-bookworm+ apt-installed toolchain; runnererzherzog-docker(tagdocker).CI path-policy guard (
ci-path-policy) runs in every pipeline that exists. It compares the real GitLabrules:changesanchors with the tested classifier (scripts/ci/path_policy.py) and prints representative selections, including themaincontext, 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-testfor native paths,test-vq/ruff-vqforvibe-queue/**,vibe-view-testforvibe-view/**,docs-buildfor 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 toweb18@vibe-qc.comwith the masked, protected$DEPLOY_SSH_KEY_B64key; the host keys are pinned indeploy/known_hosts.deploy(committed), which replaced the pre-v0.6.46 trust-on-first-usessh-keyscanafter security audit #6 (2026-05-24). Each--delete-afterprunes within its own subtree only:website-deploy→ the root. Runs on everymainpush that changeswebsite/: the onemaintrigger 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 ownnpm run build. A forced redeploy without awebsite/change goes through a manually started pipeline (Run pipeline onmain).website-staging→/web/preview/(vibe-qc.com/preview/,noindex). Samemain+website/-changed trigger; a pre-prod mirror.docs-deploy→/web/docs/. Runs automatically onrelease: public docs are the released version (conf.py’sis_devlogic and the homepage “renders the release branch” admonition depend on this). Publishing latestmaindocs on demand goes through a manually started pipeline (Run pipeline onmain, then playdocs-deploy); use it deliberately, since amainbuild 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 indeploy/htaccess-redirects.conf(old root/quickstart.html→/docs/…) live in/web/.htaccesson 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, verifycurl -sI https://vibe-qc.com/quickstart.htmlreturns 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-testran on everymainpush 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 ordinarymainpipelines entirely; the erzherzog runner now works only at releases, for MRs, and for deploys.concurrent = 3remains set on theerzherzog-dockerrunner so deploy jobs run in parallel withbuild-test. GitLab runs in Docker on erzherzog; the runner config lives on the host at/docker/gitlab-runner/config/config.toml(bind-mounted into thegitlab-runnercontainer 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:
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 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.