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