Release Management¶
How beetkeeper versions and publishes its release artifacts. All commands run from the repo root.
What gets published¶
A release publishes three artifacts, all carrying the same semver:
| Artifact | Pants target | Destination |
|---|---|---|
beetkeeper wheel |
src/python:beetkeeper-whl |
PyPI (beetkeeper) |
beetkeeper-plugin wheel |
src/beetsplug:plugin-whl |
PyPI (beetkeeper-plugin) |
beetkeeper-server image |
//:beetkeeper-server-image |
GHCR ghcr.io/zach-overflow/beetkeeper (:latest + :<version>) |
The docs site (GitHub Pages) is also rebuilt and redeployed as part of every release: mike publishes
the release's MAJOR.MINOR docs version to the gh-pages branch and points the latest alias at it.
Versioning model — the git tag is the source of truth¶
Both wheels are versioned from the vMAJOR.MINOR.PATCH git tag via Pants'
vcs_version target (setuptools-scm
under the hood); no version string is committed anywhere. The moving parts:
- Each distribution has a
vcs_versiontarget (src/python/beetkeeper/BUILD,src/beetsplug/beetkeeper_plugin/BUILD) that runs setuptools-scm against the real repo and generates a_scm_version.pymodule into every consuming Pants sandbox (dependency inference picks it up from the import in_version.py). - Each package's committed
_version.pyre-exports the generated module, falling back to0.0.0.dev0where it doesn't exist — i.e. any build/run outside Pants, such as the uv dev venv. - Each
pyproject.tomlresolvesdynamic = ["version"]via[tool.setuptools.dynamic] version = {attr = "<pkg>._version.__version__"}, so the wheel metadata gets the generated version inside Pants and the dev placeholder under uv. - On a checkout of the release tag (what the Publish workflow does), setuptools-scm yields the exact
MAJOR.MINOR.PATCH; on any other commit it yields a dev version (X.Y.Z.devN+g<hash>), which is what localpants packageproduces.
The git tag carries a leading
v(GitHub release convention); every published version id (wheel versions + the:<version>image tag) is thev-stripped semver. They can no longer disagree — the version is derived from the tag.
The tag itself is created by cocogitto (cog bump --auto) during the
Release workflow: the next semver is computed from the
conventional commits landed on main since the previous release
tag (fix: → patch, feat: → minor, BREAKING CHANGE → major). Nobody picks a version number by hand.
Conventional commits and cog.toml¶
PRs are squash-merged, so the PR title becomes the commit that lands on main (the branch's own
commits are discarded) and must be a conventional commit message — the Release workflow rejects the
release otherwise. In practice:
- Write PR titles as conventional commits:
feat(scope): ...,fix: ...,docs: ...; mark breaking changes with!(e.g.feat!: ...). The PR title workflow (.github/workflows/pr-title.yml) runscog verifyon every PR and re-runs whenever the title is edited, so a non-compliant title can't be merged unnoticed. - Keep each PR to one logical change — it gets exactly one changelog entry. If a PR contains two
changelog-worthy changes, split it. Commits within a PR branch can be anything (
wip,fixup); they're discarded by the squash.
Cocogitto's behavior is configured in cog.toml at the repo root:
tag_prefix = "v"+from_latest_tag = true— cog reads/writesvMAJOR.MINOR.PATCHtags (matching thevcs_versionscheme above) and only ever considers commits since the latest release tag, so history from before the cocogitto adoption is never checked.branch_whitelist = ["main"]—cog bumprefuses to run anywhere butmain.disable_bump_commit = true+disable_changelog = true— the bump is tag-only: nochore(version)commit and no committedCHANGELOG.md, so the release never pushes anything tomain(branch protection and required status checks can't conflict with it) and the tag lands directly on the validated commit. The changelog lives on the GitHub releases page instead.post_bump_hooks—cog bumpitself is what pushes the new tag (the GitHub action wrapping it pushes nothing).[changelog]— theremotetemplate renders GitHub-linked changelog entries;cog changelog --at vX.Y.Zgenerates each GitHub release's body straight from git history.
Cutting a release (runbook)¶
- Land conventional commits on
main. The commits since the latest tag are the release: they determine the version bump and become the changelog. There is nothing to draft or write by hand. - Run the Release workflow: Actions → Release → Run workflow (from
main). It checks the pending commits are conventional and warrant a bump, validates the repo, and builds every artifact without publishing anything. - Approve the
releaseenvironment prompt once validation is green.cog bump --autothen tags the validated commitvX.Y.Zand pushes the tag (nothing else is pushed), the GitHub release is uploaded with the cog-generated changelog as its body, and the Publish workflow takes over to build and publish the wheels, the multi-arch image, and the docs site (PyPI and Pages keep their own environment approvals).
What the Release workflow does (.github/workflows/release.yml)¶
Triggered manually via workflow_dispatch (must be run from the default branch):
check-commits— runscog check(commits since the latest tag must be conventional) andcog bump --auto --dry-run, which fails the run early if nothing warrants a bump and surfaces the pending version as a notice.- Validation, all in parallel and publishing nothing:
validate— actionlint,pants update-build-files --check ::,pants lint check test ::, and a wheel build (versioned as a dev build — the tag doesn't exist yet).build-image— the Docker image on native amd64 + arm64 runners (built, not pushed).docs-build—mkdocs build --strict.approve-and-tag— pauses on thereleaseenvironment (the one manual gate). On approval,cog bump --auto(viacocogitto/cocogitto-action) computes the version and pushes thevX.Y.Ztag, which lands directly on the validated commit (the bump is tag-only — see thecog.tomlnotes above — so nothing is pushed tomain); the job then generates the release changelog (cog changelog --at vX.Y.Z) and uploads the GitHub release with it as the body.publish— dispatches the Publish workflow (gh workflow run publish.yml) on the new tag's ref. Dispatch is the only viable hand-off: a tag created with the built-inGITHUB_TOKENcan never fire another workflow'spushtrigger (GitHub's recursive-workflow guard, from whichworkflow_dispatchis exempt), and aworkflow_callhand-off breaks PyPI trusted publishing, which rejects reusable workflows.
What the Publish workflow does (.github/workflows/publish.yml)¶
Dispatched by Release (or triggered by a manually pushed vMAJOR.MINOR.PATCH tag; it can also be run
by hand via Actions → Publish → Run workflow for an existing tag). Three build
jobs run in parallel from the tag's commit, then three publication jobs run in parallel once all
builds succeed:
| Build (parallel) | Publication (parallel, after all builds) | Approval gate |
|---|---|---|
build-wheels — both wheels, versioned from the tag checkout |
publish-pypi — OIDC trusted publishing, one upload per project |
pypi environment |
build-image — native per-arch builds, exported as tarball artifacts |
publish-image — push per-arch tags, stitch the :<version> + :latest manifest list with buildx imagetools |
none (GHCR, GITHUB_TOKEN) |
build-docs — mkdocs build --strict (validation only) |
docs-deploy — mike deploys the MAJOR.MINOR docs version (+ latest alias) to gh-pages |
github-pages environment |
Required configuration¶
releaseenvironment — must exist with a required reviewer (repo Settings → Environments); this is the single "proceed with the release?" prompt.- Tag ruleset — no ruleset may have Restrict creations enabled for
v*tags:approve-and-tagpushes the release tag with the built-inGITHUB_TOKEN, which is rejected with "Cannot create ref due to creations being restricted" and cannot be added to a ruleset bypass list (only roles, teams, deploy keys, and installed GitHub Apps can). Restricting tag updates/deletions is fine. Note the trade-off: any write-access collaborator can then push av*tag, which triggers the Publish workflow directly (PyPI/Pages publication stays environment-gated; the GHCR image push is not). mainbranch protection — optional and fully compatible: the release never pushes commits tomain(the bump is tag-only), so any branch protection/ruleset can be enabled freely. In particular, making the PR title workflow's "Verify the PR title is a conventional commit" job a required status check is the recommended way to guarantee only conventional commits land onmain.- Squash merge message defaults — repo Settings → General → Pull Requests: keep only squash
merging enabled, and set the default commit message to "Pull request title" (title =
PR_TITLE, message = blank). This makes the verified PR title the squash commit verbatim and keeps inner-commit messages out of the body, where a strayBREAKING CHANGE:line would silently force a major bump. - GHCR — uses the built-in
GITHUB_TOKEN(packages: write); no extra secret. - PyPI — OIDC trusted publishing; no stored token. Each PyPI project (
beetkeeper,beetkeeper-plugin) needs a Trusted Publisher registered for owner/repozach-overflow/beetkeeper, workflowpublish.yml, environmentpypi. Publish always runs as its own top-level workflow (dispatched, neverworkflow_called) precisely because PyPI rejects reusable workflows. - GitHub Pages — repo Pages source set to "Deploy from a branch" (
gh-pages, root); mike creates the branch on the first release deploy. Thegithub-pagesenvironment holds any deploy approval rule (it gates the workflow's mike push, which is the publish event).
Troubleshooting¶
check-commitsfails:cog checkparse error — a commit onmainsince the latest release tag isn't a valid conventional commit (usually a squash-merge PR title). History can't be rewritten onmain, so land the remaining commits with compliant messages, or as a last resort adjust[commit_types]incog.toml.check-commitsfails but the reported commit is the previous release's — running the workflow with zero commits since the latest tag makes cog fall back to checking the tagged commit itself, which can produce a confusing parse error about the already-released commit. Either way the run is correctly telling you there is nothing to release.cog bump --auto --dry-runfails: no bump warranted — the commits since the latest tag are all non-bumping types (chore:,docs:, ...). Land at least onefix:/feat:/breaking change.approve-and-tagfails pushing the release tag — check the tag ruleset note under Required configuration.uv.lock is out of sync— runuv lockand commit the result.- Publish ran but wheels carry a dev version — the build didn't see the release tag; the
build-wheelsjob must check outv<version>withfetch-depth: 0(setuptools-scm needs the tag in the checkout). - PyPI publish fails with a trusted-publisher / OIDC error — the registered Trusted Publisher must
match exactly: owner/repo
zach-overflow/beetkeeper, workflowpublish.yml, environmentpypi. Also note PyPI rejects reusable workflows outright, so Publish must never be converted toworkflow_call. - PyPI upload rejected as already existing — the publish steps use
skip-existing, so a re-run is safe; publishing a new release requires a new version (PyPI forbids overwriting an existing one).