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..ec2761107e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,8 +74,16 @@ 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 `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 + 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. * Provide a clear PR description outlining what changed, why it changed, and what validation was run. @@ -102,6 +110,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..3a7ef493d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,95 +1,12 @@ # 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/CONTRIBUTING.md b/CONTRIBUTING.md index 057901ff56..0348bedce3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -61,8 +61,15 @@ 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. 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 + 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. 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..a71f7eeb7e --- /dev/null +++ b/changelog/README.md @@ -0,0 +1,75 @@ +# 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. +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. + +| 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 | +| `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 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. +Preserve cancellation details when reporting activity failures. +``` + +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. + +## 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`, 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. Each nonempty line becomes one list item, in the order it appears +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 +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 +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. SDK-specific version and lockfile +updates happen before its `prepare` command; no hook or release-plan exchange is +required. 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/cartwheeling-cupcake.md b/changelog/breaking-changes/cartwheeling-cupcake.md new file mode 100644 index 0000000000..848a500dca --- /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..34c66fd792 --- /dev/null +++ b/changelog/breaking-changes/sleepy-unicorn.md @@ -0,0 +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`. 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/changed/dancing-marshmallow.md b/changelog/changed/dancing-marshmallow.md new file mode 100644 index 0000000000..fb8b154773 --- /dev/null +++ b/changelog/changed/dancing-marshmallow.md @@ -0,0 +1 @@ +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..c11597ec34 --- /dev/null +++ b/changelog/fixed/bouncing-badger.md @@ -0,0 +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. 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/giggling-teapot.md b/changelog/fixed/giggling-teapot.md new file mode 100644 index 0000000000..6e158b35cf --- /dev/null +++ b/changelog/fixed/giggling-teapot.md @@ -0,0 +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. 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/moonwalking-muffin.md b/changelog/fixed/moonwalking-muffin.md new file mode 100644 index 0000000000..fa35961270 --- /dev/null +++ b/changelog/fixed/moonwalking-muffin.md @@ -0,0 +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`. diff --git a/changelog/fixed/polka-dot-penguin.md b/changelog/fixed/polka-dot-penguin.md new file mode 100644 index 0000000000..c9ae521e1c --- /dev/null +++ b/changelog/fixed/polka-dot-penguin.md @@ -0,0 +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. 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. diff --git a/scripts/changelog.py b/scripts/changelog.py new file mode 100644 index 0000000000..412acf4733 --- /dev/null +++ b/scripts/changelog.py @@ -0,0 +1,54 @@ +"""Invoke the shared, pinned sdk-rust changelog tooling.""" + +from __future__ import annotations + +import datetime +import pathlib +import subprocess +from collections.abc import Sequence + + +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 +) -> None: + """Write the dated changelog and consume fragments using the shared tool.""" + run_tool( + repo_root, + [ + "prepare", + "--version", + version, + "--date", + release_date.isoformat(), + ], + ) diff --git a/scripts/prepare_release.py b/scripts/prepare_release.py index 51dc344962..4bbc0df103 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,45 @@ 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, ...]: + """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 = ( + 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" + ) + 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) + 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: 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 +222,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, - ) - 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" + consumed = prepare_release_files( + repo_root, version, release_date, skip_lock=args.skip_lock ) - - 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..9af4a6d58e 160000 --- a/temporalio/bridge/sdk-core +++ b/temporalio/bridge/sdk-core @@ -1 +1 @@ -Subproject commit e163abd6dc19040064986a63c8b8cf4756ffd320 +Subproject commit 9af4a6d58eabfcae822485222f8199497cdb7a00 diff --git a/tests/test_prepare_release.py b/tests/test_prepare_release.py index b4c121e8f5..cc35ac57dd 100644 --- a/tests/test_prepare_release.py +++ b/tests/test_prepare_release.py @@ -1,5 +1,6 @@ from __future__ import annotations +import argparse import datetime import importlib.util import pathlib @@ -9,12 +10,15 @@ import pytest +import scripts.changelog +import scripts.prepare_release 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 +35,238 @@ 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") - monkeypatch.setattr( - release_verify, - "_sdk_core_changelog_entries", - lambda *_args: (_ for _ in ()).throw(error), + + 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" + ) + + +@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: + (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.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 mock_preparation(monkeypatch: pytest.MonkeyPatch) -> str: + changelog = "# Changelog\n\n## [1.35.0] - 2026-10-02\n\n### Fixed\n\n- A fix.\n" - with pytest.raises(RuntimeError, match="compiler output"): - release_verify._sdk_core_release_notes("1.30.0", str(tmp_path)) + 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_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 test_release_commits_consumed_fragments( + monkeypatch: pytest.MonkeyPatch, release_repo: pathlib.Path +) -> None: + changelog = mock_preparation(monkeypatch) + consumed = prepare_release_files( + release_repo, "1.35.0", datetime.date(2026, 10, 2), skip_lock=True ) + 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) + 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() == 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_preparation(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_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"): + 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_preparation(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_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: @@ -143,7 +297,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 +312,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: