Skip to content

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):

  1. The current directory is a git repository (.git present).
  2. Working tree is clean (git status --porcelain is empty).
  3. make verify exits 0 — the full release gate chain (lint + unit + property + integration + docker + e2e + docs).
  4. The computed target version is strictly greater than the current.
  5. A new ## [X.Y.Z] - YYYY-MM-DD section has been prepended to CHANGELOG.md with 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)

  1. Author the changelog section. Read git log <prev-tag>..HEAD to 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 # Changelog heading; there is no ## [Unreleased] section between releases.

  2. 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 via bump_version.py), plus CHANGELOG.md (via changelog.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 - Alpha5 - Production/Stable at v1.0.0). Python version classifiers must match the CI test matrix in ci.ymlpip 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.

  1. Branch off main and stage the README flip. Conventional branch name: release/vX.Y.Z. The first commit on this branch flips the README "Project status" wording from vX.Y.Z-rc / "staged on main" prose to factual "at vX.Y.Z — the initial production-stable release" wording, and promotes the ⚪ PyPI + GHCR maturity rows to ✅ with the actual pip install / docker pull commands. The bump commit created by the orchestrator (step 6) stacks on top, and squash-merge collapses both into a single commit on main. See the README "Project status" flip subsection at the bottom of this section for the chicken-and-egg rationale.

  2. Dry-run the release locally.

    python scripts/release.py --dry-run --next minor
    

    This:

    • Verifies the working tree is clean.
    • Runs make verify (full gate chain). Pass --skip-verify only when debugging the release tooling itself.
    • Previews the proposed __init__.py bump.
    • Previews the proposed CHANGELOG finalisation.
    • Prints what the commit message + tag would be.
    • Returns 0 with the working tree completely untouched.
  3. Inspect the preview. The terminal output is the contract — the operator confirms the version, date, commit message, and tag name match expectations before applying.

  4. Apply the release.

    python scripts/release.py --apply --next minor
    

    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 in CHANGELOG.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). When git config commit.gpgsign true is 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.

  5. Open a PR. The release commit goes through CI like any other. The full gate chain must be green. CODEOWNERS approval rules apply.

  6. Merge to main. Squash-merge per the repo convention. Note: squash-merge produces a new commit SHA on main that subsumes every commit on the release branch — including the orchestrator's release: bump to vX.Y.Z commit. The annotated tag created in step 6 still points to the unmerged release-branch commit, which is no longer reachable from main after the merge. Step 9 reconciles the tag.

  7. Reconcile and push the tag. Re-create the tag on the merged commit before publishing it, otherwise release.yml builds the wheel from a commit that isn't on main:

    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 tag
    

    The orchestrator created the tag as git tag -a vX.Y.Z -m vX.Y.Z (annotation is intentionally just the tag name — release.yml pulls release-note content from CHANGELOG.md separately), so re-creating it is byte-equivalent. When git config commit.gpgsign true is 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.

  8. Smoke-test the published artefacts.

    docker pull ghcr.io/jjviscomi/bqemulator:X.Y.Z
    pip install "bqemulator==X.Y.Z"
    bqemulator version   # prints X.Y.Z
    

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:

    cosign verify ghcr.io/jjviscomi/bqemulator:X.Y.Z \
        --certificate-identity-regexp "github.com/jjviscomi/bqemulator" \
        --certificate-oidc-issuer https://token.actions.githubusercontent.com
    
  • 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.