Release process¶
Automated by scripts/release.py
(orchestrator) + .github/workflows/release.yml
+ .github/workflows/docker.yml.
Versioning¶
Semver: MAJOR.MINOR.PATCH. Pre-1.0 MINOR may include breaking changes
documented in the changelog. Post-1.0 breaking changes only in MAJOR,
preceded by ≥1 MINOR with deprecation warnings. Deprecated features
remain for ≥2 MINOR versions or 6 months.
Release tooling¶
Three Python scripts back the release flow:
| Script | Responsibility |
|---|---|
scripts/bump_version.py |
Update __version__ in src/bqemulator/__init__.py and the ?cacheSeconds=N&v=X.Y.Z cache-bust suffix on the README's shields.io PyPI / Python-versions badges (README.md). The README rewrite is idempotent and silent when the pattern is absent. Validates the new version is strictly greater than the current. |
scripts/changelog.py |
Validate the operator-authored ## [X.Y.Z] section and stamp today's date into its header (## [X.Y.Z] - YYYY-MM-DD). Refuses to stamp a missing or empty section. |
scripts/release.py |
Orchestrator. Runs make verify, calls the two scripts above, and creates the release commit + tag. Default mode is --dry-run; pass --apply to mutate state. |
All three scripts emit distinct exit codes per failure mode so
release.yml and operator scripting can pin the abort point (see the
EXIT_* constants at the top of each script).
Quick reference¶
# 1. Branch off main and stage the README "Project status" flip
# (rc → final, ⚪ → ✅ on PyPI + GHCR rows) as the FIRST commit:
git checkout -b release/vX.Y.Z
$EDITOR README.md
git add README.md && git commit -m 'docs(release): flip README to vX.Y.Z final'
# 2. Preview the release (no files touched, no git state changed):
python scripts/release.py --dry-run --next minor
# or
make release-dry-run NEXT=minor
# 3. Apply the release (runs make verify, mutates files, commits, tags):
python scripts/release.py --apply --next minor
# or
make release NEXT=minor
# 4. Push branch + open the PR; CI gates the release commit.
# 5. After squash-merge, reconcile the tag onto the merged commit:
git checkout main && git pull --ff-only
git tag -d vX.Y.Z
git tag -a vX.Y.Z -m vX.Y.Z
git push origin vX.Y.Z
# 6. .github/workflows/release.yml fires on the tag push.
The orchestrator's hard preconditions (every one of which aborts with a dedicated exit code):
- The current directory is a git repository (
.gitpresent). - Working tree is clean (
git status --porcelainis empty). make verifyexits 0 — the full release gate chain (lint + unit + property + integration + docker + e2e + docs).- The computed target version is strictly greater than the current.
- A new
## [X.Y.Z] - YYYY-MM-DDsection has been prepended toCHANGELOG.mdwith at least one entry. The section is authored during this release process (step 1 below) — not in the individual PRs that landed during the cycle.
Step-by-step (with the orchestrator)¶
-
Author the changelog section. Read
git log <prev-tag>..HEADto enumerate user-visible changes since the previous release. Synthesise one bullet per change under### Changed/### Added/### Removed/### Fixed(Common Changelog ordering — breaking changes most important). Entries follow the documentation style guide: imperative mood, single line, no per-entry sub-headings, no PR references, no fixture counts. Prepend the new section under the# Changelogheading; there is no## [Unreleased]section between releases. -
Pre-release doc sweep. The release orchestrator only mutates three surfaces:
src/bqemulator/__version__and the README shields.io badge cache-bust query parameter (both viabump_version.py), plusCHANGELOG.md(viachangelog.py). Every other version- or maturity-bearing string is manual and must be checked once per release. The audit:
| File | What to update at a MAJOR / first-stable cut |
|---|---|
pyproject.toml |
Development Status classifier (e.g. 3 - Alpha → 5 - Production/Stable at v1.0.0). Python version classifiers must match the CI test matrix in ci.yml — pip install users see this list on the PyPI page and on the Python shield in the README. |
README.md — "Project status" section |
Drop "pre-1.0" / "currently 0.x.y" language; promote any ⚪ maturity rows for things now shipped (PyPI publish, GHCR publish, etc.). Land this on the release branch itself (first commit, before make release) — see the "README 'Project status' flip" subsection below. The rest of this sweep can land in a separate pre-release housekeeping PR. |
README.md — "Conformance corpus depth" header |
If the snapshot date is older than ~30 days, regenerate with make coverage-matrix and update the prose. |
docs/getting-started.md + docs/reference/cli.md |
Example outputs that hard-code a version string ({"status":"ok","version":"0.1.0"}, bqemulator 0.1.0). |
docs/index.md — "Status" callout |
The **vX.Y.Z** — production-stable version string in the landing-page Status admonition. Not covered by bump_version.py; update every release. |
All four auto-generated reference docs (conformance-coverage-matrix, compatibility-matrix, sql-function-mapping, api-coverage) |
Run make matrix coverage-matrix and commit any diff. The umbrella make matrix covers compat-matrix + function-mapping + api-coverage in one call; make coverage-matrix is separate because it walks the conformance corpus. The Docs-drift CI gate runs the matching --check modes on every PR. |
docs/reference/api-configuration-coverage-matrix.md |
Manually-maintained audit doc (not auto-generated). Skim for new configuration knobs the release added; add a row whenever a new configuration surface lands. |
.dev/STATUS.md, .dev/v1-confidence-plan.md |
Internal status trackers — update before any external version claim references them. |
Land this sweep as a pre-release housekeeping PR before running
the orchestrator. The orchestrator's make verify step won't catch
maturity drift; CI doesn't know your Development Status is stale.
Treat the doc sweep as part of the release contract, not as an
afterthought.
-
Branch off
mainand stage the README flip. Conventional branch name:release/vX.Y.Z. The first commit on this branch flips the README "Project status" wording fromvX.Y.Z-rc/ "staged onmain" prose to factual "at vX.Y.Z — the initial production-stable release" wording, and promotes the ⚪ PyPI + GHCR maturity rows to ✅ with the actualpip install/docker pullcommands. The bump commit created by the orchestrator (step 6) stacks on top, and squash-merge collapses both into a single commit onmain. See the README "Project status" flip subsection at the bottom of this section for the chicken-and-egg rationale. -
Dry-run the release locally.
This:
- Verifies the working tree is clean.
- Runs
make verify(full gate chain). Pass--skip-verifyonly when debugging the release tooling itself. - Previews the proposed
__init__.pybump. - Previews the proposed CHANGELOG finalisation.
- Prints what the commit message + tag would be.
- Returns 0 with the working tree completely untouched.
-
Inspect the preview. The terminal output is the contract — the operator confirms the version, date, commit message, and tag name match expectations before applying.
-
Apply the release.
This re-runs steps 4–5 of the dry-run for real, then:
- Writes the new
__version__. - Stamps the release date into the operator-authored
## [X.Y.Z]section inCHANGELOG.md. - Stages every change with
git add -A. - Creates the release commit (
release: bump to vX.Y.Z). - Creates an annotated tag (
vX.Y.Z). Whengit config commit.gpgsign trueis set globally, the tag is signed automatically; the orchestrator does not force-s.
At this point the new commit + tag are in your local clone only — nothing has hit the remote yet.
- Writes the new
-
Open a PR. The release commit goes through CI like any other. The full gate chain must be green. CODEOWNERS approval rules apply.
-
Merge to
main. Squash-merge per the repo convention. Note: squash-merge produces a new commit SHA onmainthat subsumes every commit on the release branch — including the orchestrator'srelease: bump to vX.Y.Zcommit. The annotated tag created in step 6 still points to the unmerged release-branch commit, which is no longer reachable frommainafter the merge. Step 9 reconciles the tag. -
Reconcile and push the tag. Re-create the tag on the merged commit before publishing it, otherwise
release.ymlbuilds the wheel from a commit that isn't onmain:git checkout main git pull --ff-only origin main git tag -d vX.Y.Z # remove the orphaned local tag git tag -a vX.Y.Z -m vX.Y.Z # re-annotate on the squash-merge commit git push origin vX.Y.Z # publish the tagThe orchestrator created the tag as
git tag -a vX.Y.Z -m vX.Y.Z(annotation is intentionally just the tag name —release.ymlpulls release-note content fromCHANGELOG.mdseparately), so re-creating it is byte-equivalent. Whengit config commit.gpgsign trueis set globally, the re-created tag is signed automatically; the orchestrator does not force-s.Pushing the tag fires
.github/workflows/release.yml, which:- Builds the wheel + sdist with
python -m build. - Publishes to PyPI via Trusted Publishing (sigstore attestation).
- Creates the GitHub Release with auto-generated notes.
In parallel, the tag also fires
.github/workflows/docker.yml, which publishes the multi-arch image to GHCR with cosign keyless signatures. - Builds the wheel + sdist with
-
Smoke-test the published artefacts.
README "Project status" flip (folded into the release PR)¶
The README's aspirational-vs-factual wording flips on the
release branch as the first commit, before make release
runs. The bump commit created by the orchestrator stacks on top,
and squash-merge collapses both into a single commit on main.
Folding the wording flip into the release PR avoids a
chicken-and-egg gap where the release commit would otherwise
reference a vX.Y.Z-rc README that a follow-up PR has to flip
⚪ → ✅.
The actionable list of strings to flip is the inverse of step 2's pre-release sweep — the entries below can only be true after the publish workflows finish, but they are written in the release commit anyway:
| File | What to flip (release branch, first commit, before make release) |
|---|---|
README.md — "Project status" header |
vX.Y.Z-rc / "staged on main" prose → factual "at vX.Y.Z — the initial production-stable release" wording. |
README.md — "Maturity signals" rows |
⚪ "PyPI publish — wired and waiting on the tag push" → ✅ with the actual pip install / docker pull command. |
The trade-off is explicit: the README now claims the artefacts
exist a few minutes before the publish workflows finish.
That window closes within roughly 5–10 min of pushing the tag
(release.yml + docker.yml end-to-end). The convention is to
verify the artefacts (pip install, docker pull, cosign
verification) right after the tag push — if a workflow fails,
the next commit on main is the README revert, not a separate
"flip" PR.
CLI reference¶
scripts/bump_version.py¶
python scripts/bump_version.py 1.0.0 # explicit
python scripts/bump_version.py --major # 0.1.0 -> 1.0.0
python scripts/bump_version.py --minor # 0.1.0 -> 0.2.0
python scripts/bump_version.py --patch # 0.1.0 -> 0.1.1
python scripts/bump_version.py --next minor # alias for --minor
python scripts/bump_version.py --print # report current; no mutation
python scripts/bump_version.py --next minor --check
# validate without writing
Exit codes:
| Code | Meaning |
|---|---|
| 0 | OK |
| 2 | Usage error (malformed version, missing argument) |
| 3 | Proposed version not strictly greater than current |
scripts/changelog.py¶
python scripts/changelog.py 1.0.0 # stamp release date
python scripts/changelog.py 1.0.0 --date YYYY-MM-DD
python scripts/changelog.py 1.0.0 --check # validate only
Exit codes:
| Code | Meaning |
|---|---|
| 0 | OK |
| 2 | Usage error (malformed version or date, missing file) |
| 3 | No ## [X.Y.Z] section in the changelog |
| 4 | ## [X.Y.Z] section has no bullet entries |
| 5 | Topmost section's version does not match the release target |
scripts/release.py¶
python scripts/release.py --dry-run --next minor # preview (default mode)
python scripts/release.py --apply --next patch # full pipeline
python scripts/release.py --apply --version 1.0.0 # explicit version
python scripts/release.py --apply --next minor --skip-verify
# skip ``make verify``
Exit codes:
| Code | Meaning |
|---|---|
| 0 | OK |
| 2 | Argparse usage error |
| 10 | Not a git repository (or git missing on PATH) |
| 11 | Working tree is not clean |
| 12 | make verify failed |
| 13 | bump_version failed (version validation / file write) |
| 14 | changelog finalisation failed |
| 15 | git commit failed |
| 16 | git tag failed |
Artifact signing¶
-
Docker images signed with keyless cosign via GitHub OIDC. Verify:
-
PyPI wheels carry sigstore attestations via Trusted Publishing.
Abandoning a release locally¶
scripts/release.py --apply only mutates your local clone. If the
inspection after step 5 reveals a problem, you can back out cleanly:
git tag -d vX.Y.Z # delete the local tag (it's not on the remote yet)
git reset --hard HEAD~1 # discard the release commit
Tags are immutable on GitHub. Never push a tag you intend to re-build — push the corrected tag under a new version number.