Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -19,19 +18,27 @@ 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
per-invocation of the worker instead of only at startup. It is advised that
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
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions scripts/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Repository helper scripts."""
235 changes: 235 additions & 0 deletions scripts/prepare_release.py
Original file line number Diff line number Diff line change
@@ -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<version>[^\]]+)\](?:\s+-\s+.*)?\s*$")
_CHANGELOG_SUBHEADING_RE = re.compile(r"^### (?P<header>.+?)\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()
75 changes: 75 additions & 0 deletions tests/test_prepare_release.py
Original file line number Diff line number Diff line change
@@ -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"'
)
Loading