Skip to content

Commit e673487

Browse files
hdrakeclaude
andauthored
Derive the version from the git tag instead of a checked-in file (MOM6-community#50)
* Derive the version from the git tag instead of a checked-in file `sectionate/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 `sectionate/_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 the CI and publish checkouts use `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. conda/meta.yaml gains `hatch-vcs` in `host`: the recipe builds with `--no-build-isolation`, so the backend's own requirements must be installed there or the wheel build fails. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Empty commit to trigger CI rebuild Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 90265e2 commit e673487

8 files changed

Lines changed: 113 additions & 7 deletions

File tree

.github/workflows/ci.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,12 @@ jobs:
2626
with:
2727
access_token: ${{ github.token }}
2828
- name: Checkout source
29-
uses: actions/checkout@v2
29+
uses: actions/checkout@v4
30+
with:
31+
# `pip install -e .` resolves the version from the git tag via
32+
# hatch-vcs; a shallow clone has no tag, so the installed package
33+
# would report `0.1.devN` instead of the release line.
34+
fetch-depth: 0
3035

3136
- name: Conda setup
3237
uses: conda-incubator/setup-miniconda@v2

.github/workflows/publish-to-pypi.yml

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,14 @@ jobs:
88
build-and-publish:
99
runs-on: ubuntu-latest
1010
steps:
11-
- uses: actions/checkout@v2
11+
- uses: actions/checkout@v4
12+
with:
13+
# hatch-vcs derives the version from the git tag, which a shallow clone
14+
# does not have. Without this the build silently produces a
15+
# `0.0.0.dev...` artifact instead of the release version.
16+
fetch-depth: 0
1217
- name: Set up Python
13-
uses: actions/setup-python@v2
18+
uses: actions/setup-python@v5
1419
with:
1520
python-version: '3.x'
1621
- name: Install dependencies
@@ -19,6 +24,19 @@ jobs:
1924
pip install build twine
2025
- name: Build package
2126
run: python -m build
27+
# Deriving the version from the tag should make this unfalsifiable, but it
28+
# is the last point at which a mismatch is cheap to see: the alternative
29+
# symptom is a 400 from PyPI at the very end of the release, and only when
30+
# that version already exists.
31+
- name: Built version must match the release tag
32+
run: |
33+
built=$(ls dist/*.tar.gz | sed -E 's|.*/sectionate-(.*)\.tar\.gz|\1|')
34+
tag="${GITHUB_REF_NAME#v}"
35+
echo "tag=$tag built=$built"
36+
if [ "$built" != "$tag" ]; then
37+
echo "::error::Release tag $GITHUB_REF_NAME implies version '$tag', but the build produced '$built'."
38+
exit 1
39+
fi
2240
- name: Publish package
2341
env:
2442
TWINE_USERNAME: __token__

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,6 @@ venv.bak/
102102

103103
# mypy
104104
.mypy_cache/
105+
106+
# Version file generated from the git tag by hatch-vcs at build time.
107+
sectionate/_version.py

.readthedocs.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@ build:
99
os: ubuntu-24.04
1010
tools:
1111
python: "miniforge3-latest"
12+
jobs:
13+
post_checkout:
14+
# RTD clones shallow, but installing sectionate resolves its version from
15+
# the git tag via hatch-vcs, and the docs are titled with it
16+
# (`docs/source/conf.py` reads it from the installed metadata). Without
17+
# the tag they would say `.devN`. Tolerate failure: an already-complete
18+
# clone makes `--unshallow` an error, which must not fail the build.
19+
- git fetch --unshallow || true
20+
- git fetch --tags || true
1221

1322
# Build documentation in the "docs/" directory with Sphinx
1423
sphinx:

README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,37 @@ pip install -e .
2626
python -m ipykernel install --user --name docs_env_sectionate --display-name "docs_env_sectionate"
2727
jupyter-lab
2828
```
29+
30+
Releasing
31+
---------
32+
33+
**The git tag is the version.** `sectionate` has no version string checked into
34+
the source tree: `hatch-vcs` derives it from the tag at build time and writes
35+
`sectionate/_version.py` (gitignored, but shipped inside the sdist and wheel).
36+
To cut a release you tag; there is no file to bump and nothing to keep in sync.
37+
38+
1. Make sure `master` is green and has everything you want in the release.
39+
2. **Publish a GitHub Release** whose tag is `vX.Y.Z`, targeting the commit you
40+
want to ship:
41+
```bash
42+
gh release create vX.Y.Z --target "$(git rev-parse origin/master)" \
43+
--title vX.Y.Z --generate-notes
44+
```
45+
Publishing it (not merely pushing a tag) is what fires the workflow. Target
46+
the commit you actually want: a tag placed *before* the commit you meant to
47+
release builds the previous version, and PyPI rejects it as a duplicate.
48+
3. The **Publish to PyPI** workflow builds from that tag and uploads. It checks
49+
out with `fetch-depth: 0` so the tag is visible to `hatch-vcs`, and asserts
50+
that the built version matches the tag before publishing.
51+
4. Verify: <https://pypi.org/project/sectionate/>.
52+
5. conda-forge builds from the PyPI sdist and lags by design; the autotick bot
53+
opens the version-bump PR. Its recipe must list `hatch-vcs` alongside
54+
`hatchling` in `host`, since it builds with `--no-build-isolation`.
55+
56+
Two things follow from the tag being the version. Never add a version literal
57+
back to the tree — `sectionate/version.py` is a shim over the generated file, and
58+
a `0.0.0+unknown` from it means the package was imported without being built or
59+
installed, not that a number is missing. And any checkout that installs the
60+
package needs its tags: a shallow clone, or a fork that never fetched them,
61+
resolves a `.devN` version instead of the release line. That is why CI checks out
62+
with `fetch-depth: 0` and Read the Docs unshallows in `post_checkout`.

conda/meta.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ requirements:
2020
- python {{ python_min }}
2121
- pip
2222
- hatchling
23+
# The build backend resolves the version through hatch-vcs. The build above
24+
# is `--no-build-isolation`, so it has to be installed here or the wheel
25+
# build fails outright; the sdist carries the generated `_version.py`, so
26+
# git itself is not needed.
27+
- hatch-vcs
2328
run:
2429
- python >={{ python_min }}
2530
- dask

pyproject.toml

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,28 @@ dependencies = [
2828
"Sibling package" = "https://github.com/hdrake/regionate"
2929

3030
[build-system]
31-
requires = ["hatchling"]
31+
requires = ["hatchling", "hatch-vcs"]
3232
build-backend = "hatchling.build"
3333

34+
# The version comes from the git tag, not from a file in the tree. Releasing is
35+
# then a single act -- tag `vX.Y.Z` and publish the GitHub Release -- with no
36+
# second commit to remember, and no way for the tag and the built artifact to
37+
# disagree about which version they are.
3438
[tool.hatch.version]
35-
path = "sectionate/version.py"
39+
source = "vcs"
40+
41+
# Drop the `+g<sha>` local segment. On a clean tag the version is exactly X.Y.Z
42+
# either way, but an untagged build would otherwise carry a PEP 440 *local*
43+
# version, which package indexes refuse outright. Untagged builds are
44+
# `X.Y.Z.devN` instead, which an index will accept.
45+
[tool.hatch.version.raw-options]
46+
local_scheme = "no-local-version"
47+
48+
# Building writes the resolved version to `sectionate/_version.py`, which is
49+
# gitignored but *is* included in the sdist -- so `pip install` from the sdist,
50+
# and conda-forge's build from the PyPI sdist, both work without git present.
51+
[tool.hatch.build.hooks.vcs]
52+
version-file = "sectionate/_version.py"
3653

3754
[tool.hatch.build]
3855
exclude = ["examples/**", "data/**"]

sectionate/version.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,18 @@
1-
"""sectionate: version information"""
1+
"""sectionate: version information.
22
3-
__version__ = "0.3.3"
3+
The version is derived from the git tag at build time by ``hatch-vcs``, which
4+
writes the resolved value to ``sectionate/_version.py``. That file is generated
5+
rather than checked in: there is deliberately no version string in the source
6+
tree that could drift out of step with the tag it is supposed to describe.
7+
8+
``_version.py`` ships in every built artifact -- wheel, sdist, and editable
9+
install -- so the fallback below only fires when ``sectionate`` is imported
10+
straight from a source checkout that has never been built or installed.
11+
"""
12+
13+
try:
14+
from sectionate._version import __version__
15+
except ImportError: # pragma: no cover - un-built source checkout
16+
__version__ = "0.0.0+unknown"
17+
18+
__all__ = ["__version__"]

0 commit comments

Comments
 (0)