diff --git a/scripts/prepare_release.py b/scripts/prepare_release.py index 22305aa8a..2965f8da1 100644 --- a/scripts/prepare_release.py +++ b/scripts/prepare_release.py @@ -24,6 +24,13 @@ 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: @@ -94,6 +101,82 @@ def replace_service_version(text: str, version: str) -> str: ) +def create_release_branch(repo_root: pathlib.Path, version: str) -> None: + subprocess.run(["git", "fetch", "origin", "main"], cwd=repo_root, check=True) + subprocess.run( + ["git", "switch", "--create", f"chore/release-{version}", "origin/main"], + cwd=repo_root, + check=True, + ) + + +def changed_files(repo_root: pathlib.Path) -> set[str]: + result = subprocess.run( + ["git", "status", "--porcelain"], + cwd=repo_root, + check=True, + capture_output=True, + text=True, + ) + return {line[3:] for line in result.stdout.splitlines()} + + +def ensure_clean_worktree(repo_root: pathlib.Path) -> None: + changes = changed_files(repo_root) + if changes: + raise RuntimeError( + "Release preparation requires a clean worktree; found changes in " + + ", ".join(sorted(changes)) + ) + + +def ensure_only_release_changes(repo_root: pathlib.Path) -> None: + unexpected_files = changed_files(repo_root) - _RELEASE_FILE_SET + if unexpected_files: + raise RuntimeError( + "Release preparation changed unexpected files: " + + ", ".join(sorted(unexpected_files)) + ) + + +def commit_release_changes(repo_root: pathlib.Path, version: str) -> None: + subprocess.run( + ["git", "commit", "-m", f"Prepare release {version}", "--", *_RELEASE_FILES], + cwd=repo_root, + check=True, + ) + + +def push_release_branch(repo_root: pathlib.Path, version: str) -> None: + branch = f"chore/release-{version}" + subprocess.run( + ["git", "push", "--set-upstream", "origin", branch], + cwd=repo_root, + check=True, + ) + + +def create_release_pr(repo_root: pathlib.Path, version: str) -> None: + branch = f"chore/release-{version}" + subprocess.run( + [ + "gh", + "pr", + "create", + "--base", + "main", + "--head", + branch, + "--title", + f"Prepare release {version}", + "--body", + f"Prepare release {version}.", + ], + cwd=repo_root, + check=True, + ) + + def _seeded_unreleased_lines() -> list[str]: lines = ["## [Unreleased]", ""] for header in CHANGELOG_HEADERS: @@ -197,6 +280,8 @@ def main(argv: Sequence[str] | None = None) -> None: repo_root = pathlib.Path(__file__).resolve().parents[1] version = validate_version(args.version) 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" @@ -228,7 +313,14 @@ def main(argv: Sequence[str] | None = None) -> None: if not args.skip_lock: subprocess.run(["uv", "lock"], cwd=repo_root, check=True) - print(f"Prepared release {version} dated {release_date.isoformat()}") + ensure_only_release_changes(repo_root) + commit_release_changes(repo_root, version) + push_release_branch(repo_root, version) + create_release_pr(repo_root, version) + + print( + f"Prepared release {version} dated {release_date.isoformat()} and opened a PR" + ) if __name__ == "__main__": diff --git a/tests/test_prepare_release.py b/tests/test_prepare_release.py index b971d499a..6dae0794f 100644 --- a/tests/test_prepare_release.py +++ b/tests/test_prepare_release.py @@ -1,9 +1,18 @@ from __future__ import annotations import datetime +import pathlib +import subprocess + +import pytest from scripts.prepare_release import ( + create_release_branch, + create_release_pr, + ensure_clean_worktree, + ensure_only_release_changes, finalize_changelog_release, + push_release_branch, replace_project_version, replace_service_version, ) @@ -80,3 +89,112 @@ def test_replace_versions() -> None: ) == '__version__ = "1.30.0"\n\nServiceRequest = TypeVar("ServiceRequest")' ) + + +def test_create_release_branch_fetches_main_and_branches_from_it( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[tuple[list[str], pathlib.Path, bool]] = [] + + def run(command: list[str], *, cwd: pathlib.Path, check: bool) -> None: + calls.append((command, cwd, check)) + + monkeypatch.setattr(subprocess, "run", run) + + repo_root = pathlib.Path("/repo") + create_release_branch(repo_root, "1.30.0") + + assert calls == [ + (["git", "fetch", "origin", "main"], repo_root, True), + ( + ["git", "switch", "--create", "chore/release-1.30.0", "origin/main"], + repo_root, + True, + ), + ] + + +def test_ensure_clean_worktree_rejects_existing_changes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + subprocess, + "run", + lambda *_args, **_kwargs: subprocess.CompletedProcess( + args=[], returncode=0, stdout=" M temporalio/service.py\n" + ), + ) + + with pytest.raises(RuntimeError, match="clean worktree"): + ensure_clean_worktree(pathlib.Path("/repo")) + + +def test_ensure_only_release_changes_rejects_unexpected_files( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + subprocess, + "run", + lambda *_args, **_kwargs: subprocess.CompletedProcess( + args=[], returncode=0, stdout=" M unrelated.txt\n" + ), + ) + + with pytest.raises(RuntimeError, match="unexpected files: unrelated.txt"): + ensure_only_release_changes(pathlib.Path("/repo")) + + +def test_create_release_pr_uses_versioned_branch( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[tuple[list[str], pathlib.Path, bool]] = [] + + def run(command: list[str], *, cwd: pathlib.Path, check: bool) -> None: + calls.append((command, cwd, check)) + + monkeypatch.setattr(subprocess, "run", run) + + repo_root = pathlib.Path("/repo") + create_release_pr(repo_root, "1.30.0") + + assert calls == [ + ( + [ + "gh", + "pr", + "create", + "--base", + "main", + "--head", + "chore/release-1.30.0", + "--title", + "Prepare release 1.30.0", + "--body", + "Prepare release 1.30.0.", + ], + repo_root, + True, + ) + ] + + +def test_push_release_branch_uses_versioned_branch( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[tuple[list[str], pathlib.Path, bool]] = [] + + def run(command: list[str], *, cwd: pathlib.Path, check: bool) -> None: + calls.append((command, cwd, check)) + + monkeypatch.setattr(subprocess, "run", run) + + repo_root = pathlib.Path("/repo") + push_release_branch(repo_root, "1.30.0") + + assert calls == [ + ( + ["git", "push", "--set-upstream", "origin", "chore/release-1.30.0"], + repo_root, + True, + ) + ]