diff --git a/scripts/release.py b/scripts/release.py index 0b69493..7e3b73b 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -1,20 +1,51 @@ #!/usr/bin/env python3 -"""Simple semantic-versioning release utility. +"""Release helper implementing conventional commits and semantic versioning. -The script bumps the version in ``pyproject.toml``, appends entries to -``CHANGELOG.md`` based on recent git commits and creates a tagged commit. -Use ``--rollback`` to delete the most recent tag if a release goes wrong.""" +This utility inspects the commit history since the previous ``vX.Y.Z`` tag, +derives the next semantic version according to the `conventional commits`_ +specification, updates ``pyproject.toml`` and ``CHANGELOG.md`` accordingly and +creates a tagged commit. + +Use ``--rollback`` to delete the most recent tag if a release goes wrong. + +.. _conventional commits: https://www.conventionalcommits.org/en/v1.0.0/ +""" from __future__ import annotations import argparse +import datetime as dt +import re import subprocess +from dataclasses import dataclass from pathlib import Path +from typing import Iterable VERSION_FILE = Path("pyproject.toml") CHANGELOG = Path("CHANGELOG.md") +COMMIT_RE = re.compile(r"(?P\w+)(?P!)?(?:\([^)]*\))?: (?P.+)") + +SECTION_TITLES = { + "feat": "Features", + "fix": "Bug Fixes", + "docs": "Documentation", + "refactor": "Refactoring", + "perf": "Performance", + "test": "Tests", + "chore": "Chores", +} + + +@dataclass +class Commit: + type: str + description: str + hash: str + breaking: bool = False + + def current_version() -> str: for line in VERSION_FILE.read_text().splitlines(): if line.startswith("version ="): @@ -32,20 +63,58 @@ def update_version(version: str) -> None: VERSION_FILE.write_text("\n".join(lines) + "\n") -def generate_changelog(previous: str, new: str) -> None: +def last_tag() -> str | None: + try: + return ( + subprocess.check_output( + ["git", "describe", "--tags", "--abbrev=0"], + text=True, + stderr=subprocess.DEVNULL, + ).strip() + ) + except subprocess.CalledProcessError: + return None + + +def parse_commits(since: str | None) -> list[Commit]: + rev_range = f"{since}..HEAD" if since else "HEAD" log = subprocess.check_output( - ["git", "log", "--oneline", f"v{previous}..HEAD"], text=True + ["git", "log", rev_range, "--pretty=format:%s:::%h"], text=True ) - with CHANGELOG.open("a") as fh: - fh.write(f"\n## v{new}\n\n{log}\n") + commits: list[Commit] = [] + for line in log.splitlines(): + if not line.strip(): + continue + subject, sha = line.split(":::") + match = COMMIT_RE.match(subject) + if match: + commits.append( + Commit( + type=match.group("type"), + description=match.group("desc"), + hash=sha, + breaking=bool(match.group("breaking")), + ) + ) + else: + commits.append(Commit(type="other", description=subject, hash=sha)) + return commits + +def bump_level(commits: Iterable[Commit]) -> str: + if any(c.breaking for c in commits): + return "major" + if any(c.type == "feat" for c in commits): + return "minor" + return "patch" -def bump(bump_type: str) -> str: - major, minor, patch = map(int, current_version().split(".")) - if bump_type == "major": + +def increment(version: str, level: str) -> str: + major, minor, patch = map(int, version.split(".")) + if level == "major": major += 1 minor = patch = 0 - elif bump_type == "minor": + elif level == "minor": minor += 1 patch = 0 else: @@ -53,26 +122,71 @@ def bump(bump_type: str) -> str: return f"{major}.{minor}.{patch}" +def generate_changelog(version: str, commits: list[Commit]) -> None: + date = dt.date.today().isoformat() + sections: dict[str, list[str]] = {k: [] for k in SECTION_TITLES} + misc: list[str] = [] + for commit in commits: + line = f"- {commit.description} ({commit.hash})" + if commit.type in sections: + sections[commit.type].append(line) + else: + misc.append(line) + + lines: list[str] = [f"## v{version} - {date}"] + for key, title in SECTION_TITLES.items(): + entries = sections[key] + if entries: + lines.append(f"### {title}") + lines.extend(entries) + lines.append("") + if misc: + lines.append("### Miscellaneous") + lines.extend(misc) + lines.append("") + + changelog_lines = CHANGELOG.read_text().splitlines() + try: + insert_at = changelog_lines.index("## Unreleased") + 1 + except ValueError: + insert_at = 1 + changelog_lines[insert_at:insert_at] = ["", *lines] + CHANGELOG.write_text("\n".join(changelog_lines) + "\n") + + def main() -> None: - parser = argparse.ArgumentParser(description="Release helper") - parser.add_argument("bump", choices=["major", "minor", "patch"]) + parser = argparse.ArgumentParser( + description="Create a release from conventional commit messages." + ) parser.add_argument( - "--rollback", action="store_true", help="Rollback the most recent tag" + "--rollback", action="store_true", help="Delete the most recent tag" ) args = parser.parse_args() if args.rollback: - prev = current_version() - subprocess.check_call(["git", "tag", "-d", f"v{prev}"]) + tag = last_tag() + if tag: + subprocess.check_call(["git", "tag", "-d", tag]) return - new_version = bump(args.bump) - prev_version = current_version() + prev_tag = last_tag() + commits = parse_commits(prev_tag) + if not commits: + print("No commits to release.") + return + + level = bump_level(commits) + new_version = increment(current_version(), level) update_version(new_version) - generate_changelog(prev_version, new_version) - subprocess.check_call(["git", "commit", "-am", f"chore(release): v{new_version}"]) - subprocess.check_call(["git", "tag", f"v{new_version}"]) + generate_changelog(new_version, commits) + subprocess.check_call( + ["git", "commit", "-am", f"chore(release): v{new_version}"] + ) + subprocess.check_call( + ["git", "tag", "-a", f"v{new_version}", "-m", f"v{new_version}"] + ) if __name__ == "__main__": main() +