Skip to content

Commit 631ebaf

Browse files
authored
Add release preparation script (#1611)
1 parent 26df51e commit 631ebaf

5 files changed

Lines changed: 322 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,8 @@ High-level release notes.
33
Loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
44
55
When your PR includes a user-facing change, add an entry below under the
6-
appropriate heading (create the heading if it does not yet exist). Within
7-
each heading content can be free-form. Feel free to include examples, links
8-
to docs, or any other relevant information.
6+
appropriate heading. Within each heading content can be free-form. Feel free
7+
to include examples, links to docs, or any other relevant information.
98
109
### Added — new features
1110
### Changed — changes in existing functionality
@@ -19,19 +18,27 @@ to docs, or any other relevant information.
1918

2019
## [Unreleased]
2120

21+
### Added
22+
2223
### Changed
2324

2425
- AWS Lambda worker `configure` parameter supports sync, async, and async
2526
generator style functions. This callback is invoked on the asyncio event
2627
loop.
2728

29+
### Deprecated
30+
2831
### Breaking Changes
2932

3033
- AWS Lambda worker `configure` parameter has been changed to be invoked
3134
per-invocation of the worker instead of only at startup. It is advised that
3235
any shared, heavy-weight operations are performed outside of the callback
3336
before `run_worker` is invoked.
3437

38+
### Fixed
39+
40+
### Security
41+
3542
## [1.29.0] - 2026-06-17
3643

3744
### Added

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ User-facing changes are recorded in [`CHANGELOG.md`](CHANGELOG.md), loosely foll
1414

1515
If your PR includes a user-facing change (new feature, behavior change, deprecation, breaking
1616
change, notable bug fix, or security fix), add a short, high-level entry to the `## [Unreleased]`
17-
section at the top of `CHANGELOG.md` under the appropriate heading, creating it if needed:
17+
section at the top of `CHANGELOG.md` under the appropriate heading:
1818
Added, Changed, Deprecated, Breaking Changes, Fixed, or Security.
1919

2020
Keep entries high-level and written for users. The full commit log is appended at release time,

scripts/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Repository helper scripts."""

scripts/prepare_release.py

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
"""Prepare checked-in files for an SDK release."""
2+
3+
from __future__ import annotations
4+
5+
import argparse
6+
import datetime
7+
import pathlib
8+
import re
9+
import subprocess
10+
import sys
11+
from collections.abc import Sequence
12+
13+
if __package__ is None or __package__ == "":
14+
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
15+
16+
CHANGELOG_HEADERS = (
17+
"Added",
18+
"Changed",
19+
"Deprecated",
20+
"Breaking Changes",
21+
"Fixed",
22+
"Security",
23+
)
24+
VERSION_RE = re.compile(r"[0-9]+(?:\.[0-9]+)+(?:[a-zA-Z0-9_.+-]+)?")
25+
_CHANGELOG_HEADING_RE = re.compile(r"^## \[(?P<version>[^\]]+)\](?:\s+-\s+.*)?\s*$")
26+
_CHANGELOG_SUBHEADING_RE = re.compile(r"^### (?P<header>.+?)\s*$")
27+
28+
29+
def validate_version(version: str) -> str:
30+
if not VERSION_RE.fullmatch(version):
31+
raise ValueError(
32+
f"Invalid version {version!r}; expected a version like '1.30.0'"
33+
)
34+
return version
35+
36+
37+
def parse_date(date: str) -> datetime.date:
38+
try:
39+
return datetime.date.fromisoformat(date)
40+
except ValueError as err:
41+
raise ValueError(f"Invalid release date {date!r}; expected YYYY-MM-DD") from err
42+
43+
44+
def finalize_changelog_release(
45+
text: str,
46+
*,
47+
version: str,
48+
release_date: datetime.date,
49+
) -> str:
50+
validate_version(version)
51+
lines = text.splitlines()
52+
53+
if _find_version_section(lines, version) is not None:
54+
raise RuntimeError(f"Changelog already has a section for {version!r}")
55+
56+
unreleased = _find_version_section(lines, "Unreleased")
57+
if unreleased is None:
58+
raise RuntimeError("Could not find changelog section for 'Unreleased'")
59+
60+
heading_index, section_start, section_end = unreleased
61+
unreleased_lines = _strip_empty_changelog_headers(
62+
_strip_outer_blank_lines(lines[section_start:section_end])
63+
)
64+
if not unreleased_lines:
65+
raise RuntimeError("Changelog section for 'Unreleased' is empty")
66+
67+
next_lines = [
68+
*lines[:heading_index],
69+
*_seeded_unreleased_lines(),
70+
f"## [{version}] - {release_date.isoformat()}",
71+
"",
72+
*unreleased_lines,
73+
"",
74+
*lines[section_end:],
75+
]
76+
return "\n".join(_collapse_blank_lines(next_lines)).rstrip() + "\n"
77+
78+
79+
def replace_project_version(text: str, version: str) -> str:
80+
return _replace_once(
81+
r'(?m)^version = "[^"]+"\s*$',
82+
f'version = "{validate_version(version)}"',
83+
text,
84+
description="project version",
85+
)
86+
87+
88+
def replace_service_version(text: str, version: str) -> str:
89+
return _replace_once(
90+
r'(?m)^__version__ = "[^"]+"\s*$',
91+
f'__version__ = "{validate_version(version)}"',
92+
text,
93+
description="service version",
94+
)
95+
96+
97+
def _seeded_unreleased_lines() -> list[str]:
98+
lines = ["## [Unreleased]", ""]
99+
for header in CHANGELOG_HEADERS:
100+
lines.extend([f"### {header}", ""])
101+
return lines
102+
103+
104+
def _strip_empty_changelog_headers(lines: list[str]) -> list[str]:
105+
filtered: list[str] = []
106+
index = 0
107+
while index < len(lines):
108+
match = _CHANGELOG_SUBHEADING_RE.match(lines[index])
109+
if not match or match.group("header") not in CHANGELOG_HEADERS:
110+
filtered.append(lines[index])
111+
index += 1
112+
continue
113+
114+
next_index = index + 1
115+
while next_index < len(lines) and not lines[next_index].startswith("### "):
116+
next_index += 1
117+
118+
content = lines[index + 1 : next_index]
119+
if any(line.strip() for line in content):
120+
filtered.append(lines[index])
121+
filtered.extend(content)
122+
index = next_index
123+
124+
return _strip_outer_blank_lines(filtered)
125+
126+
127+
def _find_version_section(
128+
lines: list[str],
129+
version: str,
130+
) -> tuple[int, int, int] | None:
131+
for index, line in enumerate(lines):
132+
match = _CHANGELOG_HEADING_RE.match(line)
133+
if match and match.group("version") == version:
134+
section_end = len(lines)
135+
for end_index in range(index + 1, len(lines)):
136+
if lines[end_index].startswith("## "):
137+
section_end = end_index
138+
break
139+
return index, index + 1, section_end
140+
return None
141+
142+
143+
def _strip_outer_blank_lines(lines: list[str]) -> list[str]:
144+
while lines and not lines[0].strip():
145+
lines.pop(0)
146+
while lines and not lines[-1].strip():
147+
lines.pop()
148+
return lines
149+
150+
151+
def _collapse_blank_lines(lines: list[str]) -> list[str]:
152+
collapsed: list[str] = []
153+
previous_blank = False
154+
for line in lines:
155+
blank = not line.strip()
156+
if blank and previous_blank:
157+
continue
158+
collapsed.append(line)
159+
previous_blank = blank
160+
return collapsed
161+
162+
163+
def _replace_once(
164+
pattern: str,
165+
replacement: str,
166+
text: str,
167+
*,
168+
description: str,
169+
) -> str:
170+
updated, count = re.subn(pattern, replacement, text, count=1)
171+
if count != 1:
172+
raise RuntimeError(f"Could not find {description}")
173+
return updated.rstrip("\n")
174+
175+
176+
def main(argv: Sequence[str] | None = None) -> None:
177+
parser = argparse.ArgumentParser(
178+
description=(
179+
"Bump the SDK version, roll CHANGELOG.md's Unreleased section into "
180+
"a dated release section, seed a fresh Unreleased section, and "
181+
"refresh uv.lock."
182+
)
183+
)
184+
parser.add_argument("version", help="Release version, for example 1.30.0")
185+
parser.add_argument(
186+
"--date",
187+
default=datetime.date.today().isoformat(),
188+
help="Release date in YYYY-MM-DD format. Defaults to today.",
189+
)
190+
parser.add_argument(
191+
"--skip-lock",
192+
action="store_true",
193+
help="Do not run 'uv lock'. Intended only for local testing.",
194+
)
195+
args = parser.parse_args(argv)
196+
197+
repo_root = pathlib.Path(__file__).resolve().parents[1]
198+
version = validate_version(args.version)
199+
release_date = parse_date(args.date)
200+
changelog_path = repo_root / "CHANGELOG.md"
201+
pyproject_path = repo_root / "pyproject.toml"
202+
service_path = repo_root / "temporalio" / "service.py"
203+
204+
changelog_text = finalize_changelog_release(
205+
changelog_path.read_text(encoding="utf-8"),
206+
version=version,
207+
release_date=release_date,
208+
)
209+
pyproject_text = (
210+
replace_project_version(
211+
pyproject_path.read_text(encoding="utf-8"),
212+
version,
213+
)
214+
+ "\n"
215+
)
216+
service_text = (
217+
replace_service_version(
218+
service_path.read_text(encoding="utf-8"),
219+
version,
220+
)
221+
+ "\n"
222+
)
223+
224+
changelog_path.write_text(changelog_text, encoding="utf-8")
225+
pyproject_path.write_text(pyproject_text, encoding="utf-8")
226+
service_path.write_text(service_text, encoding="utf-8")
227+
228+
if not args.skip_lock:
229+
subprocess.run(["uv", "lock"], cwd=repo_root, check=True)
230+
231+
print(f"Prepared release {version} dated {release_date.isoformat()}")
232+
233+
234+
if __name__ == "__main__":
235+
main()

tests/test_prepare_release.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
from __future__ import annotations
2+
3+
import datetime
4+
5+
from scripts.prepare_release import (
6+
finalize_changelog_release,
7+
replace_project_version,
8+
replace_service_version,
9+
)
10+
11+
12+
def test_finalize_changelog_release_seeds_unreleased_and_versions_notes() -> None:
13+
changelog = """# Changelog
14+
15+
## [Unreleased]
16+
17+
### Added
18+
19+
### Changed
20+
21+
- Changed a thing.
22+
23+
### Fixed
24+
25+
## [1.29.0] - 2026-06-17
26+
27+
### Added
28+
29+
- Previous release.
30+
"""
31+
32+
updated = finalize_changelog_release(
33+
changelog,
34+
version="1.30.0",
35+
release_date=datetime.date(2026, 6, 18),
36+
)
37+
38+
assert updated.startswith(
39+
"""# Changelog
40+
41+
## [Unreleased]
42+
43+
### Added
44+
45+
### Changed
46+
47+
### Deprecated
48+
49+
### Breaking Changes
50+
51+
### Fixed
52+
53+
### Security
54+
55+
## [1.30.0] - 2026-06-18
56+
57+
### Changed
58+
59+
- Changed a thing.
60+
"""
61+
)
62+
assert "### Added\n\n### Changed\n\n- Changed a thing." not in updated
63+
64+
65+
def test_replace_versions() -> None:
66+
assert (
67+
replace_project_version(
68+
'[project]\nname = "temporalio"\nversion = "1.29.0"\n', "1.30.0"
69+
)
70+
== '[project]\nname = "temporalio"\nversion = "1.30.0"'
71+
)
72+
assert (
73+
replace_service_version('__version__ = "1.29.0"\n', "1.30.0")
74+
== '__version__ = "1.30.0"'
75+
)

0 commit comments

Comments
 (0)