From 88d9b86088cd4e1c7a40f03a0622f600a98fe6b2 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 1 Nov 2026 21:33:47 +0000 Subject: [PATCH 1/3] ci(release): gate publishes on a companion the built distribution works with scripts/check_companion_releases.py refused an adcp release only when no published adcp-reporting-storage *range* accepted it. adcp 10.0.0b4 changed the ledger column lists while the published storage (adcp>=9.0.0,<11) still accepted it, so the pair installed cleanly and failed every reporting turn (#1763). Range compatibility is not contract compatibility. The gate now proves the pair works. With --dist, it installs the wheel just built together with the published companion into a private environment and runs scripts/companion_smoke.py there, which compares the storage contract each side declares (adcp.reporting.ledger.storage_contract versus adcp_reporting_storage.contract; releases predating the declaration are classified by their column lists) and, with ADCP_PG_TEST_URL, creates the ledger schema and round-trips a configuration, obligation and revision through the installed stores on PostgreSQL, the calls that failed on the incompatible pair. Storage publishes are gated too, in the other direction: the built storage wheel is smoked with the oldest and the newest published adcp its own requirement accepts, so a floor that admits an adcp of another contract is refused. release-publish.yml builds first, runs the gate for both packages against a PostgreSQL service, then uploads. Verified locally against PyPI and PostgreSQL 16: the adcp wheel built from next is refused against the published 0.2.0b0 (contract 2 versus 1), the storage wheel from #1787 (floor 10.0.0b4) passes with 10.0.0b4, and the published 0.1.0b4 passes with adcp 9.0.0 and 9.3.1. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 7b40f3429dbbe274cd820b915eb9ba57207f6541) --- .github/workflows/release-publish.yml | 42 ++++- RELEASING.md | 14 ++ docs/releasing.md | 10 +- scripts/check_companion_releases.py | 190 +++++++++++++++++++--- scripts/companion_smoke.py | 214 +++++++++++++++++++++++++ tests/test_check_companion_releases.py | 194 +++++++++++++++++++++- 6 files changed, 635 insertions(+), 29 deletions(-) create mode 100644 scripts/companion_smoke.py diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 8f60b6a11..ada88615f 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -35,6 +35,19 @@ jobs: permissions: contents: read actions: read + services: + postgres: + image: postgres:16 + env: + POSTGRES_HOST_AUTH_METHOD: trust + POSTGRES_DB: adcp_release_gate + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 10 steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: @@ -71,12 +84,6 @@ 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 }} @@ -88,6 +95,29 @@ jobs: python -m build --outdir dist/ packages/adcp-reporting-storage fi python -m twine check dist/* + # Every release, on main and on next, needs a published companion it + # actually works with, not one whose version range merely admits it + # (#1763: adcp 10.0.0b4 installed beside adcp-reporting-storage 0.2.0b0 + # and failed every reporting turn). The gate installs the wheel just + # built with the published companion into a private environment and + # runs scripts/companion_smoke.py there: both sides must declare the same + # storage contract, and the ledger must create its schema and round-trip + # records on the PostgreSQL service above. For adcp the companion is the + # newest published adcp-reporting-storage its range admits; for + # adcp-reporting-storage it is the oldest and the newest published adcp + # its own requirement accepts. + - name: Require a companion release the built distribution works with + env: + PACKAGE: ${{ inputs.package }} + ADCP_PG_TEST_URL: postgresql://postgres@localhost:5432/adcp_release_gate + run: | + python -m pip install packaging + if [ "$PACKAGE" = adcp ]; then + version="$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')" + python scripts/check_companion_releases.py "$version" --dist dist/adcp-*.whl + else + python scripts/check_companion_releases.py --package "$PACKAGE" --dist dist/adcp_reporting_storage-*.whl + fi - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: release-dist diff --git a/RELEASING.md b/RELEASING.md index 1ead3f007..f08658d45 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -79,6 +79,20 @@ so that merges in either direction never change the other line's release flow: to `main`'s releases, and adopters opt in with `pip install --pre adcp` or `adcp==10.0.0a0`. +- The companion gate (`scripts/check_companion_releases.py`, #1605, #1763) + runs for every publish of either package, from `main` and from `next`. An + `adcp` publish needs a published `adcp-reporting-storage` whose range + accepts it **and that works with it**: the workflow installs the wheel it + just built with that storage release into a private environment and runs + `scripts/companion_smoke.py`, which refuses a pair whose storage contracts + differ (`adcp.reporting.ledger.storage_contract` versus + `adcp_reporting_storage.contract`) and round-trips ledger records on a + PostgreSQL service. A storage publish is gated the same way against the + oldest and the newest published `adcp` its own requirement accepts. So when + the contract changes, order the releases: publish the storage release that + implements the new contract (its floor is the first `adcp` of that + contract) before the `adcp` release that declares it. + One-time setup: add `next` to the `release-publish` environment's deployment branches (GitHub Settings → Environments). The PyPI Trusted Publisher needs no change, because it is bound to the `release-publish.yml` file name and the diff --git a/docs/releasing.md b/docs/releasing.md index 68deea89a..2f78d8bf1 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -75,8 +75,14 @@ commits with no such PR. Conventional-commit scopes are free text, but On a merge, `release-please.yml` queues `release-publish.yml` with `package=adcp-reporting-storage`; the workflow builds that package's sdist and wheel from `packages/adcp-reporting-storage` and publishes them through the same -`release-publish` environment approval. The storage wheel pins -`adcp>=9.0.0bN,<10`; publish the `adcp` release it depends on first. +`release-publish` environment approval. The storage wheel's `adcp` floor is +the first `adcp` release of the storage contract it implements +(`adcp_reporting_storage.contract`); publish that `adcp` release first. +Both publishes run the companion gate (`scripts/check_companion_releases.py`): +the wheel just built is installed beside the published companion in a private +environment and `scripts/companion_smoke.py` must pass there, so a pair whose +storage contracts differ, or whose ledger does not round-trip on PostgreSQL, +never reaches PyPI (#1763). One-time setup, before the first storage release: diff --git a/scripts/check_companion_releases.py b/scripts/check_companion_releases.py index 7df876bce..81d531dc6 100644 --- a/scripts/check_companion_releases.py +++ b/scripts/check_companion_releases.py @@ -1,22 +1,42 @@ #!/usr/bin/env python3 -"""Refuse to publish an ``adcp`` release its companion distributions can't install with. +"""Refuse to publish a distribution its companion can't actually work 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. +stuck on the last beta. Then adcp 10.0.0b4 changed the ledger column lists the +storage package reads against its DDL, and the published storage release +(``adcp>=9.0.0,<11``) still *installed* beside it and failed every reporting +turn (adcp-client-python#1763). Range compatibility is not enough. + +Before an ``adcp`` release publishes, every companion distribution must have a +published, non-yanked release whose ``adcp`` requirement accepts the version +being released (``compatible_release``). With ``--dist``, the gate goes +further and proves the pair works: it installs the built distribution and the +published companion into a private environment and runs +``scripts/companion_smoke.py`` there, which compares the storage contract both +sides declare and, when ``ADCP_PG_TEST_URL`` is set, creates the ledger schema +and round-trips records on PostgreSQL. ``--package adcp-reporting-storage`` +gates the companion in the other direction: the built storage distribution is +smoked with both the oldest and the newest published ``adcp`` its own +requirement accepts. """ from __future__ import annotations import argparse import json +import os +import shutil +import subprocess import sys +import tempfile import urllib.error import urllib.request +import zipfile from collections.abc import Callable +from email.parser import HeaderParser +from pathlib import Path from typing import Any from packaging.requirements import Requirement @@ -25,8 +45,12 @@ #: Distributions maintained in this repository that adopters install with adcp. COMPANIONS = ("adcp-reporting-storage",) +#: The extra that installs the PostgreSQL driver the smoke needs. +STORAGE_EXTRA = "pg" +SMOKE = Path(__file__).with_name("companion_smoke.py") Fetch = Callable[[str], Any] +Run = Callable[[list[str]], subprocess.CompletedProcess[str]] def _pypi(url: str) -> Any: @@ -40,6 +64,13 @@ def _pypi(url: str) -> Any: raise +def _run(command: list[str]) -> subprocess.CompletedProcess[str]: + environment = {key: value for key, value in os.environ.items() if key != "PYTHONPATH"} + return subprocess.run( # noqa: S603 + command, capture_output=True, text=True, timeout=900, env=environment, check=False + ) + + def _adcp_requirement(requires_dist: list[str] | None) -> Requirement | None: for line in requires_dist or (): requirement = Requirement(line) @@ -48,20 +79,25 @@ def _adcp_requirement(requires_dist: list[str] | None) -> Requirement | None: 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 +def _published_versions(project: Any) -> list[Version]: + """Non-yanked releases of a PyPI project, newest first.""" candidates = [] - for raw, files in project.get("releases", {}).items(): + for raw, files in (project or {}).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): + return sorted(candidates, reverse=True) + + +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 + for version in _published_versions(project): 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( @@ -71,18 +107,74 @@ def compatible_release(companion: str, adcp_version: Version, fetch: Fetch = _py 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) +def accepted_adcp_releases(requirement: Requirement, fetch: Fetch = _pypi) -> list[str]: + """Published, non-yanked ``adcp`` releases the requirement accepts, oldest first.""" + project = fetch("https://pypi.org/pypi/adcp/json") + accepted = [ + version + for version in _published_versions(project) + if requirement.specifier.contains(version, prereleases=True) + ] + return [str(version) for version in sorted(accepted)] + + +def wheel_adcp_requirement(distribution: Path) -> Requirement: + """The ``adcp`` requirement a built companion wheel declares.""" + with zipfile.ZipFile(distribution) as wheel: + metadata = next(name for name in wheel.namelist() if name.endswith(".dist-info/METADATA")) + headers = HeaderParser().parsestr(wheel.read(metadata).decode("utf-8")) + requirement = _adcp_requirement(headers.get_all("Requires-Dist")) + if requirement is None: + raise SystemExit(f"{distribution.name} declares no adcp requirement") + return requirement + + +def smoke(requirements: list[str], *, label: str, run: Run = _run) -> bool: + """Install ``requirements`` into a private environment and run the companion smoke.""" + with tempfile.TemporaryDirectory(prefix="adcp-companion-") as root: + environment = Path(root) / "env" + if shutil.which("uv"): + create = ["uv", "venv", "--quiet", "--python", sys.executable, str(environment)] + python = environment / "bin" / "python" + install = ["uv", "pip", "install", "--quiet", "--python", str(python)] + else: + create = [sys.executable, "-m", "venv", str(environment)] + python = environment / "bin" / "python" + install = [str(python), "-m", "pip", "install", "--quiet"] + for step, command in (("create", create), ("install", [*install, *requirements])): + completed = run(command) + if completed.returncode != 0: + print(f"{label}: could not {step} the smoke environment", file=sys.stderr) + print(completed.stderr[-4000:], file=sys.stderr) + return False + script = Path(root) / SMOKE.name + shutil.copy2(SMOKE, script) + completed = run([str(python), "-I", str(script)]) + sys.stdout.write(completed.stdout) + if completed.returncode != 0: + print(f"{label}: {completed.stderr.strip()}", file=sys.stderr) + return False + return True + + +def gate_adcp( + adcp_version: Version, distribution: Path | None, fetch: Fetch = _pypi, run: Run = _run +) -> int: + """An adcp release needs a published companion that installs with it and works with it.""" missing = [] + failed = [] 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}") + continue + print(f"{companion} {found} installs with adcp {adcp_version}") + if distribution is not None and not smoke( + [str(distribution), f"{companion}[{STORAGE_EXTRA}]=={found}"], + label=f"adcp {adcp_version} + {companion} {found}", + run=run, + ): + failed.append(f"{companion} {found}") if missing: print( f"No published release of {', '.join(missing)} accepts adcp {adcp_version}. " @@ -90,8 +182,70 @@ def main(argv: list[str] | None = None, fetch: Fetch = _pypi) -> int: file=sys.stderr, ) return 1 + if failed: + print( + f"adcp {adcp_version} does not work with the published {', '.join(failed)}. " + "Publish a companion release that matches this adcp's storage contract first.", + file=sys.stderr, + ) + return 1 + return 0 + + +def gate_companion(distribution: Path, fetch: Fetch = _pypi, run: Run = _run) -> int: + """A companion release must work with the oldest and newest adcp its range accepts.""" + requirement = wheel_adcp_requirement(distribution) + accepted = accepted_adcp_releases(requirement, fetch) + if not accepted: + print( + f"No published adcp release satisfies {requirement}; publish adcp first.", + file=sys.stderr, + ) + return 1 + failed = [] + for adcp_version in sorted({accepted[0], accepted[-1]}, key=Version): + print(f"{distribution.name} ({requirement}) accepts the published adcp {adcp_version}") + if not smoke( + [f"adcp=={adcp_version}", f"{distribution}[{STORAGE_EXTRA}]"], + label=f"{distribution.name} + adcp {adcp_version}", + run=run, + ): + failed.append(adcp_version) + if failed: + print( + f"{distribution.name} does not work with the published adcp {', '.join(failed)} its " + "requirement accepts. Raise the floor to the first adcp of its storage contract.", + file=sys.stderr, + ) + return 1 return 0 +def main(argv: list[str] | None = None, fetch: Fetch = _pypi, run: Run = _run) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "adcp_version", nargs="?", help="the adcp version about to be published (package adcp)" + ) + parser.add_argument( + "--package", + choices=("adcp", *COMPANIONS), + default="adcp", + help="the distribution about to be published", + ) + parser.add_argument( + "--dist", + type=Path, + help="the built wheel of that distribution; when given, the pair is installed and smoked", + ) + args = parser.parse_args(argv) + if args.package == "adcp": + if not args.adcp_version: + parser.error("the adcp version is required for --package adcp") + return gate_adcp(Version(args.adcp_version), args.dist, fetch, run) + if args.dist is None: + parser.error(f"--dist is required for --package {args.package}") + return gate_companion(args.dist, fetch, run) + + if __name__ == "__main__": raise SystemExit(main()) diff --git a/scripts/companion_smoke.py b/scripts/companion_smoke.py new file mode 100644 index 000000000..439e85421 --- /dev/null +++ b/scripts/companion_smoke.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +"""Prove an installed ``adcp`` + ``adcp-reporting-storage`` pair works, before publishing. + +Run inside an environment where both distributions are installed (the release +gate builds one from the distribution under release and the published +companion). Checks, in order: + +1. **Storage contract.** Both sides name the reporting ledger layout they + implement with one integer (``adcp.reporting.ledger.storage_contract``, + ``adcp_reporting_storage.contract``). Releases that predate the + declaration are classified: an adcp whose obligation column list carries + ``offering_id`` is contract 2, else 1; a storage without the module is + contract 1. A mismatch is the failure of adcp-client-python#1763, refused + here with both versions named. +2. **The ledger, on PostgreSQL** (when ``ADCP_PG_TEST_URL`` is set): in a + private schema, ``create_schema()``, then write and read back a + configuration generation, an obligation and a revision through the + installed stores, the calls that failed on the incompatible pair. + +Exit status 0 when the pair works; 1 with a one-line reason otherwise. Uses +only APIs shared by adcp 9.x and 10.x, so the gate runs on both release lines. +""" + +from __future__ import annotations + +import asyncio +import os +import secrets +import sys +from datetime import datetime, timedelta, timezone +from importlib import import_module +from importlib.metadata import PackageNotFoundError, version + + +def installed(distribution: str) -> str: + try: + return version(distribution) + except PackageNotFoundError: + return "not installed" + + +def adcp_contract() -> int: + try: + return int( + import_module("adcp.reporting.ledger.storage_contract").REPORTING_STORAGE_CONTRACT + ) + except ImportError: + columns = import_module("adcp.reporting.ledger._record_rows")._OBLIGATION_COLUMNS + return 2 if "offering_id" in [item.strip() for item in columns.split(",")] else 1 + + +def storage_contract() -> int: + try: + return int(import_module("adcp_reporting_storage.contract").STORAGE_CONTRACT) + except ImportError: + return 1 # 0.1.0b4 and 0.2.0b0 predate the declaration + + +def check_contract() -> None: + expected, implemented = adcp_contract(), storage_contract() + pair = ( + f"adcp {installed('adcp')} + adcp-reporting-storage {installed('adcp-reporting-storage')}" + ) + if expected != implemented: + raise SystemExit( + f"{pair}: adcp reads the ledger with storage contract {expected} but the storage " + f"implements contract {implemented}; the pair would fail every reporting turn" + ) + print(f"{pair}: storage contract {expected} on both sides") + + +async def check_ledger(url: str) -> None: + from adcp_reporting_storage.ledger.pg import PgReportingLedgerStore + from psycopg import AsyncConnection, sql + from psycopg_pool import AsyncConnectionPool + + from adcp.reporting.ledger import ( + ReportingConfiguration, + ReportingObligationRecord, + ReportingRevisionRecord, + ReportingScheduleSpec, + ReportingStatusCaller, + derive_period, + revision_content_sha256, + ) + from adcp.reporting.ledger.models import first_ordinal_after + + schedule = ReportingScheduleSpec(period_duration="PT1H", delivery_sla="PT1H", alignment="utc") + activated = datetime(2026, 9, 1, 0, 20, tzinfo=timezone.utc) + configuration = ReportingConfiguration( + delivery_config_id="smoke", + delivery_config_version=1, + account_id="smoke-account", + consumer_id="smoke-buyer", + report_definition_id="smoke_v1", + reporting_profile="paid_media_delivery", + feed_purpose="analytics", + schedule=schedule, + required_finality="official", + activated_at=activated, + media_buy_ids=("mb_1",), + ) + ordinal = first_ordinal_after(schedule, account_timezone="UTC", activated_at=activated) + period = derive_period(schedule, account_timezone="UTC", ordinal=ordinal) + obligation = ReportingObligationRecord( + reporting_obligation_id="rpo_smoke", + account_id=configuration.account_id, + consumer_id=configuration.consumer_id, + delivery_config_id=configuration.delivery_config_id, + delivery_config_version=configuration.delivery_config_version, + report_definition_id=configuration.report_definition_id, + reporting_profile=configuration.reporting_profile, + feed_purpose=configuration.feed_purpose, + period=period, + scope_resolved_at=period.end, + media_buy_ids=configuration.media_buy_ids, + required_finality=configuration.required_finality, + automated_recovery_deadline_at=period.expected_at + timedelta(hours=6), + schedule=schedule, + created_at=period.end, + currency="USD", + ) + rows = [{"media_buy_id": "mb_1", "impressions": 5}] + totals = (("impressions", "5"),) + revision = ReportingRevisionRecord( + reporting_revision_id="rpr_smoke", + account_id=obligation.account_id, + reporting_obligation_id=obligation.reporting_obligation_id, + finality="official", + revision_content_sha256=revision_content_sha256( + reporting_revision_id="rpr_smoke", + row_count=1, + control_totals=totals, + reporting_rows=rows, + ), + row_count=1, + control_totals=totals, + observed_at=period.end, + data_through=period.end, + created_at=period.expected_at, + finality_basis="source_final", + finality_policy_id="policy_1", + finalized_at=period.end, + ) + + schema = f"adcp_companion_smoke_{secrets.token_hex(6)}" + async with await AsyncConnection.connect(url, autocommit=True) as admin: + await admin.execute(sql.SQL("CREATE SCHEMA {}").format(sql.Identifier(schema))) + try: + async with AsyncConnectionPool( + url, + kwargs={"options": f"-csearch_path={schema}", "autocommit": True}, + min_size=1, + max_size=4, + open=False, + ) as pool: + await pool.open(wait=True) + store = PgReportingLedgerStore(pool=pool) + await store.create_schema() + await store.put_configuration(configuration) + committed = await store.commit_obligation(obligation) + await store.commit_revision(revision, rows) + found = await store.find_obligation( + account_id=configuration.account_id, + consumer_id=configuration.consumer_id, + delivery_config_id=configuration.delivery_config_id, + delivery_config_version=configuration.delivery_config_version, + period_start=period.start, + period_end=period.end, + ) + listed = await store.list_configurations( + caller=ReportingStatusCaller( + account_id=configuration.account_id, + consumer_id=configuration.consumer_id, + ) + ) + revisions = await store.list_revisions( + account_id=configuration.account_id, + reporting_obligation_id=committed.reporting_obligation_id, + ) + recovered = await store.list_all_configurations() + await store.create_schema() # a restart's bootstrap is a no-op + if found is None or found.reporting_obligation_id != "rpo_smoke": + raise SystemExit("the installed pair cannot find the obligation it committed") + if len(listed) != 1 or len(recovered) != 1: + raise SystemExit("the installed pair cannot read the configuration it stored") + if [item.reporting_revision_id for item in revisions] != ["rpr_smoke"]: + raise SystemExit("the installed pair cannot read the revision it committed") + finally: + await admin.execute(sql.SQL("DROP SCHEMA {} CASCADE").format(sql.Identifier(schema))) + print("the installed pair creates the ledger schema and round-trips its records") + + +def main() -> int: + try: + check_contract() + url = os.environ.get("ADCP_PG_TEST_URL") + if url: + asyncio.run(check_ledger(url)) + else: + print("ADCP_PG_TEST_URL not set: the PostgreSQL round trip was skipped") + except SystemExit as error: + if error.code not in (None, 0): + print(f"companion smoke failed: {error.code}", file=sys.stderr) + return 1 + raise + except Exception as error: # a driver or DDL failure is a failing gate too + print(f"companion smoke failed: {type(error).__name__}: {error}", 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 index dd1441b88..928521c22 100644 --- a/tests/test_check_companion_releases.py +++ b/tests/test_check_companion_releases.py @@ -1,15 +1,20 @@ -"""The adcp release gate requires an installable companion on PyPI.""" +"""The release gate requires a companion on PyPI that installs *and works* with the release.""" from __future__ import annotations +import subprocess +import zipfile +from pathlib import Path from typing import Any import pytest +from packaging.requirements import Requirement from packaging.version import Version from scripts import check_companion_releases as gate _PROJECT = "https://pypi.org/pypi/adcp-reporting-storage/json" +_ADCP = "https://pypi.org/pypi/adcp/json" def _release(requirement: str | None) -> dict[str, Any]: @@ -17,11 +22,16 @@ def _release(requirement: str | None) -> dict[str, Any]: return {"info": {"requires_dist": requires}} -def _fetch(releases: dict[str, tuple[str | None, bool]]) -> gate.Fetch: +def _fetch( + releases: dict[str, tuple[str | None, bool]], adcp: dict[str, bool] | None = None +) -> gate.Fetch: data: dict[str, Any] = { _PROJECT: { "releases": {version: [{"yanked": yanked}] for version, (_, yanked) in releases.items()} - } + }, + _ADCP: { + "releases": {version: [{"yanked": yanked}] for version, yanked in (adcp or {}).items()} + }, } for version, (requirement, _) in releases.items(): data[f"https://pypi.org/pypi/adcp-reporting-storage/{version}/json"] = _release(requirement) @@ -72,3 +82,181 @@ def test_newest_compatible_release_is_reported() -> None: } ) assert gate.compatible_release("adcp-reporting-storage", Version("9.1.0"), fetch) == "0.1.0b4" + + +# -- the built distribution must work with the published companion (#1763) ---- + + +class _Runner: + """A fake subprocess: records the commands, scripts the smoke's verdict.""" + + def __init__(self, smoke_returncode: int = 0, install_returncode: int = 0) -> None: + self.commands: list[list[str]] = [] + self.smoke_returncode = smoke_returncode + self.install_returncode = install_returncode + + def __call__(self, command: list[str]) -> subprocess.CompletedProcess[str]: + self.commands.append(command) + if command[-1].endswith("companion_smoke.py"): + return subprocess.CompletedProcess( + command, + self.smoke_returncode, + stdout="smoke stdout\n", + stderr="" if self.smoke_returncode == 0 else "storage contract 2 vs 1", + ) + if "install" in command: + return subprocess.CompletedProcess(command, self.install_returncode, "", "pip failed") + return subprocess.CompletedProcess(command, 0, "", "") + + def installed(self) -> list[str]: + """The requirements of the install step, without the installer's own options.""" + for command in self.commands: + if "install" not in command: + continue + arguments = iter(command[command.index("install") + 1 :]) + requirements = [] + for item in arguments: + if item == "--python": + next(arguments) # the interpreter path + elif not item.startswith("-"): + requirements.append(item) + return requirements + return [] + + def smoked(self) -> int: + return sum(1 for command in self.commands if command[-1].endswith("companion_smoke.py")) + + +def test_the_range_compatible_companion_is_smoked_with_the_built_adcp(tmp_path: Path) -> None: + dist = tmp_path / "adcp-10.0.0b6-py3-none-any.whl" + dist.touch() + fetch = _fetch({"0.2.0b1": ("adcp>=10.0.0b4,<11", False)}) + runner = _Runner() + assert gate.main(["10.0.0b6", "--dist", str(dist)], fetch=fetch, run=runner) == 0 + assert runner.installed() == [str(dist), "adcp-reporting-storage[pg]==0.2.0b1"] + assert runner.smoked() == 1 + assert all("-I" in command for command in runner.commands if command[-1].endswith(".py")) + + +def test_a_companion_that_installs_but_does_not_work_blocks_the_release( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """The #1763 pair: range-compatible, contract-incompatible.""" + dist = tmp_path / "adcp-10.0.0b4-py3-none-any.whl" + dist.touch() + fetch = _fetch({"0.2.0b0": ("adcp>=9.0.0,<11", False)}) + assert gate.main(["10.0.0b4", "--dist", str(dist)], fetch=fetch, run=_Runner(1)) == 1 + err = capsys.readouterr().err + assert "storage contract 2 vs 1" in err + assert "does not work with the published adcp-reporting-storage 0.2.0b0" in err + + +def test_a_smoke_environment_that_cannot_install_is_a_failing_gate(tmp_path: Path) -> None: + dist = tmp_path / "adcp-10.0.0b6-py3-none-any.whl" + dist.touch() + fetch = _fetch({"0.2.0b1": ("adcp>=10.0.0b4,<11", False)}) + runner = _Runner(install_returncode=1) + assert gate.main(["10.0.0b6", "--dist", str(dist)], fetch=fetch, run=runner) == 1 + assert runner.smoked() == 0 + + +def test_without_a_built_distribution_only_the_range_is_checked() -> None: + runner = _Runner(1) + fetch = _fetch({"0.2.0b0": ("adcp>=9.0.0,<11", False)}) + assert gate.main(["10.0.0b5"], fetch=fetch, run=runner) == 0 + assert runner.commands == [] + + +def _storage_wheel(path: Path, requirement: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(path, "w") as wheel: + wheel.writestr( + "adcp_reporting_storage-0.2.0b1.dist-info/METADATA", + "Metadata-Version: 2.1\nName: adcp-reporting-storage\nVersion: 0.2.0b1\n" + f"Requires-Dist: {requirement}\nRequires-Dist: psycopg>=3; extra == 'pg'\n", + ) + return path + + +def test_the_built_companion_is_smoked_with_the_oldest_and_newest_accepted_adcp( + tmp_path: Path, +) -> None: + dist = _storage_wheel( + tmp_path / "adcp_reporting_storage-0.2.0b1-py3-none-any.whl", "adcp<11,>=10.0.0b4" + ) + assert gate.wheel_adcp_requirement(dist) == Requirement("adcp<11,>=10.0.0b4") + fetch = _fetch( + {}, adcp={"10.0.0b3": False, "10.0.0b4": False, "10.0.0b5": False, "9.4.0": False} + ) + assert gate.accepted_adcp_releases(Requirement("adcp>=10.0.0b4,<11"), fetch) == [ + "10.0.0b4", + "10.0.0b5", + ] + runner = _Runner() + assert ( + gate.main( + ["--package", "adcp-reporting-storage", "--dist", str(dist)], fetch=fetch, run=runner + ) + == 0 + ) + assert runner.smoked() == 2 + installs = [c for c in runner.commands if "install" in c] + assert [c[-2] for c in installs] == ["adcp==10.0.0b4", "adcp==10.0.0b5"] + assert all(c[-1] == f"{dist}[pg]" for c in installs) + + +def test_a_companion_whose_floor_admits_an_adcp_it_fails_with_is_refused( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + dist = _storage_wheel( + tmp_path / "adcp_reporting_storage-0.2.0b1-py3-none-any.whl", "adcp>=9.0.0,<11" + ) + fetch = _fetch({}, adcp={"9.3.1": False, "10.0.0b4": False}) + assert ( + gate.main( + ["--package", "adcp-reporting-storage", "--dist", str(dist)], + fetch=fetch, + run=_Runner(1), + ) + == 1 + ) + assert "Raise the floor" in capsys.readouterr().err + + +def test_a_companion_with_no_published_adcp_in_range_is_refused(tmp_path: Path) -> None: + dist = _storage_wheel( + tmp_path / "adcp_reporting_storage-0.3.0-py3-none-any.whl", "adcp>=11,<12" + ) + fetch = _fetch({}, adcp={"10.0.0b5": False}) + assert ( + gate.main( + ["--package", "adcp-reporting-storage", "--dist", str(dist)], fetch=fetch, run=_Runner() + ) + == 1 + ) + + +def test_the_companion_package_requires_a_built_distribution() -> None: + with pytest.raises(SystemExit): + gate.main(["--package", "adcp-reporting-storage"]) + with pytest.raises(SystemExit): + gate.main(["--package", "adcp"]) + + +def test_the_smoke_script_classifies_both_sides(monkeypatch: pytest.MonkeyPatch) -> None: + """Inside this workspace both sides agree; the classification is deterministic.""" + import importlib.util + import sys + + spec = importlib.util.spec_from_file_location("companion_smoke", gate.SMOKE) + assert spec and spec.loader + smoke = importlib.util.module_from_spec(spec) + spec.loader.exec_module(smoke) + pytest.importorskip("adcp_reporting_storage") + assert smoke.adcp_contract() == smoke.storage_contract() + smoke.check_contract() + monkeypatch.setitem(sys.modules, "adcp_reporting_storage.contract", None) + assert smoke.storage_contract() == 1 # 0.1.0b4 and 0.2.0b0 predate the declaration + if smoke.adcp_contract() != 1: + with pytest.raises(SystemExit, match="storage contract"): + smoke.check_contract() From 65e280af115430f1735e6d259391a1e868cfc89b Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 1 Nov 2026 21:48:00 +0000 Subject: [PATCH 2/3] test(release): assert the gate smokes both packages and classify stubbed contracts The release configuration test expected the companion gate to run only for adcp publishes; it now runs for both packages after the build, with a PostgreSQL service. The smoke classification test stubs both declaration modules instead of assuming the workspace pair agrees, which it does not until the storage declaration (#1787) lands. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 876faa5e92ba2d571a52fa098d24b21b2f77e697) --- tests/test_check_companion_releases.py | 63 +++++++++++++++++++++----- tests/test_release_configuration.py | 32 +++++++++++++ 2 files changed, 84 insertions(+), 11 deletions(-) diff --git a/tests/test_check_companion_releases.py b/tests/test_check_companion_releases.py index 928521c22..c645f41c6 100644 --- a/tests/test_check_companion_releases.py +++ b/tests/test_check_companion_releases.py @@ -3,6 +3,8 @@ from __future__ import annotations import subprocess +import sys +import types import zipfile from pathlib import Path from typing import Any @@ -243,20 +245,59 @@ def test_the_companion_package_requires_a_built_distribution() -> None: gate.main(["--package", "adcp"]) -def test_the_smoke_script_classifies_both_sides(monkeypatch: pytest.MonkeyPatch) -> None: - """Inside this workspace both sides agree; the classification is deterministic.""" +@pytest.fixture +def smoke() -> Any: import importlib.util - import sys spec = importlib.util.spec_from_file_location("companion_smoke", gate.SMOKE) assert spec and spec.loader - smoke = importlib.util.module_from_spec(spec) - spec.loader.exec_module(smoke) - pytest.importorskip("adcp_reporting_storage") - assert smoke.adcp_contract() == smoke.storage_contract() + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _declaring(name: str, value: int) -> Any: + return types.SimpleNamespace(**{name: value}) + + +def test_the_smoke_script_reads_declared_contracts( + smoke: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setitem( + sys.modules, + "adcp.reporting.ledger.storage_contract", + _declaring("REPORTING_STORAGE_CONTRACT", 2), + ) + monkeypatch.setitem( + sys.modules, "adcp_reporting_storage.contract", _declaring("STORAGE_CONTRACT", 2) + ) + assert smoke.adcp_contract() == smoke.storage_contract() == 2 smoke.check_contract() + monkeypatch.setitem( + sys.modules, "adcp_reporting_storage.contract", _declaring("STORAGE_CONTRACT", 3) + ) + with pytest.raises(SystemExit, match="contract 2 but the storage implements contract 3"): + smoke.check_contract() + + +def test_the_smoke_script_classifies_releases_without_a_declaration( + smoke: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + """adcp 10.0.0b4/b5 by their column lists; storage 0.1.0b4/0.2.0b0 as contract 1.""" + from adcp.reporting.ledger import _record_rows + + monkeypatch.setitem(sys.modules, "adcp.reporting.ledger.storage_contract", None) monkeypatch.setitem(sys.modules, "adcp_reporting_storage.contract", None) - assert smoke.storage_contract() == 1 # 0.1.0b4 and 0.2.0b0 predate the declaration - if smoke.adcp_contract() != 1: - with pytest.raises(SystemExit, match="storage contract"): - smoke.check_contract() + assert smoke.storage_contract() == 1 + assert smoke.adcp_contract() == 2 # this checkout's columns carry offering_id + with pytest.raises( + SystemExit, match="storage contract 2 but the storage implements contract 1" + ): + smoke.check_contract() # the #1763 pair + monkeypatch.setattr( + _record_rows, + "_OBLIGATION_COLUMNS", + _record_rows._OBLIGATION_COLUMNS.replace(", offering_id", ""), + ) + assert smoke.adcp_contract() == 1 # 9.x and 10.0.0b0-b3 + smoke.check_contract() diff --git a/tests/test_release_configuration.py b/tests/test_release_configuration.py index a55915045..a2ad0a75e 100644 --- a/tests/test_release_configuration.py +++ b/tests/test_release_configuration.py @@ -6,6 +6,8 @@ import re from pathlib import Path +import yaml + from scripts.normalize_pyproject_prerelease import pep440_prerelease ROOT = Path(__file__).parent.parent @@ -69,6 +71,36 @@ def test_storage_distribution_version_matches_its_manifest_entry() -> None: assert config["packages"]["."]["exclude-paths"] == ["packages/adcp-reporting-storage"] +def test_release_publish_gates_both_packages_on_a_working_companion() -> None: + """The companion gate (#1605, #1763) runs for every publish of either package. + + It runs after the build, so the wheel just built is installed beside the + published companion and smoked, with a PostgreSQL service available: a + pair whose version ranges admit each other but whose storage contracts + differ never reaches PyPI. + """ + publisher = yaml.load( + (ROOT / ".github/workflows/release-publish.yml").read_text(), Loader=yaml.BaseLoader + ) + steps = publisher["jobs"]["build"]["steps"] + companion_steps = [ + index + for index, step in enumerate(steps) + if "check_companion_releases.py" in step.get("run", "") + ] + assert len(companion_steps) == 1 + companion = steps[companion_steps[0]] + assert "if" not in companion, "both packages are gated" + assert "--dist dist/adcp-*.whl" in companion["run"] + assert '--package "$PACKAGE" --dist dist/adcp_reporting_storage-*.whl' in companion["run"] + assert companion["env"]["ADCP_PG_TEST_URL"].startswith("postgresql://") + build_steps = [ + index for index, step in enumerate(steps) if "python -m build" in step.get("run", "") + ] + assert build_steps and build_steps[0] < companion_steps[0], "gate the built wheel" + assert publisher["jobs"]["build"]["services"]["postgres"]["image"].startswith("postgres:") + + def test_storage_distribution_pins_one_adcp_major() -> None: pyproject = (STORAGE / "pyproject.toml").read_text() assert re.search(r'"adcp>=9\.0\.0(?:b\d+)?,<10"', pyproject), "adcp range must be one major" From 99ea67ce7a605d5300809cc4e93a1c0c966c8b78 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 1 Nov 2026 22:59:19 +0000 Subject: [PATCH 3/3] test(release): classify the smoke's undeclared adcp from patched column lists The test asserted this checkout's column lists carry offering_id, which is true on next and false on main; both classifications are now driven by patched lists so the test is branch-agnostic. Co-Authored-By: Claude Fable 5.1 --- tests/test_check_companion_releases.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/tests/test_check_companion_releases.py b/tests/test_check_companion_releases.py index c645f41c6..82cbe4614 100644 --- a/tests/test_check_companion_releases.py +++ b/tests/test_check_companion_releases.py @@ -289,15 +289,13 @@ def test_the_smoke_script_classifies_releases_without_a_declaration( monkeypatch.setitem(sys.modules, "adcp.reporting.ledger.storage_contract", None) monkeypatch.setitem(sys.modules, "adcp_reporting_storage.contract", None) assert smoke.storage_contract() == 1 - assert smoke.adcp_contract() == 2 # this checkout's columns carry offering_id + without = _record_rows._OBLIGATION_COLUMNS.replace(", offering_id", "") + monkeypatch.setattr(_record_rows, "_OBLIGATION_COLUMNS", without) + assert smoke.adcp_contract() == 1 # 9.x and 10.0.0b0-b3 + smoke.check_contract() + monkeypatch.setattr(_record_rows, "_OBLIGATION_COLUMNS", without + ", offering_id") + assert smoke.adcp_contract() == 2 # 10.0.0b4 and b5 with pytest.raises( SystemExit, match="storage contract 2 but the storage implements contract 1" ): smoke.check_contract() # the #1763 pair - monkeypatch.setattr( - _record_rows, - "_OBLIGATION_COLUMNS", - _record_rows._OBLIGATION_COLUMNS.replace(", offering_id", ""), - ) - assert smoke.adcp_contract() == 1 # 9.x and 10.0.0b0-b3 - smoke.check_contract()