From 012af09d2c52d17158513b3869db0aeb4853f10e Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 10 Oct 2026 15:45:45 +0000 Subject: [PATCH 1/4] fix(release): preserve beta validation and document adoption contracts Fixes #1422 Fixes #1448 Fixes #1418 --- .github/workflows/ci.yml | 6 ++-- README.md | 36 +++++++++++++++++++ src/adcp/signing/constants.py | 3 ++ src/adcp/signing/webhook_verifier.py | 5 ++- .../signing/test_webhook_rc4_vectors.py | 25 +++++++++++++ tests/test_main_release_policies.py | 17 +++++++++ 6 files changed, 87 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4e3833d3..11779f4f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,8 +22,10 @@ env: ADCP_SDK_VERSION: "14.0.0" concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: true + # A tagged main commit needs its own full CI even when main advances. + # PR updates may still cancel superseded runs for the same PR ref. + group: ci-${{ github.event_name == 'pull_request' && github.ref || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: changes: diff --git a/README.md b/README.md index 0a03b9eae..75a653512 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,22 @@ pip install adcp > **Note**: This client requires Python 3.10 or later and supports both synchronous and asynchronous workflows. +### SDK 9 beta + +SDK 9 is available as a prerelease. Ordinary installation selects the stable +SDK 8 release; a stable `adcp==9.0.0` pin becomes available at GA. The release +tag `9.0.0-beta.2` corresponds to the Python package version `9.0.0b2`. + +```bash +pip install 'adcp==9.0.0b2' # exact beta +pip install --pre 'adcp>=9.0.0b2,<10' # latest SDK 9 prerelease +uv add --prerelease allow 'adcp>=9.0.0b2,<10' # uv prerelease opt-in +``` + +Read [the SDK 9 migration guide](MIGRATION_v8_to_v9.md) before upgrading. +The package's Production/Stable classifier describes the stable channel; +SDK 9 betas remain prereleases regardless of that classifier. + ## Quick Start: Test Helpers The fastest way to get started is using pre-configured test agents with the **`.simple` API**: @@ -533,6 +549,26 @@ import adcp.types adcp.types.Product # forces the one-time graph build now, not on the hot path ``` +Request serialization also discovers nullable paths separately for each task +and wire version. Warming the generated type graph does not warm that cache. +Allow startup time before imposing a short first-call deadline. Applications +that need to warm known task/version pairs can use the internal schema helper +at startup; its import path is not a public API stability guarantee: + +```python +from adcp._null_clear import nullable_request_paths + +nullable_request_paths("get_products", "3.2") +nullable_request_paths("create_media_buy", "3.2") +``` + +Cold discovery has taken about 127–204 ms on a loaded machine, compared with +about 0.001 ms from the cache; these are measurements, not latency guarantees. +A deadline that expires before transport dispatch returns `recovery=None` +because the request was never sent. A timeout after dispatch may need recovery +because the remote outcome is uncertain. Warming changes startup latency, not +that distinction. + #### Semantic Type Aliases For discriminated union types (success/error responses), use semantic aliases for clearer code: diff --git a/src/adcp/signing/constants.py b/src/adcp/signing/constants.py index 09a58f24f..6feddf422 100644 --- a/src/adcp/signing/constants.py +++ b/src/adcp/signing/constants.py @@ -6,6 +6,8 @@ WEBHOOK_TAG = "adcp/webhook-signing/v1" ADCP_USE_REQUEST = "request-signing" ADCP_USE_WEBHOOK = "webhook-signing" +#: Current request keys and deprecated webhook keys are both valid for webhooks. +WEBHOOK_ACCEPTED_ADCP_USES: frozenset[str] = frozenset({ADCP_USE_REQUEST, ADCP_USE_WEBHOOK}) MAX_WINDOW_SECONDS = 300 DEFAULT_EXPIRES_IN_SECONDS = 300 DEFAULT_SKEW_SECONDS = 60 @@ -21,5 +23,6 @@ "MAX_WINDOW_SECONDS", "NONCE_BYTES", "SIG_LABEL_DEFAULT", + "WEBHOOK_ACCEPTED_ADCP_USES", "WEBHOOK_TAG", ] diff --git a/src/adcp/signing/webhook_verifier.py b/src/adcp/signing/webhook_verifier.py index 9de96e82b..4478dfd0e 100644 --- a/src/adcp/signing/webhook_verifier.py +++ b/src/adcp/signing/webhook_verifier.py @@ -29,11 +29,10 @@ from adcp.signing.canonical import _lookup, parse_signature_input_header, split_structured_field from adcp.signing.constants import ( - ADCP_USE_REQUEST, - ADCP_USE_WEBHOOK, DEFAULT_SKEW_SECONDS, MAX_WINDOW_SECONDS, SIG_LABEL_DEFAULT, + WEBHOOK_ACCEPTED_ADCP_USES, WEBHOOK_TAG, ) from adcp.signing.crypto import ALLOWED_ALGS @@ -176,7 +175,7 @@ def verify_webhook_signature( max_window_seconds=options.max_window_seconds, label=options.label, expected_tag=WEBHOOK_TAG, - accepted_adcp_uses=frozenset({ADCP_USE_REQUEST, ADCP_USE_WEBHOOK}), + accepted_adcp_uses=WEBHOOK_ACCEPTED_ADCP_USES, allowed_algs=options.allowed_algs, agent_url=options.sender_url, expected_key_origins=( diff --git a/tests/conformance/signing/test_webhook_rc4_vectors.py b/tests/conformance/signing/test_webhook_rc4_vectors.py index e6d8cb233..362feda50 100644 --- a/tests/conformance/signing/test_webhook_rc4_vectors.py +++ b/tests/conformance/signing/test_webhook_rc4_vectors.py @@ -11,6 +11,7 @@ import pytest from adcp.signing import InMemoryReplayStore, SignatureVerificationError +from adcp.signing.constants import WEBHOOK_ACCEPTED_ADCP_USES from adcp.signing.revocation import RevocationList from adcp.webhooks import WebhookVerifyOptions, verify_webhook_signature @@ -83,6 +84,30 @@ def test_protocol_owned_webhook_vector(name): assert raised.value.step == expected["failed_step"] +@pytest.mark.parametrize("purpose", ["request-signing", "webhook-signing", "response-signing"]) +def test_named_webhook_key_purposes_match_actual_verification(purpose): + assert WEBHOOK_ACCEPTED_ADCP_USES == frozenset({"request-signing", "webhook-signing"}) + vector = json.loads(VECTORS.joinpath("positive/001-basic-post.json").read_text()) + options = vector_options(vector) + resolve = options.jwks_resolver + options = replace(options, jwks_resolver=lambda kid: {**resolve(kid), "adcp_use": purpose}) + request = vector["request"] + arguments = { + "method": request["method"], + "url": request["url"], + "headers": request["headers"], + "body": request["body"].encode(), + "options": options, + } + if purpose in WEBHOOK_ACCEPTED_ADCP_USES: + assert verify_webhook_signature(**arguments).label == "sig1" + else: + with pytest.raises(SignatureVerificationError) as raised: + verify_webhook_signature(**arguments) + assert raised.value.code == "webhook_signature_key_purpose_invalid" + assert raised.value.step == 8 + + @pytest.mark.parametrize( "signature", [ diff --git a/tests/test_main_release_policies.py b/tests/test_main_release_policies.py index 4ac491b11..3ab5ee4ba 100644 --- a/tests/test_main_release_policies.py +++ b/tests/test_main_release_policies.py @@ -447,3 +447,20 @@ def test_native_policy_jobs_are_read_only_pinned_and_run_on_main() -> None: } assert job["steps"][1]["run"] == f"python3 -m scripts.check_main_policies {command}" assert "secrets." not in json.dumps(job) + + +def test_main_ci_cannot_be_superseded_by_a_different_release_commit() -> None: + root = Path(__file__).resolve().parent.parent + ci = yaml.load((root / ".github/workflows/ci.yml").read_text(), Loader=yaml.BaseLoader) + concurrency = ci["concurrency"] + assert concurrency["group"] == ( + "ci-${{ github.event_name == 'pull_request' && github.ref || github.sha }}" + ) + assert concurrency["cancel-in-progress"] == "${{ github.event_name == 'pull_request' }}" + publisher = yaml.load( + (root / ".github/workflows/release-publish.yml").read_text(), Loader=yaml.BaseLoader + ) + gate = publisher["jobs"]["build"]["steps"][1]["run"] + assert '--commit "$RELEASE_SHA"' in gate + assert '.headSha == $sha and .headBranch == "main"' in gate + assert 'git checkout --detach "$RELEASE_SHA"' in gate From 62307853c6509d680cfc575ab3e76befec182add Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 10 Oct 2026 15:47:30 +0000 Subject: [PATCH 2/4] docs: align beta installation and strict declaration guidance --- README.md | 8 ++++---- docs/types-9-migration.md | 10 ++++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 75a653512..8e0e34369 100644 --- a/README.md +++ b/README.md @@ -224,12 +224,12 @@ pip install adcp SDK 9 is available as a prerelease. Ordinary installation selects the stable SDK 8 release; a stable `adcp==9.0.0` pin becomes available at GA. The release -tag `9.0.0-beta.2` corresponds to the Python package version `9.0.0b2`. +tag `9.0.0-beta.3` corresponds to the Python package version `9.0.0b3`. ```bash -pip install 'adcp==9.0.0b2' # exact beta -pip install --pre 'adcp>=9.0.0b2,<10' # latest SDK 9 prerelease -uv add --prerelease allow 'adcp>=9.0.0b2,<10' # uv prerelease opt-in +pip install 'adcp==9.0.0b3' # exact beta +pip install --pre 'adcp>=9.0.0b3,<10' # latest SDK 9 prerelease +uv add --prerelease allow 'adcp>=9.0.0b3,<10' # uv prerelease opt-in ``` Read [the SDK 9 migration guide](MIGRATION_v8_to_v9.md) before upgrading. diff --git a/docs/types-9-migration.md b/docs/types-9-migration.md index 19c352c75..f5a1db5bc 100644 --- a/docs/types-9-migration.md +++ b/docs/types-9-migration.md @@ -333,10 +333,12 @@ a declaration for a kind this pin does not know. This is a transfer of responsibility, not a new helper. Before 9.0 a closed enum refused an unknown `format_kind` inside the model, so a seller got that -refusal without asking for it. Now the SDK accepts any string on the way out -and on the way back, and the seller owns the refusal. **A seller that adds no -check has silently stopped validating something the library used to validate -for it** — no error appears, and nothing in a passing test suite says so. +refusal without asking for it. Open `Format` and other consumer models now +accept any string on the way out and on the way back, so a seller using those +models owns the refusal. **A seller using an open model without an explicit +check has stopped validating something the library used to validate for it**. +The strict `ProductFormatDeclaration` authoring class still enforces its +schema's closed set, as described above; using that class supplies the check. If you emit `format_kind`, the producer-side rule that you MUST NOT mint ad-hoc values is now yours to enforce, and `is_canonical_format_kind` is how: From a94632e8523e44cc61601b6380b73125082b19f1 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 10 Oct 2026 15:52:00 +0000 Subject: [PATCH 3/4] fix(ci): allow full coverage validation to finish before publication Fixes #1381 --- .github/workflows/ci.yml | 8 +++++--- .github/workflows/release-publish.yml | 5 +++-- tests/test_main_release_policies.py | 24 ++++++++++++++++++++++++ 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11779f4f6..f2f58a7d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,7 +102,9 @@ jobs: # 3.12 always runs the canonical suite and coverage. Ordinary PRs use # compatibility checks on the other interpreters; main/manual runs and # shared build/schema/type changes retain exhaustive interpreter coverage. - timeout-minutes: ${{ matrix.python-version == '3.12' && 70 || needs.changes.outputs.full_matrix == 'true' && 60 || 15 }} + # Recent coverage runs take 53 minutes before setup/type checks. Keep + # 15 minutes beyond each full-suite step for those other required checks. + timeout-minutes: ${{ matrix.python-version == '3.12' && 85 || needs.changes.outputs.full_matrix == 'true' && 70 || 15 }} strategy: # Complete exhaustive lanes independently so late interpreter failures # preserve coverage results. Fast PR compatibility checks cancel early. @@ -163,7 +165,7 @@ jobs: - name: Run full native suite if: matrix.python-version != '3.12' && needs.changes.outputs.full_matrix == 'true' - timeout-minutes: 45 + timeout-minutes: 55 run: python scripts/ci/run_native_tests.py --mode full --unit-workers 2 - name: Run interpreter compatibility checks @@ -173,7 +175,7 @@ jobs: - name: Run canonical suite with coverage if: matrix.python-version == '3.12' && needs.changes.outputs.release_metadata != 'true' - timeout-minutes: 55 + timeout-minutes: 70 run: python scripts/ci/run_native_tests.py --mode full --coverage --unit-workers 2 - name: Validate release metadata and built distributions diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 6a45b8c21..51fd4a685 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -22,7 +22,8 @@ jobs: build: if: github.ref == 'refs/heads/main' runs-on: ubuntu-24.04 - timeout-minutes: 75 + # Allow the 85-minute main CI lane plus queueing and distribution builds. + timeout-minutes: 120 permissions: contents: read actions: read @@ -42,7 +43,7 @@ jobs: [[ "$RELEASE_SHA" =~ ^[0-9a-f]{40}$ ]] test "$(git rev-parse "$RELEASE_TAG^{commit}")" = "$RELEASE_SHA" git merge-base --is-ancestor "$RELEASE_SHA" HEAD - for attempt in {1..130}; do + for attempt in {1..220}; do if gh run list --workflow CI --event push --commit "$RELEASE_SHA" --json headSha,headBranch,status,conclusion --limit 20 | jq -e --arg sha "$RELEASE_SHA" 'any(.[]; .headSha == $sha and .headBranch == "main" and .status == "completed" and .conclusion == "success")'; then git checkout --detach "$RELEASE_SHA" diff --git a/tests/test_main_release_policies.py b/tests/test_main_release_policies.py index 3ab5ee4ba..c7f06445f 100644 --- a/tests/test_main_release_policies.py +++ b/tests/test_main_release_policies.py @@ -5,6 +5,7 @@ import base64 import copy import json +import re import subprocess import urllib.error from pathlib import Path @@ -464,3 +465,26 @@ def test_main_ci_cannot_be_superseded_by_a_different_release_commit() -> None: assert '--commit "$RELEASE_SHA"' in gate assert '.headSha == $sha and .headBranch == "main"' in gate assert 'git checkout --detach "$RELEASE_SHA"' in gate + + +def test_release_wait_and_job_budgets_cover_full_test_steps() -> None: + root = Path(__file__).resolve().parent.parent + ci = yaml.load((root / ".github/workflows/ci.yml").read_text(), Loader=yaml.BaseLoader) + job = ci["jobs"]["test"] + budgets = [int(value) for value in re.findall(r"&& (\d+)", job["timeout-minutes"])] + steps = {step["name"]: step for step in job["steps"] if "name" in step} + coverage_minutes = int(steps["Run canonical suite with coverage"]["timeout-minutes"]) + full_minutes = int(steps["Run full native suite"]["timeout-minutes"]) + assert coverage_minutes >= 70 + assert full_minutes >= 55 + assert budgets[0] >= coverage_minutes + 15 + assert budgets[1] >= full_minutes + 15 + publisher = yaml.load( + (root / ".github/workflows/release-publish.yml").read_text(), Loader=yaml.BaseLoader + )["jobs"]["build"] + gate = publisher["steps"][1]["run"] + attempts = int(re.search(r"for attempt in \{1\.\.(\d+)\}", gate)[1]) + delay = int(re.search(r"sleep (\d+)", gate)[1]) + wait_minutes = attempts * delay / 60 + assert wait_minutes >= max(budgets) + 20 + assert int(publisher["timeout-minutes"]) >= wait_minutes + 10 From 5f2d597a58e5c10351b996179a003e4b39afefe4 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 10 Oct 2026 16:02:18 +0000 Subject: [PATCH 4/4] fix(types): enforce geographic namespace scheme spelling --- src/adcp/types/_geo_place_keys.py | 4 ++++ tests/test_geo_place_system_keys.py | 13 ++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/adcp/types/_geo_place_keys.py b/src/adcp/types/_geo_place_keys.py index 7b9c1d8df..934742ac6 100644 --- a/src/adcp/types/_geo_place_keys.py +++ b/src/adcp/types/_geo_place_keys.py @@ -21,6 +21,10 @@ def _https_system_adapter() -> TypeAdapter[AnyUrl]: def _validate_geo_system_key(value: str) -> str: """Validate the namespace and preserve its exact opaque wire spelling.""" if value not in _REGISTERED_SYSTEMS: + # URL parsing normalizes schemes, but the schema's ^https:// pattern + # applies to the original opaque identifier, before any normalization. + if not value.startswith("https://"): + raise ValueError("geographic place system URLs must start with https://") _https_system_adapter().validate_python(value) return value diff --git a/tests/test_geo_place_system_keys.py b/tests/test_geo_place_system_keys.py index 1c6c2b8a6..fe8a65a57 100644 --- a/tests/test_geo_place_system_keys.py +++ b/tests/test_geo_place_system_keys.py @@ -163,7 +163,18 @@ def test_registered_enum_keys_become_plain_strings( @pytest.mark.parametrize("model,field,value", _MODELS) @pytest.mark.parametrize( - "system", ["http://seller.example/places", "ftp://seller.example", "unknown", "", "https://"] + "system", + [ + "http://seller.example/places", + "ftp://seller.example", + "unknown", + "", + "https://", + "HTTPS://seller.example/places", + "Https://seller.example/places", + " https://seller.example/places", + "https:\\seller.example/places", + ], ) @pytest.mark.parametrize("from_json", [False, True], ids=["python", "json"]) def test_invalid_system_keys_raise_validation_errors(