Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
22 changes: 20 additions & 2 deletions .github/workflows/publish-to-pypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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__
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 9 additions & 0 deletions .readthedocs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <https://pypi.org/project/xbudget/>.
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`.
21 changes: 19 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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<sha>` 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/**"]
19 changes: 17 additions & 2 deletions xbudget/version.py
Original file line number Diff line number Diff line change
@@ -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__"]
Loading