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__"]