From cdf823fdc0b238c6bfc66fa5038c2538c9471f74 Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Thu, 18 Jun 2026 12:57:34 -0700 Subject: [PATCH] Add release preparation script --- CHANGELOG.md | 13 +- CONTRIBUTING.md | 2 +- scripts/__init__.py | 1 + scripts/prepare_release.py | 235 ++++++++++++++++++++++++++++++++++ tests/test_prepare_release.py | 75 +++++++++++ 5 files changed, 322 insertions(+), 4 deletions(-) create mode 100644 scripts/__init__.py create mode 100644 scripts/prepare_release.py create mode 100644 tests/test_prepare_release.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0183a74a4..b6e993a85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,8 @@ High-level release notes. Loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). When your PR includes a user-facing change, add an entry below under the -appropriate heading (create the heading if it does not yet exist). Within -each heading content can be free-form. Feel free to include examples, links -to docs, or any other relevant information. +appropriate heading. Within each heading content can be free-form. Feel free +to include examples, links to docs, or any other relevant information. ### Added — new features ### Changed — changes in existing functionality @@ -19,12 +18,16 @@ to docs, or any other relevant information. ## [Unreleased] +### Added + ### Changed - AWS Lambda worker `configure` parameter supports sync, async, and async generator style functions. This callback is invoked on the asyncio event loop. +### Deprecated + ### Breaking Changes - AWS Lambda worker `configure` parameter has been changed to be invoked @@ -32,6 +35,10 @@ to docs, or any other relevant information. any shared, heavy-weight operations are performed outside of the callback before `run_worker` is invoked. +### Fixed + +### Security + ## [1.29.0] - 2026-06-17 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 83b27f007..6096c5b2b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,7 +14,7 @@ User-facing changes are recorded in [`CHANGELOG.md`](CHANGELOG.md), loosely foll If your PR includes a user-facing change (new feature, behavior change, deprecation, breaking change, notable bug fix, or security fix), add a short, high-level entry to the `## [Unreleased]` -section at the top of `CHANGELOG.md` under the appropriate heading, creating it if needed: +section at the top of `CHANGELOG.md` under the appropriate heading: Added, Changed, Deprecated, Breaking Changes, Fixed, or Security. Keep entries high-level and written for users. The full commit log is appended at release time, diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 000000000..9f29c1099 --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +"""Repository helper scripts.""" diff --git a/scripts/prepare_release.py b/scripts/prepare_release.py new file mode 100644 index 000000000..68f2ba39f --- /dev/null +++ b/scripts/prepare_release.py @@ -0,0 +1,235 @@ +"""Prepare checked-in files for an SDK release.""" + +from __future__ import annotations + +import argparse +import datetime +import pathlib +import re +import subprocess +import sys +from collections.abc import Sequence + +if __package__ is None or __package__ == "": + sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) + +CHANGELOG_HEADERS = ( + "Added", + "Changed", + "Deprecated", + "Breaking Changes", + "Fixed", + "Security", +) +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*$") + + +def validate_version(version: str) -> str: + if not VERSION_RE.fullmatch(version): + raise ValueError( + f"Invalid version {version!r}; expected a version like '1.30.0'" + ) + return version + + +def parse_date(date: str) -> datetime.date: + try: + return datetime.date.fromisoformat(date) + except ValueError as err: + 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*$', + f'version = "{validate_version(version)}"', + text, + description="project version", + ) + + +def replace_service_version(text: str, version: str) -> str: + return _replace_once( + r'(?m)^__version__ = "[^"]+"\s*$', + f'__version__ = "{validate_version(version)}"', + text, + description="service version", + ) + + +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, + text: str, + *, + description: str, +) -> str: + updated, count = re.subn(pattern, replacement, text, count=1) + if count != 1: + raise RuntimeError(f"Could not find {description}") + return updated.rstrip("\n") + + +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." + ) + ) + parser.add_argument("version", help="Release version, for example 1.30.0") + parser.add_argument( + "--date", + default=datetime.date.today().isoformat(), + help="Release date in YYYY-MM-DD format. Defaults to today.", + ) + parser.add_argument( + "--skip-lock", + action="store_true", + help="Do not run 'uv lock'. Intended only for local testing.", + ) + args = parser.parse_args(argv) + + repo_root = pathlib.Path(__file__).resolve().parents[1] + version = validate_version(args.version) + release_date = parse_date(args.date) + 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" + ) + + 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) + + print(f"Prepared release {version} dated {release_date.isoformat()}") + + +if __name__ == "__main__": + main() diff --git a/tests/test_prepare_release.py b/tests/test_prepare_release.py new file mode 100644 index 000000000..07bdbd4a5 --- /dev/null +++ b/tests/test_prepare_release.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import datetime + +from scripts.prepare_release import ( + finalize_changelog_release, + replace_project_version, + replace_service_version, +) + + +def test_finalize_changelog_release_seeds_unreleased_and_versions_notes() -> None: + changelog = """# Changelog + +## [Unreleased] + +### Added + +### Changed + +- Changed a thing. + +### Fixed + +## [1.29.0] - 2026-06-17 + +### Added + +- Previous release. +""" + + updated = finalize_changelog_release( + changelog, + version="1.30.0", + release_date=datetime.date(2026, 6, 18), + ) + + assert updated.startswith( + """# Changelog + +## [Unreleased] + +### Added + +### Changed + +### Deprecated + +### Breaking Changes + +### Fixed + +### Security + +## [1.30.0] - 2026-06-18 + +### Changed + +- Changed a thing. +""" + ) + assert "### Added\n\n### Changed\n\n- Changed a thing." not in updated + + +def test_replace_versions() -> None: + assert ( + replace_project_version( + '[project]\nname = "temporalio"\nversion = "1.29.0"\n', "1.30.0" + ) + == '[project]\nname = "temporalio"\nversion = "1.30.0"' + ) + assert ( + replace_service_version('__version__ = "1.29.0"\n', "1.30.0") + == '__version__ = "1.30.0"' + )