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())