From cc7b290c05e7e07caba866d73da8ddfae732720e Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Fri, 2 Oct 2026 11:32:45 -0700 Subject: [PATCH 01/10] Migrate to category-folder changelog fragments --- .github/scripts/release_verify.py | 254 ++---------------- .github/workflows/changelog.yml | 4 +- AGENTS.md | 8 +- CHANGELOG.md | 17 +- CONTRIBUTING.md | 7 +- changelog/README.md | 63 +++++ .../breaking-changes/cartwheeling-cupcake.md | 1 + changelog/breaking-changes/sleepy-unicorn.md | 7 + changelog/changed/dancing-marshmallow.md | 3 + changelog/fixed/bouncing-badger.md | 4 + changelog/fixed/giggling-teapot.md | 2 + changelog/fixed/moonwalking-muffin.md | 4 + changelog/fixed/polka-dot-penguin.md | 5 + scripts/changelog.py | 94 +++++++ scripts/prepare_release.py | 214 +++++---------- temporalio/bridge/sdk-core | 2 +- tests/test_prepare_release.py | 237 +++++++++++----- 17 files changed, 462 insertions(+), 464 deletions(-) create mode 100644 changelog/README.md create mode 100644 changelog/breaking-changes/cartwheeling-cupcake.md create mode 100644 changelog/breaking-changes/sleepy-unicorn.md create mode 100644 changelog/changed/dancing-marshmallow.md create mode 100644 changelog/fixed/bouncing-badger.md create mode 100644 changelog/fixed/giggling-teapot.md create mode 100644 changelog/fixed/moonwalking-muffin.md create mode 100644 changelog/fixed/polka-dot-penguin.md create mode 100644 scripts/changelog.py diff --git a/.github/scripts/release_verify.py b/.github/scripts/release_verify.py index 0e13191643..60165bd888 100644 --- a/.github/scripts/release_verify.py +++ b/.github/scripts/release_verify.py @@ -4,13 +4,15 @@ import argparse import ast -import dataclasses -import difflib import pathlib import re import subprocess +import sys from collections.abc import Sequence +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2])) +from scripts.changelog import run_tool + try: import tomllib except ModuleNotFoundError: @@ -122,239 +124,31 @@ def _git(args: Sequence[str], *, cwd: pathlib.Path | None = None) -> str: ).strip() -def _version_tuple(version: str) -> tuple[int, ...] | None: - match = re.fullmatch(r"([0-9]+(?:\.[0-9]+)+)(?:[a-zA-Z0-9_.+-]+)?", version) - if not match: - return None - return tuple(int(part) for part in match.group(1).split(".")) - - -def _previous_release_tag(version: str) -> str: - current = _version_tuple(version) - if current is None: - raise RuntimeError(f"Cannot determine previous release for {version!r}") - - candidates: list[tuple[int, ...]] = [] - for tag in _git(["tag"]).splitlines(): - tag_version = _version_tuple(tag) - if tag_version is not None and tag_version < current: - candidates.append(tag_version) - if not candidates: - raise RuntimeError(f"Could not find a previous release tag before {version!r}") - return ".".join(str(part) for part in max(candidates)) - - -def _gitlink(rev: str, path: str) -> str: - output = _git(["ls-tree", rev, path]) - parts = output.split() - if len(parts) < 3 or parts[0] != "160000": - raise RuntimeError(f"Could not find submodule gitlink {path!r} at {rev!r}") - return parts[2] - - -def _clean_commit_subject(subject: str) -> str: - subject = subject.encode("ascii", "ignore").decode("ascii") - subject = re.sub(r"\s+", " ", subject).strip() - subject = re.sub(r"^:[a-z0-9_+-]+:\s*", "", subject) - return subject.replace(" : ", ": ") - - -def _link_sdk_core_prs(subject: str) -> str: - return re.sub( - r"\(#([0-9]+)\)", - r"([#\1](https://github.com/temporalio/sdk-rust/pull/\1))", - subject, - ) - - -def _changelog_entries(text: str) -> dict[str, list[list[str]]]: - entries: dict[str, list[list[str]]] = {} - header: str | None = None - entry: list[str] | None = None - - for line in text.splitlines(): - if line.startswith("### "): - if entry is not None: - entries.setdefault(header or "Other", []).append(entry) - entry = None - header = line.removeprefix("### ").strip() - elif line.startswith(("* ", "- ")): - if entry is not None: - entries.setdefault(header or "Other", []).append(entry) - entry = [line] - elif entry is not None: - if line.strip(): - entry.append(line) - else: - entries.setdefault(header or "Other", []).append(entry) - entry = None - - if entry is not None: - entries.setdefault(header or "Other", []).append(entry) - return entries - - -@dataclasses.dataclass -class _ChangelogEntry: - lines: list[str] - introduced_header: str | None = None - - -def _updated_changelog_entries( - previous_entries: dict[str, list[_ChangelogEntry]], - current_entries: dict[str, list[list[str]]], -) -> dict[str, list[_ChangelogEntry]]: - current = [ - (header, entry) - for header, header_entries in current_entries.items() - for entry in header_entries - ] - previous = [ - entry - for category_entries in previous_entries.values() - for entry in category_entries - ] - exact_matches: dict[tuple[str, ...], list[_ChangelogEntry]] = {} - for entry in previous: - exact_matches.setdefault(tuple(entry.lines), []).append(entry) - - matches: dict[int, _ChangelogEntry] = {} - matched_previous: set[int] = set() - for current_index, (_, entry) in enumerate(current): - exact = exact_matches.get(tuple(entry)) - if exact: - previous_entry = exact.pop(0) - matches[current_index] = previous_entry - matched_previous.add(id(previous_entry)) - - candidates: list[tuple[float, int, _ChangelogEntry]] = [] - for current_index, (_, current_entry) in enumerate(current): - if current_index in matches: - continue - for previous_entry in previous: - if id(previous_entry) in matched_previous: - continue - similarity = difflib.SequenceMatcher( - a="\n".join(previous_entry.lines), - b="\n".join(current_entry), - autojunk=False, - ).ratio() - if similarity >= 0.6: - candidates.append((similarity, current_index, previous_entry)) - for _, current_index, previous_entry in sorted( - candidates, key=lambda candidate: candidate[0], reverse=True - ): - if current_index not in matches and id(previous_entry) not in matched_previous: - matches[current_index] = previous_entry - matched_previous.add(id(previous_entry)) - - updated: dict[str, list[_ChangelogEntry]] = {} - for current_index, (header, entry) in enumerate(current): - previous_entry = matches.get(current_index) - updated.setdefault(header, []).append( - _ChangelogEntry( - entry, - previous_entry.introduced_header if previous_entry else header, - ) - ) - return updated - - -def _sdk_core_changelog_entries( - previous_commit: str, - current_commit: str, - path: pathlib.Path, -) -> list[str]: - output = subprocess.check_output( +def _sdk_core_release_notes(version: str, path: str) -> list[str]: + notes = run_tool( + pathlib.Path.cwd(), [ - "cargo", - "run", - "--quiet", - "-p", - "changelog-release-notes", - "--", - "--from", - previous_commit, - "--to", - current_commit, + "core-notes", + "--version", + version, + "--submodule", + path, ], - cwd=path, - encoding="utf-8", - stderr=subprocess.STDOUT, - ).strip() - return output.splitlines() if output else [] - - -def _sdk_core_release_notes(version: str, path: str) -> list[str]: - previous_tag = _previous_release_tag(version) - previous_commit = _gitlink(previous_tag, path) - current_commit = _gitlink("HEAD", path) - if previous_commit == current_commit: - return [] - - submodule_path = pathlib.Path(path) - if not (submodule_path / ".git").exists(): - raise RuntimeError( - f"Submodule {path!r} is not initialized; checkout with submodules" - ) - - try: - notes = _sdk_core_changelog_entries( - previous_commit, - current_commit, - submodule_path, - ) - except subprocess.CalledProcessError: - _git(["fetch", "--quiet", "origin", "main"], cwd=submodule_path) - try: - notes = _sdk_core_changelog_entries( - previous_commit, - current_commit, - submodule_path, - ) - except subprocess.CalledProcessError as error: - output = error.output.strip() if error.output else str(error) - raise RuntimeError( - "SDK Core changelog-release-notes failed after fetching origin/main:\n" - f"{output}" - ) from error - if not notes: - return [] - - return ["### SDK Core", "", *notes] + ).strip("\n") + return ["### SDK Core", "", *notes.splitlines()] if notes else [] def changelog_notes(args: argparse.Namespace) -> None: - changelog_path = pathlib.Path(args.changelog) - lines = changelog_path.read_text(encoding="utf-8").splitlines() - heading = re.compile(r"^## \[(?P[^\]]+)\](?:\s+-\s+.*)?\s*$") - - start = None - for index, line in enumerate(lines): - match = heading.match(line) - if match and match.group("version") == args.version: - start = index + 1 - break - - if start is None: - raise RuntimeError( - f"Could not find changelog section for version {args.version!r}" - ) - - end = len(lines) - for index in range(start, len(lines)): - if lines[index].startswith("## "): - end = index - break - - section_lines = lines[start:end] - while section_lines and not section_lines[0].strip(): - section_lines.pop(0) - while section_lines and not section_lines[-1].strip(): - section_lines.pop() - - if not section_lines: - raise RuntimeError(f"Changelog section for {args.version!r} is empty") + section_lines = run_tool( + pathlib.Path.cwd(), + [ + "notes", + "--version", + args.version, + "--changelog", + args.changelog, + ], + ).splitlines() note_lines = ["## Notable Changes", "", *section_lines] sdk_core_notes = _sdk_core_release_notes(args.version, args.sdk_core_path) diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index e5554a2255..3b247f0102 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -11,4 +11,6 @@ permissions: jobs: changelog-check: name: Changelog checkpoint - uses: temporalio/.github/.github/workflows/changelog.yml@d8129c755fbd1d3f18c5a7a5420d5754acc63e3f + uses: temporalio/.github/.github/workflows/changelog-fragments.yml@f2a689950aa8b18272aa57f6007fe9cec46b9f47 + with: + sdk-rust-path: temporalio/bridge/sdk-core diff --git a/AGENTS.md b/AGENTS.md index 7ecbb40d5c..9900b7491e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,8 +74,11 @@ For release artifacts, use `uv build`. Documentation can be generated with * Format and lint code before submitting. * Include tests for behavior changes. * Update public API documentation or doc comments for public behavior changes. -* Add a high-level changelog entry for user-facing changes according to the - existing `CHANGELOG.md` convention. +* For user-facing changes, add a Markdown fragment under `changelog//` + for each applicable category. Use fun, whimsical, unique lowercase kebab-case + filenames, such as `giggling-teapot.md`. The folder supplies the category; + write only the release-note body. See `changelog/README.md` for the convention. + `CHANGELOG.md` contains completed releases and is assembled by release tooling. * Keep commit messages short and in the imperative mood. * Provide a clear PR description outlining what changed, why it changed, and what validation was run. @@ -102,6 +105,7 @@ Reviewers will look for: * `temporalio/bridge/` - Rust bridge and generated bridge bindings. * `tests/` - pytest suites mirroring SDK areas. * `scripts/` - generation, documentation, and helper scripts. +* `changelog/` - pending per-PR release notes in category folders. * `build/apidocs/` - generated API documentation. * `dist/` - built wheels and source distributions. * `temporalio/bridge/target/` - Rust build output. You should not need to inspect diff --git a/CHANGELOG.md b/CHANGELOG.md index 100249973e..174cc705a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,17 +1,8 @@ # Changelog diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 057901ff56..30a94d2679 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -61,8 +61,11 @@ Good pull requests are focused and easy to review: * Keep each pull request scoped to one logical change. * Include tests for behavior changes. * Update public API documentation or doc comments when public behavior changes. -* Add a high-level changelog entry for user-facing changes according to the - repository's local changelog convention. +* For user-facing changes, add a Markdown fragment in each applicable + `changelog//` folder. Choose fun, whimsical, unique lowercase + kebab-case filenames, such as `giggling-teapot.md`. Include only the release-note + body; the folder supplies its category. See [the fragment guide](changelog/README.md). + Release tooling assembles `CHANGELOG.md` from these fragments. * Describe what changed, why it changed, and what validation you ran. Run the relevant local checks when practical. CI must pass before a pull request can diff --git a/changelog/README.md b/changelog/README.md new file mode 100644 index 0000000000..4fa53b9aea --- /dev/null +++ b/changelog/README.md @@ -0,0 +1,63 @@ +# Changelog fragments + +For each PR with user-facing changes, add a Markdown file in each applicable +category folder. Write high-level release notes describing what users can observe. +Use fun, whimsical, unique lowercase kebab-case filenames, such as +`fixed/giggling-teapot.md` or `added/dancing-marshmallow.md`. PR numbers are optional. + +| Folder | Changes | +| --- | --- | +| `added/` | New features | +| `changed/` | Changes in existing functionality | +| `deprecated/` | Soon-to-be-removed features | +| `breaking-changes/` | Removed or backwards-incompatible features | +| `fixed/` | Notable bug fixes | +| `security/` | Notable security fixes | + +Create a category folder when first needed. Place files directly inside it; +use one fragment per category for your PR. The folder supplies the category, so +the file contains only the entry, without a category or version heading: + +```markdown +- Reject unsupported activity executors when creating a worker, instead of + failing during activity execution. +``` + +Bodies can include paragraphs, examples, and documentation links. Do not edit +`CHANGELOG.md` for pending changes: it contains completed releases only. Maintainers +can apply `skip-changelog` to PRs that need no release note. + +## Preparing a release + +Release tooling requires Rust/Cargo and the checked-in sdk-rust submodule: + +```bash +git submodule update --init +python scripts/prepare_release.py 1.35.0 --date 2026-10-02 +``` + +Run preparation from a clean worktree. The script creates a release branch from +`origin/main`, asks the shared tool for a release plan, bumps Python versions, +refreshes `uv.lock`, and commits the assembled changelog and consumed fragment +deletions. It pushes the branch and opens a PR with the `skip-changelog` label. +Review the assembled notes before merging the release PR. + +Assembly creates a dated section at the top of the changelog, groups notes in the +category order above, and sorts filenames within each category. Empty categories +are omitted. Fragment bodies and older releases are preserved. Empty releases and +duplicate versions are rejected. Fragments are retained if validation or lockfile +refresh fails; inspect any partial version/changelog changes before retrying. + +Fragments merged after preparation remain pending for the next release. To +include them in the current release, update the release PR and regenerate its +notes from the intended fragment set before publishing. + +The shared CLI lives in sdk-rust's `changelog-release-notes` crate. To validate +pending notes without making changes: + +```bash +cargo run --manifest-path temporalio/bridge/sdk-core/crates/changelog-release-notes/Cargo.toml \ + --bin changelog-tool -- check --repo "$PWD" +``` + +See that crate's README for the reusable CLI and JSON release-plan interface. diff --git a/changelog/breaking-changes/cartwheeling-cupcake.md b/changelog/breaking-changes/cartwheeling-cupcake.md new file mode 100644 index 0000000000..2a75bd71c6 --- /dev/null +++ b/changelog/breaking-changes/cartwheeling-cupcake.md @@ -0,0 +1 @@ +- **Experimental**: Nexus Workflow Updates now require `wait_for_stage` to be explicitly set to `ACCEPTED`. diff --git a/changelog/breaking-changes/sleepy-unicorn.md b/changelog/breaking-changes/sleepy-unicorn.md new file mode 100644 index 0000000000..ee6e176754 --- /dev/null +++ b/changelog/breaking-changes/sleepy-unicorn.md @@ -0,0 +1,7 @@ +- The OpenAI Agents integration has moved to the independently versioned + [`temporalio-openai-agents`](https://pypi.org/project/temporalio-openai-agents/) + package. The existing `temporalio[openai-agents]` extra now installs that + package, and the old public `temporalio.contrib.openai_agents` imports + remain available at runtime and retain their static type information. + New code should depend on `temporalio-openai-agents` directly and import + `temporalio.openai_agents`. diff --git a/changelog/changed/dancing-marshmallow.md b/changelog/changed/dancing-marshmallow.md new file mode 100644 index 0000000000..1c3ddb22dd --- /dev/null +++ b/changelog/changed/dancing-marshmallow.md @@ -0,0 +1,3 @@ +- Payload converters exposed by data converters and workflow/activity accessors + retain transfer type conversion, so direct use behaves consistently with SDK + serialization. diff --git a/changelog/fixed/bouncing-badger.md b/changelog/fixed/bouncing-badger.md new file mode 100644 index 0000000000..026a859ea4 --- /dev/null +++ b/changelog/fixed/bouncing-badger.md @@ -0,0 +1,4 @@ +- `contrib.google_adk_agents`: agents with an `output_schema` no longer fail every workflow task + when calling the model. The schema type is now sent to the model activity as its JSON schema. + Custom Pydantic schema generation is preserved. + Integer-valued output enums are normalized to strings to match Google GenAI. diff --git a/changelog/fixed/giggling-teapot.md b/changelog/fixed/giggling-teapot.md new file mode 100644 index 0000000000..3b0672f96d --- /dev/null +++ b/changelog/fixed/giggling-teapot.md @@ -0,0 +1,2 @@ +- `temporalio.contrib.strands` activity and MCP tools now give the model the + Activity's failure message and expose its exception to after-tool hooks. diff --git a/changelog/fixed/moonwalking-muffin.md b/changelog/fixed/moonwalking-muffin.md new file mode 100644 index 0000000000..2d24e77a2a --- /dev/null +++ b/changelog/fixed/moonwalking-muffin.md @@ -0,0 +1,4 @@ +- Encoding a datetime search attribute without a timezone now raises + `ValueError("Timezone must be present on all search attribute dates")` on + the typed path, matching the deprecated untyped encoder, instead of sending + a naive ISO string that the server rejects with `BadSearchAttributes`. diff --git a/changelog/fixed/polka-dot-penguin.md b/changelog/fixed/polka-dot-penguin.md new file mode 100644 index 0000000000..1178646d63 --- /dev/null +++ b/changelog/fixed/polka-dot-penguin.md @@ -0,0 +1,5 @@ +- `temporalio.contrib.opentelemetry`: `TracingInterceptor` and `OpenTelemetryInterceptor` no longer + log `Failed to detach context` when a context is torn down on a different thread while + OpenTelemetry's threading instrumentation (enabled by strands, among others) is active; a + context is now detached exactly when its token is still valid in the current + `contextvars.Context`, which it stays when a workflow resumes on another pool thread. diff --git a/scripts/changelog.py b/scripts/changelog.py new file mode 100644 index 0000000000..b3c4de2e18 --- /dev/null +++ b/scripts/changelog.py @@ -0,0 +1,94 @@ +"""Invoke the shared, pinned sdk-rust changelog tooling.""" + +from __future__ import annotations + +import dataclasses +import datetime +import json +import pathlib +import subprocess +from collections.abc import Sequence + + +@dataclasses.dataclass(frozen=True) +class ReleasePlan: + """Validated changelog output and repository-relative fragments to consume.""" + + changelog: str + consumed_paths: tuple[str, ...] + + +def run_tool(repo_root: pathlib.Path, args: Sequence[str]) -> str: + """Run the pinned tool, retaining diagnostics on failure.""" + core = repo_root / "temporalio/bridge/sdk-core" + if not (core / ".git").exists(): + raise RuntimeError("Initialize sdk-rust with 'git submodule update --init'") + try: + result = subprocess.run( + [ + "cargo", + "run", + "--quiet", + "--manifest-path", + str(core / "crates/changelog-release-notes/Cargo.toml"), + "--bin", + "changelog-tool", + "--", + *args, + "--repo", + str(repo_root.resolve()), + ], + cwd=core, + check=True, + capture_output=True, + text=True, + ) + except subprocess.CalledProcessError as err: + raise RuntimeError(f"Shared changelog tool failed:\n{err.stderr}") from err + return result.stdout + + +def prepare_changelog( + repo_root: pathlib.Path, version: str, release_date: datetime.date +) -> ReleasePlan: + """Calculate release notes before modifying SDK files.""" + payload = json.loads( + run_tool( + repo_root, + [ + "prepare", + "--version", + version, + "--date", + release_date.isoformat(), + "--breaking-heading", + ":boom: Breaking Changes", + ], + ) + ) + if not isinstance(payload, dict) or not isinstance(payload.get("changelog"), str): + raise RuntimeError("Invalid changelog release plan") + paths = payload.get("consumed_paths") + if not isinstance(paths, list) or not all(isinstance(p, str) for p in paths): + raise RuntimeError("Invalid consumed fragment paths") + categories = { + "added", + "changed", + "deprecated", + "breaking-changes", + "fixed", + "security", + } + for path in paths: + parts = pathlib.PurePosixPath(path).parts + if ( + len(parts) != 3 + or parts[0] != "changelog" + or parts[1] not in categories + or pathlib.PurePosixPath(path).suffix != ".md" + or ".." in parts + ): + raise RuntimeError(f"Invalid consumed fragment path: {path!r}") + if len(set(paths)) != len(paths): + raise RuntimeError("Duplicate consumed fragment paths") + return ReleasePlan(payload["changelog"], tuple(paths)) diff --git a/scripts/prepare_release.py b/scripts/prepare_release.py index 51dc344962..c4c450532f 100644 --- a/scripts/prepare_release.py +++ b/scripts/prepare_release.py @@ -13,24 +13,15 @@ if __package__ is None or __package__ == "": sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) -CHANGELOG_HEADERS = ( - "Added", - "Changed", - "Deprecated", - ":boom: Breaking Changes", - "Fixed", - "Security", -) +from scripts.changelog import prepare_changelog + VERSION_RE = re.compile(r"[0-9]+(?:\.[0-9]+)+(?:[a-zA-Z0-9_.+-]+)?") -_CHANGELOG_HEADING_RE = re.compile(r"^## \[(?P[^\]]+)\](?:\s+-\s+.*)?\s*$") -_CHANGELOG_SUBHEADING_RE = re.compile(r"^### (?P
.+?)\s*$") _RELEASE_FILES = ( "CHANGELOG.md", "pyproject.toml", "temporalio/service.py", "uv.lock", ) -_RELEASE_FILE_SET = frozenset(_RELEASE_FILES) def validate_version(version: str) -> str: @@ -48,41 +39,6 @@ def parse_date(date: str) -> datetime.date: raise ValueError(f"Invalid release date {date!r}; expected YYYY-MM-DD") from err -def finalize_changelog_release( - text: str, - *, - version: str, - release_date: datetime.date, -) -> str: - validate_version(version) - lines = text.splitlines() - - if _find_version_section(lines, version) is not None: - raise RuntimeError(f"Changelog already has a section for {version!r}") - - unreleased = _find_version_section(lines, "Unreleased") - if unreleased is None: - raise RuntimeError("Could not find changelog section for 'Unreleased'") - - heading_index, section_start, section_end = unreleased - unreleased_lines = _strip_empty_changelog_headers( - _strip_outer_blank_lines(lines[section_start:section_end]) - ) - if not unreleased_lines: - raise RuntimeError("Changelog section for 'Unreleased' is empty") - - next_lines = [ - *lines[:heading_index], - *_seeded_unreleased_lines(), - f"## [{version}] - {release_date.isoformat()}", - "", - *unreleased_lines, - "", - *lines[section_end:], - ] - return "\n".join(_collapse_blank_lines(next_lines)).rstrip() + "\n" - - def replace_project_version(text: str, version: str) -> str: return _replace_once( r'(?m)^version = "[^"]+"[^\S\r\n]*$', @@ -108,17 +64,18 @@ def create_release_branch(repo_root: pathlib.Path, version: str) -> None: cwd=repo_root, check=True, ) + subprocess.run(["git", "submodule", "update", "--init"], cwd=repo_root, check=True) def changed_files(repo_root: pathlib.Path) -> set[str]: result = subprocess.run( - ["git", "status", "--porcelain"], + ["git", "status", "--porcelain", "-z", "--untracked-files=all"], cwd=repo_root, check=True, capture_output=True, text=True, ) - return {line[3:] for line in result.stdout.splitlines()} + return {entry[3:] for entry in result.stdout.split("\0") if entry} def ensure_clean_worktree(repo_root: pathlib.Path) -> None: @@ -130,8 +87,12 @@ def ensure_clean_worktree(repo_root: pathlib.Path) -> None: ) -def ensure_only_release_changes(repo_root: pathlib.Path) -> None: - unexpected_files = changed_files(repo_root) - _RELEASE_FILE_SET +def ensure_only_release_changes( + repo_root: pathlib.Path, consumed_paths: Sequence[str] = () +) -> None: + unexpected_files = ( + changed_files(repo_root) - set(_RELEASE_FILES) - set(consumed_paths) + ) if unexpected_files: raise RuntimeError( "Release preparation changed unexpected files: " @@ -139,9 +100,19 @@ def ensure_only_release_changes(repo_root: pathlib.Path) -> None: ) -def commit_release_changes(repo_root: pathlib.Path, version: str) -> None: +def commit_release_changes( + repo_root: pathlib.Path, version: str, consumed_paths: Sequence[str] = () +) -> None: subprocess.run( - ["git", "commit", "-m", f"Prepare release {version}", "--", *_RELEASE_FILES], + [ + "git", + "commit", + "-m", + f"Prepare release {version}", + "--", + *_RELEASE_FILES, + *consumed_paths, + ], cwd=repo_root, check=True, ) @@ -171,78 +142,14 @@ def create_release_pr(repo_root: pathlib.Path, version: str) -> None: f"Prepare release {version}", "--body", f"Prepare release {version}.", + "--label", + "skip-changelog", ], cwd=repo_root, check=True, ) -def _seeded_unreleased_lines() -> list[str]: - lines = ["## [Unreleased]", ""] - for header in CHANGELOG_HEADERS: - lines.extend([f"### {header}", ""]) - return lines - - -def _strip_empty_changelog_headers(lines: list[str]) -> list[str]: - filtered: list[str] = [] - index = 0 - while index < len(lines): - match = _CHANGELOG_SUBHEADING_RE.match(lines[index]) - if not match or match.group("header") not in CHANGELOG_HEADERS: - filtered.append(lines[index]) - index += 1 - continue - - next_index = index + 1 - while next_index < len(lines) and not lines[next_index].startswith("### "): - next_index += 1 - - content = lines[index + 1 : next_index] - if any(line.strip() for line in content): - filtered.append(lines[index]) - filtered.extend(content) - index = next_index - - return _strip_outer_blank_lines(filtered) - - -def _find_version_section( - lines: list[str], - version: str, -) -> tuple[int, int, int] | None: - for index, line in enumerate(lines): - match = _CHANGELOG_HEADING_RE.match(line) - if match and match.group("version") == version: - section_end = len(lines) - for end_index in range(index + 1, len(lines)): - if lines[end_index].startswith("## "): - section_end = end_index - break - return index, index + 1, section_end - return None - - -def _strip_outer_blank_lines(lines: list[str]) -> list[str]: - while lines and not lines[0].strip(): - lines.pop(0) - while lines and not lines[-1].strip(): - lines.pop() - return lines - - -def _collapse_blank_lines(lines: list[str]) -> list[str]: - collapsed: list[str] = [] - previous_blank = False - for line in lines: - blank = not line.strip() - if blank and previous_blank: - continue - collapsed.append(line) - previous_blank = blank - return collapsed - - def _replace_once( pattern: str, replacement: str, @@ -256,12 +163,41 @@ def _replace_once( return updated.rstrip("\n") +def prepare_release_files( + repo_root: pathlib.Path, + version: str, + release_date: datetime.date, + *, + skip_lock: bool = False, +) -> tuple[str, ...]: + """Apply a validated release plan and consume fragments after refreshing the lock.""" + plan = prepare_changelog(repo_root, version, release_date) + pyproject_path = repo_root / "pyproject.toml" + service_path = repo_root / "temporalio/service.py" + pyproject_text = ( + replace_project_version(pyproject_path.read_text(encoding="utf-8"), version) + + "\n" + ) + service_text = ( + replace_service_version(service_path.read_text(encoding="utf-8"), version) + + "\n" + ) + (repo_root / "CHANGELOG.md").write_text(plan.changelog, encoding="utf-8") + pyproject_path.write_text(pyproject_text, encoding="utf-8") + service_path.write_text(service_text, encoding="utf-8") + if not skip_lock: + subprocess.run(["uv", "lock"], cwd=repo_root, check=True) + ensure_only_release_changes(repo_root, plan.consumed_paths) + for path in plan.consumed_paths: + (repo_root / path).unlink() + return plan.consumed_paths + + def main(argv: Sequence[str] | None = None) -> None: parser = argparse.ArgumentParser( description=( - "Bump the SDK version, roll CHANGELOG.md's Unreleased section into " - "a dated release section, seed a fresh Unreleased section, and " - "refresh uv.lock." + "Collect changelog fragments into a dated release, bump versions, " + "refresh uv.lock, and open a PR." ) ) parser.add_argument("version", help="Release version, for example 1.30.0") @@ -282,39 +218,11 @@ def main(argv: Sequence[str] | None = None) -> None: release_date = parse_date(args.date) ensure_clean_worktree(repo_root) create_release_branch(repo_root, version) - changelog_path = repo_root / "CHANGELOG.md" - pyproject_path = repo_root / "pyproject.toml" - service_path = repo_root / "temporalio" / "service.py" - - changelog_text = finalize_changelog_release( - changelog_path.read_text(encoding="utf-8"), - version=version, - release_date=release_date, + consumed = prepare_release_files( + repo_root, version, release_date, skip_lock=args.skip_lock ) - pyproject_text = ( - replace_project_version( - pyproject_path.read_text(encoding="utf-8"), - version, - ) - + "\n" - ) - service_text = ( - replace_service_version( - service_path.read_text(encoding="utf-8"), - version, - ) - + "\n" - ) - - changelog_path.write_text(changelog_text, encoding="utf-8") - pyproject_path.write_text(pyproject_text, encoding="utf-8") - service_path.write_text(service_text, encoding="utf-8") - - if not args.skip_lock: - subprocess.run(["uv", "lock"], cwd=repo_root, check=True) - - ensure_only_release_changes(repo_root) - commit_release_changes(repo_root, version) + ensure_only_release_changes(repo_root, consumed) + commit_release_changes(repo_root, version, consumed) push_release_branch(repo_root, version) create_release_pr(repo_root, version) diff --git a/temporalio/bridge/sdk-core b/temporalio/bridge/sdk-core index e163abd6dc..50b01263df 160000 --- a/temporalio/bridge/sdk-core +++ b/temporalio/bridge/sdk-core @@ -1 +1 @@ -Subproject commit e163abd6dc19040064986a63c8b8cf4756ffd320 +Subproject commit 50b01263df49609bd05736998c63fd86cdc7c22d diff --git a/tests/test_prepare_release.py b/tests/test_prepare_release.py index b4c121e8f5..c1fab7e624 100644 --- a/tests/test_prepare_release.py +++ b/tests/test_prepare_release.py @@ -1,7 +1,9 @@ from __future__ import annotations +import argparse import datetime import importlib.util +import json import pathlib import subprocess import sys @@ -9,12 +11,16 @@ import pytest +import scripts.changelog +import scripts.prepare_release +from scripts.changelog import ReleasePlan, prepare_changelog from scripts.prepare_release import ( + commit_release_changes, create_release_branch, create_release_pr, ensure_clean_worktree, ensure_only_release_changes, - finalize_changelog_release, + prepare_release_files, push_release_branch, replace_project_version, replace_service_version, @@ -31,88 +37,194 @@ def _release_verify_module() -> ModuleType: return module -def test_sdk_core_changelog_entries_runs_core_script( +def test_sdk_core_release_notes_embed_shared_output( monkeypatch: pytest.MonkeyPatch, ) -> None: release_verify = _release_verify_module() - calls: list[tuple[list[str], pathlib.Path]] = [] - - def check_output(args: list[str], *, cwd: pathlib.Path, **_kwargs: object) -> str: - calls.append((args, cwd)) - return "#### Added\n\n* Core feature.\n" - - monkeypatch.setattr(subprocess, "check_output", check_output) - core_path = pathlib.Path("sdk-core") - assert release_verify._sdk_core_changelog_entries("old", "new", core_path) == [ - "#### Added", - "", - "* Core feature.", - ] - assert calls == [ - ( - [ - "cargo", - "run", - "--quiet", - "-p", - "changelog-release-notes", - "--", - "--from", - "old", - "--to", - "new", - ], - core_path, - ) - ] - - -def test_sdk_core_release_notes_embed_core_output( - monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path -) -> None: - release_verify = _release_verify_module() - (tmp_path / ".git").mkdir() - monkeypatch.setattr(release_verify, "_previous_release_tag", lambda _version: "old") - monkeypatch.setattr(release_verify, "_gitlink", lambda revision, _path: revision) monkeypatch.setattr( - subprocess, - "check_output", - lambda *_args, **_kwargs: "#### Commits\n\n- Core commit\n", + release_verify, "run_tool", lambda *_args: "#### Commits\n\n- Core commit\n" ) - - assert release_verify._sdk_core_release_notes("1.30.0", str(tmp_path)) == [ + assert release_verify._sdk_core_release_notes("1.35.0", "core") == [ "### SDK Core", "", "#### Commits", "", "- Core commit", ] + monkeypatch.setattr(release_verify, "run_tool", lambda *_args: "") + assert release_verify._sdk_core_release_notes("1.35.0", "core") == [] -def test_sdk_core_release_notes_preserves_generator_failure_output( +def test_published_notes_keep_python_and_core_sections( monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: release_verify = _release_verify_module() - (tmp_path / ".git").mkdir() - monkeypatch.setattr(release_verify, "_previous_release_tag", lambda _version: "old") - monkeypatch.setattr(release_verify, "_gitlink", lambda revision, _path: revision) - monkeypatch.setattr(release_verify, "_git", lambda *_args, **_kwargs: "") - error = subprocess.CalledProcessError(101, ["cargo"], output="compiler output") + + def run_tool(_root: pathlib.Path, args: list[str]) -> str: + return ( + "### Fixed\n\n- Python fix.\n" + if args[0] == "notes" + else "#### Fixed\n\n- Core fix.\n" + ) + + monkeypatch.setattr(release_verify, "run_tool", run_tool) + output = tmp_path / "notes.md" + release_verify.changelog_notes( + argparse.Namespace( + version="1.35.0", + changelog="CHANGELOG.md", + sdk_core_path="core", + output=str(output), + ) + ) + assert ( + output.read_text() + == "## Notable Changes\n\n### Fixed\n\n- Python fix.\n\n### SDK Core\n\n#### Fixed\n\n- Core fix.\n" + ) + + +def test_shared_tool_preserves_failure_diagnostics( + monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path +) -> None: + (tmp_path / "temporalio/bridge/sdk-core/.git").mkdir(parents=True) + + def fail(*_args: object, **_kwargs: object) -> None: + raise subprocess.CalledProcessError(1, ["cargo"], stderr="invalid fragment") + + monkeypatch.setattr(subprocess, "run", fail) + with pytest.raises(RuntimeError, match="invalid fragment"): + scripts.changelog.run_tool(tmp_path, ["check"]) + + +@pytest.mark.parametrize( + "paths", + [ + ["/etc/notes.md"], + ["changelog/fixed/../../service.py"], + ["changelog/unknown/note.md"], + ["changelog/fixed/note.md"] * 2, + ], +) +def test_invalid_consumed_paths_rejected( + monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path, paths: list[str] +) -> None: monkeypatch.setattr( - release_verify, - "_sdk_core_changelog_entries", - lambda *_args: (_ for _ in ()).throw(error), + scripts.changelog, + "run_tool", + lambda *_args: json.dumps({"changelog": "notes", "consumed_paths": paths}), ) + with pytest.raises(RuntimeError): + prepare_changelog(tmp_path, "1.35.0", datetime.date(2026, 10, 2)) - with pytest.raises(RuntimeError, match="compiler output"): - release_verify._sdk_core_release_notes("1.30.0", str(tmp_path)) +@pytest.fixture +def release_repo(tmp_path: pathlib.Path) -> pathlib.Path: + for name, text in { + "CHANGELOG.md": "# Changelog\n\n## [1.34.0] - 2026-09-30\nOld notes.\n", + "pyproject.toml": 'version = "1.34.0"\n', + "temporalio/service.py": '__version__ = "1.34.0"\n', + "uv.lock": "lock\n", + "changelog/fixed/giggling-teapot.md": "- A fix.\n", + }.items(): + path = tmp_path / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) + subprocess.run(["git", "add", "."], cwd=tmp_path, check=True) + subprocess.run( + [ + "git", + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "commit", + "-qm", + "Initial", + ], + cwd=tmp_path, + check=True, + ) + return tmp_path -def test_finalize_changelog_release() -> None: - text = "## [Unreleased]\n\n### Added\n\n- A thing.\n" - assert "## [1.30.0] - 2026-06-18" in finalize_changelog_release( - text, version="1.30.0", release_date=datetime.date(2026, 6, 18) + +def mock_plan(monkeypatch: pytest.MonkeyPatch) -> ReleasePlan: + plan = ReleasePlan( + "# Changelog\n\n## [1.35.0] - 2026-10-02\n\n### Fixed\n\n- A fix.\n", + ("changelog/fixed/giggling-teapot.md",), + ) + monkeypatch.setattr( + scripts.prepare_release, "prepare_changelog", lambda *_args: plan ) + return plan + + +def test_release_consumes_and_commits_only_planned_fragments( + monkeypatch: pytest.MonkeyPatch, release_repo: pathlib.Path +) -> None: + plan = mock_plan(monkeypatch) + consumed = prepare_release_files( + release_repo, "1.35.0", datetime.date(2026, 10, 2), skip_lock=True + ) + assert consumed == plan.consumed_paths + assert not (release_repo / consumed[0]).exists() + ensure_only_release_changes(release_repo, consumed) + subprocess.run(["git", "config", "user.name", "Test"], cwd=release_repo, check=True) + subprocess.run( + ["git", "config", "user.email", "test@example.com"], + cwd=release_repo, + check=True, + ) + commit_release_changes(release_repo, "1.35.0", consumed) + deleted = subprocess.check_output( + ["git", "diff", "--name-only", "--diff-filter=D", "HEAD~", "HEAD"], + cwd=release_repo, + text=True, + ).splitlines() + assert deleted == list(consumed) + assert (release_repo / "CHANGELOG.md").read_text() == plan.changelog + assert "1.35.0" in (release_repo / "temporalio/service.py").read_text() + + +def test_lock_failure_retains_fragments( + monkeypatch: pytest.MonkeyPatch, release_repo: pathlib.Path +) -> None: + mock_plan(monkeypatch) + + def fail(*_args: object, **_kwargs: object) -> None: + raise subprocess.CalledProcessError(1, ["uv", "lock"]) + + monkeypatch.setattr(subprocess, "run", fail) + with pytest.raises(subprocess.CalledProcessError): + prepare_release_files(release_repo, "1.35.0", datetime.date(2026, 10, 2)) + assert (release_repo / "changelog/fixed/giggling-teapot.md").exists() + + +def test_invalid_version_files_do_not_write_notes_or_consume_fragments( + monkeypatch: pytest.MonkeyPatch, release_repo: pathlib.Path +) -> None: + mock_plan(monkeypatch) + original = (release_repo / "CHANGELOG.md").read_text() + (release_repo / "temporalio/service.py").write_text("missing version\n") + with pytest.raises(RuntimeError, match="service version"): + prepare_release_files( + release_repo, "1.35.0", datetime.date(2026, 10, 2), skip_lock=True + ) + assert (release_repo / "CHANGELOG.md").read_text() == original + assert (release_repo / "changelog/fixed/giggling-teapot.md").exists() + + +def test_unexpected_changes_rejected_before_consuming( + monkeypatch: pytest.MonkeyPatch, release_repo: pathlib.Path +) -> None: + mock_plan(monkeypatch) + (release_repo / "changelog/fixed/late-llama.md").write_text("- Late change.\n") + with pytest.raises(RuntimeError, match="unexpected files"): + prepare_release_files( + release_repo, "1.35.0", datetime.date(2026, 10, 2), skip_lock=True + ) + assert (release_repo / "changelog/fixed/giggling-teapot.md").exists() + assert (release_repo / "changelog/fixed/late-llama.md").exists() def test_replace_versions() -> None: @@ -143,7 +255,7 @@ def test_clean_worktree_rejected(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( subprocess, "run", - lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, " M file\n"), + lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, " M file\0"), ) with pytest.raises(RuntimeError, match="clean worktree"): ensure_clean_worktree(pathlib.Path("/repo")) @@ -158,6 +270,7 @@ def test_create_release_pr(monkeypatch: pytest.MonkeyPatch) -> None: ) create_release_pr(pathlib.Path("/repo"), "1.30.0") assert "chore/release-1.30.0" in calls[0] + assert calls[0][-2:] == ["--label", "skip-changelog"] def test_push_release_branch(monkeypatch: pytest.MonkeyPatch) -> None: From 9731480c1ca1b12fe8547e5aebbe8253617362c1 Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Fri, 2 Oct 2026 11:49:59 -0700 Subject: [PATCH 02/10] Update Python versions before shared changelog preparation --- changelog/README.md | 15 ++++-- scripts/changelog.py | 64 +++++------------------ scripts/prepare_release.py | 18 ++++--- temporalio/bridge/sdk-core | 2 +- tests/test_prepare_release.py | 95 +++++++++++++++++++++-------------- 5 files changed, 91 insertions(+), 103 deletions(-) diff --git a/changelog/README.md b/changelog/README.md index 4fa53b9aea..13637d507c 100644 --- a/changelog/README.md +++ b/changelog/README.md @@ -37,16 +37,19 @@ python scripts/prepare_release.py 1.35.0 --date 2026-10-02 ``` Run preparation from a clean worktree. The script creates a release branch from -`origin/main`, asks the shared tool for a release plan, bumps Python versions, -refreshes `uv.lock`, and commits the assembled changelog and consumed fragment -deletions. It pushes the branch and opens a PR with the `skip-changelog` label. +`origin/main`, bumps Python versions and refreshes `uv.lock`, then tells the +shared tool to write the dated changelog and consume its fragments. It commits +the resulting version, lockfile, changelog, and fragment-deletion changes. It pushes the branch and opens a PR with the `skip-changelog` label. Review the assembled notes before merging the release PR. Assembly creates a dated section at the top of the changelog, groups notes in the category order above, and sorts filenames within each category. Empty categories are omitted. Fragment bodies and older releases are preserved. Empty releases and duplicate versions are rejected. Fragments are retained if validation or lockfile -refresh fails; inspect any partial version/changelog changes before retrying. +refresh fails. A changelog validation failure leaves the version and lockfile +updates in the local worktree; inspect or restore those changes before retrying. +File-system errors can leave partial changelog preparation as well; preparation +stops without committing so those changes can be inspected. Fragments merged after preparation remain pending for the next release. To include them in the current release, update the release PR and regenerate its @@ -60,4 +63,6 @@ cargo run --manifest-path temporalio/bridge/sdk-core/crates/changelog-release-no --bin changelog-tool -- check --repo "$PWD" ``` -See that crate's README for the reusable CLI and JSON release-plan interface. +See that crate's README for the reusable CLI. SDK-specific version and lockfile +updates happen before its `prepare` command; no hook or release-plan exchange is +required. diff --git a/scripts/changelog.py b/scripts/changelog.py index b3c4de2e18..c3126f913f 100644 --- a/scripts/changelog.py +++ b/scripts/changelog.py @@ -2,22 +2,12 @@ from __future__ import annotations -import dataclasses import datetime -import json import pathlib import subprocess from collections.abc import Sequence -@dataclasses.dataclass(frozen=True) -class ReleasePlan: - """Validated changelog output and repository-relative fragments to consume.""" - - changelog: str - consumed_paths: tuple[str, ...] - - def run_tool(repo_root: pathlib.Path, args: Sequence[str]) -> str: """Run the pinned tool, retaining diagnostics on failure.""" core = repo_root / "temporalio/bridge/sdk-core" @@ -50,45 +40,17 @@ def run_tool(repo_root: pathlib.Path, args: Sequence[str]) -> str: def prepare_changelog( repo_root: pathlib.Path, version: str, release_date: datetime.date -) -> ReleasePlan: - """Calculate release notes before modifying SDK files.""" - payload = json.loads( - run_tool( - repo_root, - [ - "prepare", - "--version", - version, - "--date", - release_date.isoformat(), - "--breaking-heading", - ":boom: Breaking Changes", - ], - ) +) -> None: + """Write the dated changelog and consume fragments using the shared tool.""" + run_tool( + repo_root, + [ + "prepare", + "--version", + version, + "--date", + release_date.isoformat(), + "--breaking-heading", + ":boom: Breaking Changes", + ], ) - if not isinstance(payload, dict) or not isinstance(payload.get("changelog"), str): - raise RuntimeError("Invalid changelog release plan") - paths = payload.get("consumed_paths") - if not isinstance(paths, list) or not all(isinstance(p, str) for p in paths): - raise RuntimeError("Invalid consumed fragment paths") - categories = { - "added", - "changed", - "deprecated", - "breaking-changes", - "fixed", - "security", - } - for path in paths: - parts = pathlib.PurePosixPath(path).parts - if ( - len(parts) != 3 - or parts[0] != "changelog" - or parts[1] not in categories - or pathlib.PurePosixPath(path).suffix != ".md" - or ".." in parts - ): - raise RuntimeError(f"Invalid consumed fragment path: {path!r}") - if len(set(paths)) != len(paths): - raise RuntimeError("Duplicate consumed fragment paths") - return ReleasePlan(payload["changelog"], tuple(paths)) diff --git a/scripts/prepare_release.py b/scripts/prepare_release.py index c4c450532f..4bbc0df103 100644 --- a/scripts/prepare_release.py +++ b/scripts/prepare_release.py @@ -170,8 +170,7 @@ def prepare_release_files( *, skip_lock: bool = False, ) -> tuple[str, ...]: - """Apply a validated release plan and consume fragments after refreshing the lock.""" - plan = prepare_changelog(repo_root, version, release_date) + """Update Python versions and lockfile before preparing the shared changelog.""" pyproject_path = repo_root / "pyproject.toml" service_path = repo_root / "temporalio/service.py" pyproject_text = ( @@ -182,15 +181,20 @@ def prepare_release_files( replace_service_version(service_path.read_text(encoding="utf-8"), version) + "\n" ) - (repo_root / "CHANGELOG.md").write_text(plan.changelog, encoding="utf-8") pyproject_path.write_text(pyproject_text, encoding="utf-8") service_path.write_text(service_text, encoding="utf-8") if not skip_lock: subprocess.run(["uv", "lock"], cwd=repo_root, check=True) - ensure_only_release_changes(repo_root, plan.consumed_paths) - for path in plan.consumed_paths: - (repo_root / path).unlink() - return plan.consumed_paths + ensure_only_release_changes(repo_root) + prepare_changelog(repo_root, version, release_date) + result = subprocess.run( + ["git", "ls-files", "--deleted", "-z", "--", "changelog"], + cwd=repo_root, + check=True, + capture_output=True, + text=True, + ) + return tuple(path for path in result.stdout.split("\0") if path) def main(argv: Sequence[str] | None = None) -> None: diff --git a/temporalio/bridge/sdk-core b/temporalio/bridge/sdk-core index 50b01263df..7cda99daaa 160000 --- a/temporalio/bridge/sdk-core +++ b/temporalio/bridge/sdk-core @@ -1 +1 @@ -Subproject commit 50b01263df49609bd05736998c63fd86cdc7c22d +Subproject commit 7cda99daaa5b9056a3bb573d79515a09e81558f3 diff --git a/tests/test_prepare_release.py b/tests/test_prepare_release.py index c1fab7e624..f110472e41 100644 --- a/tests/test_prepare_release.py +++ b/tests/test_prepare_release.py @@ -3,7 +3,6 @@ import argparse import datetime import importlib.util -import json import pathlib import subprocess import sys @@ -13,7 +12,6 @@ import scripts.changelog import scripts.prepare_release -from scripts.changelog import ReleasePlan, prepare_changelog from scripts.prepare_release import ( commit_release_changes, create_release_branch, @@ -96,27 +94,6 @@ def fail(*_args: object, **_kwargs: object) -> None: scripts.changelog.run_tool(tmp_path, ["check"]) -@pytest.mark.parametrize( - "paths", - [ - ["/etc/notes.md"], - ["changelog/fixed/../../service.py"], - ["changelog/unknown/note.md"], - ["changelog/fixed/note.md"] * 2, - ], -) -def test_invalid_consumed_paths_rejected( - monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path, paths: list[str] -) -> None: - monkeypatch.setattr( - scripts.changelog, - "run_tool", - lambda *_args: json.dumps({"changelog": "notes", "consumed_paths": paths}), - ) - with pytest.raises(RuntimeError): - prepare_changelog(tmp_path, "1.35.0", datetime.date(2026, 10, 2)) - - @pytest.fixture def release_repo(tmp_path: pathlib.Path) -> pathlib.Path: for name, text in { @@ -148,25 +125,27 @@ def release_repo(tmp_path: pathlib.Path) -> pathlib.Path: return tmp_path -def mock_plan(monkeypatch: pytest.MonkeyPatch) -> ReleasePlan: - plan = ReleasePlan( - "# Changelog\n\n## [1.35.0] - 2026-10-02\n\n### Fixed\n\n- A fix.\n", - ("changelog/fixed/giggling-teapot.md",), - ) - monkeypatch.setattr( - scripts.prepare_release, "prepare_changelog", lambda *_args: plan - ) - return plan +def mock_preparation(monkeypatch: pytest.MonkeyPatch) -> str: + changelog = "# Changelog\n\n## [1.35.0] - 2026-10-02\n\n### Fixed\n\n- A fix.\n" + + def prepare(repo: pathlib.Path, version: str, _date: datetime.date) -> None: + assert version in (repo / "pyproject.toml").read_text() + assert version in (repo / "temporalio/service.py").read_text() + (repo / "CHANGELOG.md").write_text(changelog) + (repo / "changelog/fixed/giggling-teapot.md").unlink() + monkeypatch.setattr(scripts.prepare_release, "prepare_changelog", prepare) + return changelog -def test_release_consumes_and_commits_only_planned_fragments( + +def test_release_commits_consumed_fragments( monkeypatch: pytest.MonkeyPatch, release_repo: pathlib.Path ) -> None: - plan = mock_plan(monkeypatch) + changelog = mock_preparation(monkeypatch) consumed = prepare_release_files( release_repo, "1.35.0", datetime.date(2026, 10, 2), skip_lock=True ) - assert consumed == plan.consumed_paths + assert consumed == ("changelog/fixed/giggling-teapot.md",) assert not (release_repo / consumed[0]).exists() ensure_only_release_changes(release_repo, consumed) subprocess.run(["git", "config", "user.name", "Test"], cwd=release_repo, check=True) @@ -182,14 +161,14 @@ def test_release_consumes_and_commits_only_planned_fragments( text=True, ).splitlines() assert deleted == list(consumed) - assert (release_repo / "CHANGELOG.md").read_text() == plan.changelog + assert (release_repo / "CHANGELOG.md").read_text() == changelog assert "1.35.0" in (release_repo / "temporalio/service.py").read_text() def test_lock_failure_retains_fragments( monkeypatch: pytest.MonkeyPatch, release_repo: pathlib.Path ) -> None: - mock_plan(monkeypatch) + mock_preparation(monkeypatch) def fail(*_args: object, **_kwargs: object) -> None: raise subprocess.CalledProcessError(1, ["uv", "lock"]) @@ -203,7 +182,7 @@ def fail(*_args: object, **_kwargs: object) -> None: def test_invalid_version_files_do_not_write_notes_or_consume_fragments( monkeypatch: pytest.MonkeyPatch, release_repo: pathlib.Path ) -> None: - mock_plan(monkeypatch) + mock_preparation(monkeypatch) original = (release_repo / "CHANGELOG.md").read_text() (release_repo / "temporalio/service.py").write_text("missing version\n") with pytest.raises(RuntimeError, match="service version"): @@ -217,7 +196,7 @@ def test_invalid_version_files_do_not_write_notes_or_consume_fragments( def test_unexpected_changes_rejected_before_consuming( monkeypatch: pytest.MonkeyPatch, release_repo: pathlib.Path ) -> None: - mock_plan(monkeypatch) + mock_preparation(monkeypatch) (release_repo / "changelog/fixed/late-llama.md").write_text("- Late change.\n") with pytest.raises(RuntimeError, match="unexpected files"): prepare_release_files( @@ -227,6 +206,44 @@ def test_unexpected_changes_rejected_before_consuming( assert (release_repo / "changelog/fixed/late-llama.md").exists() +def test_lock_update_precedes_changelog_preparation( + monkeypatch: pytest.MonkeyPatch, release_repo: pathlib.Path +) -> None: + calls: list[str] = [] + + def run(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: + if command == ["uv", "lock"]: + calls.append("lock") + return subprocess.CompletedProcess(command, 0, "") + + def prepare(*_args: object) -> None: + assert calls == ["lock"] + calls.append("changelog") + + monkeypatch.setattr(subprocess, "run", run) + monkeypatch.setattr(scripts.prepare_release, "prepare_changelog", prepare) + prepare_release_files(release_repo, "1.35.0", datetime.date(2026, 10, 2)) + assert calls == ["lock", "changelog"] + + +def test_changelog_rejection_retains_notes_and_version_updates( + monkeypatch: pytest.MonkeyPatch, release_repo: pathlib.Path +) -> None: + original = (release_repo / "CHANGELOG.md").read_text() + + def reject(*_args: object) -> None: + raise RuntimeError("invalid fragment") + + monkeypatch.setattr(scripts.prepare_release, "prepare_changelog", reject) + with pytest.raises(RuntimeError, match="invalid fragment"): + prepare_release_files( + release_repo, "1.35.0", datetime.date(2026, 10, 2), skip_lock=True + ) + assert (release_repo / "CHANGELOG.md").read_text() == original + assert (release_repo / "changelog/fixed/giggling-teapot.md").exists() + assert "1.35.0" in (release_repo / "pyproject.toml").read_text() + + def test_replace_versions() -> None: assert 'version = "1.30.0"' in replace_project_version( 'version = "1.29.0"\n', "1.30.0" From 94d416f09c47da6e691bbc9594b8b03e7e595b90 Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Fri, 2 Oct 2026 11:53:58 -0700 Subject: [PATCH 03/10] Pin shared changelog CLI using clap --- temporalio/bridge/sdk-core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/temporalio/bridge/sdk-core b/temporalio/bridge/sdk-core index 7cda99daaa..78faa9704a 160000 --- a/temporalio/bridge/sdk-core +++ b/temporalio/bridge/sdk-core @@ -1 +1 @@ -Subproject commit 7cda99daaa5b9056a3bb573d79515a09e81558f3 +Subproject commit 78faa9704a4d5392fb0b3062c6ed4111301b65d5 From 2f6d216017c3b352678baf730e7947dd13f7fe0a Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Fri, 2 Oct 2026 11:55:45 -0700 Subject: [PATCH 04/10] Pin shared changelog output simplification --- temporalio/bridge/sdk-core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/temporalio/bridge/sdk-core b/temporalio/bridge/sdk-core index 78faa9704a..dda0f0f221 160000 --- a/temporalio/bridge/sdk-core +++ b/temporalio/bridge/sdk-core @@ -1 +1 @@ -Subproject commit 78faa9704a4d5392fb0b3062c6ed4111301b65d5 +Subproject commit dda0f0f2210f9166066d1312f7b81b92c1c5870b From 61efe8274b3ed772e3c71c0a80b1a38c8b11a15e Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Mon, 5 Oct 2026 09:35:04 -0700 Subject: [PATCH 05/10] Write concise changelog fragments without list markers --- AGENTS.md | 4 +++- CONTRIBUTING.md | 3 ++- changelog/README.md | 11 +++++++---- changelog/breaking-changes/cartwheeling-cupcake.md | 2 +- changelog/breaking-changes/sleepy-unicorn.md | 14 +++++++------- changelog/changed/dancing-marshmallow.md | 6 +++--- changelog/fixed/bouncing-badger.md | 8 ++++---- changelog/fixed/giggling-teapot.md | 4 ++-- changelog/fixed/moonwalking-muffin.md | 8 ++++---- changelog/fixed/polka-dot-penguin.md | 10 +++++----- temporalio/bridge/sdk-core | 2 +- tests/test_prepare_release.py | 4 ++-- 12 files changed, 41 insertions(+), 35 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9900b7491e..53f1823da0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,7 +77,9 @@ For release artifacts, use `uv build`. Documentation can be generated with * For user-facing changes, add a Markdown fragment under `changelog//` for each applicable category. Use fun, whimsical, unique lowercase kebab-case filenames, such as `giggling-teapot.md`. The folder supplies the category; - write only the release-note body. See `changelog/README.md` for the convention. + write only the release-note body, without a leading list marker (`-`). Keep + entries concise, ideally one or two sentences. Release tooling adds the list + marker. See `changelog/README.md` for the convention. `CHANGELOG.md` contains completed releases and is assembled by release tooling. * Keep commit messages short and in the imperative mood. * Provide a clear PR description outlining what changed, why it changed, and diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 30a94d2679..3e1b2e76a3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,7 +64,8 @@ Good pull requests are focused and easy to review: * For user-facing changes, add a Markdown fragment in each applicable `changelog//` folder. Choose fun, whimsical, unique lowercase kebab-case filenames, such as `giggling-teapot.md`. Include only the release-note - body; the folder supplies its category. See [the fragment guide](changelog/README.md). + body without a leading list marker; the folder supplies its category. Keep + entries concise, ideally one or two sentences. See [the fragment guide](changelog/README.md). Release tooling assembles `CHANGELOG.md` from these fragments. * Describe what changed, why it changed, and what validation you ran. diff --git a/changelog/README.md b/changelog/README.md index 13637d507c..1456151756 100644 --- a/changelog/README.md +++ b/changelog/README.md @@ -2,6 +2,7 @@ For each PR with user-facing changes, add a Markdown file in each applicable category folder. Write high-level release notes describing what users can observe. +Keep entries concise, ideally one or two sentences. Use fun, whimsical, unique lowercase kebab-case filenames, such as `fixed/giggling-teapot.md` or `added/dancing-marshmallow.md`. PR numbers are optional. @@ -16,11 +17,12 @@ Use fun, whimsical, unique lowercase kebab-case filenames, such as Create a category folder when first needed. Place files directly inside it; use one fragment per category for your PR. The folder supplies the category, so -the file contains only the entry, without a category or version heading: +the file contains only the entry, without a category or version heading or a +leading list marker (`-`). Release tooling adds the list marker: ```markdown -- Reject unsupported activity executors when creating a worker, instead of - failing during activity execution. +Reject unsupported activity executors when creating a worker, instead of +failing during activity execution. ``` Bodies can include paragraphs, examples, and documentation links. Do not edit @@ -44,7 +46,8 @@ Review the assembled notes before merging the release PR. Assembly creates a dated section at the top of the changelog, groups notes in the category order above, and sorts filenames within each category. Empty categories -are omitted. Fragment bodies and older releases are preserved. Empty releases and +are omitted. Each fragment becomes one list item, with continuation lines indented +to keep paragraphs and examples inside it. Older releases are preserved. Empty releases and duplicate versions are rejected. Fragments are retained if validation or lockfile refresh fails. A changelog validation failure leaves the version and lockfile updates in the local worktree; inspect or restore those changes before retrying. diff --git a/changelog/breaking-changes/cartwheeling-cupcake.md b/changelog/breaking-changes/cartwheeling-cupcake.md index 2a75bd71c6..848a500dca 100644 --- a/changelog/breaking-changes/cartwheeling-cupcake.md +++ b/changelog/breaking-changes/cartwheeling-cupcake.md @@ -1 +1 @@ -- **Experimental**: Nexus Workflow Updates now require `wait_for_stage` to be explicitly set to `ACCEPTED`. +**Experimental**: Nexus Workflow Updates now require `wait_for_stage` to be explicitly set to `ACCEPTED`. diff --git a/changelog/breaking-changes/sleepy-unicorn.md b/changelog/breaking-changes/sleepy-unicorn.md index ee6e176754..320575fc15 100644 --- a/changelog/breaking-changes/sleepy-unicorn.md +++ b/changelog/breaking-changes/sleepy-unicorn.md @@ -1,7 +1,7 @@ -- The OpenAI Agents integration has moved to the independently versioned - [`temporalio-openai-agents`](https://pypi.org/project/temporalio-openai-agents/) - package. The existing `temporalio[openai-agents]` extra now installs that - package, and the old public `temporalio.contrib.openai_agents` imports - remain available at runtime and retain their static type information. - New code should depend on `temporalio-openai-agents` directly and import - `temporalio.openai_agents`. +The OpenAI Agents integration has moved to the independently versioned +[`temporalio-openai-agents`](https://pypi.org/project/temporalio-openai-agents/) +package. The existing `temporalio[openai-agents]` extra now installs that +package, and the old public `temporalio.contrib.openai_agents` imports +remain available at runtime and retain their static type information. +New code should depend on `temporalio-openai-agents` directly and import +`temporalio.openai_agents`. diff --git a/changelog/changed/dancing-marshmallow.md b/changelog/changed/dancing-marshmallow.md index 1c3ddb22dd..bbfb3cc2f5 100644 --- a/changelog/changed/dancing-marshmallow.md +++ b/changelog/changed/dancing-marshmallow.md @@ -1,3 +1,3 @@ -- Payload converters exposed by data converters and workflow/activity accessors - retain transfer type conversion, so direct use behaves consistently with SDK - serialization. +Payload converters exposed by data converters and workflow/activity accessors +retain transfer type conversion, so direct use behaves consistently with SDK +serialization. diff --git a/changelog/fixed/bouncing-badger.md b/changelog/fixed/bouncing-badger.md index 026a859ea4..c76234a7eb 100644 --- a/changelog/fixed/bouncing-badger.md +++ b/changelog/fixed/bouncing-badger.md @@ -1,4 +1,4 @@ -- `contrib.google_adk_agents`: agents with an `output_schema` no longer fail every workflow task - when calling the model. The schema type is now sent to the model activity as its JSON schema. - Custom Pydantic schema generation is preserved. - Integer-valued output enums are normalized to strings to match Google GenAI. +`contrib.google_adk_agents`: agents with an `output_schema` no longer fail every workflow task +when calling the model. The schema type is now sent to the model activity as its JSON schema. +Custom Pydantic schema generation is preserved. +Integer-valued output enums are normalized to strings to match Google GenAI. diff --git a/changelog/fixed/giggling-teapot.md b/changelog/fixed/giggling-teapot.md index 3b0672f96d..26f03e9288 100644 --- a/changelog/fixed/giggling-teapot.md +++ b/changelog/fixed/giggling-teapot.md @@ -1,2 +1,2 @@ -- `temporalio.contrib.strands` activity and MCP tools now give the model the - Activity's failure message and expose its exception to after-tool hooks. +`temporalio.contrib.strands` activity and MCP tools now give the model the +Activity's failure message and expose its exception to after-tool hooks. diff --git a/changelog/fixed/moonwalking-muffin.md b/changelog/fixed/moonwalking-muffin.md index 2d24e77a2a..8723efd4a8 100644 --- a/changelog/fixed/moonwalking-muffin.md +++ b/changelog/fixed/moonwalking-muffin.md @@ -1,4 +1,4 @@ -- Encoding a datetime search attribute without a timezone now raises - `ValueError("Timezone must be present on all search attribute dates")` on - the typed path, matching the deprecated untyped encoder, instead of sending - a naive ISO string that the server rejects with `BadSearchAttributes`. +Encoding a datetime search attribute without a timezone now raises +`ValueError("Timezone must be present on all search attribute dates")` on +the typed path, matching the deprecated untyped encoder, instead of sending +a naive ISO string that the server rejects with `BadSearchAttributes`. diff --git a/changelog/fixed/polka-dot-penguin.md b/changelog/fixed/polka-dot-penguin.md index 1178646d63..4e4a8b8b8a 100644 --- a/changelog/fixed/polka-dot-penguin.md +++ b/changelog/fixed/polka-dot-penguin.md @@ -1,5 +1,5 @@ -- `temporalio.contrib.opentelemetry`: `TracingInterceptor` and `OpenTelemetryInterceptor` no longer - log `Failed to detach context` when a context is torn down on a different thread while - OpenTelemetry's threading instrumentation (enabled by strands, among others) is active; a - context is now detached exactly when its token is still valid in the current - `contextvars.Context`, which it stays when a workflow resumes on another pool thread. +`temporalio.contrib.opentelemetry`: `TracingInterceptor` and `OpenTelemetryInterceptor` no longer +log `Failed to detach context` when a context is torn down on a different thread while +OpenTelemetry's threading instrumentation (enabled by strands, among others) is active; a +context is now detached exactly when its token is still valid in the current +`contextvars.Context`, which it stays when a workflow resumes on another pool thread. diff --git a/temporalio/bridge/sdk-core b/temporalio/bridge/sdk-core index dda0f0f221..95a80162e6 160000 --- a/temporalio/bridge/sdk-core +++ b/temporalio/bridge/sdk-core @@ -1 +1 @@ -Subproject commit dda0f0f2210f9166066d1312f7b81b92c1c5870b +Subproject commit 95a80162e6a39c8e2ef5b44e32bac8221a54fd68 diff --git a/tests/test_prepare_release.py b/tests/test_prepare_release.py index f110472e41..2b041becaf 100644 --- a/tests/test_prepare_release.py +++ b/tests/test_prepare_release.py @@ -101,7 +101,7 @@ def release_repo(tmp_path: pathlib.Path) -> pathlib.Path: "pyproject.toml": 'version = "1.34.0"\n', "temporalio/service.py": '__version__ = "1.34.0"\n', "uv.lock": "lock\n", - "changelog/fixed/giggling-teapot.md": "- A fix.\n", + "changelog/fixed/giggling-teapot.md": "A fix.\n", }.items(): path = tmp_path / name path.parent.mkdir(parents=True, exist_ok=True) @@ -197,7 +197,7 @@ def test_unexpected_changes_rejected_before_consuming( monkeypatch: pytest.MonkeyPatch, release_repo: pathlib.Path ) -> None: mock_preparation(monkeypatch) - (release_repo / "changelog/fixed/late-llama.md").write_text("- Late change.\n") + (release_repo / "changelog/fixed/late-llama.md").write_text("Late change.\n") with pytest.raises(RuntimeError, match="unexpected files"): prepare_release_files( release_repo, "1.35.0", datetime.date(2026, 10, 2), skip_lock=True From ee35fca09df86708c949012f4c8b1a49ef227a3e Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Mon, 5 Oct 2026 09:56:26 -0700 Subject: [PATCH 06/10] Keep each changelog entry on a single line --- AGENTS.md | 6 ++++-- CONTRIBUTING.md | 4 +++- changelog/README.md | 18 ++++++++++-------- changelog/breaking-changes/sleepy-unicorn.md | 8 +------- changelog/changed/dancing-marshmallow.md | 4 +--- changelog/fixed/bouncing-badger.md | 5 +---- changelog/fixed/giggling-teapot.md | 3 +-- changelog/fixed/moonwalking-muffin.md | 5 +---- changelog/fixed/polka-dot-penguin.md | 6 +----- temporalio/bridge/sdk-core | 2 +- 10 files changed, 24 insertions(+), 37 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 53f1823da0..568dbdee02 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,8 +78,10 @@ For release artifacts, use `uv build`. Documentation can be generated with for each applicable category. Use fun, whimsical, unique lowercase kebab-case filenames, such as `giggling-teapot.md`. The folder supplies the category; write only the release-note body, without a leading list marker (`-`). Keep - entries concise, ideally one or two sentences. Release tooling adds the list - marker. See `changelog/README.md` for the convention. + entries concise, ideally one or two sentences. Put each entry on a single line + without manual wrapping; a fragment may contain multiple entries. Release + tooling adds a list marker to each nonempty line. See `changelog/README.md` for + the convention. `CHANGELOG.md` contains completed releases and is assembled by release tooling. * Keep commit messages short and in the imperative mood. * Provide a clear PR description outlining what changed, why it changed, and diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3e1b2e76a3..59e28fa915 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -65,7 +65,9 @@ Good pull requests are focused and easy to review: `changelog//` folder. Choose fun, whimsical, unique lowercase kebab-case filenames, such as `giggling-teapot.md`. Include only the release-note body without a leading list marker; the folder supplies its category. Keep - entries concise, ideally one or two sentences. See [the fragment guide](changelog/README.md). + entries concise, ideally one or two sentences, with each entry on a single line + without manual wrapping. A fragment may contain multiple entries; release + tooling adds a list marker to each nonempty line. See [the fragment guide](changelog/README.md). Release tooling assembles `CHANGELOG.md` from these fragments. * Describe what changed, why it changed, and what validation you ran. diff --git a/changelog/README.md b/changelog/README.md index 1456151756..898fcc2bb2 100644 --- a/changelog/README.md +++ b/changelog/README.md @@ -2,7 +2,9 @@ For each PR with user-facing changes, add a Markdown file in each applicable category folder. Write high-level release notes describing what users can observe. -Keep entries concise, ideally one or two sentences. +Keep entries concise, ideally one or two sentences. Put each entry on a single +line without manual wrapping. A fragment may contain multiple entries; blank +lines are ignored. Use fun, whimsical, unique lowercase kebab-case filenames, such as `fixed/giggling-teapot.md` or `added/dancing-marshmallow.md`. PR numbers are optional. @@ -17,15 +19,15 @@ Use fun, whimsical, unique lowercase kebab-case filenames, such as Create a category folder when first needed. Place files directly inside it; use one fragment per category for your PR. The folder supplies the category, so -the file contains only the entry, without a category or version heading or a -leading list marker (`-`). Release tooling adds the list marker: +the file contains only the entries, without a category or version heading or +leading list markers (`-`). Release tooling adds a list marker to each nonempty line: ```markdown -Reject unsupported activity executors when creating a worker, instead of -failing during activity execution. +Reject unsupported activity executors when creating a worker, instead of failing during activity execution. +Preserve cancellation details when reporting activity failures. ``` -Bodies can include paragraphs, examples, and documentation links. Do not edit +Entries can include inline Markdown and documentation links. Do not edit `CHANGELOG.md` for pending changes: it contains completed releases only. Maintainers can apply `skip-changelog` to PRs that need no release note. @@ -46,8 +48,8 @@ Review the assembled notes before merging the release PR. Assembly creates a dated section at the top of the changelog, groups notes in the category order above, and sorts filenames within each category. Empty categories -are omitted. Each fragment becomes one list item, with continuation lines indented -to keep paragraphs and examples inside it. Older releases are preserved. Empty releases and +are omitted. Each nonempty line becomes one list item, in the order it appears +in the fragment. Older releases are preserved. Empty releases and duplicate versions are rejected. Fragments are retained if validation or lockfile refresh fails. A changelog validation failure leaves the version and lockfile updates in the local worktree; inspect or restore those changes before retrying. diff --git a/changelog/breaking-changes/sleepy-unicorn.md b/changelog/breaking-changes/sleepy-unicorn.md index 320575fc15..34c66fd792 100644 --- a/changelog/breaking-changes/sleepy-unicorn.md +++ b/changelog/breaking-changes/sleepy-unicorn.md @@ -1,7 +1 @@ -The OpenAI Agents integration has moved to the independently versioned -[`temporalio-openai-agents`](https://pypi.org/project/temporalio-openai-agents/) -package. The existing `temporalio[openai-agents]` extra now installs that -package, and the old public `temporalio.contrib.openai_agents` imports -remain available at runtime and retain their static type information. -New code should depend on `temporalio-openai-agents` directly and import -`temporalio.openai_agents`. +The OpenAI Agents integration has moved to the independently versioned [`temporalio-openai-agents`](https://pypi.org/project/temporalio-openai-agents/) package. The existing `temporalio[openai-agents]` extra now installs that package, and the old public `temporalio.contrib.openai_agents` imports remain available at runtime and retain their static type information. New code should depend on `temporalio-openai-agents` directly and import `temporalio.openai_agents`. diff --git a/changelog/changed/dancing-marshmallow.md b/changelog/changed/dancing-marshmallow.md index bbfb3cc2f5..fb8b154773 100644 --- a/changelog/changed/dancing-marshmallow.md +++ b/changelog/changed/dancing-marshmallow.md @@ -1,3 +1 @@ -Payload converters exposed by data converters and workflow/activity accessors -retain transfer type conversion, so direct use behaves consistently with SDK -serialization. +Payload converters exposed by data converters and workflow/activity accessors retain transfer type conversion, so direct use behaves consistently with SDK serialization. diff --git a/changelog/fixed/bouncing-badger.md b/changelog/fixed/bouncing-badger.md index c76234a7eb..c11597ec34 100644 --- a/changelog/fixed/bouncing-badger.md +++ b/changelog/fixed/bouncing-badger.md @@ -1,4 +1 @@ -`contrib.google_adk_agents`: agents with an `output_schema` no longer fail every workflow task -when calling the model. The schema type is now sent to the model activity as its JSON schema. -Custom Pydantic schema generation is preserved. -Integer-valued output enums are normalized to strings to match Google GenAI. +`contrib.google_adk_agents`: agents with an `output_schema` no longer fail every workflow task when calling the model. The schema type is now sent to the model activity as its JSON schema. Custom Pydantic schema generation is preserved. Integer-valued output enums are normalized to strings to match Google GenAI. diff --git a/changelog/fixed/giggling-teapot.md b/changelog/fixed/giggling-teapot.md index 26f03e9288..6e158b35cf 100644 --- a/changelog/fixed/giggling-teapot.md +++ b/changelog/fixed/giggling-teapot.md @@ -1,2 +1 @@ -`temporalio.contrib.strands` activity and MCP tools now give the model the -Activity's failure message and expose its exception to after-tool hooks. +`temporalio.contrib.strands` activity and MCP tools now give the model the Activity's failure message and expose its exception to after-tool hooks. diff --git a/changelog/fixed/moonwalking-muffin.md b/changelog/fixed/moonwalking-muffin.md index 8723efd4a8..fa35961270 100644 --- a/changelog/fixed/moonwalking-muffin.md +++ b/changelog/fixed/moonwalking-muffin.md @@ -1,4 +1 @@ -Encoding a datetime search attribute without a timezone now raises -`ValueError("Timezone must be present on all search attribute dates")` on -the typed path, matching the deprecated untyped encoder, instead of sending -a naive ISO string that the server rejects with `BadSearchAttributes`. +Encoding a datetime search attribute without a timezone now raises `ValueError("Timezone must be present on all search attribute dates")` on the typed path, matching the deprecated untyped encoder, instead of sending a naive ISO string that the server rejects with `BadSearchAttributes`. diff --git a/changelog/fixed/polka-dot-penguin.md b/changelog/fixed/polka-dot-penguin.md index 4e4a8b8b8a..c9ae521e1c 100644 --- a/changelog/fixed/polka-dot-penguin.md +++ b/changelog/fixed/polka-dot-penguin.md @@ -1,5 +1 @@ -`temporalio.contrib.opentelemetry`: `TracingInterceptor` and `OpenTelemetryInterceptor` no longer -log `Failed to detach context` when a context is torn down on a different thread while -OpenTelemetry's threading instrumentation (enabled by strands, among others) is active; a -context is now detached exactly when its token is still valid in the current -`contextvars.Context`, which it stays when a workflow resumes on another pool thread. +`temporalio.contrib.opentelemetry`: `TracingInterceptor` and `OpenTelemetryInterceptor` no longer log `Failed to detach context` when a context is torn down on a different thread while OpenTelemetry's threading instrumentation (enabled by strands, among others) is active; a context is now detached exactly when its token is still valid in the current `contextvars.Context`, which it stays when a workflow resumes on another pool thread. diff --git a/temporalio/bridge/sdk-core b/temporalio/bridge/sdk-core index 95a80162e6..e7d717541f 160000 --- a/temporalio/bridge/sdk-core +++ b/temporalio/bridge/sdk-core @@ -1 +1 @@ -Subproject commit 95a80162e6a39c8e2ef5b44e32bac8221a54fd68 +Subproject commit e7d717541f7a053d9653fe63e2cc0eee60321e4f From ca5ce7def64c84d14b512d6e3ac6875e9ca39d35 Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Mon, 5 Oct 2026 09:57:17 -0700 Subject: [PATCH 07/10] Explain why each changelog entry occupies one line --- AGENTS.md | 6 +++--- CONTRIBUTING.md | 6 +++--- changelog/README.md | 6 +++--- temporalio/bridge/sdk-core | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 568dbdee02..6721e8d9b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,9 +78,9 @@ For release artifacts, use `uv build`. Documentation can be generated with for each applicable category. Use fun, whimsical, unique lowercase kebab-case filenames, such as `giggling-teapot.md`. The folder supplies the category; write only the release-note body, without a leading list marker (`-`). Keep - entries concise, ideally one or two sentences. Put each entry on a single line - without manual wrapping; a fragment may contain multiple entries. Release - tooling adds a list marker to each nonempty line. See `changelog/README.md` for + entries concise, ideally one or two sentences. Write each entry entirely on + one line: release tooling turns each nonempty line into a separate list item. + A fragment may contain multiple entries. See `changelog/README.md` for the convention. `CHANGELOG.md` contains completed releases and is assembled by release tooling. * Keep commit messages short and in the imperative mood. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 59e28fa915..684df93428 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -65,9 +65,9 @@ Good pull requests are focused and easy to review: `changelog//` folder. Choose fun, whimsical, unique lowercase kebab-case filenames, such as `giggling-teapot.md`. Include only the release-note body without a leading list marker; the folder supplies its category. Keep - entries concise, ideally one or two sentences, with each entry on a single line - without manual wrapping. A fragment may contain multiple entries; release - tooling adds a list marker to each nonempty line. See [the fragment guide](changelog/README.md). + entries concise, ideally one or two sentences. Write each entry entirely on + one line: release tooling turns each nonempty line into a separate list item. + A fragment may contain multiple entries. See [the fragment guide](changelog/README.md). Release tooling assembles `CHANGELOG.md` from these fragments. * Describe what changed, why it changed, and what validation you ran. diff --git a/changelog/README.md b/changelog/README.md index 898fcc2bb2..8510c767a9 100644 --- a/changelog/README.md +++ b/changelog/README.md @@ -2,9 +2,9 @@ For each PR with user-facing changes, add a Markdown file in each applicable category folder. Write high-level release notes describing what users can observe. -Keep entries concise, ideally one or two sentences. Put each entry on a single -line without manual wrapping. A fragment may contain multiple entries; blank -lines are ignored. +Keep entries concise, ideally one or two sentences. Write each entry entirely on +one line: release tooling turns each nonempty line into a separate list item. +A fragment may contain multiple entries; blank lines are ignored. Use fun, whimsical, unique lowercase kebab-case filenames, such as `fixed/giggling-teapot.md` or `added/dancing-marshmallow.md`. PR numbers are optional. diff --git a/temporalio/bridge/sdk-core b/temporalio/bridge/sdk-core index e7d717541f..7efb2b7170 160000 --- a/temporalio/bridge/sdk-core +++ b/temporalio/bridge/sdk-core @@ -1 +1 @@ -Subproject commit e7d717541f7a053d9653fe63e2cc0eee60321e4f +Subproject commit 7efb2b717006190b509b36cf26fdf574187c1941 From e78af510f978ae06d883b25fcdf7dbb913917666 Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Mon, 5 Oct 2026 11:39:45 -0700 Subject: [PATCH 08/10] Document stabilized changelog entries --- AGENTS.md | 3 ++- CONTRIBUTING.md | 3 ++- changelog/README.md | 1 + temporalio/bridge/sdk-core | 2 +- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6721e8d9b2..ec2761107e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,7 +75,8 @@ For release artifacts, use `uv build`. Documentation can be generated with * Include tests for behavior changes. * Update public API documentation or doc comments for public behavior changes. * For user-facing changes, add a Markdown fragment under `changelog//` - for each applicable category. Use fun, whimsical, unique lowercase kebab-case + for each applicable category. Use `stabilized/` for features that are no longer + experimental. Use fun, whimsical, unique lowercase kebab-case filenames, such as `giggling-teapot.md`. The folder supplies the category; write only the release-note body, without a leading list marker (`-`). Keep entries concise, ideally one or two sentences. Write each entry entirely on diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 684df93428..0348bedce3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -62,7 +62,8 @@ Good pull requests are focused and easy to review: * Include tests for behavior changes. * Update public API documentation or doc comments when public behavior changes. * For user-facing changes, add a Markdown fragment in each applicable - `changelog//` folder. Choose fun, whimsical, unique lowercase + `changelog//` folder. Use `stabilized/` for features that are no longer + experimental. Choose fun, whimsical, unique lowercase kebab-case filenames, such as `giggling-teapot.md`. Include only the release-note body without a leading list marker; the folder supplies its category. Keep entries concise, ideally one or two sentences. Write each entry entirely on diff --git a/changelog/README.md b/changelog/README.md index 8510c767a9..675b798e5d 100644 --- a/changelog/README.md +++ b/changelog/README.md @@ -11,6 +11,7 @@ Use fun, whimsical, unique lowercase kebab-case filenames, such as | Folder | Changes | | --- | --- | | `added/` | New features | +| `stabilized/` | Features that are no longer experimental | | `changed/` | Changes in existing functionality | | `deprecated/` | Soon-to-be-removed features | | `breaking-changes/` | Removed or backwards-incompatible features | diff --git a/temporalio/bridge/sdk-core b/temporalio/bridge/sdk-core index 7efb2b7170..3926072834 160000 --- a/temporalio/bridge/sdk-core +++ b/temporalio/bridge/sdk-core @@ -1 +1 @@ -Subproject commit 7efb2b717006190b509b36cf26fdf574187c1941 +Subproject commit 392607283485bb08ff34fed040b7c9c02e1b2c39 From 8d31c1be421d73175e7ef2449c899d0b12594ce6 Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Mon, 5 Oct 2026 12:38:09 -0700 Subject: [PATCH 09/10] Use standardized changelog release behavior --- changelog/README.md | 5 +++-- scripts/changelog.py | 2 -- temporalio/bridge/sdk-core | 2 +- tests/test_prepare_release.py | 25 +++++++++++++++++++++++++ 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/changelog/README.md b/changelog/README.md index 675b798e5d..a71f7eeb7e 100644 --- a/changelog/README.md +++ b/changelog/README.md @@ -50,8 +50,9 @@ Review the assembled notes before merging the release PR. Assembly creates a dated section at the top of the changelog, groups notes in the category order above, and sorts filenames within each category. Empty categories are omitted. Each nonempty line becomes one list item, in the order it appears -in the fragment. Older releases are preserved. Empty releases and -duplicate versions are rejected. Fragments are retained if validation or lockfile +in the fragment. Breaking changes use `### :boom: Breaking Changes`. Older releases +are preserved. Empty releases are allowed; duplicate versions are rejected. +Fragments are retained if validation or lockfile refresh fails. A changelog validation failure leaves the version and lockfile updates in the local worktree; inspect or restore those changes before retrying. File-system errors can leave partial changelog preparation as well; preparation diff --git a/scripts/changelog.py b/scripts/changelog.py index c3126f913f..412acf4733 100644 --- a/scripts/changelog.py +++ b/scripts/changelog.py @@ -50,7 +50,5 @@ def prepare_changelog( version, "--date", release_date.isoformat(), - "--breaking-heading", - ":boom: Breaking Changes", ], ) diff --git a/temporalio/bridge/sdk-core b/temporalio/bridge/sdk-core index 3926072834..9af4a6d58e 160000 --- a/temporalio/bridge/sdk-core +++ b/temporalio/bridge/sdk-core @@ -1 +1 @@ -Subproject commit 392607283485bb08ff34fed040b7c9c02e1b2c39 +Subproject commit 9af4a6d58eabfcae822485222f8199497cdb7a00 diff --git a/tests/test_prepare_release.py b/tests/test_prepare_release.py index 2b041becaf..cc35ac57dd 100644 --- a/tests/test_prepare_release.py +++ b/tests/test_prepare_release.py @@ -81,6 +81,31 @@ def run_tool(_root: pathlib.Path, args: list[str]) -> str: ) +@pytest.mark.parametrize("core_notes", ["", "#### Fixed\n\n- Core fix.\n"]) +def test_empty_python_release_notes_allow_core_only_releases( + monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path, core_notes: str +) -> None: + release_verify = _release_verify_module() + + def run_tool(_root: pathlib.Path, args: list[str]) -> str: + return "" if args[0] == "notes" else core_notes + + monkeypatch.setattr(release_verify, "run_tool", run_tool) + output = tmp_path / "notes.md" + release_verify.changelog_notes( + argparse.Namespace( + version="1.35.0", + changelog="CHANGELOG.md", + sdk_core_path="core", + output=str(output), + ) + ) + expected = "## Notable Changes\n\n" + if core_notes: + expected += "\n### SDK Core\n\n" + core_notes + assert output.read_text() == expected + + def test_shared_tool_preserves_failure_diagnostics( monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: From 977995da91aaf67b056e853016d8aa6b783e0e3b Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Mon, 5 Oct 2026 15:10:54 -0700 Subject: [PATCH 10/10] Migrate new main branch changelog entries to fragments --- CHANGELOG.md | 74 ------------------- changelog/added/chuckling-chameleon.md | 1 + changelog/added/spiraling-sparrow.md | 1 + .../breaking-changes/bumbling-bumblebee.md | 1 + .../breaking-changes/tap-dancing-turtle.md | 1 + changelog/breaking-changes/waltzing-walrus.md | 1 + changelog/fixed/cheerful-chinchilla.md | 1 + changelog/fixed/juggling-jellyfish.md | 1 + changelog/fixed/prancing-puffin.md | 1 + changelog/fixed/skipping-squirrel.md | 1 + changelog/fixed/whistling-wombat.md | 1 + changelog/stabilized/serene-salamander.md | 1 + 12 files changed, 11 insertions(+), 74 deletions(-) create mode 100644 changelog/added/chuckling-chameleon.md create mode 100644 changelog/added/spiraling-sparrow.md create mode 100644 changelog/breaking-changes/bumbling-bumblebee.md create mode 100644 changelog/breaking-changes/tap-dancing-turtle.md create mode 100644 changelog/breaking-changes/waltzing-walrus.md create mode 100644 changelog/fixed/cheerful-chinchilla.md create mode 100644 changelog/fixed/juggling-jellyfish.md create mode 100644 changelog/fixed/prancing-puffin.md create mode 100644 changelog/fixed/skipping-squirrel.md create mode 100644 changelog/fixed/whistling-wombat.md create mode 100644 changelog/stabilized/serene-salamander.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 174cc705a9..3a7ef493d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,80 +7,6 @@ This file contains assembled releases only. # Changelog -## [Unreleased] - -### Added - -- Worker now warns when an activity method is registered from the class - instead of from an instance (i.e. its first parameter is an unbound `self`). -- `workflow.new_random()` accepts an optional `name` that is mixed into the seed, so differently - named generators, and `workflow.random()`, produce different sequences. - -### Changed - -- Removed experimental labels from the core plugin APIs. -- Payload converters exposed by data converters and workflow/activity accessors - retain transfer type conversion, so direct use behaves consistently with SDK - serialization. - -### Deprecated - -### :boom: Breaking Changes - -- `temporalio.contrib.google_adk_agents`: ADK-generated ids and retry jitter now draw from a - workflow-private deterministic stream (a `workflow.new_random()` per run) instead of - `workflow.random()`. A workflow started under 1.34.0 that generated - ADK ids or jitter (for example one waiting on a HITL response) may not replay - deterministically across this upgrade; drain such workflows or use worker versioning. -- The OpenAI Agents integration has moved to the independently versioned - [`temporalio-openai-agents`](https://pypi.org/project/temporalio-openai-agents/) - package. The existing `temporalio[openai-agents]` extra now installs that - package, and the old public `temporalio.contrib.openai_agents` imports - remain available at runtime and retain their static type information. - New code should depend on `temporalio-openai-agents` directly and import - `temporalio.openai_agents`. -- **Experimental**: Nexus Workflow Updates now require `wait_for_stage` to be explicitly set to `ACCEPTED`. -- `SchedulePolicy.catchup_window` now defaults to `None`. An unspecified window will use the server - configured default. -- `temporalio.contrib.opentelemetry`: removed `TemporalIdGenerator.seed_span_id()` and - `seed_trace_id()`. - -### Fixed - -- Experimental Workflow Streams background publishing now retries transient - signal delivery failures without delaying payload conversion errors. -- Fixed Pydantic constraints being silently ignored inside or outside workflow - sandboxes by passing through `annotated_types` by default. - -- `GoogleAdkPlugin`'s deterministic providers now work in read-only contexts (query handlers, - update validators), returning the workflow's deterministic time and fresh entropy without - touching the workflow's random state. -- `contrib.google_adk_agents`: agents with an `output_schema` no longer fail every workflow task - when calling the model. The schema type is now sent to the model activity as its JSON schema. - Custom Pydantic schema generation is preserved. - Integer-valued output enums are normalized to strings to match Google GenAI. - -- `temporalio.contrib.strands` activity and MCP tools now give the model the - Activity's failure message and expose its exception to after-tool hooks. -- Reject unsupported `InterpreterPoolExecutor` activity executors when creating - a worker instead of failing during activity execution. - -- Encoding a datetime search attribute without a timezone now raises - `ValueError("Timezone must be present on all search attribute dates")` on - the typed path, matching the deprecated untyped encoder, instead of sending - a naive ISO string that the server rejects with `BadSearchAttributes`. - -- `temporalio.contrib.opentelemetry`: `TracingInterceptor` and `OpenTelemetryInterceptor` no longer - log `Failed to detach context` when a context is torn down on a different thread while - OpenTelemetry's threading instrumentation (enabled by strands, among others) is active; a - context is now detached exactly when its token is still valid in the current - `contextvars.Context`, which it stays when a workflow resumes on another pool thread. - -- Decode enums that mix in `str` or `int` (for example `class Color(str, Enum)`) to the enum - instead of a list of characters or an error, and decode `dict` keys typed as an `int` enum. - -### Security - ## [1.34.0] - 2026-09-30 ### Added diff --git a/changelog/added/chuckling-chameleon.md b/changelog/added/chuckling-chameleon.md new file mode 100644 index 0000000000..dece23d5b1 --- /dev/null +++ b/changelog/added/chuckling-chameleon.md @@ -0,0 +1 @@ +Worker now warns when an activity method is registered from the class instead of from an instance (i.e. its first parameter is an unbound `self`). diff --git a/changelog/added/spiraling-sparrow.md b/changelog/added/spiraling-sparrow.md new file mode 100644 index 0000000000..5b08e3a966 --- /dev/null +++ b/changelog/added/spiraling-sparrow.md @@ -0,0 +1 @@ +`workflow.new_random()` accepts an optional `name` that is mixed into the seed, so differently named generators, and `workflow.random()`, produce different sequences. diff --git a/changelog/breaking-changes/bumbling-bumblebee.md b/changelog/breaking-changes/bumbling-bumblebee.md new file mode 100644 index 0000000000..51b917d258 --- /dev/null +++ b/changelog/breaking-changes/bumbling-bumblebee.md @@ -0,0 +1 @@ +`SchedulePolicy.catchup_window` now defaults to `None`. An unspecified window will use the server configured default. diff --git a/changelog/breaking-changes/tap-dancing-turtle.md b/changelog/breaking-changes/tap-dancing-turtle.md new file mode 100644 index 0000000000..1c398f0d68 --- /dev/null +++ b/changelog/breaking-changes/tap-dancing-turtle.md @@ -0,0 +1 @@ +`temporalio.contrib.google_adk_agents`: ADK-generated ids and retry jitter now draw from a workflow-private deterministic stream (a `workflow.new_random()` per run) instead of `workflow.random()`. A workflow started under 1.34.0 that generated ADK ids or jitter (for example one waiting on a HITL response) may not replay deterministically across this upgrade; drain such workflows or use worker versioning. diff --git a/changelog/breaking-changes/waltzing-walrus.md b/changelog/breaking-changes/waltzing-walrus.md new file mode 100644 index 0000000000..8927b554c0 --- /dev/null +++ b/changelog/breaking-changes/waltzing-walrus.md @@ -0,0 +1 @@ +`temporalio.contrib.opentelemetry`: removed `TemporalIdGenerator.seed_span_id()` and `seed_trace_id()`. diff --git a/changelog/fixed/cheerful-chinchilla.md b/changelog/fixed/cheerful-chinchilla.md new file mode 100644 index 0000000000..526958f995 --- /dev/null +++ b/changelog/fixed/cheerful-chinchilla.md @@ -0,0 +1 @@ +Decode enums that mix in `str` or `int` (for example `class Color(str, Enum)`) to the enum instead of a list of characters or an error, and decode `dict` keys typed as an `int` enum. diff --git a/changelog/fixed/juggling-jellyfish.md b/changelog/fixed/juggling-jellyfish.md new file mode 100644 index 0000000000..d9967563d7 --- /dev/null +++ b/changelog/fixed/juggling-jellyfish.md @@ -0,0 +1 @@ +Fixed Pydantic constraints being silently ignored inside or outside workflow sandboxes by passing through `annotated_types` by default. diff --git a/changelog/fixed/prancing-puffin.md b/changelog/fixed/prancing-puffin.md new file mode 100644 index 0000000000..d7eeaffe89 --- /dev/null +++ b/changelog/fixed/prancing-puffin.md @@ -0,0 +1 @@ +Reject unsupported `InterpreterPoolExecutor` activity executors when creating a worker instead of failing during activity execution. diff --git a/changelog/fixed/skipping-squirrel.md b/changelog/fixed/skipping-squirrel.md new file mode 100644 index 0000000000..b1177a41cb --- /dev/null +++ b/changelog/fixed/skipping-squirrel.md @@ -0,0 +1 @@ +Experimental Workflow Streams background publishing now retries transient signal delivery failures without delaying payload conversion errors. diff --git a/changelog/fixed/whistling-wombat.md b/changelog/fixed/whistling-wombat.md new file mode 100644 index 0000000000..527e535985 --- /dev/null +++ b/changelog/fixed/whistling-wombat.md @@ -0,0 +1 @@ +`GoogleAdkPlugin`'s deterministic providers now work in read-only contexts (query handlers, update validators), returning the workflow's deterministic time and fresh entropy without touching the workflow's random state. diff --git a/changelog/stabilized/serene-salamander.md b/changelog/stabilized/serene-salamander.md new file mode 100644 index 0000000000..7228ad325f --- /dev/null +++ b/changelog/stabilized/serene-salamander.md @@ -0,0 +1 @@ +Removed experimental labels from the core plugin APIs.