From 6edb3d5e6eeffe5bb7760da77c2132534cbe2589 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley <134922+bokelley@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:43:41 +0000 Subject: [PATCH 1/3] chore(release): start the SDK 9 beta series Keep main on the beta channel while the breaking generated type surface is adopted, and mark GitHub releases as prereleases. Document #1401 as a pre-GA naming and validation follow-up. Keep the post-dispatch recovery tests from measuring cold schema loading, and assert the mocked dispatch phase explicitly. Release-As: 9.0.0-beta.1 --- docs/releasing.md | 30 +++++++++++++++++++++++------ release-please-config.json | 3 +++ tests/test_release_configuration.py | 17 ++++++++-------- tests/test_task_options.py | 12 ++++++++++-- 4 files changed, 46 insertions(+), 16 deletions(-) diff --git a/docs/releasing.md b/docs/releasing.md index dca2b4485..e370f5288 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -6,16 +6,34 @@ normalizes the proposed `pyproject.toml` prerelease version to PEP 440 (a no-op for stable versions). Review and merge that PR once its required checks and reviews pass. -SDK 8 uses normal SemVer versioning: `release-please-config.json` has no -`versioning: prerelease`, `prerelease-type`, or `prerelease` keys. To choose an -exact version (for example the first stable release after an rc series), the -squash commit that lands on `main` must end its body with a -`Release-As: X.Y.Z` footer. The repository squashes with the branch commit -messages as the default body, so check the final message in the merge dialog +SDK 9 is on the beta channel. The root package in +`release-please-config.json` uses `versioning: prerelease`, +`prerelease-type: beta`, and `prerelease: true`, so subsequent releases +remain beta and GitHub marks them as prereleases. The adopting commit ends +with `Release-As: 9.0.0-beta.1`; the release workflow normalizes that to +`9.0.0b1` in `pyproject.toml` and on PyPI. Do not merge a release PR that +proposes stable `9.0.0` while this beta channel is active. + +To choose an exact version, the squash commit that lands on `main` must +end its body with a `Release-As: X.Y.Z` footer. The repository squashes with +the branch commit messages as the default body, so check the final message in the merge dialog before confirming; the PR title and description alone are not parsed. After re-enabling a disabled Release Please workflow, run it once manually on `main` to process the current head; subsequent main pushes start it automatically. +Before SDK 9 GA, resolve the public declaration naming in +[#1401](https://github.com/adcontextprotocol/adcp-client-python/issues/1401). +`ProductFormatDeclaration` currently aliases the canonical `Format` helper, +while the schema's discriminated union is exposed as +`LegacyProductFormatDeclaration`. Simply rebinding the public name to that +union loses the mutual-exclusion validator for `canonical_formats_only` and +`v1_format_ref`. The fix must preserve that validation, credential-key +screening, typed parameter access, and the explicit legacy projection +boundary. Make any public-name changes during beta and document the migration. + +At the reviewed GA exit, remove the three prerelease configuration keys, +update the release-channel contract test, and select `Release-As: 9.0.0`. + On the merge, Release Please creates the tag and GitHub release, then starts `release-publish.yml` on `main`. The publish workflow waits for successful CI on the **exact release commit**, builds the sdist and wheel once, and checks both diff --git a/release-please-config.json b/release-please-config.json index cee59b352..81a7d6407 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -4,6 +4,9 @@ "release-type": "python", "package-name": "adcp", "changelog-path": "CHANGELOG.md", + "versioning": "prerelease", + "prerelease-type": "beta", + "prerelease": true, "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": false, "include-component-in-tag": false diff --git a/tests/test_release_configuration.py b/tests/test_release_configuration.py index 553b4d651..902238d82 100644 --- a/tests/test_release_configuration.py +++ b/tests/test_release_configuration.py @@ -1,4 +1,4 @@ -"""Release automation is the single source of truth for the stable SDK 8 line.""" +"""Release automation owns the SDK version and the SDK 9 beta channel.""" from __future__ import annotations @@ -23,14 +23,15 @@ def test_worktree_version_matches_normalized_release_manifest() -> None: assert project_section.group(1) == pep440_prerelease(manifest["."]) -def test_release_please_uses_stable_semver_versioning() -> None: - """SDK 8 is stable: no prerelease channel may be configured. +def test_release_please_keeps_sdk_9_on_the_beta_channel() -> None: + """The breaking SDK 9 surface must remain beta until the reviewed GA exit. - The ``Release-As: 8.0.0`` footer on the adopting commit selects the first - stable version. Without these keys, later ``fix:``/``feat:`` commits bump - normal SemVer (8.0.1, 8.1.0) instead of starting another rc series. + The adopting commit's ``Release-As: 9.0.0-beta.1`` footer selects the + first beta. Subsequent fixes increment the beta, and GitHub releases + are marked as prereleases rather than presented as stable releases. """ config = json.loads((ROOT / "release-please-config.json").read_text()) package = config["packages"]["."] - for key in ("versioning", "prerelease-type", "prerelease"): - assert key not in package, key + assert package["versioning"] == "prerelease" + assert package["prerelease-type"] == "beta" + assert package["prerelease"] is True diff --git a/tests/test_task_options.py b/tests/test_task_options.py index d14a8e40c..8c7198ddb 100644 --- a/tests/test_task_options.py +++ b/tests/test_task_options.py @@ -13,6 +13,7 @@ from pydantic import BaseModel, TypeAdapter from adcp import ADCPClient, TaskOptions, TaskRecoveryMetadata, _idempotency +from adcp._null_clear import nullable_request_paths from adcp.exceptions import ADCPTimeoutError from adcp.protocols.base import ProtocolAdapter from adcp.protocols.mcp import MCPAdapter @@ -179,6 +180,11 @@ async def dispatched(_params: dict[str, Any]) -> TaskResult[Any]: async def test_new_mutations_timeout_with_recovery_metadata(task_name: str) -> None: client = _client() key = f"0123456789abcdef-{task_name}" + dispatched_event = asyncio.Event() + # This case exercises a timeout AFTER dispatch. Cold nullable-schema loading + # can itself exhaust the budget before dispatch, correctly leaving recovery + # unset; the pre-dispatch deadline case below covers that separate contract. + nullable_request_paths(task_name, client.get_adcp_version()) async def dispatched(params: dict[str, Any]) -> TaskResult[Any]: mark_task_dispatched( @@ -187,16 +193,18 @@ async def dispatched(params: dict[str, Any]) -> TaskResult[Any]: mutating=_idempotency.is_mutating(task_name), idempotency_key=params["idempotency_key"], ) - await asyncio.sleep(1) + dispatched_event.set() + await asyncio.Event().wait() return _completed() with patch.object(client.adapter, task_name, new=dispatched): with pytest.raises(ADCPTimeoutError) as caught: await getattr(client, task_name)( _Request(idempotency_key=key), - options=TaskOptions(timeout=0.01), + options=TaskOptions(timeout=0.1), ) + assert dispatched_event.is_set() assert caught.value.recovery is not None assert caught.value.recovery.task_name == task_name assert caught.value.recovery.idempotency_key == key From a7bb7c02591f583cd256360f795e73e0f9c94280 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley <134922+bokelley@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:11:50 +0000 Subject: [PATCH 2/3] fix(ci): validate prerelease release metadata Recognize normalized alpha, beta, and rc versions in version-only bot release PRs. Require increasing version order and matching SemVer manifest values while preserving identity, diff, build input, and artifact guards. Main pushes and publication continue to require the complete CI matrix. Release-As: 9.0.0-beta.1 --- docs/releasing.md | 3 +++ scripts/ci/ci_scope.py | 36 ++++++++++++++++++++++---- tests/test_release_ci.py | 55 ++++++++++++++++++++++++++++++++++------ 3 files changed, 81 insertions(+), 13 deletions(-) diff --git a/docs/releasing.md b/docs/releasing.md index e370f5288..6bb2f7f78 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -6,6 +6,9 @@ normalizes the proposed `pyproject.toml` prerelease version to PEP 440 (a no-op for stable versions). Review and merge that PR once its required checks and reviews pass. +Version-only bot release PRs, including betas, run interpreter compatibility +checks and verify the metadata in their built wheel and sdist. + SDK 9 is on the beta channel. The root package in `release-please-config.json` uses `versioning: prerelease`, `prerelease-type: beta`, and `prerelease: true`, so subsequent releases diff --git a/scripts/ci/ci_scope.py b/scripts/ci/ci_scope.py index 329677fd0..44180c324 100644 --- a/scripts/ci/ci_scope.py +++ b/scripts/ci/ci_scope.py @@ -12,7 +12,10 @@ DOCUMENTATION_FILES = {"AGENTS.md", "CHANGELOG.md", "CLAUDE.md", "CONTRIBUTING.md", "LADON.md"} SHA = re.compile(r"[0-9a-f]{40}") STABLE_VERSION = r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)" -PROJECT_VERSION = re.compile(r'(version\s*=\s*")(' + STABLE_VERSION + r')("\s*)') +PRERELEASE = re.compile(r"(" + STABLE_VERSION + r")(a|b|rc)(0|[1-9][0-9]*)") +PROJECT_VERSION = re.compile( + r'(version\s*=\s*")(' + STABLE_VERSION + r'(?:(?:a|b|rc)(?:0|[1-9][0-9]*))?)("\s*)' +) RELEASE_FILES = {"pyproject.toml", ".release-please-manifest.json", "CHANGELOG.md"} RELEASE_BRANCHES = { "release-please--branches--main", @@ -132,7 +135,7 @@ def scope_for_paths(paths: list[str]) -> Scope: def project_version(text: str) -> tuple[str, str] | None: - """Remove one stable [project] version while preserving every other byte.""" + """Remove one normalized [project] version while preserving every other byte.""" try: import tomllib except ModuleNotFoundError: @@ -161,6 +164,27 @@ def project_version(text: str) -> tuple[str, str] | None: return found[0], "".join(normalized) +def release_version(version: str) -> tuple[tuple[int, ...], str]: + """Compare supported PEP 440 versions and recover the SemVer manifest value. + + This script runs alone in CI's sparse checkout, before installing any + dependencies. Accept exactly the stable and alpha/beta/rc spellings emitted + by the release normalizer; every other version requires full CI. + """ + match = PRERELEASE.fullmatch(version) + if match: + release, label, number = match.groups() + labels = {"a": (0, "alpha"), "b": (1, "beta"), "rc": (2, "rc")} + rank, semver_label = labels[label] + return ( + (*map(int, release.split(".")), rank, int(number)), + f"{release}-{semver_label}.{number}", + ) + if not re.fullmatch(STABLE_VERSION, version): + raise ValueError("unsupported release version") + return (*map(int, version.split(".")), 3, 0), version + + def is_release_metadata(event: dict, paths: list[str], *, cwd: Path | None) -> bool: """Require a same-repository bot PR whose complete diff is version-only.""" pr = event["pull_request"] @@ -190,11 +214,13 @@ def git(*args: str) -> str: after = project_version(git("show", f"{head}:pyproject.toml")) if before is None or after is None or before[1] != after[1]: return False - if tuple(map(int, after[0].split("."))) <= tuple(map(int, before[0].split("."))): + before_order, before_manifest = release_version(before[0]) + after_order, after_manifest = release_version(after[0]) + if after_order <= before_order: return False return json.loads(git("show", f"{base}:.release-please-manifest.json")) == { - ".": before[0] - } and json.loads(git("show", f"{head}:.release-please-manifest.json")) == {".": after[0]} + ".": before_manifest + } and json.loads(git("show", f"{head}:.release-please-manifest.json")) == {".": after_manifest} def select_scope(event_name: str, event: dict, *, cwd: Path | None = None) -> Scope: diff --git a/tests/test_release_ci.py b/tests/test_release_ci.py index fd669f521..c03bc7965 100644 --- a/tests/test_release_ci.py +++ b/tests/test_release_ci.py @@ -17,8 +17,12 @@ from tests.test_ci_scope import commit -@pytest.fixture -def release_repository(tmp_path: Path) -> tuple[Path, str]: +@pytest.fixture(params=[("9.0.0", "9.0.0"), ("9.0.0b1", "9.0.0-beta.1")]) +def release_repository(tmp_path: Path, request: pytest.FixtureRequest) -> tuple[Path, str]: + return make_release_repository(tmp_path, request.param) + + +def make_release_repository(tmp_path: Path, release: tuple[str, str]) -> tuple[Path, str]: root = tmp_path for args in ( ("init", "-q"), @@ -35,9 +39,10 @@ def release_repository(tmp_path: Path) -> tuple[Path, str]: (root / ".release-please-manifest.json").write_text('{".": "8.0.0"}\n') (root / "CHANGELOG.md").write_text("# Changelog\n") base = commit((root, ""))["pull_request"]["head"]["sha"] - (root / "pyproject.toml").write_text('[project]\nname = "adcp"\nversion = "9.0.0"\n') - (root / ".release-please-manifest.json").write_text('{".": "9.0.0"}\n') - (root / "CHANGELOG.md").write_text("# Changelog\n\n## 9.0.0\n") + version, manifest_version = release + (root / "pyproject.toml").write_text(f'[project]\nname = "adcp"\nversion = "{version}"\n') + (root / ".release-please-manifest.json").write_text(json.dumps({".": manifest_version})) + (root / "CHANGELOG.md").write_text(f"# Changelog\n\n## {manifest_version}\n") return root, base @@ -61,6 +66,39 @@ def test_only_release_metadata_gets_targeted_checks(release_repository: tuple[Pa assert ci_scope.select_scope(name, event, cwd=root) == ci_scope.Scope.full() +@pytest.mark.parametrize( + "before,after,expected", + [ + (("9.0.0b1", "9.0.0-beta.1"), ("9.0.0b2", "9.0.0-beta.2"), True), + (("9.0.0b2", "9.0.0-beta.2"), ("9.0.0", "9.0.0"), True), + (("9.0.0a0", "9.0.0-alpha.0"), ("9.0.0b0", "9.0.0-beta.0"), True), + (("9.0.0b10", "9.0.0-beta.10"), ("9.0.0rc1", "9.0.0-rc.1"), True), + (("9.0.0rc1", "9.0.0-rc.1"), ("9.0.0", "9.0.0"), True), + (("9.0.0b2", "9.0.0-beta.2"), ("9.0.0b1", "9.0.0-beta.1"), False), + (("9.0.0", "9.0.0"), ("9.0.0b3", "9.0.0-beta.3"), False), + (("8.0.0", "8.0.0"), ("9.0.0b1", "9.0.0b1"), False), + (("8.0.0", "8.0.0"), ("9.0.0b1", "9.0.0-beta.2"), False), + (("8.0.0", "8.0.0"), ("9.0.0-beta.1", "9.0.0-beta.1"), False), + (("8.0.0", "8.0.0"), ("9.0.0b01", "9.0.0-beta.1"), False), + (("8.0.0", "8.0.0"), ("9.0.0.post1", "9.0.0.post1"), False), + ], +) +def test_release_transition_requires_increasing_normalized_versions( + tmp_path: Path, before: tuple[str, str], after: tuple[str, str], expected: bool +) -> None: + root, _ = make_release_repository(tmp_path, before) + base = commit((root, ""))["pull_request"]["head"]["sha"] + version, manifest_version = after + (root / "pyproject.toml").write_text(f'[project]\nname = "adcp"\nversion = "{version}"\n') + (root / ".release-please-manifest.json").write_text(json.dumps({".": manifest_version})) + (root / "CHANGELOG.md").write_text(f"# Changelog\n\n## {manifest_version}\n\nChange\n") + event = release_event((root, base)) + scope = ci_scope.select_scope("pull_request", event, cwd=root) + assert scope == ( + ci_scope.Scope(run_tests=True, release_metadata=True) if expected else ci_scope.Scope.full() + ) + + @pytest.mark.parametrize( "case", [ @@ -173,14 +211,15 @@ def test_metadata_scope_reads_blobs_in_sparse_checkout( assert ci_scope.select_scope("pull_request", event, cwd=clone).release_metadata -def test_version_line_inside_toml_string_is_not_project_metadata() -> None: - text = """[project] +@pytest.mark.parametrize("fake_version", ["9.0.0", "9.0.0b1"]) +def test_version_line_inside_toml_string_is_not_project_metadata(fake_version: str) -> None: + text = f"""[project] name = "adcp" version = '8.0.0' [tool.notes] text = ''' [project] -version = "9.0.0" +version = "{fake_version}" ''' """ assert ci_scope.project_version(text) is None From 950f848ae4851b25b84cb4a10c4306fa8b51d808 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley <134922+bokelley@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:46:17 +0000 Subject: [PATCH 3/3] fix(ci): normalize prerelease artifact expectations Verify Python distribution and installed SDK metadata against the PEP 440 version derived from the SemVer release manifest. Exercise stable and beta versions, and verify a real beta wheel and source distribution locally. Release-As: 9.0.0-beta.1 --- scripts/ci/check_release_artifacts.py | 4 +++- tests/test_release_ci.py | 22 +++++++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/scripts/ci/check_release_artifacts.py b/scripts/ci/check_release_artifacts.py index eb7609eea..e9f99d77e 100644 --- a/scripts/ci/check_release_artifacts.py +++ b/scripts/ci/check_release_artifacts.py @@ -13,6 +13,8 @@ from email.parser import BytesParser from pathlib import Path +from scripts.normalize_pyproject_prerelease import pep440_prerelease + def check_metadata(data: bytes, version: str) -> None: metadata = BytesParser(policy=policy.default).parsebytes(data) @@ -47,7 +49,7 @@ def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("directory", type=Path) directory = parser.parse_args().directory - version = json.loads(Path(".release-please-manifest.json").read_text())["."] + version = pep440_prerelease(json.loads(Path(".release-please-manifest.json").read_text())["."]) check_artifacts(directory, version) wheel = next(directory.glob("*.whl")).resolve() with tempfile.TemporaryDirectory(prefix="adcp-release-install-") as temporary: diff --git a/tests/test_release_ci.py b/tests/test_release_ci.py index c03bc7965..73e437d31 100644 --- a/tests/test_release_ci.py +++ b/tests/test_release_ci.py @@ -5,6 +5,7 @@ import io import json import subprocess +import sys import tarfile import zipfile from pathlib import Path @@ -12,7 +13,7 @@ import pytest import yaml -from scripts.ci import ci_scope +from scripts.ci import check_release_artifacts, ci_scope from scripts.ci.check_release_artifacts import check_artifacts from tests.test_ci_scope import commit @@ -278,6 +279,25 @@ def artifacts(directory: Path, version: str, *, name: str = "adcp") -> None: archive.addfile(member, io.BytesIO(data)) +@pytest.mark.parametrize("manifest,version", [("9.0.0", "9.0.0"), ("9.0.0-beta.1", "9.0.0b1")]) +def test_artifact_cli_uses_the_python_package_version( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + manifest: str, + version: str, +) -> None: + artifacts(tmp_path, version) + (tmp_path / ".release-please-manifest.json").write_text(json.dumps({".": manifest})) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(sys, "argv", ["check_release_artifacts", str(tmp_path)]) + # Metadata validation uses real wheel/sdist records. The external uv and + # installed-interpreter commands have separate end-to-end release coverage. + monkeypatch.setattr(subprocess, "run", lambda *args, **kwargs: None) + check_release_artifacts.main() + assert f"all report {version}" in capsys.readouterr().out + + @pytest.mark.parametrize( "case", ["good", "wrong-version", "wrong-package", "missing", "extra-wheel"] )