Skip to content

Derive the version from the git tag instead of a checked-in file - #31

Merged
hdrake merged 1 commit into
mainfrom
dynamic-version
Jul 28, 2026
Merged

Derive the version from the git tag instead of a checked-in file#31
hdrake merged 1 commit into
mainfrom
dynamic-version

Conversation

@hdrake

@hdrake hdrake commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Ports the scheme from hdrake/xeos#10 to xbudget: the version is derived from the git tag by hatch-vcs instead of being read from a literal in xbudget/version.py.

Why

xbudget/version.py held __version__ = "0.7.0", which had to be bumped in its own commit before every release. Nothing tied that commit to the tag the release was actually cut from, so the two could disagree — and when they did, the symptom was a 400 File already exists from PyPI at the very end of the release. That is exactly how the xeos v0.2.1 release failed: the tag was placed one commit before the bump, the workflow built the previous version, and PyPI rejected it.

With the tag as the source of truth, tagging is the bump. There is no second commit to remember and no way for the tag and the artifact to disagree.

What changed

  • pyproject.tomlhatch-vcs added to build-system.requires; [tool.hatch.version] switches from path = "xbudget/version.py" to source = "vcs"; local_scheme = "no-local-version" so untagged builds are X.Y.Z.devN rather than PEP 440 local versions that indexes refuse; a build hook writes the resolved version to xbudget/_version.py.
  • xbudget/version.py — now a shim that imports from the generated _version.py, with a 0.0.0+unknown fallback for a checkout that has never been built or installed. xbudget.__version__ is unchanged for anything installed from a release.
  • .gitignore — ignores the generated xbudget/_version.py.
  • .github/workflows/publish-to-pypi.ymlfetch-depth: 0 (a shallow clone has no tag, so the build would silently produce a .devN artifact), plus a step asserting the built version matches the release tag. checkout/setup-python bumped off the long-EOL v2.
  • .github/workflows/ci.ymlfetch-depth: 0 on both checkouts, since both install the package.
  • .readthedocs.yamlpost_checkout unshallows and fetches tags, so the docs are titled with the release version rather than a .devN.
  • README.md — a ## Releasing section documenting the tag-is-the-version procedure.
  • CLAUDE.md, CHANGELOG.md — the invariants and the user-visible consequences.

Verified locally

  • Clean tag → exact version: building at a throwaway v9.9.9 produces xbudget-9.9.9.tar.gz / .whl, no suffix.
  • Untagged main builds as 0.7.1.dev2 (last tag v0.7.0 + distance).
  • The sdist contains the generated xbudget/_version.py, and extracting it outside any git repo and building a wheel from it yields 9.9.9 — the property conda-forge depends on.

Follow-up needed on the feedstock

conda-forge builds with --no-build-isolation, so the backend's requirements must be present in host. conda-forge/xbudget-feedstock's recipe currently lists only hatchling; I confirmed locally that building the sdist with --no-build-isolation and no hatch-vcs installed fails in hatchling.builders.plugin.interface.get_build_hooks. hatch-vcs must be added next to hatchling in the feedstock's host requirements before the next release builds. ci/check_conda_parity.py compares only run requirements, so it will not catch this; the requirement is written down in CLAUDE.md and the README instead.

Note on untagged builds

A checkout without tags — a shallow clone, or a fork that never fetched them — now resolves a .devN version rather than the release line. That is why every checkout in CI uses fetch-depth: 0.

🤖 Generated with Claude Code

`xbudget/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 `xbudget/_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 every checkout that installs the
package uses `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.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@hdrake hdrake left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good.

@hdrake
hdrake merged commit cac04d1 into main Jul 28, 2026
7 checks passed
@hdrake
hdrake deleted the dynamic-version branch July 28, 2026 16:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant