Skip to content
Closed
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand Down
52 changes: 52 additions & 0 deletions scripts/check_release_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand All @@ -34,6 +35,7 @@
from scripts.release_compatibility import (
CompatibilityContractError,
declared_runtime_protocol_version,
validate_server_release_baselines,
)


Expand All @@ -53,6 +55,8 @@
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):
Expand All @@ -70,6 +74,7 @@
summary: str
classifiers: tuple[str, ...]
readme: str
server_compatibility: bytes | None = None


@dataclass(frozen=True)
Expand Down Expand Up @@ -125,6 +130,30 @@
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
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
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,
}
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:
Expand Down Expand Up @@ -155,6 +184,7 @@
summary=summary,
classifiers=tuple(classifiers),
readme=readme,
server_compatibility=server_compatibility,
)


Expand Down Expand Up @@ -196,13 +226,22 @@
raise ReleaseMetadataError(f"{label} long description differs from README.md at {source.commit}")


def verify_built_distributions(dist: Path, source: SourceMetadata) -> None:

Check failure on line 229 in scripts/check_release_metadata.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 25 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=durable-workflow_sdk-python&issues=AaGdjfC507fV_W4dS2Rt&open=AaGdjfC507fV_W4dS2Rt&pullRequest=227
wheels = sorted(dist.glob("*.whl"))
sdists = sorted(dist.glob("*.tar.gz"))
if len(wheels) != 1 or len(sdists) != 1:
raise ReleaseMetadataError("dist must contain exactly one wheel and one .tar.gz source distribution")

with zipfile.ZipFile(wheels[0]) as archive:
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")
Expand All @@ -218,6 +257,19 @@
)

with tarfile.open(sdists[0], mode="r:gz") as archive:
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])
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()
Expand Down
22 changes: 22 additions & 0 deletions scripts/release_compatibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,34 @@
import re

PROTOCOL_VERSION_PATTERN = re.compile(r"(?P<major>0|[1-9][0-9]*)\.(?P<minor>0|[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]*)")

Check warning on line 9 in scripts/release_compatibility.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use concise character class syntax '\d' instead of '[0-9]'.

See more on https://sonarcloud.io/project/issues?id=durable-workflow_sdk-python&issues=AaGdjfG207fV_W4dS2Rv&open=AaGdjfG207fV_W4dS2Rv&pullRequest=227

Check warning on line 9 in scripts/release_compatibility.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use concise character class syntax '\d' instead of '[0-9]'.

See more on https://sonarcloud.io/project/issues?id=durable-workflow_sdk-python&issues=AaGdjfG207fV_W4dS2Ru&open=AaGdjfG207fV_W4dS2Ru&pullRequest=227

Check warning on line 9 in scripts/release_compatibility.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use concise character class syntax '\d' instead of '[0-9]'.

See more on https://sonarcloud.io/project/issues?id=durable-workflow_sdk-python&issues=AaGdjfG207fV_W4dS2Rw&open=AaGdjfG207fV_W4dS2Rw&pullRequest=227


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:

Check warning on line 26 in scripts/release_compatibility.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use concise character class syntax '\d' instead of '[0-9]'.

See more on https://sonarcloud.io/project/issues?id=durable-workflow_sdk-python&issues=AaGdjfG207fV_W4dS2Rx&open=AaGdjfG207fV_W4dS2Rx&pullRequest=227
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``."""

Expand Down
14 changes: 14 additions & 0 deletions scripts/smoke-built-package.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}")
Expand Down
7 changes: 7 additions & 0 deletions src/durable_workflow/server_compatibility.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"supported-server-versions": "2.8.0",
"supported-server-release-baselines": {
"2": "2.8.0",
"3": "3.0.0"
}
}
Loading
Loading