diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 01237d5..53c3d91 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
diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml
index b3699bd..b32ef47 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|.*/sectionate-(.*)\.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 894a44c..6926ab5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -102,3 +102,6 @@ venv.bak/
# mypy
.mypy_cache/
+
+# Version file generated from the git tag by hatch-vcs at build time.
+sectionate/_version.py
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
index 6451026..cfa4fad 100644
--- a/.readthedocs.yaml
+++ b/.readthedocs.yaml
@@ -9,6 +9,15 @@ build:
os: ubuntu-24.04
tools:
python: "miniforge3-latest"
+ jobs:
+ post_checkout:
+ # RTD clones shallow, but installing sectionate resolves its version from
+ # the git tag via hatch-vcs, and the docs are titled with it
+ # (`docs/source/conf.py` reads it from the installed metadata). Without
+ # the tag they would say `.devN`. 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 documentation in the "docs/" directory with Sphinx
sphinx:
diff --git a/README.md b/README.md
index 057665e..0e61bb5 100644
--- a/README.md
+++ b/README.md
@@ -26,3 +26,37 @@ pip install -e .
python -m ipykernel install --user --name docs_env_sectionate --display-name "docs_env_sectionate"
jupyter-lab
```
+
+Releasing
+---------
+
+**The git tag is the version.** `sectionate` has no version string checked into
+the source tree: `hatch-vcs` derives it from the tag at build time and writes
+`sectionate/_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 `master` is green and has everything you want in the release.
+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/master)" \
+ --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. Its recipe must list `hatch-vcs` alongside
+ `hatchling` in `host`, since it builds with `--no-build-isolation`.
+
+Two things follow from the tag being the version. Never add a version literal
+back to the tree — `sectionate/version.py` is a shim over the generated file, and
+a `0.0.0+unknown` from it means the package was imported without being built or
+installed, not that a number is missing. And any checkout that installs the
+package needs its tags: a shallow clone, or a fork that never fetched them,
+resolves a `.devN` version instead of the release line. That is why CI checks out
+with `fetch-depth: 0` and Read the Docs unshallows in `post_checkout`.
diff --git a/conda/meta.yaml b/conda/meta.yaml
index 37cdadf..c672dca 100644
--- a/conda/meta.yaml
+++ b/conda/meta.yaml
@@ -20,6 +20,11 @@ requirements:
- python {{ python_min }}
- pip
- hatchling
+ # The build backend resolves the version through hatch-vcs. The build above
+ # is `--no-build-isolation`, so it has to be installed here or the wheel
+ # build fails outright; the sdist carries the generated `_version.py`, so
+ # git itself is not needed.
+ - hatch-vcs
run:
- python >={{ python_min }}
- dask
diff --git a/pyproject.toml b/pyproject.toml
index 0a0cf1a..b8228f3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -28,11 +28,28 @@ dependencies = [
"Sibling package" = "https://github.com/hdrake/regionate"
[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 = "sectionate/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 `sectionate/_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 = "sectionate/_version.py"
[tool.hatch.build]
exclude = ["examples/**", "data/**"]
diff --git a/sectionate/version.py b/sectionate/version.py
index c4589c1..3398d75 100644
--- a/sectionate/version.py
+++ b/sectionate/version.py
@@ -1,3 +1,18 @@
-"""sectionate: version information"""
+"""sectionate: version information.
-__version__ = "0.3.3"
+The version is derived from the git tag at build time by ``hatch-vcs``, which
+writes the resolved value to ``sectionate/_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 ``sectionate`` is imported
+straight from a source checkout that has never been built or installed.
+"""
+
+try:
+ from sectionate._version import __version__
+except ImportError: # pragma: no cover - un-built source checkout
+ __version__ = "0.0.0+unknown"
+
+__all__ = ["__version__"]