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