The package version lives in exactly one place:
src/vfbquery/_version.py -> __version__ = "X.Y.Z"
Everything else derives from it, so the fields can never drift apart:
setup.pyreads_version.pyat build time (viaexec, without importing the package), so the wheel/sdist metadata matches.vfbquery/__init__.pydoesfrom ._version import __version__, sovfbquery.__version__(andha_api.py's version reporting) matches.- The SOLR result cache stamps entries with this version (major.minor) and uses it for invalidation — see CACHING.md.
Do not hard-code the version anywhere else.
- Create a GitHub Release with a tag of the form
vX.Y.Z(e.g.v1.21.0).
That's it — the Publish 🐍 📦 to PyPI workflow
(.github/workflows/publish_to_pypi.yaml) does the rest:
- Checks out the tag, extracts
X.Y.Zfromrefs/tags/vX.Y.Z, and writes it into_version.py(sed). - Builds the sdist/wheel (version comes from
_version.py) and verifies the metadata matches the tag. - Publishes to PyPI via trusted publishing.
- Commits the bump back to
main— switches from the detached tag checkout to livemain, re-appliesX.Y.Zto_version.py, and pushesBump version to X.Y.Z [skip ci].
So after a release, main reflects the released version too — you don't have
to bump it by hand.
The documentation site at https://vfbquery.readthedocs.io is built by Read the
Docs from .readthedocs.yaml and docs/conf.py, and needs nothing added to the
release procedure above: RTD builds latest from every push to main and
stable from every tag, so cutting a release publishes the matching docs on its
own.
Two details are worth knowing when a release does something unexpected:
- The docs version comes from
_version.pyas well.docs/conf.pyreads it the same waysetup.pydoes, so the site header and the{{ release }}substitution on the landing page name the version being documented. Since the publish workflow rewrites_version.pyand commits it back tomain, bothstableandlatestend up correct without a second bump. stableis what PyPI links to. TheDocumentationURL insetup.py'sproject_urls(and in the client'spyproject.toml) points at/en/stable/, because somebody arriving from PyPI has installed the released version, notmain. If a documentation fix needs to reach them before the next release, it has to go out as a release — merging it tomainonly updateslatest.
The build treats warnings as errors (fail_on_warning in .readthedocs.yaml,
-W in the docs workflow), so a broken cross-reference fails the build rather
than shipping a dead link. .readthedocs.yaml also asks for a PDF, which is
built by xelatex and is where the awkward cases live: the PDF drops the emoji
that the generated documents are full of, translating ✅/❌ in a status
column to [OK]/[FAIL], and it elides code blocks over 80 lines because the
1,100-line JSON examples in README.md overflow a TeX box and stop the build
outright. Both transforms are in docs/conf.py, both apply to the PDF only, and
the HTML remains the complete version. docs/_root/ is generated at build time — conf.py
copies the repo-root markdown into it — and is gitignored; do not commit it, and
do not fix a docs-only problem by editing README.md, which is itself generated
(see the note at the top of docs/conf.py).
A minor/major bump invalidates the previous version's cache entries (see CACHING.md), so they're refilled with the new version's output. That happens two ways, with no dedicated release-triggered step:
- Lazily, by the deployed production service as it serves traffic (the primary path — each query refreshes on first read).
- By the
performance-testworkflow onmain— its perf steps are writable on push-to-mainand scheduled (daily) runs (read-only only on PRs), so they recompute and re-cache the perf-test query set under the currentmainversion. The daily schedule guarantees the new version's entries are warmed within a day of a release, so later PR runs read a warm cache.
- The commit-back step runs only after a successful publish and only for
refs/tags/v*(if: success() && startsWith(github.ref, 'refs/tags/v')). - It's a no-op if
mainis already at that version (guarded bygit diff --staged --quiet), so you can also bump_version.pyin a PR before tagging and the workflow won't create an empty commit. - The push needs
contents: write, which is declared in the workflow's jobpermissionsalongside theid-token: writeused for PyPI. [skip ci]keeps the housekeeping commit from retriggering the test/perf workflows.
Because the cache namespace is keyed on major.minor (see CACHING.md):
- Bump the patch for changes that don't alter query output — cached results stay valid (no invalidation).
- Bump minor/major when query output changes — older cache entries are then invalidated on read, so users get refreshed results.