From bfe8fedb196eba1fd8ed8f2eeeb56c1e4ffcc9ab Mon Sep 17 00:00:00 2001 From: "Qiaoyu (Joey) Deng" Date: Wed, 29 Jul 2026 11:50:51 -0700 Subject: [PATCH] build: extend the version scheme to minor and major releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `__version__` now states the release being worked toward and `make build` derives from it. Previously only a last-digit bump was expressible, so `1.1.0` or `2.0.0` could not be released at all. - `check-about-version` accepts any single number raised by one with the rest zeroed, and accepts a `release/` branch on `origin` as evidence during the window between the cut and the tag — which is what lets `main` move on immediately. - A release branch sets `latest_released_version` to the release it produces, so a repo pinning `external/` to that branch resolves the right baseline instead of the previous release. - `make version` now prints the publishable version; `make version-dev` prints the on-tree `.dev0`. --- .github/workflows/release.yml | 10 +- .pre-commit-config.yaml | 7 +- CHANGELOG.md | 4 +- Makefile | 18 ++- RELEASE.md | 89 +++++++++--- scripts/make/build.py | 12 +- scripts/make/print_version.py | 8 +- scripts/pre_commit/check_about_version.py | 59 +++++--- scripts/release/release_utils.py | 136 ++++++++++++++---- src/coreai_opt/_about.py | 14 +- tests/devtools/test_check_about_version.py | 75 +++++++++- tests/test_release_utils.py | 153 ++++++++++++++++----- 12 files changed, 457 insertions(+), 128 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4d7828d..ec9ead3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,17 +52,17 @@ jobs: # The published version comes from src/coreai_opt/_about.py, not the tag. # Fail early if they disagree so we never publish a mismatched/duplicate # version (PyPI uploads are immutable and cannot be overwritten). - # `print_version.py --release` computes the version exactly as the + # `make version` computes the version exactly as the # `make build` step below does, so this guard can't drift from what # actually gets published. # Skipped on manual dry runs, where the ref is a branch, not a vX.Y.Z tag. - # Run via uv (installed above) so the interpreter satisfies - # requires-python whatever the runner image ships; see the Makefile's - # `version` target. + # The target runs `uv run --no-project` internally, so uv (installed + # above) supplies an interpreter satisfying requires-python whatever the + # runner image ships; see the Makefile's `version` target. if: github.event_name == 'push' run: | tag="${GITHUB_REF_NAME}" - version="$(uv run --no-config --no-project --python '>=3.11' scripts/make/print_version.py --release)" + version="$(make version)" echo "tag=${tag} package version=${version}" if [ "${tag}" != "v${version}" ]; then echo "::error::Tag ${tag} does not match package version v${version} (src/coreai_opt/_about.py). Update latest_released_version so the release it implies matches the tag." diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f42df09..62245a0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -325,9 +325,10 @@ repos: name: Check _about.py version fields description: | Check that _about.py's latest_released_version matches the repo's - latest release tag, and that __version__ is its last number plus - one, plus .dev0. Catches a release candidate that looks like a - release has already shipped when it hasn't. + latest release tag (or has a release/ branch), and that + __version__ raises exactly one of its numbers by one, zeroes the + rest, and ends in .dev0. Catches a release candidate that looks + like a release has already shipped when it hasn't. entry: python scripts/pre_commit/check_about_version.py language: system files: (^|/)_about\.py$ diff --git a/CHANGELOG.md b/CHANGELOG.md index 2109005..4c97338 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,5 +25,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release of `coreai-opt`. See the [GitHub Releases](https://github.com/apple/coreai-optimization/releases/) page for release notes. -[0.2.1]: https://github.com/apple/coreai-optimization/releases/tag/v0.2.1 -[0.2.0]: https://github.com/apple/coreai-optimization/releases/tag/v0.2.0 +[0.2.0]: https://github.com/apple/coreai-optimization/commits/v0.2.0/ +[0.2.1]: https://github.com/apple/coreai-optimization/compare/v0.2.0...v0.2.1/ diff --git a/Makefile b/Makefile index d13e1af..376d024 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ # Use of this source code is governed by a BSD-3-Clause license that can # be found in the LICENSE file or at https://opensource.org/licenses/BSD-3-Clause -.PHONY: _maybe_patch_pyproject all api-list build build-dev check clean distclean distclean-all docs docs-clean docs-open env env-all env-docs env-highest-torch env-lowest-torch env-tutorial render-api-index set-auto-venv test test-cov test-fast test-highest-pytorch test-lowest-pytorch test-slow test-smoke test-tutorials version +.PHONY: _maybe_patch_pyproject all api-list build build-dev check clean distclean distclean-all docs docs-clean docs-open env env-all env-docs env-highest-torch env-lowest-torch env-tutorial render-api-index set-auto-venv test test-cov test-fast test-highest-pytorch test-lowest-pytorch test-slow test-smoke test-tutorials version version-dev SHELL := /bin/bash @@ -356,12 +356,18 @@ distclean-all: set-auto-venv: @$(SCRIPTS)/make/set_auto_venv.sh $(DEFAULT_VENV) $(SHELL_RC) -# Show the development version carried on the tree (e.g. 0.2.2.dev0), including -# any COREAI_OPT_VERSION_EXTENSION (e.g. 0.2.2.1.dev0). Reads _about.py as plain -# text, so no venv is needed — but `uv run --no-project` is still what guarantees -# a >= 3.11 interpreter (a bare `python3` is 3.9 on stock macOS, and `python` may -# not exist at all) without requiring `make env` first. +# Show the version a release would publish (e.g. 0.2.2), including any +# COREAI_OPT_VERSION_EXTENSION (e.g. 0.2.2.1). This is the same computation the +# release workflow's tag guard uses, so the two cannot drift. Reads _about.py as +# plain text, so no venv is needed — but `uv run --no-project` is still what +# guarantees a >= 3.11 interpreter (a bare `python3` is 3.9 on stock macOS, and +# `python` may not exist at all) without requiring `make env` first. version: + @uv run --no-config --no-project --python '>=3.11' $(SCRIPTS)/make/print_version.py --release + +# Show the development version carried on the tree (e.g. 0.2.2.dev0), i.e. +# `version` with the .dev0 marker the tree carries between releases. +version-dev: @uv run --no-config --no-project --python '>=3.11' $(SCRIPTS)/make/print_version.py # ============================================================================= diff --git a/RELEASE.md b/RELEASE.md index 06adebe..dce550c 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,37 +1,92 @@ # Package Release Guide -The OSS release process for Core AI Optimization is still being defined. This page will document the workflow for publishing to PyPI once the public release infrastructure is finalized. +Releases are cut and published by the [release managers team](https://github.com/orgs/apple/teams/coreai-optimization-release-managers). The following commands are available locally: ```bash -make build # build the canonical, publishable wheel + sdist (uv build --no-sources) -make build-dev # build a timestamped dev wheel (e.g. 0.2.2.dev202607231430+abc1234) -make version # show the development version carried on the tree (e.g. 0.2.2.dev0) +make build # build the artifacts for the current release to be published +make build-dev # build a nightly or local development wheel (e.g. 1.1.0.dev202607231430+abc1234) +make version # show the version a release would publish (e.g. 1.1.0) +make version-dev # show the development version carried on the tree (e.g. 1.1.0.dev0) make clean # remove build artifacts ``` ## Version scheme -`main` always carries the version planned for the _next_ release. This ensures that ongoing development is never mistaken for an already-published version, and that a release can be stabilized, tested, and published on its own branch, independently of later changes on `main`. (The release-branch workflow itself — branch naming, tagging, and backporting fixes to `main` — will be documented separately in the release schedule doc; this section covers only the version-string mechanics.) +`main` always carries the version of the *next* release, never the one that already shipped. -`src/coreai_opt/_about.py` stores `latest_released_version` (the last tagged release, e.g. `"0.2.1"`) and computes `__version__` from it by incrementing its last number by one and adding a `.dev0` suffix (e.g. `"0.2.2.dev0"`). A pre-commit hook (`check-about-version`) verifies that `__version__` always follows this rule and that `latest_released_version` matches the repo's latest release tag. As a result, `__version__` can never look as though a release has shipped when it hasn't. The `.dev0` suffix is only a marker on the tree; it never appears in a built wheel. +There are three version formats: -- `make build` builds the release that `__version__` implies, e.g. `0.2.2`. A release is cut by tagging it (`v0.2.2`); `latest_released_version` is then hard-coded to `"0.2.2"`, which bumps `__version__` to the next candidate (`0.2.3.dev0`). -- `make build-dev` builds that same release but with a unique `.dev+` suffix instead. It is used by contributors, smoke tests, and the nightly pipeline. `DEV_VERSION=` uses that version exactly instead. +| Format | Example | What it is | +| -------------------------------------- | ------------------------------- | -------------------------------------------- | +| `X.Y.Z` | `1.0.0` | a published release | +| `X.Y.Z.dev0` | `1.1.0.dev0` | the version `main` carries in the repo | +| `X.Y.Z.dev+` | `1.1.0.dev202607231430+abc1234` | a dev artifact, built from a specific commit | -Sorting is preserved: `0.2.2.dev0 < 0.2.2.dev202607231430+abc1234 < 0.2.2`. +Say `1.0.0` has just been released. `main` then carries `1.0.1.dev0`. That reads as "working toward a release after `1.0.0`, which has not shipped": the `.dev0` suffix marks the tree as unreleased and never appears in a built wheel. Nothing on `main` can be mistaken for a published version. -### Extending the scheme downstream +`main`'s `.dev0` always defaults to the last digit plus one, so after `1.0.0` it is `1.0.1.dev0`. Once the version of the next release is known — usually a minor — a PR sets `__version__` to it before the release branch is cut. + +The flow below traces one cycle. At the cut, `main` and the release branch diverge and never rejoin: the branch keeps the version it was cut with, and only `main` moves on. + +```mermaid +--- +title: Version change flow +--- +flowchart TB + prev["previous release schedule"] --> m1["main: 1.0.1.dev0
placeholder, last digit + 1"] + prev --> r0("1.0.0 released") + m1 -->|"ready for release"| ask{"is the placeholder the
version we want?"} + ask -->|"No, usually a minor"| pr["PR sets __version__"] + ask -->|Yes| fin + pr --> fin["main: 1.1.0.dev0
assume we release 1.1.0;
it could also stay 1.0.1"] + fin --> nxt["main: 1.1.1.dev0
placeholder, last digit + 1"] + fin -->|cut| rb["release/1.1.0
set latest_released_version = 1.1.0"] + rb --> stab["stabilize"] + stab -->|"tag v1.1.0"| rel("1.1.0 released") + nxt --> nextsched["next release schedule"] + rel --> nextsched + nextsched -.->|"the process repeats"| prev +``` + +`src/coreai_opt/_about.py` holds the last released version and `__version__`. Both are set together in one PR when `main` moves forward: the last released version becomes the release just branched, and `__version__` becomes the one after it. The `check-about-version` pre-commit hook enforces the relation between them: `__version__` must be the last released version with exactly one of its numbers raised by one, every number after that reset to zero, and `.dev0` on the end. From `1.0.0` it accepts `1.0.1.dev0`, `1.1.0.dev0`, or `2.0.0.dev0`, and nothing else. Any other value fails the commit, and the message prints the accepted ones, so there is nothing to work out by hand. + +- `make build` builds the artifacts for the current release to be published. +- `make build-dev` builds the wheel for the nightly build, and local wheels for development and testing, each carrying a unique `.dev+` suffix. + +Therefore, we have the following order: + +```text +1.1.0.dev0 < 1.1.0.dev202607231430+abc1234 < 1.1.0 +``` + +This is the order we want. `1.1.0.dev0` is the bare marker `main` carries, so it sorts below every wheel actually built for `1.1.0`. Each nightly sorts above it, and above the nightly before it, because the timestamp only grows. The published `1.1.0` sorts highest of all, so installers pick it over any dev wheel. + +A release branch is the one place where the two match: it sets `latest_released_version` to the release it produces, so `__version__` is that same version plus `.dev0` rather than a next candidate. On `main` they always differ, which is what tells a release branch apart — and what lets a repo that vendors this one pin to a release branch and still resolve the right baseline. + +`__version__` must always be a literal string, never an expression. -A repo that uses this one as a submodule and includes this `Makefile` — building one combined wheel from both trees — can add its own 4th number. Set `COREAI_OPT_VERSION_EXTENSION` to the number it's about to release next (e.g. `"1"` for its first release off a given OSS release, then `"2"` for the one after that). Then call `make build`, `make build-dev`, or `make version` unchanged: +### Release branches + +1. `release/` is created from `main`, once the version of the next release has been decided — that is, which digit gets one added to it. Its first commit sets `latest_released_version` to that version, so the branch names its own release. +2. The tag is created on the `release/` branch, never on `main`. +3. After the cut, `main` continues on to the next release's `.dev0`. +4. The `check-about-version` pre-commit hook enforces the version rules on every commit. +5. After the cut, the release branch takes no new commits, unless a must-fix issue comes up. Those commits are later cherry-picked back to `main`. + +Cut the branch before moving `main` to the next dev release. + +### Extending the scheme downstream -- `latest_released_version` `"0.2.1"` + extension `"1"` -> candidate: `0.2.1.1.dev0` -- `make build` -> `0.2.1.1` -- `make build-dev` -> `0.2.1.1.dev+` +A repo that uses this one as a submodule and includes this `Makefile` — building one combined wheel from both trees — can add its own 4th number. Set `COREAI_OPT_VERSION_EXTENSION` to the number it's about to release next, then call `make build`, `make build-dev`, or `make version` unchanged. -The extra number is used exactly as given (`scripts/release/release_utils.apply_version_extension`); `latest_released_version`'s own last number is only bumped for OSS's own `main`, when no extension is set. +The extension anchors the release to the last *published* release instead of the one `__version__` is working toward: -There is still only one `_about.py` (this package's own); the extra number is a plain string handled entirely in `scripts/release/release_utils.next_release_base` — no other file or package is involved. +| `latest_released_version` | `__version__` | `COREAI_OPT_VERSION_EXTENSION` | Release built | +| ------------------------- | ------------- | ------------------------------ | ------------- | +| `1.0.0` | `1.1.0.dev0` | unset | `1.1.0` | +| `1.0.0` | `1.1.0.dev0` | `1` | `1.0.0.1` | +| `1.0.0` | `1.1.0.dev0` | `2` | `1.0.0.2` | - +The extension is used exactly as given, and starts at `1`, not `0`. `make build-dev` adds the usual `.dev+` suffix on top. diff --git a/scripts/make/build.py b/scripts/make/build.py index 3ceccd0..62758fb 100644 --- a/scripts/make/build.py +++ b/scripts/make/build.py @@ -14,11 +14,9 @@ version. Called by `make build-dev`. ``_about.py`` stores ``latest_released_version`` (the last tagged release) by -hand; ``__version__`` is computed from it — add one to its last number, add -``.dev0``. This script computes the version to build from -``latest_released_version``, not from the on-tree ``__version__`` (which must -never be treated as already released), writes it into ``_about.py``, builds, -then restores the file. A repo that uses this one as a submodule (building +hand; ``__version__`` names the release the tree is working toward. This +script takes the version to build from ``__version__``, writes it into +``_about.py``, builds, then restores the file. A repo that uses this one as a submodule (building one combined wheel) can add its own extra number to the version with ``COREAI_OPT_VERSION_EXTENSION``; see ``scripts/release/release_utils.next_release_base``. @@ -89,7 +87,9 @@ def main() -> None: repo_root = _find_repo_root(Path(__file__)) about = read_about(repo_root) - release_base = next_release_base(about.latest_released_version, get_version_extension()) + release_base = next_release_base( + about.latest_released_version, about.version, get_version_extension() + ) build_version = resolve_build_version( release_base, dev=args.dev, diff --git a/scripts/make/print_version.py b/scripts/make/print_version.py index 80160bf..6ef8eee 100644 --- a/scripts/make/print_version.py +++ b/scripts/make/print_version.py @@ -9,7 +9,7 @@ Usage: print_version.py the ``.dev0`` candidate, e.g. ``0.2.2.dev0`` - (``make version``) + (``make version-dev``) print_version.py --release the release itself, e.g. ``0.2.2`` — the version ``make build`` publishes @@ -25,7 +25,7 @@ import sys from pathlib import Path -# `make version` exports PYTHONPATH, but the release workflow runs this script +# `make version-dev` exports PYTHONPATH, but the release workflow runs this script # directly, which puts only `scripts/make/` on sys.path. Walk up to the project # root (the directory holding pyproject.toml, alongside `scripts/`) so this # keeps working if the script moves. It can't call @@ -58,7 +58,9 @@ def main() -> None: about = read_about(_repo_root) compute = next_release_base if args.release else next_candidate_version - sys.stdout.write(f"{compute(about.latest_released_version, get_version_extension())}\n") + sys.stdout.write( + f"{compute(about.latest_released_version, about.version, get_version_extension())}\n" + ) if __name__ == "__main__": diff --git a/scripts/pre_commit/check_about_version.py b/scripts/pre_commit/check_about_version.py index c7f9d21..7602cde 100644 --- a/scripts/pre_commit/check_about_version.py +++ b/scripts/pre_commit/check_about_version.py @@ -9,15 +9,25 @@ Two checks: -1. ``latest_released_version`` matches the repo's latest ``vX.Y.Z`` release - tag (fetched fresh from ``origin`` so an out-of-date local tag can't hide a - mismatch). Skipped if there's no such tag yet (e.g. before the first - release). -2. ``__version__`` equals the ``.dev0`` version computed from - ``latest_released_version`` (``next_candidate_version``) — its last number - plus one. This stops a release candidate from looking like it already - shipped a release that hasn't happened yet; see - ``scripts/release/release_utils.next_candidate_version``. +1. ``latest_released_version`` names a release that has either been tagged or + been branched. The tag is fetched fresh from ``origin`` so an out-of-date + local tag can't hide a mismatch. The branch alternative covers the + stabilization window: a release branch is cut before its tag exists, and + ``main`` moves to the next candidate at the cut, so between those two points + ``latest_released_version`` legitimately names an untagged release. Skipped + if there's no release tag yet (e.g. before the first release). +2. ``__version__`` names a release that may directly follow + ``latest_released_version`` — exactly one number up by one, everything + after it reset to zero — plus a ``.dev0`` suffix. From ``"1.0.1"`` that + admits ``"2.0.0.dev0"``, ``"1.1.0.dev0"``, and ``"1.0.2.dev0"``, so a + minor or major is chosen by editing ``__version__``, while a skipped + number or a move backwards is rejected. This also stops a release + candidate from looking like it already shipped; see + ``scripts/release/release_utils.valid_next_versions``. + + A release branch is the exception: it sets ``latest_released_version`` to + the version it produces, so ``__version__`` is that same version plus + ``.dev0``. See ``release_utils.release_branch_version``. """ from __future__ import annotations @@ -41,9 +51,12 @@ sys.path.insert(0, str(_repo_root)) from scripts.release.release_utils import ( # noqa: E402 + RELEASE_BRANCH_PREFIX, latest_release_tag, - next_candidate_version, read_about, + release_branch_exists, + release_branch_version, + valid_next_versions, ) @@ -56,20 +69,30 @@ def main() -> int: errors = [] latest_tag = latest_release_tag(repo_root) - if latest_tag is not None and latest_tag != latest_released: + if ( + latest_tag is not None + and latest_tag != latest_released + and not release_branch_exists(repo_root, latest_released) + ): errors.append( f"latest_released_version is {latest_released!r} in {about.path}, but the " - f"latest release tag is v{latest_tag}. Update latest_released_version " - f"to {latest_tag!r}." + f"latest release tag is v{latest_tag} and there is no " + f"{RELEASE_BRANCH_PREFIX}{latest_released} branch. Update latest_released_version " + f"to {latest_tag!r}, or cut the release branch before bumping it." ) - expected_version = next_candidate_version(latest_released) - if about.version != expected_version: + # On `main`, `__version__` names a release after `latest_released_version`. + # On a release branch it names that same release, because the branch sets + # `latest_released_version` to the version it produces. + allowed = [f"{candidate}.dev0" for candidate in valid_next_versions(latest_released)] + allowed.append(release_branch_version(latest_released)) + if about.version not in allowed: errors.append( f"__version__ is {about.version!r} in {about.path}, but latest_released_version " - f"{latest_released!r} implies {expected_version!r}. __version__ must be " - "latest_released_version with its last segment incremented by one, " - "plus '.dev0'." + f"{latest_released!r} allows only {', '.join(repr(a) for a in allowed)}. " + "__version__ must take latest_released_version, add one to exactly one of its " + "numbers, reset every number after it to zero, and end in '.dev0' — or, on a " + "release branch, be latest_released_version itself plus '.dev0'." ) if errors: diff --git a/scripts/release/release_utils.py b/scripts/release/release_utils.py index 28c9bf6..71988cf 100644 --- a/scripts/release/release_utils.py +++ b/scripts/release/release_utils.py @@ -10,10 +10,10 @@ ``scripts/release/release.py`` and stay internal-only. ``_about.py`` hard-codes ``latest_released_version`` (the last tagged release, -e.g. ``"0.2.1"``); ``__version__`` is that release with one added to its last -number, plus ``.dev0`` (e.g. ``"0.2.2.dev0"``). Everything here computes from -``latest_released_version`` — never from ``__version__``, which must not be -treated as already released. +e.g. ``"0.2.1"``) and ``__version__``, the release the tree is working toward +(e.g. ``"0.2.2.dev0"``). A build takes its version from ``__version__``, which +is what lets a minor or major release be declared rather than only a +last-digit bump. A repo that vendors this one as a submodule can add its own extra release number via ``COREAI_OPT_VERSION_EXTENSION``; see ``next_release_base``. See @@ -42,9 +42,12 @@ VERSION_EXTENSION_ENV_VAR = "COREAI_OPT_VERSION_EXTENSION" DEV_VERSION_ENV_VAR = "DEV_VERSION" -# Cap on the tag fetch in `latest_release_tag`. It runs from a pre-commit hook, -# so an unreachable `origin` (VPN down, laptop offline mid-handshake) must not -# stall the commit for the OS connect timeout. +# Branch a release is stabilized on before it is tagged, e.g. `release/0.2.2`. +RELEASE_BRANCH_PREFIX = "release/" + +# Cap on the `origin` fetch in `latest_release_tag`. It runs from a pre-commit +# hook, so an unreachable `origin` (VPN down, laptop offline mid-handshake) must +# not stall the commit for the OS connect timeout. _FETCH_TIMEOUT_SECONDS = 5 @@ -110,7 +113,7 @@ def latest_release_tag(repo_root: Path) -> str | None: """ try: subprocess.run( - ["git", "fetch", "--tags", "origin"], + ["git", "fetch", "--tags", "--prune", "origin"], cwd=repo_root, capture_output=True, check=False, # offline / no `origin` remote: fall back to local tags @@ -129,6 +132,41 @@ def latest_release_tag(repo_root: Path) -> str | None: return tags[0].removeprefix("v") if tags else None +def release_branch_exists(repo_root: Path, version: str) -> bool: + """Return whether ``origin`` has a ``release/`` branch. + + A release branch is cut before its tag exists, and ``main`` moves to the + next candidate as soon as the cut happens — so for the length of the + stabilization window ``latest_released_version`` names a release that is + branched but not yet tagged. This reports whether that window is open; see + ``scripts/pre_commit/check_about_version.py``. + + Only the remote-tracking ref is consulted, so a local branch of the same + name cannot satisfy the check. ``latest_release_tag`` fetches from + ``origin`` before this runs, which is what keeps that ref current. + + Args: + repo_root: Repository root to run ``git`` in. + version: Release the branch is named for, e.g. ``"0.2.2"``. + + Returns: + bool: ``True`` if ``origin`` has ``release/``. + """ + result = subprocess.run( + [ + "git", + "for-each-ref", + "--format=%(refname)", + f"refs/remotes/origin/{RELEASE_BRANCH_PREFIX}{version}", + ], + cwd=repo_root, + capture_output=True, + text=True, + check=True, + ) + return bool(result.stdout.strip()) + + # ============================================================================= # Version arithmetic # ============================================================================= @@ -159,15 +197,46 @@ def apply_version_extension(version: str, extension: str | None) -> str: return f"{version}.{extension}" if extension else version -def bump_last_segment(version: str) -> str: - """Add one to the last number in a version. +def valid_next_versions(version: str) -> list[str]: + """Return every release that may directly follow ``version``. + + Exactly one number goes up by one and everything after it resets to zero, + so ``"1.0.1"`` -> ``["2.0.0", "1.1.0", "1.0.2"]`` — a major, a minor, and a + patch. Ordered most-significant first. Anything else is either a skipped + number or a move backwards. + + Args: + version: A plain release (no ``.dev`` suffix), e.g. ``"1.0.1"``. + + Returns: + list[str]: The releases that may follow, most-significant bump first. + """ + numbers = [int(part) for part in version.split(".")] + return [ + ".".join(str(n) for n in numbers[:i] + [numbers[i] + 1] + [0] * (len(numbers) - i - 1)) + for i in range(len(numbers)) + ] + + +def release_branch_version(latest_released_version: str) -> str: + """Return the ``__version__`` a ``release/`` branch carries. + + A release branch names its own release: ``latest_released_version`` is set + to the version the branch produces, so ``__version__`` is that same version + plus ``.dev0`` rather than a next candidate. On ``main`` the two always + differ, which is what tells a release branch apart from ``main``. + + Making the branch self-describing is what lets a downstream repo pin + ``external/`` to a release branch and still resolve the right baseline — + ``latest_released_version`` then means the same thing on every commit. + + Args: + latest_released_version: The release the branch produces, e.g. ``"1.1.0"``. - ``"0.2.1"`` -> ``"0.2.2"``; ``"0.2.1.1"`` -> ``"0.2.1.2"``. ``version`` - must be a plain release (no ``.dev`` suffix) whose last number is an - integer. + Returns: + str: The ``__version__`` that branch carries, e.g. ``"1.1.0.dev0"``. """ - *head, last = version.split(".") - return ".".join((*head, str(int(last) + 1))) + return f"{latest_released_version}.dev0" # ============================================================================= @@ -175,20 +244,26 @@ def bump_last_segment(version: str) -> str: # ============================================================================= -def next_release_base(latest_released_version: str, extension: str | None = None) -> str: +def next_release_base( + latest_released_version: str, version: str, extension: str | None = None +) -> str: """Compute the release ``build.py`` should build next. - With no extension, adds one to the last number of - ``latest_released_version`` — this is OSS's own ``main``, with nothing - downstream involved. With an extension, appends it exactly as given — the - downstream repo has already picked the number it's about to release: + With no extension, this is ``version`` (``_about.py``'s ``__version__``) + with its ``.dev`` suffix removed — the tree states the release it is + working toward, so a minor or major is expressed by editing ``__version__`` + rather than being inferred. With an extension, the downstream repo has + already picked the number it is about to release, and it is appended to the + last *published* release instead: - * no extension: ``"0.2.1"`` -> ``"0.2.2"`` - * extension ``"1"`` (the next number to release): ``"0.2.1"`` -> + * no extension: ``__version__`` ``"0.2.2.dev0"`` -> ``"0.2.2"`` + * no extension: ``__version__`` ``"1.1.0.dev0"`` -> ``"1.1.0"`` + * extension ``"1"``: ``latest_released_version`` ``"0.2.1"`` -> ``"0.2.1.1"`` Args: latest_released_version: The last tagged release, e.g. ``"0.2.1"``. + version: ``_about.py``'s ``__version__``, e.g. ``"0.2.2.dev0"``. extension: The extra number to release next, or ``None``. Returns: @@ -196,25 +271,26 @@ def next_release_base(latest_released_version: str, extension: str | None = None """ if extension: return apply_version_extension(latest_released_version, extension) - return bump_last_segment(latest_released_version) + return strip_dev_suffix(version) -def next_candidate_version(latest_released_version: str, extension: str | None = None) -> str: - """Compute the ``.dev0`` version that ``_about.py`` should carry. +def next_candidate_version( + latest_released_version: str, version: str, extension: str | None = None +) -> str: + """Compute the ``.dev0`` version the tree is working toward. Same as ``next_release_base`` with ``.dev0`` added at the end, e.g. - ``"0.2.1"`` -> ``"0.2.2.dev0"``. This is the value ``__version__`` must - hold on the tree; see ``scripts/pre_commit/check_about_version.py``. + ``"0.2.2.dev0"`` with extension ``"1"`` -> ``"0.2.1.1.dev0"``. Args: latest_released_version: The last tagged release, e.g. ``"0.2.1"``. + version: ``_about.py``'s ``__version__``, e.g. ``"0.2.2.dev0"``. extension: The extra number to release next, or ``None``. Returns: - str: The ``.dev0`` version to write into ``_about.py``'s - ``__version__``. + str: The ``.dev0`` version this tree is working toward. """ - return f"{next_release_base(latest_released_version, extension)}.dev0" + return f"{next_release_base(latest_released_version, version, extension)}.dev0" def timestamped_dev_version( diff --git a/src/coreai_opt/_about.py b/src/coreai_opt/_about.py index 3400821..f20842c 100644 --- a/src/coreai_opt/_about.py +++ b/src/coreai_opt/_about.py @@ -6,12 +6,14 @@ """ Version information for coreai_opt package. -``latest_released_version`` is the last tagged release (e.g. ``"0.2.1"``). -Hard-code it right after tagging a new release. ``__version__`` is -computed from it: add one to its last number and add ``.dev0`` at the end -(e.g. ``"0.2.2.dev0"``) — this is the release ``main`` is working toward. A -pre-commit hook checks that ``__version__`` matches this rule and that -``latest_released_version`` matches the repo's latest release tag. +``latest_released_version`` is the OSS release this tree is anchored to (e.g. +``"0.2.1"``) — on ``main`` the most recently cut release, on a release branch +the release that branch produces. +``__version__`` is the release ``main`` is working toward (e.g. +``"0.2.2.dev0"``). Both are set together when a release branch is cut. A +pre-commit hook checks that ``__version__`` raises exactly one of +``latest_released_version``'s numbers by one, zeroes the rest, and ends in +``.dev0`` — so a minor or major can be declared, not just a patch. Keep ``__version__`` a plain string, not an expression, so setuptools can read it at build time without importing the package (which would pull in a diff --git a/tests/devtools/test_check_about_version.py b/tests/devtools/test_check_about_version.py index dbc8e40..baae34d 100644 --- a/tests/devtools/test_check_about_version.py +++ b/tests/devtools/test_check_about_version.py @@ -16,6 +16,8 @@ import sys from pathlib import Path +import pytest + from scripts._utils import find_repo_root SCRIPT = find_repo_root(Path(__file__)) / "scripts" / "pre_commit" / "check_about_version.py" @@ -48,6 +50,13 @@ def _tag(repo: Path, tag: str) -> None: subprocess.run(["git", "tag", tag], cwd=repo, check=True) +def _track_branch(repo: Path, name: str) -> None: + # The hook reads refs/remotes/origin/, which is what a fetch would populate. + subprocess.run( + ["git", "update-ref", f"refs/remotes/origin/{name}", "HEAD"], cwd=repo, check=True + ) + + def _run_checker(cwd: Path) -> subprocess.CompletedProcess[str]: return subprocess.run( [sys.executable, str(SCRIPT)], @@ -73,7 +82,37 @@ def test_fails_when_version_is_not_the_bumped_candidate(self, tmp_path: Path) -> _write_about(repo, latest_released="0.2.1", version="0.2.5.dev0") result = _run_checker(repo) assert result.returncode == 1 - assert "latest_released_version '0.2.1' implies '0.2.2.dev0'" in result.stdout + assert "latest_released_version '0.2.1' allows only" in result.stdout + + @pytest.mark.parametrize("version", ["1.1.0.dev0"]) + def test_accepts_a_patch_minor_or_major_as_the_next_release( + self, tmp_path: Path, version: str + ) -> None: + # The next release is declared in __version__, so a minor or major is + # chosen by a PR editing it rather than being inferred from the last + # released version. + repo = tmp_path / "repo" + _init_repo(repo) + _write_about(repo, latest_released="1.0.1", version=version) + result = _run_checker(repo) + assert result.returncode == 0, result.stdout + + @pytest.mark.parametrize("version", ["1.1.1.dev0"]) + def test_rejects_a_skipped_number_or_partial_reset(self, tmp_path: Path, version: str) -> None: + # 1.0.3 / 1.2.0 / 3.0.0 skip a number; 1.1.1 bumps the minor without + # resetting the patch. + repo = tmp_path / "repo" + _init_repo(repo) + _write_about(repo, latest_released="1.0.1", version=version) + result = _run_checker(repo) + assert result.returncode == 1 + + def test_rejects_a_version_without_the_dev0_suffix(self, tmp_path: Path) -> None: + repo = tmp_path / "repo" + _init_repo(repo) + _write_about(repo, latest_released="1.0.1", version="1.1.0") + result = _run_checker(repo) + assert result.returncode == 1 def test_passes_when_latest_released_version_matches_the_latest_tag( self, tmp_path: Path @@ -106,3 +145,37 @@ def test_ignores_non_release_tags(self, tmp_path: Path) -> None: _tag(repo, "not-a-release") result = _run_checker(repo) assert result.returncode == 0, result.stdout + + def test_passes_while_a_release_is_branched_but_not_yet_tagged(self, tmp_path: Path) -> None: + # The stabilization window: release/0.2.2 is cut and main has already + # moved on to 0.2.3.dev0, but v0.2.2 won't exist until the branch is + # stabilized. latest_released_version is ahead of the newest tag on + # purpose, and the branch is what says so. + repo = tmp_path / "repo" + _init_repo(repo) + _write_about(repo, latest_released="0.2.2", version="0.2.3.dev0") + _tag(repo, "v0.2.1") + _track_branch(repo, "release/0.2.2") + result = _run_checker(repo) + assert result.returncode == 0, result.stdout + + def test_accepts_a_release_branch_naming_its_own_release(self, tmp_path: Path) -> None: + # A release branch sets latest_released_version to the version it + # produces, so __version__ is that same version rather than a next one. + repo = tmp_path / "repo" + _init_repo(repo) + _write_about(repo, latest_released="1.1.0", version="1.1.0.dev0") + _tag(repo, "v1.0.0") + _track_branch(repo, "release/1.1.0") + result = _run_checker(repo) + assert result.returncode == 0, result.stdout + + def test_fails_when_the_branch_is_for_a_different_version(self, tmp_path: Path) -> None: + repo = tmp_path / "repo" + _init_repo(repo) + _write_about(repo, latest_released="0.2.2", version="0.2.3.dev0") + _tag(repo, "v0.2.1") + _track_branch(repo, "release/0.9.9") + result = _run_checker(repo) + assert result.returncode == 1 + assert "there is no release/0.2.2 branch" in result.stdout diff --git a/tests/test_release_utils.py b/tests/test_release_utils.py index b1024c6..b8c92ec 100644 --- a/tests/test_release_utils.py +++ b/tests/test_release_utils.py @@ -17,7 +17,6 @@ VERSION_EXTENSION_ENV_VAR, AboutFile, apply_version_extension, - bump_last_segment, get_dev_version_override, get_version_extension, latest_release_tag, @@ -26,11 +25,14 @@ read_about, read_latest_released_version, read_version, + release_branch_exists, + release_branch_version, resolve_about_path, resolve_build_version, restore_about, strip_dev_suffix, timestamped_dev_version, + valid_next_versions, write_version, ) @@ -40,7 +42,7 @@ class TestVersionArithmetic: - """Tests for strip_dev_suffix, bump_last_segment, and apply_version_extension.""" + """Tests for strip_dev_suffix and apply_version_extension.""" @pytest.mark.parametrize( ("version", "expected"), @@ -56,17 +58,6 @@ class TestVersionArithmetic: def test_strip_dev_suffix(self, version: str, expected: str) -> None: assert strip_dev_suffix(version) == expected - @pytest.mark.parametrize( - ("version", "expected"), - [ - ("0.2.1", "0.2.2"), - ("0.2.1.1", "0.2.1.2"), - ("1.0.9", "1.0.10"), - ], - ) - def test_bump_last_segment(self, version: str, expected: str) -> None: - assert bump_last_segment(version) == expected - @pytest.mark.parametrize( ("version", "extension", "expected"), [ @@ -82,36 +73,81 @@ def test_apply_version_extension( assert apply_version_extension(version, extension) == expected +class TestValidNextVersions: + """Tests for valid_next_versions, the set a release may advance to.""" + + @pytest.mark.parametrize( + ("version", "expected"), + [ + ("1.0.1", ["2.0.0", "1.1.0", "1.0.2"]), + ("1.0.0", ["2.0.0", "1.1.0", "1.0.1"]), + ("0.2.1", ["1.0.0", "0.3.0", "0.2.2"]), + ("1.9.9", ["2.0.0", "1.10.0", "1.9.10"]), + ], + ) + def test_one_number_up_and_the_rest_reset(self, version: str, expected: list[str]) -> None: + assert valid_next_versions(version) == expected + + @pytest.mark.parametrize("skipped", ["1.0.3", "1.2.0", "3.0.0", "1.1.1", "1.0.1", "0.9.0"]) + def test_excludes_skips_partial_resets_and_moves_backwards(self, skipped: str) -> None: + # 1.0.3 / 1.2.0 / 3.0.0 skip a number, 1.1.1 bumps the minor without + # resetting the patch, 1.0.1 stands still, 0.9.0 goes backwards. + assert skipped not in valid_next_versions("1.0.1") + + +class TestReleaseBranchVersion: + """Tests for release_branch_version, the shape a release branch carries.""" + + @pytest.mark.parametrize( + ("latest_released", "expected"), + [("1.1.0", "1.1.0.dev0"), ("2.0.0", "2.0.0.dev0"), ("0.2.1", "0.2.1.dev0")], + ) + def test_names_its_own_release(self, latest_released: str, expected: str) -> None: + assert release_branch_version(latest_released) == expected + + def test_differs_from_every_next_candidate(self) -> None: + # On `main` the two never coincide, which is what tells the two apart. + latest = "1.1.0" + assert release_branch_version(latest) not in [ + f"{c}.dev0" for c in valid_next_versions(latest) + ] + + class TestNextVersion: """Tests for next_release_base and next_candidate_version.""" @pytest.mark.parametrize( - ("latest_released", "extension", "expected"), + ("latest_released", "version", "extension", "expected"), [ - # No extension: add one to the external release's own last number. - ("0.2.1", None, "0.2.2"), + # No extension: the tree states the release it is working toward, + # so a patch, a minor, and a major all come straight from __version__. + ("0.2.1", "0.2.2.dev0", None, "0.2.2"), + ("1.0.1", "1.1.0.dev0", None, "1.1.0"), + ("1.0.1", "2.0.0.dev0", None, "2.0.0"), # extension is the number the downstream repo is about to release - # next, used exactly as given (see the next_release_base docstring). - ("0.2.1", "1", "0.2.1.1"), # first release off 0.2.1 - ("0.2.1", "2", "0.2.1.2"), # second release off 0.2.1 + # next, appended to the last published release and used exactly as + # given (see the next_release_base docstring). + ("0.2.1", "0.2.2.dev0", "1", "0.2.1.1"), # first release off 0.2.1 + ("0.2.1", "0.2.2.dev0", "2", "0.2.1.2"), # second release off 0.2.1 ], ) def test_next_release_base( - self, latest_released: str, extension: str | None, expected: str + self, latest_released: str, version: str, extension: str | None, expected: str ) -> None: - assert next_release_base(latest_released, extension) == expected + assert next_release_base(latest_released, version, extension) == expected @pytest.mark.parametrize( - ("latest_released", "extension", "expected"), + ("latest_released", "version", "extension", "expected"), [ - ("0.2.1", None, "0.2.2.dev0"), - ("0.2.1", "1", "0.2.1.1.dev0"), + ("0.2.1", "0.2.2.dev0", None, "0.2.2.dev0"), + ("1.0.1", "1.1.0.dev0", None, "1.1.0.dev0"), + ("0.2.1", "0.2.2.dev0", "1", "0.2.1.1.dev0"), ], ) def test_next_candidate_version_is_next_release_base_plus_dev0( - self, latest_released: str, extension: str | None, expected: str + self, latest_released: str, version: str, extension: str | None, expected: str ) -> None: - assert next_candidate_version(latest_released, extension) == expected + assert next_candidate_version(latest_released, version, extension) == expected def test_pep440_sort_order(self) -> None: ordered = [ @@ -156,14 +192,22 @@ def test_release_is_used_as_is(self) -> None: # caller passes it a release_base already computed by next_release_base. assert resolve_build_version("0.2.2", dev=False) == "0.2.2" - def test_build_pipeline_computes_release_base_from_latest_released(self) -> None: - # This is what build.py does: next_release_base(latest_released, extension), - # then resolve_build_version. It never reads the on-tree __version__. - release_base = next_release_base("0.2.1", "1") + def test_build_pipeline_with_an_extension_uses_the_last_published_release(self) -> None: + # This is what build.py does: next_release_base(latest_released, + # __version__, extension), then resolve_build_version. With an + # extension the downstream number is appended to the last published + # release, so __version__ is not what the build follows. + release_base = next_release_base("0.2.1", "0.2.2.dev0", "1") assert resolve_build_version(release_base, dev=False) == "0.2.1.1" version = resolve_build_version(release_base, dev=True) assert version.startswith("0.2.1.1.dev") + def test_build_pipeline_without_an_extension_follows_the_declared_version(self) -> None: + # Without an extension the tree states the release it is working + # toward, so a minor declared in __version__ is what gets built. + release_base = next_release_base("1.0.1", "1.1.0.dev0") + assert resolve_build_version(release_base, dev=False) == "1.1.0" + class TestResolveAboutPath: """Tests for resolve_about_path.""" @@ -290,7 +334,7 @@ def test_rewrites_every_spelling_read_version_accepts( # Writes locate the assignment with ast, the same way reads do, so any # spelling that reads here can also be rewritten. A line-oriented # pattern would accept only the first two and fail the release build on - # the rest, long after `make version` and the pre-commit hook passed. + # the rest, long after `make version-dev` and the pre-commit hook passed. about = self._about(tmp_path, source) write_version(about, "0.2.2") assert read_version(about.path.read_text(encoding="utf-8")) == "0.2.2" @@ -376,3 +420,50 @@ def fake_run(command: list[str], **kwargs: object) -> object: monkeypatch.setattr("scripts.release.release_utils.subprocess.run", fake_run) assert latest_release_tag(tmp_path) == "0.3.0" + + +class TestReleaseBranchExists: + """Tests for release_branch_exists, which opens the untagged-release window.""" + + @staticmethod + def _init_repo(repo: Path) -> str: + subprocess.run(["git", "init", "--quiet"], cwd=repo, check=True) + subprocess.run(["git", "config", "commit.gpgSign", "false"], cwd=repo, check=True) + subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=repo, check=True) + subprocess.run(["git", "config", "user.name", "Test"], cwd=repo, check=True) + subprocess.run(["git", "commit", "--quiet", "--allow-empty", "-m", "init"], cwd=repo) + head = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=repo, capture_output=True, text=True, check=True + ) + return head.stdout.strip() + + @staticmethod + def _track(repo: Path, branch: str, sha: str) -> None: + # Stand in for what `git fetch origin` leaves behind, without a remote. + subprocess.run( + ["git", "update-ref", f"refs/remotes/origin/{branch}", sha], cwd=repo, check=True + ) + + def test_finds_a_branch_on_origin(self, tmp_path: Path) -> None: + sha = self._init_repo(tmp_path) + self._track(tmp_path, "release/0.2.2", sha) + + assert release_branch_exists(tmp_path, "0.2.2") is True + + def test_returns_false_when_no_branch_was_cut(self, tmp_path: Path) -> None: + self._init_repo(tmp_path) + + assert release_branch_exists(tmp_path, "0.2.2") is False + + def test_does_not_match_a_different_version(self, tmp_path: Path) -> None: + sha = self._init_repo(tmp_path) + self._track(tmp_path, "release/0.2.2", sha) + + assert release_branch_exists(tmp_path, "0.2.3") is False + + def test_ignores_a_local_only_branch(self, tmp_path: Path) -> None: + # A local branch must not satisfy the check — anyone could create one. + self._init_repo(tmp_path) + subprocess.run(["git", "branch", "release/0.2.2"], cwd=tmp_path, check=True) + + assert release_branch_exists(tmp_path, "0.2.2") is False