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
14 changes: 14 additions & 0 deletions .github/release-candidates/2.17.2.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
178 changes: 178 additions & 0 deletions .github/scripts/promote_release.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
#!/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 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 distribution_names(version: str) -> set[str]:
if re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", version) is None:

Check warning on line 33 in .github/scripts/promote_release.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=AaHVY0KywjWeGkj6lUnE&open=AaHVY0KywjWeGkj6lUnE&pullRequest=318

Check warning on line 33 in .github/scripts/promote_release.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=AaHVY0KywjWeGkj6lUnD&open=AaHVY0KywjWeGkj6lUnD&pullRequest=318

Check warning on line 33 in .github/scripts/promote_release.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=AaHVY0KywjWeGkj6lUnC&open=AaHVY0KywjWeGkj6lUnC&pullRequest=318
raise ValueError("release version must be exact stable SemVer")
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")
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 = 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()):
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"]
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.
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


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")
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
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 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(contents[name])


def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("operation", choices=("resolve", "extract"))
parser.add_argument("--version", required=True)
args = parser.parse_args()
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, Path(".release-candidate"), Path("dist"))


if __name__ == "__main__":
main()
60 changes: 56 additions & 4 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: ''
Expand Down Expand Up @@ -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 }}
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -137,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
Expand Down
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,20 @@ 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)
- 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

- 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

Expand Down
26 changes: 26 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,29 @@ Existing evidence is append-only; protocol evolution adds a new fixture with a
```bash
python scripts/ci/validate-regression-corpus.py --base-ref <target>
```

## Reviewed package publication

Build a release candidate without publishing:

```bash
gh workflow run publish.yml --ref <release-branch> -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/<version>.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. 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.
6 changes: 3 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.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"
Expand Down Expand Up @@ -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"
Expand Down
Loading
Loading