From a8e2903d771ae174784e24a79802c0180bf0da64 Mon Sep 17 00:00:00 2001 From: actionq-dispatcher Date: Thu, 13 Aug 2026 19:45:51 +0300 Subject: [PATCH] feat(adapter): adopt shared Vuoro adapter kit --- .github/workflows/release-sprintctl.yaml | 106 ++++++++++++++++ pyproject.toml | 7 +- sprintctl/__init__.py | 2 +- sprintctl/vuoro_adapter.py | 116 +++++++++-------- tests/test_adapter_kit_migration.py | 125 +++++++++++++++++++ tests/test_release_contract.py | 115 +++++++++++++++++ tests/test_vuoro_work_adapter_integration.py | 50 ++++++++ uv.lock | 16 ++- verification/validate_release_contract.py | 124 ++++++++++++++++++ 9 files changed, 605 insertions(+), 56 deletions(-) create mode 100644 .github/workflows/release-sprintctl.yaml create mode 100644 tests/test_adapter_kit_migration.py create mode 100644 tests/test_release_contract.py create mode 100644 verification/validate_release_contract.py diff --git a/.github/workflows/release-sprintctl.yaml b/.github/workflows/release-sprintctl.yaml new file mode 100644 index 0000000..655c280 --- /dev/null +++ b/.github/workflows/release-sprintctl.yaml @@ -0,0 +1,106 @@ +name: Release Sprintctl wheel + +on: + push: + tags: + - "v*" + +permissions: + attestations: write + contents: write + id-token: write + +jobs: + tests: + name: Release gate (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12"] + steps: + - name: Checkout Sprintctl at the release tag + uses: actions/checkout@v4 + with: + ref: ${{ github.ref }} + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Setup uv + uses: astral-sh/setup-uv@v6 + - name: Synchronize frozen development dependencies + run: uv sync --frozen --extra dev + - name: Run the full test suite + run: uv run pytest -q + + publish: + name: Build, attest, and publish the Sprintctl wheel + needs: tests + runs-on: ubuntu-latest + steps: + - name: Checkout Sprintctl at the release tag + uses: actions/checkout@v4 + with: + ref: ${{ github.ref }} + - name: Setup uv + uses: astral-sh/setup-uv@v6 + with: + python-version: "3.12" + - name: Synchronize frozen development dependencies + run: uv sync --frozen --extra dev + - name: Build exactly one release wheel + shell: bash + run: | + set -euo pipefail + rm -rf dist + mkdir -p dist + uv build --wheel --out-dir dist + shopt -s nullglob + wheels=(dist/*.whl) + if [[ "${#wheels[@]}" -ne 1 ]]; then + echo "expected exactly one release wheel, found ${#wheels[@]}" >&2 + exit 1 + fi + - name: Validate wheel metadata, tag, and immutable adapter dependency + run: uv run python verification/validate_release_contract.py dist/*.whl --tag "$GITHUB_REF_NAME" + - name: Record deterministic wheel identity + id: wheel + shell: bash + run: | + set -euo pipefail + wheel=(dist/*.whl) + wheel_name="$(basename "${wheel[0]}")" + sha256="$(sha256sum "${wheel[0]}" | awk '{print $1}')" + { + echo "path=${wheel[0]}" + echo "name=$wheel_name" + echo "sha256=$sha256" + } >> "$GITHUB_OUTPUT" + - name: Attest the exact release wheel + uses: actions/attest@v4 + with: + subject-path: "${{ steps.wheel.outputs.path }}" + - name: Create the draft GitHub release with the wheel + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + tag="$GITHUB_REF_NAME" + wheel="${{ steps.wheel.outputs.path }}" + wheel_name="${{ steps.wheel.outputs.name }}" + sha256="${{ steps.wheel.outputs.sha256 }}" + notes=$(printf 'tag: %s\ndistribution: sprintctl\nwheel: %s\nsha256: %s\n' \ + "$tag" "$wheel_name" "$sha256") + gh release create "$tag" \ + --verify-tag \ + --repo "$GITHUB_REPOSITORY" \ + --draft \ + --title "$tag — Sprintctl — $wheel_name — sha256:$sha256" \ + --notes "$notes" \ + "$wheel" + - name: Publish the GitHub release after attestation and draft creation + env: + GH_TOKEN: ${{ github.token }} + run: gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --draft=false diff --git a/pyproject.toml b/pyproject.toml index 458d0cb..6f36048 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,9 +4,12 @@ build-backend = "setuptools.build_meta" [project] name = "sprintctl" -version = "0.2.23" +version = "0.2.24" requires-python = ">=3.11" -dependencies = ["click>=8.1"] +dependencies = [ + "click>=8.1", + "vuoro-adapter-kit @ https://github.com/bayleafwalker/vuoro/releases/download/vuoro-adapter-kit-v0.1.0/vuoro_adapter_kit-0.1.0-py3-none-any.whl#sha256=0037898a4c9f01720a42302365b0172ecd203732070326ea2abdf549a44bf0c2", +] [project.scripts] sprintctl = "sprintctl.cli:cli" diff --git a/sprintctl/__init__.py b/sprintctl/__init__.py index 9b808c2..f91d77d 100755 --- a/sprintctl/__init__.py +++ b/sprintctl/__init__.py @@ -1,4 +1,4 @@ -__version__ = "0.2.23" +__version__ = "0.2.24" # Keep these identifiers stable: the doctor command compares the running # package with the capabilities declared by a checked-out source tree. diff --git a/sprintctl/vuoro_adapter.py b/sprintctl/vuoro_adapter.py index 726dde4..c3b109a 100644 --- a/sprintctl/vuoro_adapter.py +++ b/sprintctl/vuoro_adapter.py @@ -11,6 +11,13 @@ from dataclasses import dataclass from typing import Any, Literal +from vuoro_adapter_kit import ( + SCHEMA_DIALECT as _ADAPTER_SCHEMA_DIALECT, + SCHEMA_FEATURES as _ADAPTER_SCHEMA_FEATURES, + object_schema, + operation_spec, +) + from .application import ( ApplicationRejection, ProjectWorkApplication, @@ -20,7 +27,8 @@ WORK_API_VERSION = "work-api/v1" WORK_SCHEMA_VERSION = "work-schema/v1" -SCHEMA_DIALECT = "https://json-schema.org/draft/2020-12/schema" +SCHEMA_DIALECT = _ADAPTER_SCHEMA_DIALECT +SCHEMA_FEATURES = _ADAPTER_SCHEMA_FEATURES @dataclass(frozen=True, slots=True) @@ -31,27 +39,11 @@ class WorkOperationContract: required_authority: str | None execution_semantics: Literal["read", "write", "enqueue", "admin"] idempotency: Literal["not-allowed", "optional", "required"] - required_client_schema_features: tuple[str, ...] = ("json-schema-draft-2020-12",) + required_client_schema_features: tuple[str, ...] = SCHEMA_FEATURES result_contract_resource_kind: str | None = None -def _object_schema( - properties: dict[str, Any], - *, - required: tuple[str, ...] = (), - definitions: dict[str, Any] | None = None, -) -> dict[str, Any]: - schema: dict[str, Any] = { - "$schema": SCHEMA_DIALECT, - "type": "object", - "properties": properties, - "additionalProperties": False, - } - if required: - schema["required"] = list(required) - if definitions: - schema["$defs"] = definitions - return schema +_object_schema = object_schema def _result_schema( @@ -1084,6 +1076,45 @@ def _result_schema( ) +_RESOURCE_OPERATIONS = frozenset( + { + "work.maintenance.resource.prepare", + "work.maintenance.resource.get", + "work.maintenance.resource.changes", + } +) + + +def catalog_operation_specs( + *, resource_schema_available: bool +) -> tuple[dict[str, Any], ...]: + """Return fresh data-only Vuoro definitions in owner-declared order.""" + + return tuple( + operation_spec( + contract.name, + owning_domain="work", + input_schema=contract.input_schema, + result_schema=contract.result_schema, + required_authority=contract.required_authority, + execution_semantics=contract.execution_semantics, + idempotency=contract.idempotency, + repo_scoped=not contract.name.startswith("work.project."), + required_client_schema_features=contract.required_client_schema_features, + result_contract=( + { + "mode": "resource-reference", + "resource_kind": contract.result_contract_resource_kind, + } + if contract.result_contract_resource_kind + else None + ), + ) + for contract in WORK_OPERATION_CONTRACTS + if resource_schema_available or contract.name not in _RESOURCE_OPERATIONS + ) + + def register_work_catalog( registry: Any, application: WorkApplication, @@ -1098,20 +1129,18 @@ def register_work_catalog( OperationDefinition, ResourceKindDefinition, ResourceObservationContract, - ResourceResultContract, ) - resource_kind_registered = False resource_schema_available = application.maintenance_resource_schema_available() - resource_operations = { - "work.maintenance.resource.prepare", - "work.maintenance.resource.get", - "work.maintenance.resource.changes", - } - for contract in WORK_OPERATION_CONTRACTS: - if contract.name in resource_operations and not resource_schema_available: - continue - if contract.result_contract_resource_kind and not resource_kind_registered: + resource_kind_registered = False + contracts = {contract.name: contract for contract in WORK_OPERATION_CONTRACTS} + for raw_spec in catalog_operation_specs( + resource_schema_available=resource_schema_available + ): + operation = raw_spec["name"] + contract = contracts[operation] + result_contract_resource_kind = contract.result_contract_resource_kind + if result_contract_resource_kind and not resource_kind_registered: registry.register_resource_kind( ResourceKindDefinition( resource_kind="work.maintenance-capability", @@ -1132,29 +1161,10 @@ def register_work_catalog( # ProjectWorkApplication) -- they have no single repo_id to scope # to, so they stay outside the envelope-level repo_id/authorization # gate that every other work.* operation requires. - definition = OperationDefinition( - name=contract.name, - owning_domain="work", - input_schema=contract.input_schema, - result_schema=contract.result_schema, - required_authority=contract.required_authority, - execution_semantics=contract.execution_semantics, - idempotency=contract.idempotency, - repo_scoped=not contract.name.startswith("work.project."), - required_client_schema_features=list( - contract.required_client_schema_features - ), - result_contract=( - ResourceResultContract( - resource_kind=contract.result_contract_resource_kind - ) - if contract.result_contract_resource_kind - else None - ), - ) + definition = OperationDefinition(**raw_spec) def handler( - arguments: Any, context: Any, *, operation: str = contract.name + arguments: Any, context: Any, *, operation: str = operation ) -> Any: try: if operation.startswith("work.project."): @@ -1180,7 +1190,7 @@ def handler( result["repo_id"] ).maintenance_resource_reference(result) ) - if contract.result_contract_resource_kind else None + if result_contract_resource_kind else None ), ) @@ -1188,9 +1198,11 @@ def handler( __all__ = [ "LEGACY_REMOTE_COMMAND_PARITY", "SCHEMA_DIALECT", + "SCHEMA_FEATURES", "WORK_API_VERSION", "WORK_OPERATION_CONTRACTS", "WORK_SCHEMA_VERSION", "WorkOperationContract", + "catalog_operation_specs", "register_work_catalog", ] diff --git a/tests/test_adapter_kit_migration.py b/tests/test_adapter_kit_migration.py new file mode 100644 index 0000000..addf025 --- /dev/null +++ b/tests/test_adapter_kit_migration.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from importlib.metadata import PackageNotFoundError, distribution +from pathlib import Path +import tomllib + +import pytest + +from sprintctl.vuoro_adapter import ( + SCHEMA_DIALECT, + SCHEMA_FEATURES, + WORK_OPERATION_CONTRACTS, + catalog_operation_specs, +) +from vuoro_adapter_kit import ( + SCHEMA_DIALECT as ADAPTER_SCHEMA_DIALECT, + SCHEMA_FEATURES as ADAPTER_SCHEMA_FEATURES, +) + + +ROOT = Path(__file__).parents[1] +ADAPTER_URL = ( + "https://github.com/bayleafwalker/vuoro/releases/download/" + "vuoro-adapter-kit-v0.1.0/" + "vuoro_adapter_kit-0.1.0-py3-none-any.whl" +) +ADAPTER_DIGEST = "0037898a4c9f01720a42302365b0172ecd203732070326ea2abdf549a44bf0c2" + + +def test_shared_schema_metadata_and_owner_contract_order_are_preserved() -> None: + assert SCHEMA_DIALECT == ADAPTER_SCHEMA_DIALECT + assert SCHEMA_FEATURES == ADAPTER_SCHEMA_FEATURES + + available = catalog_operation_specs(resource_schema_available=True) + assert [spec["name"] for spec in available] == [ + contract.name for contract in WORK_OPERATION_CONTRACTS + ] + for contract, spec in zip(WORK_OPERATION_CONTRACTS, available, strict=True): + assert spec["owning_domain"] == "work" + assert spec["input_schema"] == contract.input_schema + assert spec["result_schema"] == contract.result_schema + assert spec["required_authority"] == contract.required_authority + assert spec["execution_semantics"] == contract.execution_semantics + assert spec["idempotency"] == contract.idempotency + assert spec["required_client_schema_features"] == list( + contract.required_client_schema_features + ) + assert spec["repo_scoped"] is not contract.name.startswith("work.project.") + + by_name = {spec["name"]: spec for spec in available} + assert by_name["work.maintenance.resource.prepare"]["result_contract"] == { + "mode": "resource-reference", + "resource_kind": "work.maintenance-capability", + } + assert all( + "result_contract" not in spec + for name, spec in by_name.items() + if name != "work.maintenance.resource.prepare" + ) + + +def test_catalog_specs_are_deeply_isolated_from_owner_contracts_and_each_other() -> None: + first = catalog_operation_specs(resource_schema_available=True) + first[0]["input_schema"]["properties"]["mutation"] = {"type": "string"} + first[0]["required_client_schema_features"].append("mutation") + + second = catalog_operation_specs(resource_schema_available=True) + assert "mutation" not in second[0]["input_schema"]["properties"] + assert second[0]["required_client_schema_features"] == list(SCHEMA_FEATURES) + assert "mutation" not in WORK_OPERATION_CONTRACTS[0].input_schema["properties"] + + +def test_resource_schema_gate_removes_exactly_the_three_owner_operations() -> None: + available = catalog_operation_specs(resource_schema_available=True) + unavailable = catalog_operation_specs(resource_schema_available=False) + resource_names = { + "work.maintenance.resource.prepare", + "work.maintenance.resource.get", + "work.maintenance.resource.changes", + } + + assert len(available) == 46 + assert len(unavailable) == 43 + assert {spec["name"] for spec in available} - { + spec["name"] for spec in unavailable + } == resource_names + + +def test_runtime_dependency_and_lock_select_one_immutable_adapter_wheel() -> None: + with (ROOT / "pyproject.toml").open("rb") as stream: + project = tomllib.load(stream)["project"] + requirements = [ + requirement + for requirement in project["dependencies"] + if requirement.startswith("vuoro-adapter-kit @ ") + ] + assert requirements == [ + f"vuoro-adapter-kit @ {ADAPTER_URL}#sha256={ADAPTER_DIGEST}" + ] + + with (ROOT / "uv.lock").open("rb") as stream: + lock = tomllib.load(stream) + package = [ + package + for package in lock["package"] + if package["name"] == "vuoro-adapter-kit" + ] + assert len(package) == 1 + assert package[0]["source"] == {"url": ADAPTER_URL} + assert package[0]["wheels"] == [ + {"url": ADAPTER_URL, "hash": f"sha256:{ADAPTER_DIGEST}"} + ] + + +def test_installed_distribution_metadata_preserves_adapter_url_and_digest() -> None: + try: + requirements = distribution("sprintctl").requires or [] + except PackageNotFoundError: + pytest.skip("sprintctl is not installed as a distribution") + assert any( + requirement.startswith("vuoro-adapter-kit @ ") + and ADAPTER_URL in requirement + and ADAPTER_DIGEST in requirement + for requirement in requirements + ) diff --git a/tests/test_release_contract.py b/tests/test_release_contract.py new file mode 100644 index 0000000..6114aba --- /dev/null +++ b/tests/test_release_contract.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from verification.validate_release_contract import ( + _adapter_requirement, + _locked_adapter_requirement, + _validate_adapter_pin, + validate_wheel, +) + + +ROOT = Path(__file__).parents[1] +WORKFLOW = ROOT / ".github" / "workflows" / "release-sprintctl.yaml" + + +def test_adapter_dependency_is_an_immutable_github_wheel_pin() -> None: + requirement = _adapter_requirement() + lock_url, lock_digest = _locked_adapter_requirement() + digest = _validate_adapter_pin(requirement) + + assert requirement.split("#", 1)[0] == lock_url + assert digest == lock_digest + assert len(digest) == 64 + + +def _assert_release_workflow(workflow: str) -> None: + assert 'tags:\n - "v*"' in workflow + assert "workflow_dispatch" not in workflow + assert "pypi" not in workflow.lower() + assert "gh release upload" not in workflow + assert "--clobber" not in workflow + assert "uv publish" not in workflow + assert "attestations: write" in workflow + assert "contents: write" in workflow + assert "id-token: write" in workflow + + +def _assert_release_order(workflow: str) -> None: + assert workflow.count('"3.11"') == 1 + assert workflow.count('"3.12"') == 2 + assert workflow.count("uv build --wheel") == 1 + assert workflow.count("uses: actions/attest@v4") == 1 + assert "needs: tests" in workflow + + sync = workflow.index("uv sync --frozen --extra dev") + full_suite = workflow.index("uv run pytest -q") + build = workflow.index("uv build --wheel --out-dir dist") + validation = workflow.index("verification/validate_release_contract.py") + attestation = workflow.index("uses: actions/attest@v4") + release_create = workflow.index('gh release create "$tag"') + release_publish = workflow.index('gh release edit "$GITHUB_REF_NAME"') + assert sync < full_suite < build < validation < attestation < release_create + assert release_create < release_publish + assert "--verify-tag" in workflow + assert '"$wheel"' in workflow + + +def test_release_workflow_is_tag_only_github_only_and_attested() -> None: + _assert_release_workflow(WORKFLOW.read_text(encoding="utf-8")) + + +def test_release_workflow_has_two_python_gates_and_one_gated_build() -> None: + _assert_release_order(WORKFLOW.read_text(encoding="utf-8")) + + +@pytest.mark.parametrize( + "broken", + [ + lambda workflow: workflow.replace( + "uv sync --frozen --extra dev", "uv sync --frozen" + ), + lambda workflow: workflow.replace( + "uv run pytest -q", "uv run pytest tests/test_release_contract.py" + ), + lambda workflow: workflow.replace( + " uv build --wheel --out-dir dist\n", + " uv build --wheel --out-dir dist\n" + " uv build --wheel --out-dir dist\n", + ), + lambda workflow: workflow.replace(" --verify-tag \\\n", ""), + lambda workflow: workflow.replace( + ' "$wheel"\n', ' --clobber "$wheel"\n' + ), + lambda workflow: workflow.replace(" contents: write", " contents: read"), + lambda workflow: workflow.replace(" id-token: write", " id-token: read"), + ], + ids=( + "partial-sync", + "partial-suite", + "second-build", + "missing-verify-tag", + "clobber-release-asset", + "read-only-release-permission", + "read-only-attestation-permission", + ), +) +def test_release_workflow_contract_rejects_regressions(broken) -> None: + workflow = WORKFLOW.read_text(encoding="utf-8") + with pytest.raises((AssertionError, ValueError)): + mutated = broken(workflow) + _assert_release_workflow(mutated) + _assert_release_order(mutated) + + +@pytest.mark.skipif( + not list((ROOT / "dist").glob("*.whl")), + reason="release wheel is built by the release workflow", +) +def test_built_wheel_satisfies_release_contract() -> None: + wheels = sorted((ROOT / "dist").glob("*.whl")) + assert len(wheels) == 1 + validate_wheel(wheels[0], tag=f"v{wheels[0].name.split('-')[1]}") diff --git a/tests/test_vuoro_work_adapter_integration.py b/tests/test_vuoro_work_adapter_integration.py index 2f93ac7..28c07b9 100644 --- a/tests/test_vuoro_work_adapter_integration.py +++ b/tests/test_vuoro_work_adapter_integration.py @@ -1,6 +1,8 @@ # ruff: noqa: E402 - optional Vuoro imports follow explicit availability gates. from __future__ import annotations +import hashlib +import json from types import SimpleNamespace import pytest @@ -28,6 +30,54 @@ def anyio_backend() -> str: return "asyncio" +@pytest.mark.parametrize( + ("remote_schema_version", "operation_count", "byte_count", "operations_sha", "revision"), + [ + ( + 6, + 43, + 51_800, + "b2d241957a02ae648a2e31a25a7e0f4bb616656286618864f02a1c9180138205", + "b2d241957a02ae648a2e31a25a7e0f4bb616656286618864f02a1c9180138205", + ), + ( + 7, + 46, + 56_915, + "a111136548a051be949b32a9b29b60847287a84aef403de2fc6aa8ce141ca3b7", + "25367985cffea28c8de8e2c25140c3f2f8ebc0f64166b70a4b37ba8724e9c4df", + ), + ], +) +def test_adapter_kit_migration_preserves_catalog_bytes_and_registry_revision( + remote_schema_version, operation_count, byte_count, operations_sha, revision +): + store = SimpleNamespace( + repo_id="sprintctl", remote_schema_version=remote_schema_version + ) + work = WorkApplication( + repo_id="sprintctl", + store=store, + backend=object(), + ingest_records=lambda records: [], + arbitrate_command=lambda record, credentials: None, + list_records=lambda after, limit: [], + list_decisions=lambda after, limit: [], + ) + registry = CatalogRegistry() + register_work_catalog(registry, work) + operations = [ + operation.model_dump(mode="json") + for operation in registry.catalog().operations + ] + payload = json.dumps(operations, sort_keys=True, separators=(",", ":")).encode() + + assert len(operations) == operation_count + assert len(payload) == byte_count + assert hashlib.sha256(payload).hexdigest() == operations_sha + assert registry.revision == revision + + @pytest.mark.anyio async def test_preexisting_generic_client_discovers_cutover_evidence(monkeypatch): evidence = { diff --git a/uv.lock b/uv.lock index ab7580d..48202e3 100755 --- a/uv.lock +++ b/uv.lock @@ -517,10 +517,11 @@ wheels = [ [[package]] name = "sprintctl" -version = "0.2.23" +version = "0.2.24" source = { editable = "." } dependencies = [ { name = "click" }, + { name = "vuoro-adapter-kit" }, ] [package.optional-dependencies] @@ -539,6 +540,7 @@ requires-dist = [ { name = "click", specifier = ">=8.1" }, { name = "psycopg", extras = ["binary"], marker = "extra == 'remote'", specifier = ">=3.1" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8" }, + { name = "vuoro-adapter-kit", url = "https://github.com/bayleafwalker/vuoro/releases/download/vuoro-adapter-kit-v0.1.0/vuoro_adapter_kit-0.1.0-py3-none-any.whl" }, { name = "vuoro-client", marker = "python_full_version >= '3.12' and extra == 'served'", url = "https://github.com/bayleafwalker/vuoro/releases/download/vuoro-client-v0.1.0/vuoro_client-0.1.0-py3-none-any.whl" }, ] provides-extras = ["dev", "remote", "served"] @@ -573,6 +575,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, ] +[[package]] +name = "vuoro-adapter-kit" +version = "0.1.0" +source = { url = "https://github.com/bayleafwalker/vuoro/releases/download/vuoro-adapter-kit-v0.1.0/vuoro_adapter_kit-0.1.0-py3-none-any.whl" } +wheels = [ + { url = "https://github.com/bayleafwalker/vuoro/releases/download/vuoro-adapter-kit-v0.1.0/vuoro_adapter_kit-0.1.0-py3-none-any.whl", hash = "sha256:0037898a4c9f01720a42302365b0172ecd203732070326ea2abdf549a44bf0c2" }, +] + +[package.metadata] +requires-dist = [{ name = "pytest", marker = "extra == 'test'", specifier = ">=8.3,<9" }] +provides-extras = ["test"] + [[package]] name = "vuoro-client" version = "0.1.0" diff --git a/verification/validate_release_contract.py b/verification/validate_release_contract.py new file mode 100644 index 0000000..962dd2a --- /dev/null +++ b/verification/validate_release_contract.py @@ -0,0 +1,124 @@ +"""Validate the exact Sprintctl wheel selected for a GitHub release.""" + +from __future__ import annotations + +import argparse +from email import policy +from email.parser import BytesParser +import re +import sys +import tomllib +import zipfile +from pathlib import Path +from urllib.parse import urldefrag, urlparse + + +ROOT = Path(__file__).resolve().parents[1] +RELEASE_VERSION = "0.2.24" +ADAPTER_NAME = "vuoro-adapter-kit" +ADAPTER_DIGEST_RE = re.compile(r"^sha256=(?P[0-9a-f]{64})$") +ADAPTER_PATH_RE = re.compile( + r"^/bayleafwalker/vuoro/releases/download/" + r"vuoro-adapter-kit-v(?P[^/]+)/" + r"vuoro_adapter_kit-(?P[^-]+)-py3-none-any\.whl$" +) +SPRINTCTL_WHEEL_RE = re.compile(r"^sprintctl-(?P[^-]+)-.+\.whl$") + + +def _project_metadata() -> dict: + with (ROOT / "pyproject.toml").open("rb") as stream: + return tomllib.load(stream)["project"] + + +def _adapter_requirement() -> str: + requirements = [ + requirement + for requirement in _project_metadata()["dependencies"] + if requirement.startswith(f"{ADAPTER_NAME} @ ") + ] + if len(requirements) != 1: + raise AssertionError("pyproject must declare exactly one adapter-kit URL") + return requirements[0].split(" @ ", 1)[1] + + +def _locked_adapter_requirement() -> tuple[str, str]: + with (ROOT / "uv.lock").open("rb") as stream: + lock = tomllib.load(stream) + packages = [package for package in lock["package"] if package["name"] == ADAPTER_NAME] + if len(packages) != 1: + raise AssertionError("uv.lock must contain exactly one adapter-kit package") + wheels = packages[0].get("wheels", []) + if len(wheels) != 1 or not wheels[0]["hash"].startswith("sha256:"): + raise AssertionError("uv.lock must contain one SHA-256-bound adapter wheel") + return packages[0]["source"]["url"], wheels[0]["hash"].removeprefix("sha256:") + + +def _validate_adapter_pin(url: str) -> str: + plain_url, fragment = urldefrag(url) + parsed = urlparse(plain_url) + if parsed.scheme != "https" or parsed.netloc != "github.com": + raise AssertionError("adapter-kit dependency must use an HTTPS GitHub URL") + match = ADAPTER_PATH_RE.fullmatch(parsed.path) + if match is None or match.group("release_version") != match.group("wheel_version"): + raise AssertionError("adapter-kit URL must identify one versioned GitHub wheel") + digest_match = ADAPTER_DIGEST_RE.fullmatch(fragment) + if digest_match is None: + raise AssertionError("adapter-kit dependency must include a SHA-256 fragment") + return digest_match.group("digest") + + +def validate_wheel(wheel_path: Path, tag: str | None = None) -> None: + if not wheel_path.is_file() or wheel_path.suffix != ".whl": + raise AssertionError(f"wheel does not exist: {wheel_path}") + wheel_match = SPRINTCTL_WHEEL_RE.fullmatch(wheel_path.name) + if wheel_match is None: + raise AssertionError(f"wheel is not a Sprintctl wheel: {wheel_path.name}") + + project = _project_metadata() + if project["version"] != RELEASE_VERSION: + raise AssertionError(f"release contract is frozen to Sprintctl {RELEASE_VERSION}") + pyproject_url = _adapter_requirement() + pyproject_digest = _validate_adapter_pin(pyproject_url) + lock_url, lock_digest = _locked_adapter_requirement() + if lock_url != urldefrag(pyproject_url)[0] or lock_digest != pyproject_digest: + raise AssertionError("uv.lock does not preserve the pyproject adapter URL and digest") + + with zipfile.ZipFile(wheel_path) as wheel: + metadata_names = [ + name for name in wheel.namelist() if name.endswith(".dist-info/METADATA") + ] + if len(metadata_names) != 1: + raise AssertionError("wheel must contain exactly one dist-info/METADATA file") + metadata = BytesParser(policy=policy.default).parsebytes( + wheel.read(metadata_names[0]) + ) + + if metadata["Name"] != "sprintctl": + raise AssertionError(f"wheel metadata name is not sprintctl: {metadata['Name']!r}") + version = metadata["Version"] + if version != wheel_match.group("version") or version != project["version"]: + raise AssertionError("wheel filename, metadata, and source versions differ") + expected_tag = f"v{version}" + if tag is not None and tag != expected_tag: + raise AssertionError(f"release tag {tag!r} does not match {expected_tag!r}") + requirements = metadata.get_all("Requires-Dist", []) + if not any(requirement.endswith(pyproject_url) for requirement in requirements): + raise AssertionError("wheel metadata does not preserve the adapter URL and digest") + print(f"validated {wheel_path.name}: tag={expected_tag}") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("wheel", type=Path) + parser.add_argument("--tag") + args = parser.parse_args(argv) + try: + validate_wheel(args.wheel, args.tag) + except (AssertionError, KeyError, OSError, zipfile.BadZipFile) as error: + print(f"release contract failed: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())