From 95d04df798b3f8c32de17b7dfeb70a71c081c2f0 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:28:04 +0000 Subject: [PATCH 1/2] Package Server compatibility baselines per major --- CHANGELOG.md | 9 +++ README.md | 10 ++- pyproject.toml | 10 ++- scripts/check_release_metadata.py | 31 +++++++++ scripts/release_compatibility.py | 22 +++++++ .../server_compatibility.json | 7 ++ tests/test_release_metadata.py | 65 +++++++++++++++++++ 7 files changed, 149 insertions(+), 5 deletions(-) create mode 100644 src/durable_workflow/server_compatibility.json diff --git a/CHANGELOG.md b/CHANGELOG.md index ea33aa3e..0047a6de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.12.4] - 2026-11-02 + +### Added + +- Declare minimum stable Server releases per major, including native Server 3.x, + while preserving the legacy 2.x declaration. Include the compatibility record + in installed wheels and source distributions and verify it against release + source. Worker protocol and capability negotiation remain unchanged. + ## [2.12.3] - 2026-11-02 ### Fixed diff --git a/README.md b/README.md index c6d8aee1..3812aff4 100644 --- a/README.md +++ b/README.md @@ -113,8 +113,14 @@ portable between both runtime choices. ## Compatibility Stable `2.x` SDK releases follow semantic versioning and negotiate runtime -capabilities with Server at startup. Use stable `2.x` SDK and Server channels -for new applications. The [compatibility guide](https://durable-workflow.com/docs/2.0/compatibility/) +capabilities with Server at startup. The installed package includes +`durable_workflow/server_compatibility.json`, matching the release declarations in +`pyproject.toml`. Its `supported-server-release-baselines` maps each supported +Server major to its minimum stable release. Readers validate every entry before +selection and refuse missing majors, malformed entries, or a contradiction with +the unchanged legacy `supported-server-versions` 2.x baseline. An absent map +retains legacy reader behavior. This declaration does not replace protocol or +capability negotiation. The [compatibility guide](https://durable-workflow.com/docs/2.0/compatibility/) documents protocol and upgrade guarantees. ## Worker protocol and cooperative cancellation diff --git a/pyproject.toml b/pyproject.toml index 21776d72..b1bc8a38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "durable-workflow" -version = "2.12.3" +version = "2.12.4" description = "Python client and worker SDK for Durable Workflow Cloud and self-hosted Server" readme = "README.md" requires-python = ">=3.10" @@ -79,9 +79,10 @@ durable-workflow-replay-conformance = "durable_workflow.replay_conformance:main" durable-workflow-workflow-updates-conformance = "durable_workflow.workflow_updates_conformance:main" [tool.durable-workflow] -product-train = "2.12.3" -registry-version = "2.12.3" +product-train = "2.12.4" +registry-version = "2.12.4" supported-server-versions = "2.8.0" +supported-server-release-baselines = { "2" = "2.8.0", "3" = "3.0.0" } worker-protocol-version = "1.20" control-plane-version = "2" durable-selection = true @@ -106,6 +107,9 @@ saga-cancellation = "cooperative" [tool.setuptools.packages.find] where = ["src"] +[tool.setuptools.package-data] +durable_workflow = ["server_compatibility.json"] + [tool.pytest.ini_options] testpaths = ["tests"] asyncio_mode = "auto" diff --git a/scripts/check_release_metadata.py b/scripts/check_release_metadata.py index d6b2d275..a68647d1 100644 --- a/scripts/check_release_metadata.py +++ b/scripts/check_release_metadata.py @@ -26,6 +26,7 @@ from scripts.release_compatibility import ( CompatibilityContractError, declared_runtime_protocol_version, + validate_server_release_baselines, ) except ModuleNotFoundError as error: # pragma: no cover - direct command-line execution if error.name != "scripts": @@ -34,6 +35,7 @@ from scripts.release_compatibility import ( CompatibilityContractError, declared_runtime_protocol_version, + validate_server_release_baselines, ) @@ -70,6 +72,7 @@ class SourceMetadata: summary: str classifiers: tuple[str, ...] readme: str + server_compatibility: bytes | None = None @dataclass(frozen=True) @@ -125,6 +128,19 @@ def load_source_metadata(source_ref: str) -> SourceMetadata: raise ReleaseMetadataError("registry-version is not the PEP 440 form of project.version") if not isinstance(server_version, str) or VERSION_PATTERN.fullmatch(server_version) is None: raise ReleaseMetadataError("supported-server-versions must identify one exact qualified Server release") + server_compatibility = None + if "supported-server-release-baselines" in tool: + try: + baselines = validate_server_release_baselines(server_version, tool["supported-server-release-baselines"]) + except CompatibilityContractError as error: + raise ReleaseMetadataError(str(error)) from error + server_compatibility = _git("show", f"{commit}:src/durable_workflow/server_compatibility.json") + expected_compatibility = { + "supported-server-versions": server_version, + "supported-server-release-baselines": baselines, + } + if server_compatibility != (json.dumps(expected_compatibility, indent=2) + "\n").encode(): + raise ReleaseMetadataError("packaged Server compatibility record differs from pyproject.toml") if not isinstance(worker_protocol_version, str): raise ReleaseMetadataError("worker-protocol-version must be a string") try: @@ -155,6 +171,7 @@ def load_source_metadata(source_ref: str) -> SourceMetadata: summary=summary, classifiers=tuple(classifiers), readme=readme, + server_compatibility=server_compatibility, ) @@ -203,6 +220,10 @@ def verify_built_distributions(dist: Path, source: SourceMetadata) -> None: raise ReleaseMetadataError("dist must contain exactly one wheel and one .tar.gz source distribution") with zipfile.ZipFile(wheels[0]) as archive: + if source.server_compatibility is not None: + path = "durable_workflow/server_compatibility.json" + if archive.namelist().count(path) != 1 or archive.read(path) != source.server_compatibility: + raise ReleaseMetadataError("wheel Server compatibility record differs from exact source") metadata_names = [name for name in archive.namelist() if name.endswith(".dist-info/METADATA")] if len(metadata_names) != 1: raise ReleaseMetadataError("wheel must contain exactly one .dist-info/METADATA") @@ -218,6 +239,16 @@ def verify_built_distributions(dist: Path, source: SourceMetadata) -> None: ) with tarfile.open(sdists[0], mode="r:gz") as archive: + if source.server_compatibility is not None: + compatibility_members = [ + member for member in archive.getmembers() + if member.name.split("/", 1)[-1] == "src/durable_workflow/server_compatibility.json" + ] + if len(compatibility_members) != 1 or not compatibility_members[0].isfile(): + raise ReleaseMetadataError("sdist must contain one Server compatibility record") + compatibility_file = archive.extractfile(compatibility_members[0]) + if compatibility_file is None or compatibility_file.read() != source.server_compatibility: + raise ReleaseMetadataError("sdist Server compatibility record differs from exact source") pkg_info_members = [ member for member in archive.getmembers() diff --git a/scripts/release_compatibility.py b/scripts/release_compatibility.py index a30105e1..5b0f2b29 100644 --- a/scripts/release_compatibility.py +++ b/scripts/release_compatibility.py @@ -6,12 +6,34 @@ import re PROTOCOL_VERSION_PATTERN = re.compile(r"(?P0|[1-9][0-9]*)\.(?P0|[1-9][0-9]*)") +STABLE_SERVER_VERSION_PATTERN = re.compile(r"([1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)") class CompatibilityContractError(ValueError): """Shipped runtime compatibility metadata is invalid.""" +def validate_server_release_baselines(legacy: str, baselines: object) -> dict[str, str]: + """Validate the complete additive map before any consumer selects a major.""" + + legacy_match = STABLE_SERVER_VERSION_PATTERN.fullmatch(legacy) + if legacy_match is None: + raise CompatibilityContractError("legacy Server baseline must be one canonical stable release") + if not isinstance(baselines, dict) or not baselines: + raise CompatibilityContractError("Server release baselines must be a non-empty major-to-release map") + validated: dict[str, str] = {} + for major, minimum in baselines.items(): + if not isinstance(major, str) or re.fullmatch(r"[1-9][0-9]*", major) is None: + raise CompatibilityContractError("Server baseline major keys must be canonical positive integers") + match = STABLE_SERVER_VERSION_PATTERN.fullmatch(minimum) if isinstance(minimum, str) else None + if match is None or match.group(1) != major: + raise CompatibilityContractError("each Server baseline must be a stable release of its keyed major") + validated[major] = minimum + if validated.get(legacy_match.group(1)) != legacy: + raise CompatibilityContractError("Server release baselines contradict or omit the legacy baseline") + return validated + + def declared_runtime_protocol_version(source: str) -> str: """Read the literal protocol authority shipped in ``client.py``.""" diff --git a/src/durable_workflow/server_compatibility.json b/src/durable_workflow/server_compatibility.json new file mode 100644 index 00000000..2c879920 --- /dev/null +++ b/src/durable_workflow/server_compatibility.json @@ -0,0 +1,7 @@ +{ + "supported-server-versions": "2.8.0", + "supported-server-release-baselines": { + "2": "2.8.0", + "3": "3.0.0" + } +} diff --git a/tests/test_release_metadata.py b/tests/test_release_metadata.py index 252c319d..bf556339 100644 --- a/tests/test_release_metadata.py +++ b/tests/test_release_metadata.py @@ -1,7 +1,11 @@ from __future__ import annotations +import io import json import sys +import tarfile +import zipfile +from dataclasses import replace from pathlib import Path from types import ModuleType @@ -9,7 +13,9 @@ from scripts import check_release_metadata from scripts.check_release_metadata import ReleaseMetadataError, SourceMetadata from scripts.release_compatibility import ( + CompatibilityContractError, declared_runtime_protocol_version, + validate_server_release_baselines, ) from durable_workflow.client import PROTOCOL_VERSION @@ -48,12 +54,71 @@ def test_worker_release_identity_matches_supported_server_and_protocol() -> None assert release["worker-protocol-version"] == PROTOCOL_VERSION == "1.20" assert release["durable-selection"] is True assert release["durable-selection-minimum-worker-protocol-version"] == "1.19" + baselines = validate_server_release_baselines( + release["supported-server-versions"], release["supported-server-release-baselines"] + ) + assert baselines == {"2": release["supported-server-versions"], "3": "3.0.0"} + record = (REPO_ROOT / "src/durable_workflow/server_compatibility.json").read_bytes() + assert record == (json.dumps({ + "supported-server-versions": release["supported-server-versions"], + "supported-server-release-baselines": baselines, + }, indent=2) + "\n").encode() assert declared_runtime_protocol_version( (REPO_ROOT / "src" / "durable_workflow" / "client.py").read_text(encoding="utf-8") ) == "1.20" +@pytest.mark.parametrize("baselines", [ + None, {}, [], "3.0.0", {2: "2.8.0"}, {"02": "2.8.0"}, + {"0": "0.1.0"}, {"2": "2.8.0", "3": 3}, + {"2": "2.8.0", "3": "3.0.0-rc.1"}, + {"2": "2.8.0", "3": "3.00.0"}, + {"2": "2.8.0", "4": "3.0.0"}, + {"2": "2.9.0", "3": "3.0.0"}, {"3": "3.0.0"}, +]) +def test_server_baselines_refuse_malformed_or_contradictory_maps(baselines: object) -> None: + with pytest.raises(CompatibilityContractError): + validate_server_release_baselines("2.8.0", baselines) + + +def test_server_baselines_do_not_imply_unlisted_major_support() -> None: + baselines = validate_server_release_baselines("2.8.0", {"2": "2.8.0", "3": "3.0.0"}) + assert "4" not in baselines + + +@pytest.mark.parametrize("corrupt", [None, "wheel", "sdist"]) +def test_built_packages_must_retain_exact_server_compatibility(tmp_path: Path, corrupt: str | None) -> None: + source = replace(source_metadata(), server_compatibility=b'{"supported-server-versions":"2.0.0"}\n') + headers = ( + f"Name: {source.name}\nVersion: {source.registry_version}\nSummary: {source.summary}\n" + "Description-Content-Type: text/markdown\n" + + "".join(f"Classifier: {value}\n" for value in source.classifiers) + + "\n" + source.readme + ).encode() + with zipfile.ZipFile(tmp_path / "sdk.whl", "w") as archive: + archive.writestr("sdk.dist-info/METADATA", headers) + archive.writestr("durable_workflow/server_compatibility.json", + b"{}\n" if corrupt == "wheel" else source.server_compatibility) + with tarfile.open(tmp_path / "sdk.tar.gz", "w:gz") as archive: + for path, content in { + "sdk/PKG-INFO": headers, + "sdk/README.md": source.readme.encode(), + "sdk/src/durable_workflow/server_compatibility.json": ( + b"{}\n" if corrupt == "sdist" else source.server_compatibility + ), + }.items(): + assert content is not None + member = tarfile.TarInfo(path) + member.size = len(content) + archive.addfile(member, io.BytesIO(content)) + if corrupt is None: + check_release_metadata.verify_built_distributions(tmp_path, source) + else: + with pytest.raises(ReleaseMetadataError, match=f"{corrupt} Server compatibility record"): + check_release_metadata.verify_built_distributions(tmp_path, source) + + def pypi_json(source: SourceMetadata, **overrides: object) -> bytes: info: dict[str, object] = { "name": source.name, From c4eb94a2508b5fdb115b3ea5ef7cdb113a2abca0 Mon Sep 17 00:00:00 2001 From: Durable Workflow Swarm Date: Mon, 2 Nov 2026 12:34:29 -0600 Subject: [PATCH 2/2] Refuse unverified Server compatibility records in release artifacts The release verifier compared the packaged Server compatibility record only when pyproject.toml declared supported-server-release-baselines. Dropping the map while leaving src/durable_workflow/server_compatibility.json would ship a stale record that nothing checked, and artifacts from legacy source were not refused when they carried one. - Bind the record's presence at the exact source commit to the map: a record without the map, or the map without a record, is refused. - Refuse a wheel or sdist that carries a record its legacy source lacks. - Add loader controls for legacy, exact, dropped, missing, contradictory, malformed and legacy-omitting declarations, plus missing-record wheel and sdist controls. - Make the installed-package smoke read the record through importlib.resources and require it to match the source checkout. Co-Authored-By: Claude Opus 5.5 --- scripts/check_release_metadata.py | 43 ++++++--- scripts/smoke-built-package.py | 14 +++ tests/test_release_metadata.py | 152 ++++++++++++++++++++++++++---- 3 files changed, 181 insertions(+), 28 deletions(-) diff --git a/scripts/check_release_metadata.py b/scripts/check_release_metadata.py index a68647d1..bb3275d6 100644 --- a/scripts/check_release_metadata.py +++ b/scripts/check_release_metadata.py @@ -55,6 +55,8 @@ def _load_toml_parser() -> Any: COMMIT_PATTERN = re.compile(r"[0-9a-f]{40}") VERSION_PATTERN = re.compile(r"2\.(?:0|[1-9][0-9]*)\.(?:0-rc\.[1-9][0-9]*|0|[1-9][0-9]*)") BETA_CLASSIFIER = "Development Status :: 4 - Beta" +SERVER_COMPATIBILITY_PACKAGE_PATH = "durable_workflow/server_compatibility.json" +SERVER_COMPATIBILITY_SOURCE_PATH = f"src/{SERVER_COMPATIBILITY_PACKAGE_PATH}" class ReleaseMetadataError(RuntimeError): @@ -129,12 +131,23 @@ def load_source_metadata(source_ref: str) -> SourceMetadata: if not isinstance(server_version, str) or VERSION_PATTERN.fullmatch(server_version) is None: raise ReleaseMetadataError("supported-server-versions must identify one exact qualified Server release") server_compatibility = None - if "supported-server-release-baselines" in tool: + record_listed = _git("ls-tree", "--name-only", commit, "--", SERVER_COMPATIBILITY_SOURCE_PATH).strip() + has_record = record_listed == SERVER_COMPATIBILITY_SOURCE_PATH.encode() + if "supported-server-release-baselines" not in tool: + if has_record: + raise ReleaseMetadataError( + "Server compatibility record is present without supported-server-release-baselines in pyproject.toml" + ) + else: try: baselines = validate_server_release_baselines(server_version, tool["supported-server-release-baselines"]) except CompatibilityContractError as error: raise ReleaseMetadataError(str(error)) from error - server_compatibility = _git("show", f"{commit}:src/durable_workflow/server_compatibility.json") + if not has_record: + raise ReleaseMetadataError( + "supported-server-release-baselines requires the packaged Server compatibility record" + ) + server_compatibility = _git("show", f"{commit}:{SERVER_COMPATIBILITY_SOURCE_PATH}") expected_compatibility = { "supported-server-versions": server_version, "supported-server-release-baselines": baselines, @@ -220,10 +233,15 @@ def verify_built_distributions(dist: Path, source: SourceMetadata) -> None: raise ReleaseMetadataError("dist must contain exactly one wheel and one .tar.gz source distribution") with zipfile.ZipFile(wheels[0]) as archive: - if source.server_compatibility is not None: - path = "durable_workflow/server_compatibility.json" - if archive.namelist().count(path) != 1 or archive.read(path) != source.server_compatibility: - raise ReleaseMetadataError("wheel Server compatibility record differs from exact source") + wheel_records = archive.namelist().count(SERVER_COMPATIBILITY_PACKAGE_PATH) + if source.server_compatibility is None: + if wheel_records: + raise ReleaseMetadataError("wheel Server compatibility record is absent from exact source") + elif ( + wheel_records != 1 + or archive.read(SERVER_COMPATIBILITY_PACKAGE_PATH) != source.server_compatibility + ): + raise ReleaseMetadataError("wheel Server compatibility record differs from exact source") metadata_names = [name for name in archive.namelist() if name.endswith(".dist-info/METADATA")] if len(metadata_names) != 1: raise ReleaseMetadataError("wheel must contain exactly one .dist-info/METADATA") @@ -239,11 +257,14 @@ def verify_built_distributions(dist: Path, source: SourceMetadata) -> None: ) with tarfile.open(sdists[0], mode="r:gz") as archive: - if source.server_compatibility is not None: - compatibility_members = [ - member for member in archive.getmembers() - if member.name.split("/", 1)[-1] == "src/durable_workflow/server_compatibility.json" - ] + compatibility_members = [ + member for member in archive.getmembers() + if member.name.split("/", 1)[-1] == SERVER_COMPATIBILITY_SOURCE_PATH + ] + if source.server_compatibility is None: + if compatibility_members: + raise ReleaseMetadataError("sdist Server compatibility record is absent from exact source") + else: if len(compatibility_members) != 1 or not compatibility_members[0].isfile(): raise ReleaseMetadataError("sdist must contain one Server compatibility record") compatibility_file = archive.extractfile(compatibility_members[0]) diff --git a/scripts/smoke-built-package.py b/scripts/smoke-built-package.py index 450670b9..0c739aaa 100644 --- a/scripts/smoke-built-package.py +++ b/scripts/smoke-built-package.py @@ -39,6 +39,7 @@ def smoke_distribution(artifact: Path, *, repo_root: Path, readme: Path) -> None smoke_code = r''' import importlib import importlib.metadata +import importlib.resources import json import os import re @@ -78,6 +79,19 @@ def completed(result): if not typed_marker.is_file(): raise AssertionError(f"installed package is missing PEP 561 marker: {typed_marker}") +compatibility_name = "server_compatibility.json" +source_compatibility = repo_root / "src" / "durable_workflow" / compatibility_name +installed_compatibility = importlib.resources.files("durable_workflow").joinpath(compatibility_name) +if source_compatibility.is_file() != installed_compatibility.is_file(): + raise AssertionError("installed Server compatibility record presence differs from the source checkout") +if source_compatibility.is_file(): + if installed_compatibility.read_bytes() != source_compatibility.read_bytes(): + raise AssertionError("installed Server compatibility record differs from the source checkout") + record = json.loads(installed_compatibility.read_bytes()) + baselines = record["supported-server-release-baselines"] + if baselines.get(record["supported-server-versions"].split(".", 1)[0]) != record["supported-server-versions"]: + raise AssertionError("installed Server compatibility record contradicts its legacy baseline") + missing_exports = [name for name in durable_workflow.__all__ if not hasattr(durable_workflow, name)] if missing_exports: raise AssertionError(f"durable_workflow.__all__ names missing exports: {missing_exports}") diff --git a/tests/test_release_metadata.py b/tests/test_release_metadata.py index bf556339..1537493a 100644 --- a/tests/test_release_metadata.py +++ b/tests/test_release_metadata.py @@ -87,38 +87,154 @@ def test_server_baselines_do_not_imply_unlisted_major_support() -> None: assert "4" not in baselines -@pytest.mark.parametrize("corrupt", [None, "wheel", "sdist"]) -def test_built_packages_must_retain_exact_server_compatibility(tmp_path: Path, corrupt: str | None) -> None: - source = replace(source_metadata(), server_compatibility=b'{"supported-server-versions":"2.0.0"}\n') +COMPATIBILITY_RECORD = b'{"supported-server-versions":"2.0.0"}\n' + + +def write_distributions( + dist: Path, + source: SourceMetadata, + *, + wheel_record: bytes | None, + sdist_record: bytes | None, +) -> None: headers = ( f"Name: {source.name}\nVersion: {source.registry_version}\nSummary: {source.summary}\n" "Description-Content-Type: text/markdown\n" + "".join(f"Classifier: {value}\n" for value in source.classifiers) + "\n" + source.readme ).encode() - with zipfile.ZipFile(tmp_path / "sdk.whl", "w") as archive: + with zipfile.ZipFile(dist / "sdk.whl", "w") as archive: archive.writestr("sdk.dist-info/METADATA", headers) - archive.writestr("durable_workflow/server_compatibility.json", - b"{}\n" if corrupt == "wheel" else source.server_compatibility) - with tarfile.open(tmp_path / "sdk.tar.gz", "w:gz") as archive: - for path, content in { - "sdk/PKG-INFO": headers, - "sdk/README.md": source.readme.encode(), - "sdk/src/durable_workflow/server_compatibility.json": ( - b"{}\n" if corrupt == "sdist" else source.server_compatibility - ), - }.items(): - assert content is not None + if wheel_record is not None: + archive.writestr("durable_workflow/server_compatibility.json", wheel_record) + members = {"sdk/PKG-INFO": headers, "sdk/README.md": source.readme.encode()} + if sdist_record is not None: + members["sdk/src/durable_workflow/server_compatibility.json"] = sdist_record + with tarfile.open(dist / "sdk.tar.gz", "w:gz") as archive: + for path, content in members.items(): member = tarfile.TarInfo(path) member.size = len(content) archive.addfile(member, io.BytesIO(content)) - if corrupt is None: + + +@pytest.mark.parametrize(("wheel_record", "sdist_record", "refusal"), [ + (COMPATIBILITY_RECORD, COMPATIBILITY_RECORD, None), + (b"{}\n", COMPATIBILITY_RECORD, "wheel Server compatibility record differs"), + (COMPATIBILITY_RECORD, b"{}\n", "sdist Server compatibility record differs"), + (None, COMPATIBILITY_RECORD, "wheel Server compatibility record differs"), + (COMPATIBILITY_RECORD, None, "sdist must contain one Server compatibility record"), +]) +def test_built_packages_must_retain_exact_server_compatibility( + tmp_path: Path, + wheel_record: bytes | None, + sdist_record: bytes | None, + refusal: str | None, +) -> None: + source = replace(source_metadata(), server_compatibility=COMPATIBILITY_RECORD) + write_distributions(tmp_path, source, wheel_record=wheel_record, sdist_record=sdist_record) + if refusal is None: check_release_metadata.verify_built_distributions(tmp_path, source) else: - with pytest.raises(ReleaseMetadataError, match=f"{corrupt} Server compatibility record"): + with pytest.raises(ReleaseMetadataError, match=refusal): check_release_metadata.verify_built_distributions(tmp_path, source) +@pytest.mark.parametrize(("wheel_record", "sdist_record", "refusal"), [ + (None, None, None), + (COMPATIBILITY_RECORD, None, "wheel Server compatibility record is absent from exact source"), + (None, COMPATIBILITY_RECORD, "sdist Server compatibility record is absent from exact source"), +]) +def test_legacy_source_packages_must_not_carry_unverified_server_compatibility( + tmp_path: Path, + wheel_record: bytes | None, + sdist_record: bytes | None, + refusal: str | None, +) -> None: + source = source_metadata() + assert source.server_compatibility is None + write_distributions(tmp_path, source, wheel_record=wheel_record, sdist_record=sdist_record) + if refusal is None: + check_release_metadata.verify_built_distributions(tmp_path, source) + else: + with pytest.raises(ReleaseMetadataError, match=refusal): + check_release_metadata.verify_built_distributions(tmp_path, source) + + +EXACT_SOURCE_RECORD = ( + json.dumps({ + "supported-server-versions": "2.8.0", + "supported-server-release-baselines": {"2": "2.8.0", "3": "3.0.0"}, + }, indent=2) + "\n" +).encode() + + +@pytest.mark.parametrize(("baselines", "record", "refusal"), [ + (None, None, None), + ('{ "2" = "2.8.0", "3" = "3.0.0" }', EXACT_SOURCE_RECORD, None), + (None, EXACT_SOURCE_RECORD, "record is present without supported-server-release-baselines"), + ('{ "2" = "2.8.0", "3" = "3.0.0" }', None, "requires the packaged Server compatibility record"), + ('{ "2" = "2.8.0", "3" = "3.0.0" }', EXACT_SOURCE_RECORD.replace(b"3.0.0", b"3.1.0"), + "packaged Server compatibility record differs from pyproject.toml"), + ('{ "2" = "2.8.0", "3" = "3.0.0" }', EXACT_SOURCE_RECORD.rstrip(b"\n"), + "packaged Server compatibility record differs from pyproject.toml"), + ('{ "2" = "2.8.0" }', EXACT_SOURCE_RECORD, "packaged Server compatibility record differs from pyproject.toml"), + ('{ "2" = "2.8.0", "3" = "3.0.0-rc.1" }', EXACT_SOURCE_RECORD, "stable release of its keyed major"), + ('{ "3" = "3.0.0" }', EXACT_SOURCE_RECORD, "contradict or omit the legacy baseline"), + ('{ "2" = "2.9.0", "3" = "3.0.0" }', EXACT_SOURCE_RECORD, "contradict or omit the legacy baseline"), + ("{}", EXACT_SOURCE_RECORD, "non-empty major-to-release map"), + ('"3.0.0"', EXACT_SOURCE_RECORD, "non-empty major-to-release map"), +]) +def test_release_metadata_loader_binds_server_compatibility_to_exact_source( + monkeypatch: pytest.MonkeyPatch, + baselines: str | None, + record: bytes | None, + refusal: str | None, +) -> None: + commit = "c" * 40 + baseline_line = "" if baselines is None else f"supported-server-release-baselines = {baselines}\n" + pyproject = f""" +[project] +name = "durable-workflow" +version = "2.12.4" +description = "Python SDK for Durable Workflow 2.0" +readme = "README.md" +classifiers = ["Programming Language :: Python :: 3"] + +[tool.durable-workflow] +product-train = "2.12.4" +registry-version = "2.12.4" +supported-server-versions = "2.8.0" +{baseline_line}worker-protocol-version = "1.20" +""".encode() + record_path = "src/durable_workflow/server_compatibility.json" + + def git(*arguments: str) -> bytes: + if arguments[0] == "rev-parse": + return f"{commit}\n".encode() + if arguments[0] == "ls-tree": + assert arguments[1:] == ("--name-only", commit, "--", record_path) + return b"" if record is None else f"{record_path}\n".encode() + assert arguments[0] == "show" + if arguments[1] == f"{commit}:pyproject.toml": + return pyproject + if arguments[1] == f"{commit}:README.md": + return b"# Durable Workflow\n\nBuild durable Python workflows.\n" + if arguments[1] == f"{commit}:src/durable_workflow/client.py": + return b'PROTOCOL_VERSION = "1.20"\n' + assert arguments[1] == f"{commit}:{record_path}" + assert record is not None + return record + + monkeypatch.setattr(check_release_metadata, "_git", git) + + if refusal is not None: + with pytest.raises(ReleaseMetadataError, match=refusal): + check_release_metadata.load_source_metadata("HEAD") + return + source = check_release_metadata.load_source_metadata("HEAD") + assert source.server_compatibility == record + + def pypi_json(source: SourceMetadata, **overrides: object) -> bytes: info: dict[str, object] = { "name": source.name, @@ -181,6 +297,8 @@ def test_release_metadata_loader_accepts_the_authorized_stable_identity( def git(*arguments: str) -> bytes: if arguments[0] == "rev-parse": return f"{commit}\n".encode() + if arguments[0] == "ls-tree": + return b"" assert arguments[0] == "show" if arguments[1].endswith(":pyproject.toml"): return pyproject