From 992dce7a0e6d5162635c983d14bdef5b848b7f0e Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 14:16:47 +0000 Subject: [PATCH 1/5] Prepare Python SDK 2.17.2 receipt documentation patch --- CHANGELOG.md | 8 +++++++- pyproject.toml | 6 +++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fa16b3..40e17b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,13 +6,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.17.2] - 2026-11-13 + +### Fixed + +- The query client guide, SDK reference and API docstring now document the Server receipt returned by `Client.query_workflow()`, `WorkflowHandle.query()` and the synchronous client. Read `receipt["result"]` for the decoded handler value, including a successful `None`. Runtime behavior is unchanged. (#316, #317) + ### Changed - Replay refuses explicit `parallel_group_policy` histories before constructing application state until a qualified policy consumer is enabled. Absent-policy histories retain their wait, failure-selection and clock behavior. Explicit propagation is being prepared behind disabled worker admission. (#307) ### Added -- Timeout cleanup interruption (workflow#863, workflow#1129). `CancellationContext.from_dict()` reads the context's optional top-level `cause`: absent or `"cancellation"` is an ordinary cancellation, `"timeout"` marks a timeout-cleanup request (the run passed its work deadline and is now in cleanup), and any other value raises `ValueError`. The context carries no other timeout fields: its existing `cleanup_deadline_at` is the cleanup deadline, and the timeout kind and work deadline stay on the `WorkflowTimeoutCleanupStarted` event. `cause` and `is_timeout` are readable on the context, and `to_dict()` writes `cause` back exactly as received, including an explicit `"cancellation"`, leaving ordinary contexts byte-identical. A timeout request is raised at the authored call as the new experimental `WorkflowTimeoutCleanupRequested`, a subclass of `WorkflowCancelled`, so existing `except WorkflowCancelled:` handlers, such as saga compensation, run on timeout too. It stays `BaseException`-rooted and is not an `asyncio.CancelledError`. Test `cause`, the cross-SDK discriminator, to tell the two apart. `cause` is never inherited: a child cancelled under a timeout request, scoped or not, receives an ordinary cancellation without `cause`. +- Experimental timeout-cleanup parser/exception preparation (workflow#863, workflow#1129, server#1201). This patch advertises no timeout-cleanup capability and does not qualify producer delivery or enable timeout cleanup. `CancellationContext.from_dict()` reads the context's optional top-level `cause`: absent or `"cancellation"` is an ordinary cancellation, `"timeout"` marks a timeout-cleanup request (the run passed its work deadline and is now in cleanup), and any other value raises `ValueError`. The context carries no other timeout fields: its existing `cleanup_deadline_at` is the cleanup deadline, and the timeout kind and work deadline stay on the `WorkflowTimeoutCleanupStarted` event. `cause` and `is_timeout` are readable on the context, and `to_dict()` writes `cause` back exactly as received, including an explicit `"cancellation"`, leaving ordinary contexts byte-identical. A timeout request is raised at the authored call as the new experimental `WorkflowTimeoutCleanupRequested`, a subclass of `WorkflowCancelled`, so existing `except WorkflowCancelled:` handlers, such as saga compensation, run on timeout too. It stays `BaseException`-rooted and is not an `asyncio.CancelledError`. Test `cause`, the cross-SDK discriminator, to tell the two apart. `cause` is never inherited: a child cancelled under a timeout request, scoped or not, receives an ordinary cancellation without `cause`. ## [2.17.1] - 2026-11-13 diff --git a/pyproject.toml b/pyproject.toml index c71e776..388f210 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "durable-workflow" -version = "2.17.1" +version = "2.17.2" description = "Python client and worker SDK for Durable Workflow Cloud and self-hosted Server" readme = "README.md" requires-python = ">=3.10" @@ -79,8 +79,8 @@ 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.17.1" -registry-version = "2.17.1" +product-train = "2.17.2" +registry-version = "2.17.2" supported-server-versions = "2.8.0" supported-server-release-baselines = { "2" = "2.8.0", "3" = "3.0.0" } worker-protocol-version = "1.20" From 6e323598514d7e15dbeb4247b5e59f43d87e18b0 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 14:36:39 +0000 Subject: [PATCH 2/5] Promote reviewed distribution bytes before protected publication --- .github/scripts/promote_release.py | 161 +++++++++++++++++++ .github/workflows/publish.yml | 37 ++++- CONTRIBUTING.md | 24 +++ tests/test_release_promotion.py | 246 +++++++++++++++++++++++++++++ 4 files changed, 465 insertions(+), 3 deletions(-) create mode 100644 .github/scripts/promote_release.py create mode 100644 tests/test_release_promotion.py diff --git a/.github/scripts/promote_release.py b/.github/scripts/promote_release.py new file mode 100644 index 0000000..67f2e38 --- /dev/null +++ b/.github/scripts/promote_release.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Promote the exact reviewed build-only distributions before PyPI upload.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import subprocess +import tarfile +from pathlib import Path, PurePosixPath + +REPOSITORY = "durable-workflow/sdk-python" +MAX_FILE_BYTES = 64 * 1024 * 1024 +SHA = re.compile(r"[0-9a-f]{40}") +DIGEST = re.compile(r"[0-9a-f]{64}") +KEYS = {"schema", "version", "source_commit", "source_tree", "run_id", "run_attempt", + "artifact_id", "artifact_sha256", "files"} + + +def git(root: Path, *args: str) -> str: + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def unique_object(pairs: list[tuple[str, object]]) -> dict: + result = {} + for key, value in pairs: + if key in result: + raise ValueError("release approval contains a duplicate member") + result[key] = value + return result + + +def load_approval(root: Path, version: str) -> dict: + if re.fullmatch(r"\d+\.\d+\.\d+", version) is None: + raise ValueError("release version must be exact stable SemVer") + name = f".github/release-candidates/{version}.json" + path = root / name + if path.is_symlink() or not path.is_file() or path.stat().st_size > 65536: + raise ValueError("release approval must be a bounded regular file") + approval = json.loads(path.read_text(), object_pairs_hook=unique_object) + if (not isinstance(approval, dict) or set(approval) != KEYS + or approval["schema"] != "sdk-python.reviewed-release/v1" or approval["version"] != version): + raise ValueError("release approval schema or version mismatch") + for key in ("source_commit", "source_tree"): + if not isinstance(approval[key], str) or SHA.fullmatch(approval[key]) is None: + raise ValueError("release approval requires exact source identities") + for key in ("run_id", "run_attempt", "artifact_id"): + if type(approval[key]) is not int or approval[key] <= 0: + raise ValueError("release approval requires positive numeric producer identities") + files = approval["files"] + expected = {f"durable_workflow-{version}-py3-none-any.whl", f"durable_workflow-{version}.tar.gz"} + if not isinstance(files, dict) or set(files) != expected: + raise ValueError("release approval must name exactly the wheel and sdist") + for value in (approval["artifact_sha256"], *files.values()): + if not isinstance(value, str) or DIGEST.fullmatch(value) is None: + raise ValueError("release approval requires exact artifact digests") + source = approval["source_commit"] + subprocess.run(["git", "-C", str(root), "merge-base", "--is-ancestor", source, "HEAD"], check=True) + if git(root, "rev-parse", f"{source}^{{tree}}") != approval["source_tree"]: + raise ValueError("reviewed source tree mismatch") + # The approval cannot be embedded in its own reviewed source commit. + # Every executable/configuration/package change must precede the build. + if git(root, "diff", "--no-renames", "--name-status", source, "HEAD") != f"A\t{name}": + raise ValueError("publication source differs from reviewed source beyond its approval file") + return approval + + +def verify_authority(approval: dict, run: dict, artifact: dict, repository_id: int) -> None: + repository = {"id": repository_id, "full_name": REPOSITORY} + if (run.get("id") != approval["run_id"] or run.get("run_attempt") != approval["run_attempt"] + or run.get("head_sha") != approval["source_commit"] or run.get("event") != "workflow_dispatch" + or run.get("path") != ".github/workflows/publish.yml" or run.get("status") != "completed" + or run.get("conclusion") != "success" + or any(run.get(key, {}).get(field) != value + for key in ("repository", "head_repository") for field, value in repository.items())): + raise ValueError("reviewed build run authority mismatch") + producer = artifact.get("workflow_run", {}) + if (artifact.get("id") != approval["artifact_id"] or artifact.get("name") != "dist-handoff.tar" + or artifact.get("expired") is not False + or artifact.get("digest") != "sha256:" + approval["artifact_sha256"] + or type(artifact.get("size_in_bytes")) is not int + or not 0 < artifact["size_in_bytes"] <= 2 * MAX_FILE_BYTES + 65536 + or producer.get("id") != approval["run_id"] + or producer.get("head_sha") != approval["source_commit"] + or producer.get("repository_id") != repository_id + or producer.get("head_repository_id") != repository_id): + raise ValueError("reviewed artifact authority mismatch") + + +def api(path: str) -> dict: + result = subprocess.check_output(["gh", "api", f"repos/{REPOSITORY}/{path}"]) + if len(result) > 1048576: + raise ValueError("producer metadata exceeds its bound") + return json.loads(result) + + +def file_sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1048576), b""): + digest.update(chunk) + return digest.hexdigest() + + +def extract_reviewed(approval: dict, incoming: Path, dist: Path) -> None: + archive = incoming / "dist-handoff.tar" + if (incoming.is_symlink() or not incoming.is_dir() or set(incoming.iterdir()) != {archive} + or archive.is_symlink() or not archive.is_file() + or archive.stat().st_size > 2 * MAX_FILE_BYTES + 65536 + or file_sha256(archive) != approval["artifact_sha256"]): + raise ValueError("downloaded producer artifact differs from reviewed bytes") + contents = {} + with tarfile.open(archive, mode="r:") as handle: + for member in handle: + if member.name in (".", "./") and member.isdir() and member.size == 0: + continue + name = PurePosixPath(member.name) + if (name.is_absolute() or len(name.parts) != 1 or "\\" in member.name + or name.name not in approval["files"] or name.name in contents + or not member.isreg() or member.mode != 0o644 or not 0 < member.size <= MAX_FILE_BYTES): + raise ValueError("producer artifact has unexpected, duplicate or unsafe members") + stream = handle.extractfile(member) + if stream is None: + raise ValueError("producer artifact member cannot be read") + data = stream.read(MAX_FILE_BYTES + 1) + if len(data) != member.size or hashlib.sha256(data).hexdigest() != approval["files"][name.name]: + raise ValueError("distribution differs from reviewed bytes") + contents[name.name] = data + if set(contents) != set(approval["files"]): + raise ValueError("producer artifact does not contain both reviewed distributions") + # Validate everything before creating output; never extract archive paths. + dist.mkdir() + for name, data in sorted(contents.items()): + descriptor = os.open(dist / name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o644) + with os.fdopen(descriptor, "wb") as output: + output.write(data) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("operation", choices=("resolve", "extract")) + parser.add_argument("--version", required=True) + parser.add_argument("--root", type=Path, default=Path.cwd()) + parser.add_argument("--incoming", type=Path, default=Path(".release-candidate")) + parser.add_argument("--dist", type=Path, default=Path("dist")) + args = parser.parse_args() + approval = load_approval(args.root, args.version) + if args.operation == "resolve": + verify_authority(approval, api(f"actions/runs/{approval['run_id']}"), + api(f"actions/artifacts/{approval['artifact_id']}"), int(os.environ["GITHUB_REPOSITORY_ID"])) + with Path(os.environ["GITHUB_OUTPUT"]).open("a") as output: + output.write(f"run-id={approval['run_id']}\nartifact-id={approval['artifact_id']}\n") + else: + extract_reviewed(approval, args.incoming, args.dist) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 93e6e51..9620983 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -11,7 +11,7 @@ on: workflow_dispatch: inputs: release_tag: - description: 'Existing immutable SDK release tag; empty permits a build-only run' + description: 'Existing immutable tag for publication; build-only runs use the selected source commit' required: false type: string default: '' @@ -41,6 +41,9 @@ concurrency: jobs: build: runs-on: ubuntu-latest + permissions: + contents: read + actions: read outputs: artifact-digest: ${{ steps.privileged-handoff.outputs.artifact-digest }} artifact-id: ${{ steps.privileged-handoff.outputs.artifact-id }} @@ -54,8 +57,8 @@ jobs: with: fetch-depth: 0 ref: >- - ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag != '' - && format('refs/tags/{0}', inputs.release_tag) || github.ref }} + ${{ github.event_name == 'workflow_dispatch' && inputs.publish && inputs.release_tag != '' + && format('refs/tags/{0}', inputs.release_tag) || github.sha }} - name: Resolve exact release identity id: release_source @@ -95,18 +98,45 @@ jobs: fi fi else + test "$head_commit" = "$GITHUB_SHA" REQUESTED_TAG="$package_version" fi { printf 'tag=%s\n' "$REQUESTED_TAG" printf 'commit=%s\n' "$head_commit" printf 'base_commit=%s\n' "$base_commit" + printf 'publish=%s\n' "$PUBLISH_REQUESTED" } >> "$GITHUB_OUTPUT" - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: "3.12" + - name: Resolve the reviewed build-only artifact + if: steps.release_source.outputs.publish == 'true' + id: candidate + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ steps.release_source.outputs.tag }} + run: python .github/scripts/promote_release.py resolve --version "$RELEASE_VERSION" + + - name: Download the exact reviewed distributions + if: steps.release_source.outputs.publish == 'true' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + artifact-ids: ${{ steps.candidate.outputs.artifact-id }} + run-id: ${{ steps.candidate.outputs.run-id }} + github-token: ${{ github.token }} + repository: ${{ github.repository }} + path: .release-candidate + archive: false + + - name: Verify reviewed bytes before extracting distributions + if: steps.release_source.outputs.publish == 'true' + env: + RELEASE_VERSION: ${{ steps.release_source.outputs.tag }} + run: python .github/scripts/promote_release.py extract --version "$RELEASE_VERSION" + - name: Bootstrap patched pip trust uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust with: @@ -117,6 +147,7 @@ jobs: pip install --only-binary :all: build==1.6.1 twine==7.0.0 - name: Build package + if: steps.release_source.outputs.publish != 'true' run: python -m build - name: Compare built release metadata with the exact source commit diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 70110e9..3aecc12 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,3 +20,27 @@ Existing evidence is append-only; protocol evolution adds a new fixture with a ```bash python scripts/ci/validate-regression-corpus.py --base-ref ``` + +## Reviewed package publication + +Build a release candidate without publishing: + +```bash +gh workflow run publish.yml --ref -f publish=false +``` + +Before tagging, obtain Security review of that run's exact wheel and sdist and +qualify the installed candidate against the selected published Server. Record +the reviewed producer in `.github/release-candidates/.json`, using the +closed `sdk-python.reviewed-release/v1` schema checked by +`.github/scripts/promote_release.py`: version, source commit/tree, run ID and +attempt, raw `dist-handoff.tar` artifact ID/SHA256, and the wheel/sdist filename +to SHA256 map. The approval file itself receives normal PR/Security review. + +The final tag may differ from the reviewed build's source only by adding that +approval file. Any other source or workflow change requires a new build and +review. Tagged publication checks the successful same-repository producer and +promotes its exact distribution bytes through the existing protected handoff; +it does not rebuild them. Missing, expired, changed or unreviewed artifacts +refuse publication before upload. Finish registry installation and stable +portal readback after publication. diff --git a/tests/test_release_promotion.py b/tests/test_release_promotion.py new file mode 100644 index 0000000..02381f4 --- /dev/null +++ b/tests/test_release_promotion.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +import hashlib +import importlib.util +import io +import json +import os +import subprocess +import sys +import tarfile +from copy import deepcopy +from pathlib import Path + +import pytest +import yaml + +SCRIPT = Path(__file__).parents[1] / ".github/scripts/promote_release.py" +spec = importlib.util.spec_from_file_location("promote_release", SCRIPT) +assert spec is not None and spec.loader is not None +promotion = importlib.util.module_from_spec(spec) +spec.loader.exec_module(promotion) +VERSION = "2.17.2" +APPROVAL_PATH = f".github/release-candidates/{VERSION}.json" +FILES = {f"durable_workflow-{VERSION}-py3-none-any.whl": b"reviewed wheel", + f"durable_workflow-{VERSION}.tar.gz": b"reviewed sdist"} + + +def commit(root: Path) -> str: + promotion.git(root, "add", ".") + promotion.git(root, "-c", "user.name=Release test", "-c", "user.email=release@example.invalid", + "commit", "--quiet", "-m", "fixture") + return promotion.git(root, "rev-parse", "HEAD") + + +def write_tar(path: Path, entries: list[tuple[str, bytes, bytes, int]]) -> None: + with tarfile.open(path, "w") as archive: + for name, data, kind, mode in entries: + member = tarfile.TarInfo(name) + member.type = kind + member.mode = mode + member.size = len(data) + archive.addfile(member, io.BytesIO(data)) + + +def digest(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +@pytest.fixture +def candidate(tmp_path: Path) -> tuple[Path, dict, Path]: + root = tmp_path / "source" + root.mkdir() + promotion.git(root, "init", "--quiet") + (root / "pyproject.toml").write_text(f'[project]\nversion = "{VERSION}"\n') + source = commit(root) + incoming = tmp_path / "incoming" + incoming.mkdir() + archive = incoming / "dist-handoff.tar" + write_tar(archive, [("./", b"", tarfile.DIRTYPE, 0o755)] + [ + ("./" + name, data, tarfile.REGTYPE, 0o644) for name, data in FILES.items() + ]) + approval = {"schema": "sdk-python.reviewed-release/v1", "version": VERSION, + "source_commit": source, "source_tree": promotion.git(root, "rev-parse", "HEAD^{tree}"), + "run_id": 12345, "run_attempt": 1, "artifact_id": 67890, + "artifact_sha256": digest(archive), + "files": {name: hashlib.sha256(data).hexdigest() for name, data in FILES.items()}} + path = root / APPROVAL_PATH + path.parent.mkdir(parents=True) + path.write_text(json.dumps(approval)) + commit(root) + return root, approval, incoming + + +def test_promotes_the_exact_reviewed_bytes_after_an_approval_only_commit(candidate, tmp_path: Path) -> None: + root, approval, incoming = candidate + checked = promotion.load_approval(root, VERSION) + assert checked == approval + dist = tmp_path / "dist" + promotion.extract_reviewed(checked, incoming, dist) + assert {path.name: path.read_bytes() for path in dist.iterdir()} == FILES + + +@pytest.mark.parametrize("stale", [False, True]) +@pytest.mark.skipif(sys.version_info < (3, 11), reason="publication uses Python 3.12's builtin tomllib") +def test_build_only_source_resolution_requires_the_event_commit(candidate, tmp_path: Path, stale: bool) -> None: + root, _, _ = candidate + workflow = yaml.safe_load((SCRIPT.parents[1] / "workflows/publish.yml").read_text()) + step = next(step for step in workflow["jobs"]["build"]["steps"] if step.get("id") == "release_source") + output = tmp_path / "output" + environment = {**os.environ, "PUBLISH_REQUESTED": "false", "REQUESTED_TAG": "release-branch", + "REQUESTED_COMMIT": "", "GITHUB_OUTPUT": str(output), + "GITHUB_SHA": "0" * 40 if stale else promotion.git(root, "rev-parse", "HEAD"), + "PATH": str(Path(sys.executable).parent) + os.pathsep + os.environ["PATH"]} + result = subprocess.run(["bash", "-c", step["run"]], cwd=root, env=environment, capture_output=True) + if stale: + assert result.returncode != 0 + assert not output.exists() + else: + assert result.returncode == 0, result.stderr + assert "publish=false\n" in output.read_text() + + +def test_refuses_source_changes_after_the_reviewed_build(candidate) -> None: + root, _, _ = candidate + (root / "pyproject.toml").write_text("changed after review") + commit(root) + with pytest.raises(ValueError, match="beyond its approval"): + promotion.load_approval(root, VERSION) + + +def test_refuses_an_unrelated_source_tree(candidate) -> None: + root, approval, _ = candidate + approval["source_tree"] = "0" * 40 + (root / APPROVAL_PATH).write_text(json.dumps(approval)) + commit(root) + with pytest.raises(ValueError, match="source tree"): + promotion.load_approval(root, VERSION) + + +def test_refuses_duplicate_approval_members(candidate) -> None: + root, approval, _ = candidate + (root / APPROVAL_PATH).write_text('{"version":"2.17.2",' + json.dumps(approval)[1:]) + commit(root) + with pytest.raises(ValueError, match="duplicate member"): + promotion.load_approval(root, VERSION) + + +@pytest.mark.parametrize("field,value", [ + ("schema", "other"), ("version", "2.17.1"), ("source_commit", "main"), + ("run_id", True), ("run_attempt", 0), ("artifact_id", "67890"), + ("artifact_sha256", "sha256:" + "0" * 64), ("files", {"other.whl": "0" * 64}), + ("unknown", "refuse"), +]) +def test_refuses_malformed_or_open_approval_descriptors(candidate, field, value) -> None: + root, approval, _ = candidate + approval[field] = value + (root / APPROVAL_PATH).write_text(json.dumps(approval)) + commit(root) + with pytest.raises(ValueError): + promotion.load_approval(root, VERSION) + + +def authority(approval: dict) -> tuple[dict, dict]: + repository = {"id": 98765, "full_name": "durable-workflow/sdk-python"} + run = {"id": approval["run_id"], "run_attempt": 1, "head_sha": approval["source_commit"], + "event": "workflow_dispatch", "path": ".github/workflows/publish.yml", "status": "completed", + "conclusion": "success", "repository": repository, "head_repository": repository} + artifact = {"id": approval["artifact_id"], "name": "dist-handoff.tar", "expired": False, + "digest": "sha256:" + approval["artifact_sha256"], "size_in_bytes": 10240, + "workflow_run": {"id": approval["run_id"], "head_sha": approval["source_commit"], + "repository_id": 98765, "head_repository_id": 98765}} + return run, artifact + + +def test_requires_the_successful_same_repository_build_and_exact_artifact(candidate) -> None: + _, approval, _ = candidate + run, artifact = authority(approval) + promotion.verify_authority(approval, run, artifact, 98765) + + +@pytest.mark.parametrize("kind,field,value", [ + ("run", "id", 555), ("run", "run_attempt", 2), ("run", "head_sha", "0" * 40), + ("run", "event", "pull_request"), ("run", "path", ".github/workflows/other.yml"), + ("run", "status", "in_progress"), ("run", "conclusion", "failure"), + ("run", "head_repository", {"id": 98765, "full_name": "fork/sdk-python"}), + ("artifact", "id", 555), ("artifact", "name", "other.tar"), ("artifact", "expired", True), + ("artifact", "digest", "sha256:" + "0" * 64), ("artifact", "size_in_bytes", True), + ("artifact", "workflow_run", {"id": 555}), +]) +def test_refuses_stale_forked_or_substituted_build_authority(candidate, kind, field, value) -> None: + _, approval, _ = candidate + run, artifact = authority(approval) + selected = run if kind == "run" else artifact + selected[field] = value + with pytest.raises(ValueError, match="authority mismatch"): + promotion.verify_authority(approval, run, artifact, 98765) + + +def test_refuses_changed_outer_bytes_before_creating_output(candidate, tmp_path: Path) -> None: + _, approval, incoming = candidate + with (incoming / "dist-handoff.tar").open("ab") as output: + output.write(b"tampered") + dist = tmp_path / "dist" + with pytest.raises(ValueError, match="reviewed bytes"): + promotion.extract_reviewed(approval, incoming, dist) + assert not dist.exists() + + +@pytest.mark.parametrize("name,kind,mode", [ + ("../escape", tarfile.REGTYPE, 0o644), ("/absolute", tarfile.REGTYPE, 0o644), + ("extra.json", tarfile.REGTYPE, 0o644), (next(iter(FILES)), tarfile.SYMTYPE, 0o644), + (next(iter(FILES)), tarfile.LNKTYPE, 0o644), (next(iter(FILES)), tarfile.FIFOTYPE, 0o644), + (next(iter(FILES)), tarfile.REGTYPE, 0o755), +]) +def test_refuses_unsafe_or_unexpected_members_even_with_a_matching_outer_hash( + candidate, tmp_path: Path, name, kind, mode, +) -> None: + _, approval, incoming = candidate + archive = incoming / "dist-handoff.tar" + write_tar(archive, [(name, b"unsafe", kind, mode)]) + approval["artifact_sha256"] = digest(archive) + dist = tmp_path / "dist" + with pytest.raises(ValueError): + promotion.extract_reviewed(approval, incoming, dist) + assert not dist.exists() + + +@pytest.mark.parametrize("fault", ["duplicate", "missing", "changed_inner", "extra_download"]) +def test_refuses_partial_or_substituted_distributions(candidate, tmp_path: Path, fault: str) -> None: + _, original, incoming = candidate + approval = deepcopy(original) + entries = [(name, data, tarfile.REGTYPE, 0o644) for name, data in FILES.items()] + if fault == "duplicate": + entries.append(entries[0]) + elif fault == "missing": + entries.pop() + elif fault == "changed_inner": + entries[0] = (entries[0][0], b"changed", tarfile.REGTYPE, 0o644) + else: + (incoming / "extra").write_bytes(b"extra") + archive = incoming / "dist-handoff.tar" + write_tar(archive, entries) + approval["artifact_sha256"] = digest(archive) + dist = tmp_path / "dist" + with pytest.raises(ValueError): + promotion.extract_reviewed(approval, incoming, dist) + assert not dist.exists() + + +def test_refuses_a_symlink_in_place_of_the_download(candidate, tmp_path: Path) -> None: + _, approval, incoming = candidate + archive = incoming / "dist-handoff.tar" + archive.rename(incoming / "real") + archive.symlink_to("real") + with pytest.raises(ValueError): + promotion.extract_reviewed(approval, incoming, tmp_path / "dist") + + +def test_cannot_overwrite_existing_output(candidate, tmp_path: Path) -> None: + _, approval, incoming = candidate + dist = tmp_path / "dist" + dist.mkdir() + (dist / "retain").write_text("keep") + with pytest.raises(FileExistsError): + promotion.extract_reviewed(approval, incoming, dist) + assert (dist / "retain").read_text() == "keep" From 2137136a410ad775643289a453ae7dfb7aa3b420 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 14:48:30 +0000 Subject: [PATCH 3/5] Recheck approved distribution bytes immediately before the handoff --- .github/scripts/promote_release.py | 73 +++++++++++++++--------- .github/workflows/publish.yml | 23 +++++++- CONTRIBUTING.md | 4 +- tests/test_publish_workflow_isolation.py | 4 +- tests/test_release_promotion.py | 62 +++++++++++++++++--- 5 files changed, 128 insertions(+), 38 deletions(-) diff --git a/.github/scripts/promote_release.py b/.github/scripts/promote_release.py index 67f2e38..bcbcc71 100644 --- a/.github/scripts/promote_release.py +++ b/.github/scripts/promote_release.py @@ -20,10 +20,6 @@ "artifact_id", "artifact_sha256", "files"} -def git(root: Path, *args: str) -> str: - return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() - - def unique_object(pairs: list[tuple[str, object]]) -> dict: result = {} for key, value in pairs: @@ -33,14 +29,18 @@ def unique_object(pairs: list[tuple[str, object]]) -> dict: return result -def load_approval(root: Path, version: str) -> dict: - if re.fullmatch(r"\d+\.\d+\.\d+", version) is None: +def distribution_names(version: str) -> set[str]: + if re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", version) is None: raise ValueError("release version must be exact stable SemVer") - name = f".github/release-candidates/{version}.json" - path = root / name - if path.is_symlink() or not path.is_file() or path.stat().st_size > 65536: - raise ValueError("release approval must be a bounded regular file") - approval = json.loads(path.read_text(), object_pairs_hook=unique_object) + return {f"durable_workflow-{version}-py3-none-any.whl", f"durable_workflow-{version}.tar.gz"} + + +def require_digest(value: object) -> None: + if not isinstance(value, str) or DIGEST.fullmatch(value) is None: + raise ValueError("release approval requires exact artifact digests") + + +def validate_descriptor(approval: dict, version: str) -> None: if (not isinstance(approval, dict) or set(approval) != KEYS or approval["schema"] != "sdk-python.reviewed-release/v1" or approval["version"] != version): raise ValueError("release approval schema or version mismatch") @@ -51,19 +51,38 @@ def load_approval(root: Path, version: str) -> dict: if type(approval[key]) is not int or approval[key] <= 0: raise ValueError("release approval requires positive numeric producer identities") files = approval["files"] - expected = {f"durable_workflow-{version}-py3-none-any.whl", f"durable_workflow-{version}.tar.gz"} + expected = distribution_names(version) if not isinstance(files, dict) or set(files) != expected: raise ValueError("release approval must name exactly the wheel and sdist") for value in (approval["artifact_sha256"], *files.values()): - if not isinstance(value, str) or DIGEST.fullmatch(value) is None: - raise ValueError("release approval requires exact artifact digests") + require_digest(value) + + +def load_approval(root: Path, version: str) -> dict: + distribution_names(version) + name = f".github/release-candidates/{version}.json" + path = root / name + if path.is_symlink() or not path.is_file() or path.stat().st_size > 65536: + raise ValueError("release approval must be a bounded regular file") + approval = json.loads(path.read_text(), object_pairs_hook=unique_object) + validate_descriptor(approval, version) source = approval["source_commit"] - subprocess.run(["git", "-C", str(root), "merge-base", "--is-ancestor", source, "HEAD"], check=True) - if git(root, "rev-parse", f"{source}^{{tree}}") != approval["source_tree"]: + if SHA.fullmatch(source) is None: + raise ValueError("reviewed source must be an exact commit") + directory = str(root.resolve()) + subprocess.run(["git", "-C", directory, "merge-base", "--is-ancestor", "--", source, "HEAD"], check=True) + tree = subprocess.check_output( + ["git", "-C", directory, "rev-parse", "--verify", "--end-of-options", f"{source}^{{tree}}"], text=True, + ).strip() + if tree != approval["source_tree"]: raise ValueError("reviewed source tree mismatch") # The approval cannot be embedded in its own reviewed source commit. # Every executable/configuration/package change must precede the build. - if git(root, "diff", "--no-renames", "--name-status", source, "HEAD") != f"A\t{name}": + changed = subprocess.check_output( + ["git", "-C", directory, "diff", "--no-ext-diff", "--no-textconv", "--no-renames", + "--name-status", source, "HEAD", "--"], text=True, + ).strip() + if changed != f"A\t{name}": raise ValueError("publication source differs from reviewed source beyond its approval file") return approval @@ -122,10 +141,8 @@ def extract_reviewed(approval: dict, incoming: Path, dist: Path) -> None: or name.name not in approval["files"] or name.name in contents or not member.isreg() or member.mode != 0o644 or not 0 < member.size <= MAX_FILE_BYTES): raise ValueError("producer artifact has unexpected, duplicate or unsafe members") - stream = handle.extractfile(member) - if stream is None: - raise ValueError("producer artifact member cannot be read") - data = stream.read(MAX_FILE_BYTES + 1) + with handle.extractfile(member) as stream: + data = stream.read(MAX_FILE_BYTES + 1) if len(data) != member.size or hashlib.sha256(data).hexdigest() != approval["files"][name.name]: raise ValueError("distribution differs from reviewed bytes") contents[name.name] = data @@ -133,28 +150,28 @@ def extract_reviewed(approval: dict, incoming: Path, dist: Path) -> None: raise ValueError("producer artifact does not contain both reviewed distributions") # Validate everything before creating output; never extract archive paths. dist.mkdir() - for name, data in sorted(contents.items()): + for name in sorted(distribution_names(approval["version"])): descriptor = os.open(dist / name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o644) with os.fdopen(descriptor, "wb") as output: - output.write(data) + output.write(contents[name]) def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("operation", choices=("resolve", "extract")) parser.add_argument("--version", required=True) - parser.add_argument("--root", type=Path, default=Path.cwd()) - parser.add_argument("--incoming", type=Path, default=Path(".release-candidate")) - parser.add_argument("--dist", type=Path, default=Path("dist")) args = parser.parse_args() - approval = load_approval(args.root, args.version) + approval = load_approval(Path.cwd(), args.version) if args.operation == "resolve": verify_authority(approval, api(f"actions/runs/{approval['run_id']}"), api(f"actions/artifacts/{approval['artifact_id']}"), int(os.environ["GITHUB_REPOSITORY_ID"])) with Path(os.environ["GITHUB_OUTPUT"]).open("a") as output: output.write(f"run-id={approval['run_id']}\nartifact-id={approval['artifact_id']}\n") + for name in sorted(distribution_names(args.version)): + kind = "wheel" if name.endswith(".whl") else "sdist" + output.write(f"{kind}-sha256={approval['files'][name]}\n") else: - extract_reviewed(approval, args.incoming, args.dist) + extract_reviewed(approval, Path(".release-candidate"), Path("dist")) if __name__ == "__main__": diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9620983..33fd0a3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -168,7 +168,28 @@ jobs: path: dist/ - name: Package the privileged PyPI handoff as one immutable file - run: tar -cf dist-handoff.tar -C dist . + env: + PROMOTE_REVIEWED: ${{ steps.release_source.outputs.publish }} + RELEASE_VERSION: ${{ steps.release_source.outputs.tag }} + REVIEWED_WHEEL_SHA256: ${{ steps.candidate.outputs.wheel-sha256 }} + REVIEWED_SDIST_SHA256: ${{ steps.candidate.outputs.sdist-sha256 }} + run: | + set -euo pipefail + if [ "$PROMOTE_REVIEWED" = true ]; then + [[ "$RELEASE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] + [[ "$REVIEWED_WHEEL_SHA256" =~ ^[0-9a-f]{64}$ ]] + [[ "$REVIEWED_SDIST_SHA256" =~ ^[0-9a-f]{64}$ ]] + shopt -s dotglob nullglob + files=(dist/*) + test "${#files[@]}" -eq 2 + wheel="dist/durable_workflow-${RELEASE_VERSION}-py3-none-any.whl" + sdist="dist/durable_workflow-${RELEASE_VERSION}.tar.gz" + test -f "$wheel" && test ! -L "$wheel" + test -f "$sdist" && test ! -L "$sdist" + printf '%s %s\n%s %s\n' \ + "$REVIEWED_WHEEL_SHA256" "$wheel" "$REVIEWED_SDIST_SHA256" "$sdist" | sha256sum --check --strict - + fi + tar -cf dist-handoff.tar -C dist . - name: Bind the privileged PyPI handoff identity and digest id: privileged-handoff diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3aecc12..a3211e3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,6 +41,8 @@ The final tag may differ from the reviewed build's source only by adding that approval file. Any other source or workflow change requires a new build and review. Tagged publication checks the successful same-repository producer and promotes its exact distribution bytes through the existing protected handoff; -it does not rebuild them. Missing, expired, changed or unreviewed artifacts +it does not rebuild them. After package checks and smoke execution, the handoff +step rechecks both files against digests captured before that execution. +Missing, expired, changed or unreviewed artifacts refuse publication before upload. Finish registry installation and stable portal readback after publication. diff --git a/tests/test_publish_workflow_isolation.py b/tests/test_publish_workflow_isolation.py index a880591..24f6a5b 100644 --- a/tests/test_publish_workflow_isolation.py +++ b/tests/test_publish_workflow_isolation.py @@ -145,5 +145,7 @@ def test_the_github_release_follows_verification_and_runs_no_third_party_code() def test_the_build_job_holds_no_publication_credential() -> None: build = jobs()["build"] - assert "permissions" not in build + assert build["permissions"] == {"contents": "read", "actions": "read"} + assert "id-token" not in build["permissions"] + assert all(value == "read" for value in build["permissions"].values()) assert not any(action.startswith(PYPI_PUBLISH_ACTION) for action in uses(build)) diff --git a/tests/test_release_promotion.py b/tests/test_release_promotion.py index 02381f4..263a74a 100644 --- a/tests/test_release_promotion.py +++ b/tests/test_release_promotion.py @@ -16,7 +16,8 @@ SCRIPT = Path(__file__).parents[1] / ".github/scripts/promote_release.py" spec = importlib.util.spec_from_file_location("promote_release", SCRIPT) -assert spec is not None and spec.loader is not None +assert spec is not None +assert spec.loader is not None promotion = importlib.util.module_from_spec(spec) spec.loader.exec_module(promotion) VERSION = "2.17.2" @@ -25,11 +26,15 @@ f"durable_workflow-{VERSION}.tar.gz": b"reviewed sdist"} +def git(root: Path, *arguments: str) -> str: + return subprocess.check_output(["git", "-C", str(root), *arguments], text=True).strip() + + def commit(root: Path) -> str: - promotion.git(root, "add", ".") - promotion.git(root, "-c", "user.name=Release test", "-c", "user.email=release@example.invalid", + git(root, "add", ".") + git(root, "-c", "user.name=Release test", "-c", "user.email=release@example.invalid", "commit", "--quiet", "-m", "fixture") - return promotion.git(root, "rev-parse", "HEAD") + return git(root, "rev-parse", "HEAD") def write_tar(path: Path, entries: list[tuple[str, bytes, bytes, int]]) -> None: @@ -50,7 +55,7 @@ def digest(path: Path) -> str: def candidate(tmp_path: Path) -> tuple[Path, dict, Path]: root = tmp_path / "source" root.mkdir() - promotion.git(root, "init", "--quiet") + git(root, "init", "--quiet") (root / "pyproject.toml").write_text(f'[project]\nversion = "{VERSION}"\n') source = commit(root) incoming = tmp_path / "incoming" @@ -60,7 +65,7 @@ def candidate(tmp_path: Path) -> tuple[Path, dict, Path]: ("./" + name, data, tarfile.REGTYPE, 0o644) for name, data in FILES.items() ]) approval = {"schema": "sdk-python.reviewed-release/v1", "version": VERSION, - "source_commit": source, "source_tree": promotion.git(root, "rev-parse", "HEAD^{tree}"), + "source_commit": source, "source_tree": git(root, "rev-parse", "HEAD^{tree}"), "run_id": 12345, "run_attempt": 1, "artifact_id": 67890, "artifact_sha256": digest(archive), "files": {name: hashlib.sha256(data).hexdigest() for name, data in FILES.items()}} @@ -89,7 +94,7 @@ def test_build_only_source_resolution_requires_the_event_commit(candidate, tmp_p output = tmp_path / "output" environment = {**os.environ, "PUBLISH_REQUESTED": "false", "REQUESTED_TAG": "release-branch", "REQUESTED_COMMIT": "", "GITHUB_OUTPUT": str(output), - "GITHUB_SHA": "0" * 40 if stale else promotion.git(root, "rev-parse", "HEAD"), + "GITHUB_SHA": "0" * 40 if stale else git(root, "rev-parse", "HEAD"), "PATH": str(Path(sys.executable).parent) + os.pathsep + os.environ["PATH"]} result = subprocess.run(["bash", "-c", step["run"]], cwd=root, env=environment, capture_output=True) if stale: @@ -244,3 +249,46 @@ def test_cannot_overwrite_existing_output(candidate, tmp_path: Path) -> None: with pytest.raises(FileExistsError): promotion.extract_reviewed(approval, incoming, dist) assert (dist / "retain").read_text() == "keep" + + +@pytest.mark.parametrize("fault", [ + None, "changed_wheel", "changed_sdist", "extra", "hidden", "symlink", "missing", "directory", "empty_digest", +]) +def test_final_handoff_rechecks_reviewed_bytes_after_candidate_execution(tmp_path: Path, fault: str | None) -> None: + workflow = yaml.safe_load((SCRIPT.parents[1] / "workflows/publish.yml").read_text()) + step = next(step for step in workflow["jobs"]["build"]["steps"] + if step.get("name") == "Package the privileged PyPI handoff as one immutable file") + dist = tmp_path / "dist" + dist.mkdir() + for name, data in FILES.items(): + (dist / name).write_bytes(data) + wheel = next(name for name in FILES if name.endswith(".whl")) + sdist = next(name for name in FILES if name.endswith(".tar.gz")) + environment = {**os.environ, "PROMOTE_REVIEWED": "true", "RELEASE_VERSION": VERSION, + "REVIEWED_WHEEL_SHA256": hashlib.sha256(FILES[wheel]).hexdigest(), + "REVIEWED_SDIST_SHA256": hashlib.sha256(FILES[sdist]).hexdigest()} + if fault in ("changed_wheel", "changed_sdist"): + target = wheel if fault == "changed_wheel" else sdist + (dist / target).write_bytes(b"changed after smoke") + elif fault in ("extra", "hidden"): + (dist / ("extra" if fault == "extra" else ".extra")).write_bytes(b"unexpected") + elif fault in ("symlink", "missing", "directory"): + (dist / wheel).unlink() + if fault == "symlink": + (tmp_path / "external").write_bytes(FILES[wheel]) + (dist / wheel).symlink_to(tmp_path / "external") + elif fault == "directory": + (dist / wheel).mkdir() + elif fault == "empty_digest": + environment["REVIEWED_WHEEL_SHA256"] = "" + result = subprocess.run(["bash", "-c", step["run"]], cwd=tmp_path, env=environment, capture_output=True) + archive = tmp_path / "dist-handoff.tar" + if fault is not None: + assert result.returncode != 0 + assert not archive.exists() + else: + assert result.returncode == 0, result.stderr + with tarfile.open(archive) as handle: + promoted = {Path(member.name).name: handle.extractfile(member).read() + for member in handle if member.isreg()} + assert promoted == FILES From 885395866fa54786adb3cffa7ead9dbf0419e90a Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 15:07:27 +0000 Subject: [PATCH 4/5] Preserve Avro query bytes without an unencodable plain projection --- CHANGELOG.md | 1 + src/durable_workflow/client.py | 10 +++++- .../query-result-nested-bytes.json | 34 +++++++++++++++++++ tests/test_client.py | 32 +++++++++++++++++ 4 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/codec_regressions/query-result-nested-bytes.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 40e17b8..69a93c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - The query client guide, SDK reference and API docstring now document the Server receipt returned by `Client.query_workflow()`, `WorkflowHandle.query()` and the synchronous client. Read `receipt["result"]` for the decoded handler value, including a successful `None`. Runtime behavior is unchanged. (#316, #317) +- Query workers return valid Avro values containing bytes without also trying to serialize those bytes as plain JSON. The authoritative `result_envelope` is unchanged; JSON-compatible results retain their optional plain projection. (#319) ### Changed diff --git a/src/durable_workflow/client.py b/src/durable_workflow/client.py index e2f33e1..294c4c7 100644 --- a/src/durable_workflow/client.py +++ b/src/durable_workflow/client.py @@ -22,6 +22,7 @@ import contextlib import copy import hashlib +import json import json as json_module import math import os @@ -6612,7 +6613,6 @@ async def complete_query_task( body: dict[str, Any] = { "lease_owner": lease_owner, "query_task_attempt": query_task_attempt, - "result": result, "result_envelope": self._payload_envelope( result, codec=codec, @@ -6624,6 +6624,14 @@ async def complete_query_task( external_storage_threshold_bytes=external_storage_threshold_bytes, ), } + # The envelope is authoritative. The optional plain projection must + # not impose a JSON-only restriction on valid Avro values such as bytes. + try: + json.dumps(result, allow_nan=False) + except (TypeError, ValueError): + pass + else: + body["result"] = result return await self._request( "POST", f"/worker/query-tasks/{query_task_id}/complete", worker=True, json=body ) diff --git a/tests/fixtures/codec_regressions/query-result-nested-bytes.json b/tests/fixtures/codec_regressions/query-result-nested-bytes.json new file mode 100644 index 0000000..397a57d --- /dev/null +++ b/tests/fixtures/codec_regressions/query-result-nested-bytes.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://raw.githubusercontent.com/durable-workflow/.github/main/regression-corpus/evidence-schema.json", + "fixture_schema": "durable-workflow.codec-regression/v1", + "id": "query-result-nested-bytes", + "protocol": { + "codec": "avro", + "schema": "durable_workflow.protocol.Value", + "version": "1", + "fingerprint": "e2a33dff55802237" + }, + "bindings": [ + "python" + ], + "value": { + "type": "map", + "entries": [ + { + "key": "payload", + "value": { + "type": "bytes", + "base64": "AP8=" + } + } + ] + }, + "framing": { + "encoding": "avro-single-object", + "wire_base64": "wwHioz3/VYAiNw4CDnBheWxvYWQIBAD/AA==" + }, + "failure_policy": { + "operation": "round_trip", + "error": null + } +} diff --git a/tests/test_client.py b/tests/test_client.py index f9e5538..d780a17 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -4152,6 +4152,38 @@ async def test_complete_query_task_sends_avro_result_envelope(self, client: Clie assert body["result_envelope"]["codec"] == "avro" assert serializer.decode_envelope(body["result_envelope"]) == {"ok": True} + @pytest.mark.asyncio + @pytest.mark.parametrize("nested", [False, True]) + async def test_complete_query_task_encodes_bytes_through_real_http_transport( + self, client: Client, nested: bool, + ) -> None: + fixture_path = Path(__file__).parent / "fixtures/codec_regressions/query-result-nested-bytes.json" + fixture = json.loads(fixture_path.read_text()) + value = serializer.decode(fixture["framing"]["wire_base64"], codec="avro") + result_value = value if nested else value["payload"] + seen = [] + + def receive(request: httpx.Request) -> httpx.Response: + body = json.loads(request.content) + seen.append(body) + assert request.url.path == "/api/worker/query-tasks/qt-bytes/complete" + assert "result" not in body + assert body["result_envelope"]["codec"] == "avro" + assert serializer.decode_envelope(body["result_envelope"]) == result_value + if nested: + assert body["result_envelope"]["blob"] == fixture["framing"]["wire_base64"] + return httpx.Response(200, json={"outcome": "completed"}) + + client._cluster_info = _supported_runtime_info() + await client._http.aclose() + client._http = httpx.AsyncClient(base_url=client.base_url, transport=httpx.MockTransport(receive)) + async with client: + receipt = await client.complete_query_task( + query_task_id="qt-bytes", lease_owner="worker-bytes", query_task_attempt=1, result=result_value, + ) + assert receipt["outcome"] == "completed" + assert len(seen) == 1 + @pytest.mark.asyncio async def test_complete_query_task_rejects_json_payload_codec(self, client: Client) -> None: with pytest.raises(ValueError, match="unsupported_payload_codec.*HTTP document transport"): From 36237a606992e0d87f0ec1d816cd552742cdd42a Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 15:19:55 +0000 Subject: [PATCH 5/5] Propose exact 2.17.2 build promotion binding --- .github/release-candidates/2.17.2.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .github/release-candidates/2.17.2.json diff --git a/.github/release-candidates/2.17.2.json b/.github/release-candidates/2.17.2.json new file mode 100644 index 0000000..577806e --- /dev/null +++ b/.github/release-candidates/2.17.2.json @@ -0,0 +1,14 @@ +{ + "schema": "sdk-python.reviewed-release/v1", + "version": "2.17.2", + "source_commit": "885395866fa54786adb3cffa7ead9dbf0419e90a", + "source_tree": "cb7fb96110cecf1911989eab3924863d65ce2ea4", + "run_id": 43449487744, + "run_attempt": 1, + "artifact_id": 14049382780, + "artifact_sha256": "9b2eb18707378b9d33965692332e4d34bec683836ee3b06b7fc1c5bb445018d8", + "files": { + "durable_workflow-2.17.2-py3-none-any.whl": "7dc77d3882fa5cbddbeaee6a4cc63b05285e280523c8ea40dc31e03cc12a40bc", + "durable_workflow-2.17.2.tar.gz": "76feb6ce60eb92710c718ece3fe63bb9bfb3b1cafc7fa0dbdce0a9e0b8321956" + } +}