Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 27 additions & 6 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,37 @@ 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
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
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
Expand Down
3 changes: 3 additions & 0 deletions release-please-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion scripts/ci/check_release_artifacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
36 changes: 31 additions & 5 deletions scripts/ci/ci_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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:
Expand Down
77 changes: 68 additions & 9 deletions tests/test_release_ci.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,25 @@
import io
import json
import subprocess
import sys
import tarfile
import zipfile
from pathlib import Path

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


@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"),
Expand All @@ -35,9 +40,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


Expand All @@ -61,6 +67,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",
[
Expand Down Expand Up @@ -173,14 +212,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
Expand Down Expand Up @@ -239,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"]
)
Expand Down
17 changes: 9 additions & 8 deletions tests/test_release_configuration.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
12 changes: 10 additions & 2 deletions tests/test_task_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down
Loading