From e7a87a32eb6a4d409ae6be58a2034a809ee76462 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 25 Oct 2026 16:55:38 +0000 Subject: [PATCH] ci(release): refuse an adcp release its companion packages 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 install failed and Postgres adopters were stuck on the last beta. Before an adcp release builds, require each companion distribution to have a published, non-yanked release whose adcp requirement accepts the new version. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/release-publish.yml | 6 ++ scripts/check_companion_releases.py | 97 ++++++++++++++++++++++++++ tests/test_check_companion_releases.py | 74 ++++++++++++++++++++ 3 files changed, 177 insertions(+) create mode 100644 scripts/check_companion_releases.py create mode 100644 tests/test_check_companion_releases.py 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"