From 25d2a11e6154ac99d787f4efbc6a3e6c73d1e9a8 Mon Sep 17 00:00:00 2001 From: Henri Drake Date: Tue, 28 Jul 2026 08:33:20 -0700 Subject: [PATCH] Derive the version from the git tag instead of a checked-in file `xbudget/version.py` held a literal that had to be bumped in its own commit before every release, and nothing tied that commit to the tag the release was actually cut from. The two could disagree, and when they did the symptom was a 400 from PyPI at the very end of the release. hatch-vcs derives the version from the tag at build time and writes it to a generated `xbudget/_version.py`, so tagging *is* the bump. `version.py` becomes a shim over the generated file, with a `0.0.0+unknown` fallback for a checkout that has never been built. The tag has to be visible for that to work, so every checkout that installs the package uses `fetch-depth: 0` and Read the Docs unshallows in `post_checkout`; without it the build quietly produces a `.devN` artifact. The publish workflow also asserts that the version it built matches the tag it was fired from, which is the check that would have caught the failure mode described above. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 12 +++++++++++- .github/workflows/publish-to-pypi.yml | 22 +++++++++++++++++++-- .gitignore | 3 +++ .readthedocs.yaml | 9 +++++++++ CHANGELOG.md | 12 ++++++++++++ CLAUDE.md | 12 ++++++++++++ README.md | 28 +++++++++++++++++++++++++++ pyproject.toml | 21 ++++++++++++++++++-- xbudget/version.py | 19 ++++++++++++++++-- 9 files changed, 131 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 134420e..052901a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,12 @@ jobs: with: access_token: ${{ github.token }} - name: Checkout source - uses: actions/checkout@v2 + uses: actions/checkout@v4 + with: + # `pip install -e .` resolves the version from the git tag via + # hatch-vcs; a shallow clone has no tag, so the installed package + # would report `0.1.devN` instead of the release line. + fetch-depth: 0 - name: Conda setup uses: conda-incubator/setup-miniconda@v2 @@ -60,6 +65,11 @@ jobs: steps: - name: Checkout source uses: actions/checkout@v4 + with: + # The docs install xbudget and title the pages with its version + # (`docs/conf.py` reads it from the installed metadata), which + # hatch-vcs resolves from the git tag. + fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v5 diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml index b3699bd..24d5c23 100644 --- a/.github/workflows/publish-to-pypi.yml +++ b/.github/workflows/publish-to-pypi.yml @@ -8,9 +8,14 @@ jobs: build-and-publish: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 + with: + # hatch-vcs derives the version from the git tag, which a shallow clone + # does not have. Without this the build silently produces a + # `0.0.0.dev...` artifact instead of the release version. + fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: '3.x' - name: Install dependencies @@ -19,6 +24,19 @@ jobs: pip install build twine - name: Build package run: python -m build + # Deriving the version from the tag should make this unfalsifiable, but it + # is the last point at which a mismatch is cheap to see: the alternative + # symptom is a 400 from PyPI at the very end of the release, and only when + # that version already exists. + - name: Built version must match the release tag + run: | + built=$(ls dist/*.tar.gz | sed -E 's|.*/xbudget-(.*)\.tar\.gz|\1|') + tag="${GITHUB_REF_NAME#v}" + echo "tag=$tag built=$built" + if [ "$built" != "$tag" ]; then + echo "::error::Release tag $GITHUB_REF_NAME implies version '$tag', but the build produced '$built'." + exit 1 + fi - name: Publish package env: TWINE_USERNAME: __token__ diff --git a/.gitignore b/.gitignore index e72b25b..f184e87 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ __pycache__ /data/* .pytest_cache .DS_Store + +# Version file generated from the git tag by hatch-vcs at build time. +xbudget/_version.py diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 7943a2b..184500b 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -7,6 +7,15 @@ build: os: ubuntu-24.04 tools: python: "3.12" + jobs: + post_checkout: + # RTD clones shallow, but installing xbudget resolves its version from the + # git tag via hatch-vcs. Without the tag the docs get titled with a + # `.devN` version instead of the release. Tolerate failure: an + # already-complete clone makes `--unshallow` an error, which must not fail + # the build. + - git fetch --unshallow || true + - git fetch --tags || true # Build the Sphinx docs. Warnings fail the build (matches CI's -W). sphinx: diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ee4df2..d581319 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,18 @@ changes, which fails if the feedstock's run requirements disagree with `pyproject.toml`. The autotick bot syncs a release's version and hash but never its dependencies. +- The version is now derived from the git tag by `hatch-vcs` instead of being + read from a literal in `xbudget/version.py`. Releasing is just publishing a + GitHub Release tagged `vX.Y.Z` — there is no bump commit, and the tag and the + built artifact cannot disagree. `xbudget/_version.py` is generated at build + time (gitignored, shipped in the sdist and wheel) and `xbudget/version.py` is + now a shim that imports from it; `xbudget.__version__` is unchanged for + anything installed from a release. + + Two consequences for anyone building xbudget themselves: a checkout without + tags (a shallow clone, or a fork that never fetched them) resolves a `.devN` + version rather than the release line, and the conda-forge feedstock will need + `hatch-vcs` added to its `host` requirements before the next release builds. [feedstock]: https://github.com/conda-forge/xbudget-feedstock diff --git a/CLAUDE.md b/CLAUDE.md index deb7fc5..99b2de0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,6 +86,18 @@ recipe ──parse_budgets──▶ typed tree (nodes.py) ──evaluate_budgets - Docs (`docs/`) are Sphinx + Furo with `myst_nb`, which renders both the `.md` pages and the `examples/*.ipynb` notebooks (copied into `docs/examples/` at build time by a hook in `conf.py`). Notebooks are **not** executed at build time (`nb_execution_mode = "off"`); their committed outputs are rendered as-is. `quickstart.md` and `recipes.md` are hand-written. Build locally exactly as CI does: `python -m sphinx -b html -W --keep-going docs docs/_build/html`. `-W` mirrors `.readthedocs.yaml`'s `fail_on_warning: true` — a broken cross-reference fails the build. - Docs are built three ways, deliberately: the `docs` job in `ci.yml` (pre-merge gate, uploads the rendered HTML as a `docs-html` artifact), Read the Docs PR builds (the hosted preview link on the PR), and RTD `latest` on merge. If you change the Sphinx config, keep the CI command and `.readthedocs.yaml` in agreement. +## Versioning + +**The git tag is the single source of truth.** `hatch-vcs` (`[tool.hatch.version] source = "vcs"`) derives the version from the tag at build time and writes it to `xbudget/_version.py`, which is **gitignored** — there is no version string in the source tree. `xbudget/version.py` is a thin shim that imports from it, with a `0.0.0+unknown` fallback for an un-built checkout. + +Consequences worth remembering when editing: + +- **Never add a version literal back to the tree**, and never "fix" a `0.0.0+unknown` by hardcoding one — it means the package was imported without being built or installed. +- **Any CI job that installs the package needs `fetch-depth: 0`.** A shallow clone cannot see the tag, so hatch-vcs silently resolves a `0.1.devN` version instead of failing. Both checkouts in `ci.yml` and the one in `publish-to-pypi.yml` set it, and `.readthedocs.yaml` unshallows in `post_checkout` for the same reason. +- `_version.py` **is** shipped inside the sdist, so building from the sdist (as conda-forge does) works with no git present. Do not add it to `[tool.hatch.build] exclude`. +- The conda-forge feedstock builds with `--no-build-isolation`, so its `host` requirements must list `hatch-vcs` next to `hatchling`. `ci/check_conda_parity.py` compares only *run* requirements, so it will not catch that. +- Releasing is just publishing a GitHub Release tagged `vX.Y.Z`; there is no bump commit. See "Releasing" in `README.md`. + ## Pull request workflow When you push a new commit to a branch that already has an open pull request, update the PR description (the top comment / body) so it stays consistent with the latest commit — don't leave it describing only the original state: diff --git a/README.md b/README.md index 2e5df8e..22fb104 100644 --- a/README.md +++ b/README.md @@ -22,3 +22,31 @@ pip install -e . python -m ipykernel install --user --name docs_env_xbudget --display-name "docs_env_xbudget" jupyter-lab ``` + +## Releasing + +**The git tag is the version.** `xbudget` has no version string checked into the +source tree: `hatch-vcs` derives it from the tag at build time and writes +`xbudget/_version.py` (gitignored, but shipped inside the sdist and wheel). To +cut a release you tag; there is no file to bump and nothing to keep in sync. + +1. Make sure `main` is green and has everything you want in the release, and + move the `## Unreleased` entries in [CHANGELOG.md](CHANGELOG.md) under the + new version heading. +2. **Publish a GitHub Release** whose tag is `vX.Y.Z`, targeting the commit you + want to ship: + ```bash + gh release create vX.Y.Z --target "$(git rev-parse origin/main)" \ + --title vX.Y.Z --generate-notes + ``` + Publishing it (not merely pushing a tag) is what fires the workflow. Target + the commit you actually want: a tag placed *before* the commit you meant to + release builds the previous version, and PyPI rejects it as a duplicate. +3. The **Publish to PyPI** workflow builds from that tag and uploads. It checks + out with `fetch-depth: 0` so the tag is visible to `hatch-vcs`, and asserts + that the built version matches the tag before publishing. +4. Verify: . +5. conda-forge builds from the PyPI sdist and lags by design; the autotick bot + opens the version-bump PR. The feedstock recipe must list `hatch-vcs` + alongside `hatchling` in its `host` requirements, since it builds with + `--no-build-isolation`. diff --git a/pyproject.toml b/pyproject.toml index 97cbdda..996c419 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,11 +24,28 @@ dependencies = [ "Bugs/Issues/Features" = "https://github.com/hdrake/xbudget/issues" [build-system] -requires = ["hatchling"] +requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" +# The version comes from the git tag, not from a file in the tree. Releasing is +# then a single act -- tag `vX.Y.Z` and publish the GitHub Release -- with no +# second commit to remember, and no way for the tag and the built artifact to +# disagree about which version they are. [tool.hatch.version] -path = "xbudget/version.py" +source = "vcs" + +# Drop the `+g` local segment. On a clean tag the version is exactly X.Y.Z +# either way, but an untagged build would otherwise carry a PEP 440 *local* +# version, which package indexes refuse outright. Untagged builds are +# `X.Y.Z.devN` instead, which an index will accept. +[tool.hatch.version.raw-options] +local_scheme = "no-local-version" + +# Building writes the resolved version to `xbudget/_version.py`, which is +# gitignored but *is* included in the sdist -- so `pip install` from the sdist, +# and conda-forge's build from the PyPI sdist, both work without git present. +[tool.hatch.build.hooks.vcs] +version-file = "xbudget/_version.py" [tool.hatch.build] exclude = ["examples/**", "data/**"] diff --git a/xbudget/version.py b/xbudget/version.py index 48bd9f8..b6e4c83 100644 --- a/xbudget/version.py +++ b/xbudget/version.py @@ -1,3 +1,18 @@ -"""xbudget: version information""" +"""xbudget: version information. -__version__ = "0.7.0" +The version is derived from the git tag at build time by ``hatch-vcs``, which +writes the resolved value to ``xbudget/_version.py``. That file is generated +rather than checked in: there is deliberately no version string in the source +tree that could drift out of step with the tag it is supposed to describe. + +``_version.py`` ships in every built artifact -- wheel, sdist, and editable +install -- so the fallback below only fires when ``xbudget`` is imported +straight from a source checkout that has never been built or installed. +""" + +try: + from xbudget._version import __version__ +except ImportError: # pragma: no cover - un-built source checkout + __version__ = "0.0.0+unknown" + +__all__ = ["__version__"]