diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 22105b728..61e9de55f 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -71,6 +71,12 @@ jobs: - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: '3.12' + - name: Require installable companion distributions for an adcp release + if: inputs.package == 'adcp' + run: | + python -m pip install packaging + version="$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')" + python scripts/check_companion_releases.py "$version" - name: Build and check distributions env: PACKAGE: ${{ inputs.package }} diff --git a/scripts/check_companion_releases.py b/scripts/check_companion_releases.py new file mode 100644 index 000000000..7df876bce --- /dev/null +++ b/scripts/check_companion_releases.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Refuse to publish an ``adcp`` release its companion distributions can't install with. + +9.0.0 moved the PostgreSQL reporting stores into ``adcp-reporting-storage`` +before that package was on PyPI, so the documented +``pip install "adcp-reporting-storage[pg]"`` failed and Postgres adopters were +stuck on the last beta. Before an ``adcp`` release publishes, every companion +distribution must have a published, non-yanked release whose ``adcp`` +requirement accepts the version being released. +""" + +from __future__ import annotations + +import argparse +import json +import sys +import urllib.error +import urllib.request +from collections.abc import Callable +from typing import Any + +from packaging.requirements import Requirement +from packaging.utils import canonicalize_name +from packaging.version import InvalidVersion, Version + +#: Distributions maintained in this repository that adopters install with adcp. +COMPANIONS = ("adcp-reporting-storage",) + +Fetch = Callable[[str], Any] + + +def _pypi(url: str) -> Any: + request = urllib.request.Request(url, headers={"Accept": "application/json"}) + try: + with urllib.request.urlopen(request, timeout=30) as response: # noqa: S310 + return json.load(response) + except urllib.error.HTTPError as error: + if error.code == 404: + return None + raise + + +def _adcp_requirement(requires_dist: list[str] | None) -> Requirement | None: + for line in requires_dist or (): + requirement = Requirement(line) + if canonicalize_name(requirement.name) == "adcp" and requirement.marker is None: + return requirement + return None + + +def compatible_release(companion: str, adcp_version: Version, fetch: Fetch = _pypi) -> str | None: + """Return the newest published ``companion`` release that installs with ``adcp_version``.""" + project = fetch(f"https://pypi.org/pypi/{companion}/json") + if not project: + return None + candidates = [] + for raw, files in project.get("releases", {}).items(): + try: + version = Version(raw) + except InvalidVersion: + continue + if files and not all(file.get("yanked") for file in files): + candidates.append(version) + for version in sorted(candidates, reverse=True): + release = fetch(f"https://pypi.org/pypi/{companion}/{version}/json") + requirement = _adcp_requirement((release or {}).get("info", {}).get("requires_dist")) + if requirement is not None and requirement.specifier.contains( + adcp_version, prereleases=True + ): + return str(version) + return None + + +def main(argv: list[str] | None = None, fetch: Fetch = _pypi) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("adcp_version", help="the adcp version about to be published") + args = parser.parse_args(argv) + adcp_version = Version(args.adcp_version) + missing = [] + for companion in COMPANIONS: + found = compatible_release(companion, adcp_version, fetch) + if found is None: + missing.append(companion) + else: + print(f"{companion} {found} installs with adcp {adcp_version}") + if missing: + print( + f"No published release of {', '.join(missing)} accepts adcp {adcp_version}. " + "Publish a compatible companion release first.", + file=sys.stderr, + ) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_check_companion_releases.py b/tests/test_check_companion_releases.py new file mode 100644 index 000000000..dd1441b88 --- /dev/null +++ b/tests/test_check_companion_releases.py @@ -0,0 +1,74 @@ +"""The adcp release gate requires an installable companion on PyPI.""" + +from __future__ import annotations + +from typing import Any + +import pytest +from packaging.version import Version + +from scripts import check_companion_releases as gate + +_PROJECT = "https://pypi.org/pypi/adcp-reporting-storage/json" + + +def _release(requirement: str | None) -> dict[str, Any]: + requires = ["psycopg>=3; extra == 'pg'"] + ([requirement] if requirement else []) + return {"info": {"requires_dist": requires}} + + +def _fetch(releases: dict[str, tuple[str | None, bool]]) -> gate.Fetch: + data: dict[str, Any] = { + _PROJECT: { + "releases": {version: [{"yanked": yanked}] for version, (_, yanked) in releases.items()} + } + } + for version, (requirement, _) in releases.items(): + data[f"https://pypi.org/pypi/adcp-reporting-storage/{version}/json"] = _release(requirement) + return lambda url: data.get(url) + + +def test_missing_companion_blocks_the_release() -> None: + assert gate.main(["9.0.0"], fetch=lambda url: None) == 1 + + +def test_compatible_companion_beta_allows_the_release() -> None: + fetch = _fetch({"0.1.0b4": ("adcp>=9.0.0,<10", False)}) + assert gate.compatible_release("adcp-reporting-storage", Version("9.1.0"), fetch) == "0.1.0b4" + assert gate.main(["9.1.0"], fetch=fetch) == 0 + + +def test_a_new_adcp_major_needs_a_companion_that_accepts_it() -> None: + fetch = _fetch({"0.1.0b4": ("adcp>=9.0.0,<10", False)}) + assert gate.main(["10.0.0"], fetch=fetch) == 1 + + +def test_prerelease_adcp_versions_match_a_prerelease_floor() -> None: + fetch = _fetch({"0.1.0b1": ("adcp>=9.0.0b4,<10", False)}) + assert gate.compatible_release("adcp-reporting-storage", Version("9.0.0b5"), fetch) + + +@pytest.mark.parametrize( + "releases", + [ + {"0.1.0b4": ("adcp>=9.0.0,<10", True)}, + {"0.1.0b4": (None, False)}, + {"0.1.0b4": ("adcp>=9.2.0,<10", False)}, + ], + ids=["yanked", "no-adcp-requirement", "floor-above"], +) +def test_unusable_companion_releases_do_not_count( + releases: dict[str, tuple[str | None, bool]], +) -> None: + assert gate.main(["9.1.0"], fetch=_fetch(releases)) == 1 + + +def test_newest_compatible_release_is_reported() -> None: + fetch = _fetch( + { + "0.1.0b1": ("adcp>=9.0.0b4,<10", False), + "0.2.0": ("adcp>=10,<11", False), + "0.1.0b4": ("adcp>=9.0.0,<10", False), + } + ) + assert gate.compatible_release("adcp-reporting-storage", Version("9.1.0"), fetch) == "0.1.0b4"