From be0bc0c47045189409dff911026bbe0fa4d15aa0 Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Fri, 11 Sep 2026 10:17:07 -0500 Subject: [PATCH] Release when uxarray does, not on the fifth of the month The release poll ran at 05:00 on the 5th, but upstream skipped 2026.01 and 2026.05, shipped twice in August, and released 2026.09.0 on the 10th, so the poll was early or late every time and never on the thing it was waiting for. It now runs daily and reads the newest uxarray from PyPI rather than from upstream's tags, which mix v2026.09.0 and v2026.4.0. Versions become CalVer: upstream's year.month, our own patch. Zero padding is refused where it enters, because PEP 440 strips the leading zero and a dist built from 2026.09.0 publishes as 2026.9.0 and disagrees with the tag it came from. The release commit moves both ends of the uxarray pin; raising the floor alone would leave a ceiling written for the previous month and make the package uninstallable beside the release it was just tested against. An unreachable PyPI decides nothing: the version falls back to a patch bump and the pin is left as reviewed. The workflow no longer tags, creates a GitHub release, or dispatches the publish job. Green opens a release pull request against the upstream version it was checked with, red opens an issue naming it, and a human merge is the only path to PyPI. The checks run with --no-sync so the explicit install of that upstream release is not undone by a re-sync to uv.lock. The cutover from 0.3.1 is deliberately not here; it is a manual dispatch. --- .github/workflows/monthly-release.yml | 167 ------------- .github/workflows/release-on-upstream.yml | 187 +++++++++++++++ .github/zizmor.yml | 2 +- CHANGELOG.md | 20 +- docs/release.md | 76 ++++-- scripts/prepare_release.py | 169 ++++++++++++- tests/test_calver_release.py | 279 ++++++++++++++++++++++ 7 files changed, 703 insertions(+), 197 deletions(-) delete mode 100644 .github/workflows/monthly-release.yml create mode 100644 .github/workflows/release-on-upstream.yml create mode 100644 tests/test_calver_release.py diff --git a/.github/workflows/monthly-release.yml b/.github/workflows/monthly-release.yml deleted file mode 100644 index 5d208da..0000000 --- a/.github/workflows/monthly-release.yml +++ /dev/null @@ -1,167 +0,0 @@ -name: Monthly release - -on: - schedule: - # 05:00 UTC on the 5th day of every month. - - cron: "0 5 5 * *" - workflow_dispatch: - inputs: - version: - description: "Explicit version to release, e.g. 0.1.0. Leave empty to auto-bump patch." - required: false - default: "" - force: - description: "Release even if there are no commits since the latest v* tag." - required: false - type: boolean - default: false - -permissions: - contents: write - actions: write - -jobs: - prepare-release: - if: github.repository == 'UXARRAY/uxarray-mcp-server' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - persist-credentials: true - - - name: Set up Git identity - run: | - git config user.name "Rajeev Jain" - git config user.email "rajeeja@gmail.com" - - - name: Prepare version - id: prep - env: - INPUT_VERSION: ${{ inputs.version }} - INPUT_FORCE: ${{ inputs.force }} - run: | - args=() - if [ -n "$INPUT_VERSION" ]; then - args+=(--version "$INPUT_VERSION") - fi - if [ "$INPUT_FORCE" = "true" ]; then - args+=(--force) - fi - python scripts/prepare_release.py "${args[@]}" - - - name: Stop if no release is needed - if: steps.prep.outputs.release_needed != 'true' - run: | - echo "No commits since latest release tag; skipping monthly release." - - - name: Install uv - if: steps.prep.outputs.release_needed == 'true' - uses: astral-sh/setup-uv@v5 - - - name: Set up Python 3.12 - if: steps.prep.outputs.release_needed == 'true' - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Relock uv.lock at the new version - if: steps.prep.outputs.release_needed == 'true' - run: | - # uv.lock records the project's own version, so the bump above left - # it describing the previous release. Nothing in CI passes --locked, - # so the mismatch is invisible here and surfaces for whoever checks - # out the tag and runs `uv sync --locked`. - uv lock - uv sync --locked --dev - - - name: Run release checks - if: steps.prep.outputs.release_needed == 'true' - run: | - uv run pre-commit run --all-files - uv run pytest tests/ -m "not hpc and not live" -v - uv run --extra hpc pytest tests/ -m "hpc" -v - uv run --extra docs sphinx-build -b html docs docs/_build/html -W --keep-going - - - name: Build and check distributions - if: steps.prep.outputs.release_needed == 'true' - run: | - uv build - uvx twine check dist/* - uv venv /tmp/uxarray-mcp-wheel --python 3.12 - uv pip install --python /tmp/uxarray-mcp-wheel/bin/python dist/uxarray_mcp-*.whl - /tmp/uxarray-mcp-wheel/bin/uxarray-mcp --help - - - name: Update conda recipe source hash - if: steps.prep.outputs.release_needed == 'true' - env: - RELEASE_VERSION: ${{ steps.prep.outputs.version }} - run: | - python scripts/update_conda_recipe.py \ - --version "$RELEASE_VERSION" \ - --sdist dist/uxarray_mcp-*.tar.gz - - - name: Commit version bump - if: steps.prep.outputs.release_needed == 'true' - env: - RELEASE_VERSION: ${{ steps.prep.outputs.version }} - run: | - git add pyproject.toml src/uxarray_mcp/__init__.py \ - conda/recipe/meta.yaml CHANGELOG.md uv.lock - if git diff --cached --quiet; then - echo "Version files already match ${RELEASE_VERSION}; no release commit needed." - else - git commit -m "Release ${RELEASE_VERSION}" - fi - - - name: Tag release - if: steps.prep.outputs.release_needed == 'true' - env: - RELEASE_VERSION: ${{ steps.prep.outputs.version }} - RELEASE_TAG: ${{ steps.prep.outputs.tag }} - run: | - git tag -a "$RELEASE_TAG" -m "Release ${RELEASE_VERSION}" - - - name: Push release commit and tag - if: steps.prep.outputs.release_needed == 'true' - env: - RELEASE_TAG: ${{ steps.prep.outputs.tag }} - run: | - git push origin HEAD:main - git push origin "$RELEASE_TAG" - - - name: Create GitHub release - if: steps.prep.outputs.release_needed == 'true' - env: - GH_TOKEN: ${{ github.token }} - RELEASE_VERSION: ${{ steps.prep.outputs.version }} - RELEASE_TAG: ${{ steps.prep.outputs.tag }} - PREVIOUS_TAG: ${{ steps.prep.outputs.previous_tag }} - CHANGED_COMMITS: ${{ steps.prep.outputs.changed_commits }} - run: | - { - echo "Automated release ${RELEASE_VERSION}." - if [ -n "$PREVIOUS_TAG" ]; then - echo "" - echo "Changes since ${PREVIOUS_TAG}: ${CHANGED_COMMITS} commit(s)." - else - echo "" - echo "Initial release." - fi - } > release-notes.md - gh release create "$RELEASE_TAG" \ - --repo "$GITHUB_REPOSITORY" \ - --title "$RELEASE_TAG" \ - --notes-file release-notes.md \ - dist/* - - - name: Dispatch PyPI publish workflow - if: steps.prep.outputs.release_needed == 'true' - env: - GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ steps.prep.outputs.tag }} - run: | - gh workflow run release.yml \ - --repo "$GITHUB_REPOSITORY" \ - --ref "$RELEASE_TAG" \ - -f tag="$RELEASE_TAG" diff --git a/.github/workflows/release-on-upstream.yml b/.github/workflows/release-on-upstream.yml new file mode 100644 index 0000000..7c3970e --- /dev/null +++ b/.github/workflows/release-on-upstream.yml @@ -0,0 +1,187 @@ +name: Release on upstream + +# Upstream uxarray does not release on a schedule: it skipped 2026.01 and +# 2026.05, shipped twice in August, and released 2026.09.0 on the 10th. A +# monthly cron on the 5th was therefore either early or late every time. This +# polls daily and reacts to what upstream actually published. +# +# It never publishes. Green opens a release PR; red opens an issue. Everything +# that reaches PyPI does so through a human merge of that PR. + +on: + schedule: + # 05:17 UTC daily. + - cron: "17 5 * * *" + workflow_dispatch: + inputs: + version: + description: "Explicit version to release, e.g. 2026.9.0. Leave empty to derive from upstream." + required: false + default: "" + upstream: + description: "Upstream uxarray version to mirror. Leave empty to read PyPI." + required: false + default: "" + force: + description: "Prepare a release even with no commits and no new upstream." + required: false + type: boolean + default: false + +permissions: + contents: write + pull-requests: write + issues: write + +jobs: + prepare-release: + if: github.repository == 'UXARRAY/uxarray-mcp-server' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: true + + - name: Set up Git identity + run: | + git config user.name "Rajeev Jain" + git config user.email "rajeeja@gmail.com" + + - name: Prepare version + id: prep + env: + INPUT_VERSION: ${{ inputs.version }} + INPUT_UPSTREAM: ${{ inputs.upstream }} + INPUT_FORCE: ${{ inputs.force }} + run: | + args=() + if [ -n "$INPUT_VERSION" ]; then + args+=(--version "$INPUT_VERSION") + fi + if [ -n "$INPUT_UPSTREAM" ]; then + args+=(--upstream "$INPUT_UPSTREAM") + fi + if [ "$INPUT_FORCE" = "true" ]; then + args+=(--force) + fi + python scripts/prepare_release.py "${args[@]}" + + - name: Stop if no release is needed + if: steps.prep.outputs.release_needed != 'true' + run: | + echo "No commits since the latest tag and no new upstream release." + + - name: Install uv + if: steps.prep.outputs.release_needed == 'true' + uses: astral-sh/setup-uv@v5 + + - name: Set up Python 3.12 + if: steps.prep.outputs.release_needed == 'true' + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Relock uv.lock at the new version + if: steps.prep.outputs.release_needed == 'true' + run: | + # uv.lock records the project's own version and the resolved uxarray, + # so the bump above left it describing the previous release against + # the previous upstream. Nothing in CI passes --locked, so the + # mismatch is invisible here and surfaces for whoever checks out the + # tag and runs `uv sync --locked`. + # Extras go in here, not on the `uv run` lines below: those pass + # --no-sync, and a `uv run --extra` would have to sync to honour it. + uv lock + uv sync --locked --dev --extra hpc --extra docs + + - name: Install the upstream release being mirrored + if: steps.prep.outputs.release_needed == 'true' + env: + UPSTREAM: ${{ steps.prep.outputs.upstream }} + run: | + if [ -n "$UPSTREAM" ]; then + uv pip install --upgrade "uxarray==${UPSTREAM}" + fi + uv pip list | grep -E '^uxarray ' + + - name: Run release checks + if: steps.prep.outputs.release_needed == 'true' + # --no-sync, or this step silently undoes the install above it. A bare + # `uv run` re-syncs the environment to `uv.lock` first, which would put + # back whatever uxarray the lock resolved and leave this job proving + # nothing about the upstream release it is named for. Measured in + # upstream-compat.yml: a bare `uv run` reported the locked version + # after an explicit `uv pip install` of a different one. + run: | + uv run --no-sync pre-commit run --all-files + uv run --no-sync pytest tests/ -m "not live" -v + uv run --no-sync --extra docs sphinx-build -b html docs docs/_build/html -W --keep-going + + - name: Build and check distributions + if: steps.prep.outputs.release_needed == 'true' + run: | + uv build + uvx twine check dist/* + uv venv /tmp/uxarray-mcp-wheel --python 3.12 + uv pip install --python /tmp/uxarray-mcp-wheel/bin/python dist/uxarray_mcp-*.whl + /tmp/uxarray-mcp-wheel/bin/uxarray-mcp --help + + - name: Update conda recipe source hash + if: steps.prep.outputs.release_needed == 'true' + env: + RELEASE_VERSION: ${{ steps.prep.outputs.version }} + run: | + python scripts/update_conda_recipe.py \ + --version "$RELEASE_VERSION" \ + --sdist dist/uxarray_mcp-*.tar.gz + + - name: Open the release pull request + if: steps.prep.outputs.release_needed == 'true' + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ steps.prep.outputs.version }} + RELEASE_TAG: ${{ steps.prep.outputs.tag }} + PREVIOUS_TAG: ${{ steps.prep.outputs.previous_tag }} + CHANGED_COMMITS: ${{ steps.prep.outputs.changed_commits }} + UPSTREAM: ${{ steps.prep.outputs.upstream }} + run: | + # No tag, no GitHub release, no dispatch to release.yml. The tag is + # cut from the merge commit by a human; nothing here can reach PyPI. + branch="release/${RELEASE_VERSION}" + git checkout -b "$branch" + git add pyproject.toml src/uxarray_mcp/__init__.py \ + conda/recipe/meta.yaml CHANGELOG.md uv.lock + if git diff --cached --quiet; then + echo "Version files already match ${RELEASE_VERSION}; nothing to open." + exit 0 + fi + git commit -m "Release ${RELEASE_VERSION}" + git push origin "$branch" + { + echo "Release ${RELEASE_VERSION} against uxarray ${UPSTREAM:-unchanged}, ${CHANGED_COMMITS} commit(s) since ${PREVIOUS_TAG:-the first commit}." + echo "Checks, build and wheel smoke test passed on this branch before it was opened." + echo "Merging this, then tagging ${RELEASE_TAG} on main, is what publishes." + } > pr-body.md + gh pr create \ + --repo "$GITHUB_REPOSITORY" \ + --base main \ + --head "$branch" \ + --title "Release ${RELEASE_VERSION}" \ + --body-file pr-body.md + + - name: Report a failed release check + if: failure() && steps.prep.outputs.release_needed == 'true' + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ steps.prep.outputs.version }} + UPSTREAM: ${{ steps.prep.outputs.upstream }} + run: | + { + echo "Preparing ${RELEASE_VERSION} against uxarray ${UPSTREAM:-unchanged} failed before any pull request was opened." + echo "Run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + } > issue-body.md + gh issue create \ + --repo "$GITHUB_REPOSITORY" \ + --title "Release ${RELEASE_VERSION} blocked by uxarray ${UPSTREAM:-unchanged}" \ + --body-file issue-body.md diff --git a/.github/zizmor.yml b/.github/zizmor.yml index a5e8b69..79ce497 100644 --- a/.github/zizmor.yml +++ b/.github/zizmor.yml @@ -10,6 +10,6 @@ rules: ignore: - ci.yml - upstream-compat.yml - - monthly-release.yml + - release-on-upstream.yml - release.yml - zizmor.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 54bf3dc..402c443 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,8 @@ # Changelog -All notable changes are recorded here. Dates are ISO 8601 (UTC). The project -uses Semantic Versioning for public releases. +All notable changes are recorded here. Dates are ISO 8601 (UTC). Releases are +CalVer, `..`, mirroring the `uxarray` release they were +built against; see `docs/release.md`. Versions through `0.3.1` were SemVer. ## Unreleased ### Added @@ -27,6 +28,21 @@ uses Semantic Versioning for public releases. an explicit `get_submission_id()`, which keeps the wire shape in one place and lets all 44 tests run against a fake client with no credentials in CI. +### Changed +- Releases now follow upstream instead of the calendar. The workflow polled on + the 5th of every month, but upstream skipped 2026.01 and 2026.05, shipped + twice in August, and released 2026.09.0 on the 10th, so the poll was either + early or late every time. It now runs daily, reads the newest `uxarray` from + PyPI (not from upstream's tags, which mix `v2026.09.0` and `v2026.4.0`), and + releases `..` mirroring upstream's month. The release + commit moves both ends of the uxarray pin: a floor raised without its + ceiling leaves the package uninstallable beside the very release it was + tested against. Zero-padded versions are refused rather than normalized, + because PEP 440 strips the zero and `2026.09.0` would publish as a dist + disagreeing with its own tag. The workflow no longer tags, releases or + publishes on its own — green opens a release pull request, red opens an + issue, and a human merge is what reaches PyPI. + ### Fixed - A directory of SCRIP meshes was classified as nothing and advised nothing. `_GRID_HINTS` held `grid`, `mesh`, `topo`, `coord` and `geo` but not diff --git a/docs/release.md b/docs/release.md index da6f311..96ca9bf 100644 --- a/docs/release.md +++ b/docs/release.md @@ -1,37 +1,65 @@ # Release Process -This project follows the same broad release model as UXarray, with an added -scheduled release workflow: +This project follows the same broad release model as UXarray, and releases +when UXarray does: 1. GitHub CI must be green on `main`. -2. On the 5th of every month, GitHub Actions checks for commits since the - latest `v*` tag. -3. If there are no new commits, the release is skipped. -4. If there are new commits, the workflow bumps the patch version, commits it, - creates a `v` tag, and publishes a GitHub Release. -5. The release workflow builds and publishes the Python package to PyPI. +2. Every day, GitHub Actions reads the newest `uxarray` release from PyPI and + checks for commits since the latest `v*` tag. +3. If upstream has not moved and no commits have landed, nothing happens. +4. Otherwise the workflow computes the version, rewrites the version files and + the uxarray pin, runs the full checks against that upstream release, and + opens a release pull request. +5. A human merges that pull request and tags `v` on `main`. Tagging + is what publishes; nothing reaches PyPI unattended. 6. Conda packages are handled through a conda-forge feedstock. -## Monthly Automation +## Versioning -`.github/workflows/monthly-release.yml` runs at 05:00 UTC on the 5th of every -month. It can also be run manually with `workflow_dispatch`. +Versions are CalVer: `..`, mirroring the `year.month` of +the `uxarray` release they were built against, with the patch counting our own +releases within that month. `2026.9.2` is our third release against upstream's +September 2026 line, not upstream's third patch. + +Components are never zero-padded. Upstream tags `v2026.09.0`, but PEP 440 +strips the leading zero, so a dist built from a padded version publishes as +`2026.9.0` and disagrees with the tag it came from. `prepare_release.py` +rejects a padded version rather than normalizing it silently. + +Upstream is read from PyPI rather than from upstream's git tags, which mix +`v2026.09.0` and `v2026.4.0`; PyPI normalizes both. + +## Daily Automation + +`.github/workflows/release-on-upstream.yml` runs at 05:17 UTC daily. It can +also be run manually with `workflow_dispatch`. Default behavior: -- no previous `v*` tag: release the current version from `pyproject.toml` -- previous `vX.Y.Z` tag exists and commits have landed since then: release - `X.Y.(Z+1)` -- no commits since the latest tag: skip +- a `uxarray` release with a `year.month` we have not shipped against: release + `..0` +- the same `year.month` we already shipped against, with commits since the + latest tag: bump our patch +- neither: skip + +The release commit moves both ends of the uxarray pin, to +`uxarray>=,`. Raising only the floor would leave a +ceiling written for the previous month, which makes the package uninstallable +beside the upstream release it was just tested against. + +If PyPI cannot be reached, the version falls back to a patch bump and the pin +is left exactly as it was; a ceiling is never guessed from a version that +could not be read. Manual inputs: -- `version`: release an explicit version such as `0.1.0` -- `force`: release even if there are no commits since the latest tag +- `version`: release an explicit version such as `2026.9.0` +- `upstream`: mirror a specific `uxarray` version instead of reading PyPI +- `force`: prepare a release with no commits and no new upstream The workflow updates: -- `pyproject.toml` +- `pyproject.toml` — the version and both ends of the uxarray pin - `src/uxarray_mcp/__init__.py` - `conda/recipe/meta.yaml` - `CHANGELOG.md` — the `## Unreleased` section is closed under a heading for @@ -40,8 +68,16 @@ The workflow updates: relocked. Nothing in CI passes `--locked`, which means a stale lock is invisible here and only fails for someone checking out the tag -Then it runs the release checks, builds the package, commits the version bump, -tags it, pushes to `main`, and creates the GitHub Release. +Then it installs the upstream release being mirrored, runs the release checks +against it with `uv run --no-sync` (a bare `uv run` re-syncs to `uv.lock` and +would silently put the locked uxarray back), builds the package, and opens the +release pull request. On failure it opens an issue naming the upstream version +and stops. It never tags, never publishes, and never pushes to `main`. + +The cutover from `0.3.1` to CalVer is a one-time manual `workflow_dispatch` +with `version=2026.9.0`, run after reading the diff. It is irreversible: PEP +440 makes `2026.9.0 > 0.3.1`, so anyone pinned `>=0.3` moves to CalVer on +their next resolve. ## PyPI diff --git a/scripts/prepare_release.py b/scripts/prepare_release.py index b72e3ca..17e9444 100644 --- a/scripts/prepare_release.py +++ b/scripts/prepare_release.py @@ -2,6 +2,12 @@ The script is intentionally small and dependency-free so it can run inside a GitHub Actions release job before the package environment is installed. + +Versions mirror upstream `uxarray`'s `year.month` and own the patch, so a +release says which upstream month it was built against without a second +lookup. The same call moves both ends of the uxarray pin, because a floor +raised without its ceiling is a package that cannot be installed beside the +next upstream month. """ from __future__ import annotations @@ -22,11 +28,134 @@ VERSION_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") +UPSTREAM_PACKAGE = "uxarray" +UPSTREAM_PYPI_URL = f"https://pypi.org/pypi/{UPSTREAM_PACKAGE}/json" + +# The one line in pyproject.toml that pins upstream. Matched as a whole entry +# so the rewrite cannot land inside a different dependency that happens to +# contain the same substring. +UXARRAY_PIN_RE = re.compile(r'^(?P[ \t]*)"uxarray[^"]*",$', re.MULTILINE) + def _run(args: list[str]) -> str: return subprocess.check_output(args, cwd=ROOT, text=True).strip() +def _parse_version(version: str) -> tuple[int, int, int]: + """Split `X.Y.Z` into integers, refusing anything PEP 440 would rewrite. + + Zero padding is rejected loudly rather than normalized. Upstream tags + `v2026.09.0`, but PEP 440 strips the leading zero, so a dist built from a + padded version publishes as `2026.9.0` and disagrees with its own git tag. + Copying upstream's padding into our version files would reproduce that + mismatch in a repository that has no reason to inherit it. + """ + match = VERSION_RE.match(version) + if not match: + raise ValueError(f"Automated releases require X.Y.Z versions, got {version!r}") + parts = match.groups() + padded = [part for part in parts if len(part) > 1 and part.startswith("0")] + if padded: + unpadded = ".".join(str(int(part)) for part in parts) + raise ValueError( + f"Zero-padded component in {version!r}: PEP 440 strips the zero, so " + f"this would publish as {unpadded} and disagree with tag v{version}. " + f"Use {unpadded}." + ) + year, month, patch = (int(part) for part in parts) + return year, month, patch + + +def _fetch_pypi_json(url: str) -> dict: + import json + import urllib.request + + with urllib.request.urlopen(url, timeout=30) as response: # noqa: S310 + return json.load(response) + + +def _latest_upstream(fetch=_fetch_pypi_json) -> str | None: + """Newest published `uxarray`, read from PyPI rather than upstream's tags. + + Upstream's tags mix `v2026.09.0` and `v2026.4.0`; PyPI normalizes both, so + it is the only source that answers "which month is upstream on" without a + second parser for the padding. Releases whose files are all yanked are + skipped, and anything that is not a plain `X.Y.Z` -- a prerelease, a + post-release -- is not a month we mirror. + + Returns `None` when PyPI cannot be reached: an unreachable index must not + decide a version number, so the caller falls back to a patch bump. + """ + try: + payload = fetch(UPSTREAM_PYPI_URL) + except Exception as exc: # network, JSON, HTTP -- all mean "do not know" + print(f"Could not read {UPSTREAM_PACKAGE} from PyPI: {exc}") + return None + candidates: list[tuple[int, int, int]] = [] + for raw, files in (payload.get("releases") or {}).items(): + match = VERSION_RE.match(raw) + if not match: + continue + if files and all(file.get("yanked") for file in files): + continue + candidates.append(tuple(int(part) for part in match.groups())) + if not candidates: + newest = (payload.get("info") or {}).get("version") + if not newest or not VERSION_RE.match(newest): + return None + candidates.append( + tuple(int(part) for part in VERSION_RE.match(newest).groups()) + ) + year, month, patch = max(candidates) + return f"{year}.{month}.{patch}" + + +def _next_version(base: str, upstream: str) -> str: + """Mirror upstream's `year.month`; own the patch. + + Same month as the version we last shipped means this is our second release + within upstream's month, so only our patch moves. A month upstream has not + had before resets the patch to 0. A `base` that predates the scheme -- + `0.3.1` -- simply never matches, so the first CalVer release lands on + upstream's month with patch 0. + """ + up_year, up_month, _ = _parse_version(upstream) + base_year, base_month, base_patch = _parse_version(base) + if (base_year, base_month) == (up_year, up_month): + return f"{up_year}.{up_month}.{base_patch + 1}" + return f"{up_year}.{up_month}.0" + + +def _uxarray_pin(upstream: str) -> str: + """The pin a release against `upstream` should carry, both ends moved.""" + year, month, _ = _parse_version(upstream) + ceiling = f"{year + 1}.1" if month == 12 else f"{year}.{month + 1}" + return f"uxarray>={upstream},<{ceiling}" + + +def _current_uxarray_floor() -> str | None: + match = re.search(r'"uxarray>=([^",<]+)', PYPROJECT.read_text()) + return match.group(1) if match else None + + +def _write_uxarray_pin(upstream: str) -> None: + """Move the floor and the ceiling together. + + A release that raises only the floor is uninstallable alongside the next + upstream month, because the ceiling it kept was written for the month + before. The two ends are one decision, so they are one edit. + """ + text = PYPROJECT.read_text() + matches = UXARRAY_PIN_RE.findall(text) + if len(matches) != 1: + raise RuntimeError( + f"Expected exactly one uxarray dependency line in pyproject.toml, " + f"found {len(matches)}; refusing to guess which one pins upstream." + ) + pin = _uxarray_pin(upstream) + PYPROJECT.write_text(UXARRAY_PIN_RE.sub(f'\\g"{pin}",', text, count=1)) + + def _latest_tag() -> str | None: tags = _run(["git", "tag", "--list", "v[0-9]*", "--sort=-v:refname"]) return tags.splitlines()[0] if tags else None @@ -40,10 +169,7 @@ def _current_version() -> str: def _bump_patch(version: str) -> str: - match = VERSION_RE.match(version) - if not match: - raise ValueError(f"Automated releases require X.Y.Z versions, got {version!r}") - major, minor, patch = (int(part) for part in match.groups()) + major, minor, patch = _parse_version(version) return f"{major}.{minor}.{patch + 1}" @@ -147,33 +273,60 @@ def main() -> int: parser.add_argument( "--force", action="store_true", help="Release even with no changes" ) + parser.add_argument( + "--upstream", + default=None, + help=f"Upstream {UPSTREAM_PACKAGE} version to mirror. Default: read PyPI.", + ) + parser.add_argument( + "--no-upstream", + action="store_true", + help="Ignore upstream entirely and patch-bump the latest tag.", + ) args = parser.parse_args() latest_tag = _latest_tag() commits = _commits_since(latest_tag) current = _current_version() - if commits == 0 and not args.force: + if args.no_upstream: + upstream = None + else: + upstream = args.upstream or _latest_upstream() + + floor = _current_uxarray_floor() + upstream_is_new = bool( + upstream and floor and _parse_version(upstream) > _parse_version(floor) + ) + + if commits == 0 and not args.force and not upstream_is_new: _github_output( release_needed="false", previous_tag=latest_tag or "", changed_commits=commits, version=current, tag=f"v{current}", + upstream=upstream or "", + upstream_is_new="false", ) return 0 if args.version: version = args.version + elif upstream: + version = _next_version( + latest_tag.removeprefix("v") if latest_tag else current, upstream + ) elif latest_tag is None: version = current else: version = _bump_patch(latest_tag.removeprefix("v")) - if not VERSION_RE.match(version): - raise ValueError(f"Invalid release version {version!r}; expected X.Y.Z") + _parse_version(version) _write_version(version) + if upstream: + _write_uxarray_pin(upstream) _stamp_changelog_file(version) _relock(version) _github_output( @@ -182,6 +335,8 @@ def main() -> int: changed_commits=commits, version=version, tag=f"v{version}", + upstream=upstream or "", + upstream_is_new=str(upstream_is_new).lower(), ) return 0 diff --git a/tests/test_calver_release.py b/tests/test_calver_release.py new file mode 100644 index 0000000..300d334 --- /dev/null +++ b/tests/test_calver_release.py @@ -0,0 +1,279 @@ +"""Tests for the CalVer version rules and the uxarray pin they carry. + +The release job decides a version number and rewrites four files with it while +nobody is watching, so the arithmetic is the part that has to be right before +it runs. Upstream is the input: its `year.month` is copied, its patch is not, +and the pin that says which upstream months we accept moves with it. +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import pytest + +_SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "prepare_release.py" +_spec = importlib.util.spec_from_file_location("prepare_release_calver", _SCRIPT) +assert _spec and _spec.loader +prepare_release = importlib.util.module_from_spec(_spec) +sys.modules["prepare_release_calver"] = prepare_release +_spec.loader.exec_module(prepare_release) + +_next_version = prepare_release._next_version +_parse_version = prepare_release._parse_version +_uxarray_pin = prepare_release._uxarray_pin +_latest_upstream = prepare_release._latest_upstream + + +def _pypi(*versions: str, yanked: set[str] | None = None) -> dict: + """A PyPI JSON payload carrying exactly these releases.""" + yanked = yanked or set() + return { + "info": {"version": versions[-1] if versions else ""}, + "releases": { + version: [ + {"filename": f"uxarray-{version}.tar.gz", "yanked": version in yanked} + ] + for version in versions + }, + } + + +class TestTheVersionFollowsUpstreamsMonth: + def test_a_second_release_in_upstreams_month_bumps_only_our_patch(self): + assert _next_version("2026.9.0", "2026.9.0") == "2026.9.1" + + def test_a_new_upstream_month_resets_the_patch(self): + assert _next_version("2026.9.3", "2026.10.0") == "2026.10.0" + + def test_the_first_calver_release_lands_on_upstreams_month(self): + """`0.3.1` is not a month, so it can never match one.""" + assert _next_version("0.3.1", "2026.9.0") == "2026.9.0" + + def test_a_month_upstream_skipped_is_simply_not_used(self): + """Upstream skipped 2026.01 and 2026.05. We follow where it went. + + The intervening months are not released quietly on our side; the + version jumps with upstream's. + """ + assert _next_version("2026.4.2", "2026.6.0") == "2026.6.0" + + def test_upstream_releasing_twice_in_a_month_does_not_move_our_month(self): + """August 2026 had two upstream releases and would have had one of ours. + + Upstream's own patch is not mirrored -- only the month is -- so our + patch counts our releases, not theirs. + """ + assert _next_version("2026.8.0", "2026.8.1") == "2026.8.1" + assert _next_version("2026.8.1", "2026.8.2") == "2026.8.2" + + def test_december_is_not_a_thirteenth_month(self): + assert _next_version("2026.11.0", "2026.12.0") == "2026.12.0" + assert _next_version("2026.12.0", "2027.1.0") == "2027.1.0" + + +class TestZeroPaddingIsRefused: + """Upstream tags `v2026.09.0`; PEP 440 publishes it as `2026.9.0`. + + Copying the padding across would ship a dist whose version disagrees with + the tag it was built from, so the padded form is rejected where it enters + rather than normalized silently. + """ + + def test_a_padded_month_is_rejected(self): + with pytest.raises(ValueError, match="Zero-padded"): + _parse_version("2026.09.0") + + def test_the_message_names_the_version_to_use_instead(self): + with pytest.raises(ValueError, match=r"Use 2026\.9\.0"): + _parse_version("2026.09.0") + + def test_a_padded_patch_is_rejected_too(self): + with pytest.raises(ValueError, match="Zero-padded"): + _parse_version("2026.9.01") + + def test_a_plain_zero_is_not_padding(self): + assert _parse_version("2026.9.0") == (2026, 9, 0) + + def test_padded_upstream_cannot_sneak_in_through_next_version(self): + with pytest.raises(ValueError, match="Zero-padded"): + _next_version("2026.8.0", "2026.09.0") + + +class TestThePinMovesBothEnds: + def test_the_ceiling_is_the_month_after_upstream(self): + assert _uxarray_pin("2026.9.0") == "uxarray>=2026.9.0,<2026.10" + + def test_the_floor_is_the_upstream_being_released_against(self): + assert _uxarray_pin("2026.10.2").startswith("uxarray>=2026.10.2,") + + def test_december_rolls_the_ceiling_into_the_next_year(self): + """`<2026.13` is not a version anyone will ever publish.""" + assert _uxarray_pin("2026.12.0") == "uxarray>=2026.12.0,<2027.1" + + def test_the_pin_is_rewritten_whole_in_pyproject(self, tmp_path, monkeypatch): + """A floor raised without its ceiling is the bug this guards. + + The next upstream month would then be excluded by a ceiling written + for the month before, and the package becomes uninstallable beside + the release it was tested against. + """ + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + "dependencies = [\n" + ' "pyyaml>=6.0.1",\n' + ' "uxarray>=2026.9.0,<2026.10",\n' + "]\n" + ) + monkeypatch.setattr(prepare_release, "PYPROJECT", pyproject) + + prepare_release._write_uxarray_pin("2026.10.0") + + assert '"uxarray>=2026.10.0,<2026.11",' in pyproject.read_text() + assert '"pyyaml>=6.0.1",' in pyproject.read_text() + + def test_a_bare_floor_gains_a_ceiling(self): + """What `pyproject.toml` carries today is a floor and nothing else.""" + text = 'dependencies = [\n "uxarray>=2026.9.0",\n]\n' + assert ( + prepare_release.UXARRAY_PIN_RE.sub( + '\\g"' + _uxarray_pin("2026.9.0") + '",', text + ) + == 'dependencies = [\n "uxarray>=2026.9.0,<2026.10",\n]\n' + ) + + def test_an_ambiguous_pyproject_is_refused_rather_than_guessed( + self, tmp_path, monkeypatch + ): + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text(' "uxarray>=2026.9.0",\n "uxarray-viz>=1.0",\n') + monkeypatch.setattr(prepare_release, "PYPROJECT", pyproject) + + with pytest.raises(RuntimeError, match="exactly one uxarray"): + prepare_release._write_uxarray_pin("2026.10.0") + + +class TestUpstreamIsReadFromPypi: + def test_the_newest_release_wins_not_the_last_key(self): + """Dict order in the JSON is upload order, which is not version order.""" + payload = _pypi("2026.4.0", "2026.10.0", "2026.9.0") + assert _latest_upstream(fetch=lambda url: payload) == "2026.10.0" + + def test_prereleases_are_not_months_we_mirror(self): + payload = _pypi("2026.9.0", "2026.10.0rc1") + assert _latest_upstream(fetch=lambda url: payload) == "2026.9.0" + + def test_a_fully_yanked_release_is_skipped(self): + payload = _pypi("2026.9.0", "2026.10.0", yanked={"2026.10.0"}) + assert _latest_upstream(fetch=lambda url: payload) == "2026.9.0" + + def test_padding_from_pypi_is_normalized_not_propagated(self): + payload = _pypi("2026.09.0") + assert _latest_upstream(fetch=lambda url: payload) == "2026.9.0" + + def test_an_unreachable_index_decides_nothing(self): + """No answer is not `0.0.0`. The caller falls back to a patch bump.""" + + def _fail(url): + raise OSError("no route to host") + + assert _latest_upstream(fetch=_fail) is None + + +class TestTheDailyPollDecidesWhenToRelease: + """A daily cron asks this script "is there anything to do today". + + Commits are no longer the only reason to answer yes: a new upstream month + is a release even when nothing in this repository changed, because the pin + that says which upstream we support is itself the change. + """ + + @pytest.fixture + def sandbox(self, tmp_path, monkeypatch): + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + 'version = "2026.9.0"\n' + "dependencies = [\n" + ' "uxarray>=2026.9.0,<2026.10",\n' + "]\n" + ) + init = tmp_path / "__init__.py" + init.write_text('__version__ = "2026.9.0"\n') + recipe = tmp_path / "meta.yaml" + recipe.write_text('{% set version = "2026.9.0" %}\n') + changelog = tmp_path / "CHANGELOG.md" + changelog.write_text("# Changelog\n\n## Unreleased\n\n- Something.\n") + + monkeypatch.setattr(prepare_release, "PYPROJECT", pyproject) + monkeypatch.setattr(prepare_release, "INIT", init) + monkeypatch.setattr(prepare_release, "CONDA_RECIPE", recipe) + monkeypatch.setattr(prepare_release, "CHANGELOG", changelog) + monkeypatch.setattr(prepare_release, "_relock", lambda version: None) + monkeypatch.setattr(prepare_release, "_latest_tag", lambda: "v2026.9.0") + + output = tmp_path / "github_output" + output.write_text("") + monkeypatch.setenv("GITHUB_OUTPUT", str(output)) + monkeypatch.setattr(sys, "argv", ["prepare_release.py"]) + return {"pyproject": pyproject, "changelog": changelog, "output": output} + + def _outputs(self, sandbox) -> dict[str, str]: + return dict( + line.split("=", 1) + for line in sandbox["output"].read_text().splitlines() + if line + ) + + def test_a_new_upstream_month_is_a_release_with_no_commits_of_our_own( + self, sandbox, monkeypatch + ): + monkeypatch.setattr(prepare_release, "_commits_since", lambda tag: 0) + monkeypatch.setattr(prepare_release, "_latest_upstream", lambda: "2026.10.0") + + assert prepare_release.main() == 0 + + outputs = self._outputs(sandbox) + assert outputs["release_needed"] == "true" + assert outputs["version"] == "2026.10.0" + assert outputs["tag"] == "v2026.10.0" + assert outputs["upstream_is_new"] == "true" + assert '"uxarray>=2026.10.0,<2026.11",' in sandbox["pyproject"].read_text() + assert "## 2026.10.0 —" in sandbox["changelog"].read_text() + + def test_the_same_upstream_and_no_commits_writes_nothing( + self, sandbox, monkeypatch + ): + monkeypatch.setattr(prepare_release, "_commits_since", lambda tag: 0) + monkeypatch.setattr(prepare_release, "_latest_upstream", lambda: "2026.9.0") + before = sandbox["pyproject"].read_text() + + assert prepare_release.main() == 0 + + assert self._outputs(sandbox)["release_needed"] == "false" + assert sandbox["pyproject"].read_text() == before + assert "## 2026" not in sandbox["changelog"].read_text() + + def test_an_unreachable_pypi_falls_back_to_a_patch_bump(self, sandbox, monkeypatch): + """The version still moves, and the pin is left exactly as it was. + + Guessing a ceiling from a version we could not read would be worse + than shipping the one that was already reviewed. + """ + monkeypatch.setattr(prepare_release, "_commits_since", lambda tag: 3) + monkeypatch.setattr(prepare_release, "_latest_upstream", lambda: None) + + assert prepare_release.main() == 0 + + assert self._outputs(sandbox)["version"] == "2026.9.1" + assert '"uxarray>=2026.9.0,<2026.10",' in sandbox["pyproject"].read_text() + + +class TestTheRealPyprojectStaysParseable: + def test_the_shipped_pin_matches_exactly_once(self): + text = (Path(__file__).resolve().parents[1] / "pyproject.toml").read_text() + assert len(prepare_release.UXARRAY_PIN_RE.findall(text)) == 1 + + def test_the_shipped_floor_is_readable(self): + assert _parse_version(prepare_release._current_uxarray_floor())