Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add release.py script for release automation #16

Merged
merged 3 commits into from
Nov 13, 2024
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
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
4 changes: 4 additions & 0 deletions scripts/mkdocs_generate_release_notes.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
#!/usr/bin/env -S uv run
"""
Generate release notes from CHANGELOG.md and write them to docs/release-notes.md.
"""

from pathlib import Path

REPO_ROOT = Path(__file__).parent.parent
Expand Down
263 changes: 263 additions & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
#!/usr/bin/env -S uv run
# /// script
# dependencies = [
# "requests"
# ]
# ///
"""
Generate and publish a new release of the project.

Intended for use by maintainers only.

This script will:
- Read pyproject.toml to automatically determine the new version number
- Update pyproject.toml with the new version number
- Update CHANGELOG.md, creating a new section for the release and moving unreleased changes there
- Commit and push changes to pyproject.toml and CHANGELOG.md
- Create and push a git tag for the new version
- Create a new release on GitHub ($GITHUB_TOKEN environment variable must be set)

Usage:
uv run python release.py [--patch | --minor | --major] [--dry-run]
"""

import argparse
import datetime
import os
import re
import subprocess
import sys
from pathlib import Path

# installed via inline script metadata, but mypy doesn't know that
import requests # type: ignore

# for creating GitHub release
GITHUB_REPO_SLUG = "stephanlensky/zendriver"

PYPROJECT_TOML = Path("pyproject.toml")
PYPROJECT_VERSION_REGEX = r"^version = \"(?P<version>\d+\.\d+\.\d+)\"$"

CHANGELOG_MD = Path("CHANGELOG.md")
CHANGELOG_MD_UNRELEASED_REGEX = (
r"## \[Unreleased\]\s+"
r"### Fixed\n(?P<fixed>[\s\S]+?)"
r"### Added\n(?P<added>[\s\S]+?)"
r"### Changed\n(?P<changed>[\s\S]+?)"
r"### Removed\n(?P<removed>[\s\S]+?)"
r"##"
)
CHANGELOG_MD_UNRELEASED_TEMPLATE = (
"## [Unreleased]\n\n"
"### Fixed\n\n"
"### Added\n\n"
"### Changed\n\n"
"### Removed\n\n"
)


def set_working_directory() -> None:
repo_root = Path(__file__).parent.parent
print(f"Changing working directory to {repo_root}")
os.chdir(repo_root)


def ensure_clean_working_directory() -> None:
if subprocess.run(["git", "diff", "--quiet"]).returncode != 0:
print("Error: Working directory is not clean, please commit or stash changes")
sys.exit(1)


def ensure_on_main_branch() -> None:
current_branch = (
subprocess.run(["git", "branch", "--show-current"], capture_output=True)
.stdout.decode()
.strip()
)
if current_branch != "main":
print("Error: Not on the main branch")
sys.exit(1)


def ensure_github_token() -> str:
github_token = os.environ.get("GITHUB_TOKEN")
if not github_token:
print("Error: $GITHUB_TOKEN environment variable must be set")
sys.exit(1)
return github_token


def get_current_version() -> str:
content = PYPROJECT_TOML.read_text()
if match := re.search(PYPROJECT_VERSION_REGEX, content, re.MULTILINE):
return match.group("version")

print("Error: Could not find version in pyproject.toml")
sys.exit(1)


def get_new_version(current_version: str, patch: bool, minor: bool, major: bool) -> str:
current_major, current_minor, current_patch = map(int, current_version.split("."))
if major:
return f"{current_major + 1}.0.0"
if minor:
return f"{current_major}.{current_minor + 1}.0"
if patch:
return f"{current_major}.{current_minor}.{current_patch + 1}"
raise ValueError("No version increment specified")


def write_new_version_to_pyproject(new_version: str, dryrun: bool) -> None:
if dryrun:
print(f"Would update pyproject.toml with new version: {new_version}")
return

content = PYPROJECT_TOML.read_text()
PYPROJECT_TOML.write_text(
re.sub(PYPROJECT_VERSION_REGEX, f'version = "{new_version}"', content)
)


def write_changelog(new_version: str, dryrun: bool) -> str:
changelog_content = CHANGELOG_MD.read_text()
match = re.search(CHANGELOG_MD_UNRELEASED_REGEX, changelog_content)
if not match:
print("Error: Could not parse CHANGELOG.md")
sys.exit(1)
unreleased_changes = {
section: match.group(section).strip() for section in match.groupdict()
}
if not any(content for content in unreleased_changes.values()):
print("Error: No changes found in CHANGELOG.md")
sys.exit(1)

new_version_changes = "\n\n".join(
f"### {section.title()}\n\n{unreleased_changes[section]}"
for section in ("fixed", "added", "changed", "removed")
if unreleased_changes[section]
)

new_version_section = (
f"## [{new_version}] - {datetime.date.today()}\n\n{new_version_changes}"
)
print(f"Changelog:\n{new_version_section}")

new_changelog_content = re.sub(
CHANGELOG_MD_UNRELEASED_REGEX,
f"{CHANGELOG_MD_UNRELEASED_TEMPLATE}{new_version_section}\n\n##",
changelog_content,
)

if dryrun:
print("Would update CHANGELOG.md with new version section")
else:
CHANGELOG_MD.write_text(new_changelog_content)

return new_version_changes


def show_diff() -> None:
subprocess.run(["git", "diff", "--color"])


def commit_and_push_changes(new_version: str, dryrun: bool) -> None:
if dryrun:
print("Would commit and push changes to the repository")
return

subprocess.run(["git", "add", "pyproject.toml", "CHANGELOG.md"])
subprocess.run(["git", "commit", "-m", f"Bump version to {new_version}"])
subprocess.run(["git", "push"])


def create_and_push_tag(new_version: str, dryrun: bool) -> None:
if dryrun:
print(f"Would create and push tag: v{new_version}")
return

subprocess.run(["git", "tag", f"v{new_version}"])
subprocess.run(["git", "push", "--tags"])


def create_github_release(new_version: str, body: str, dryrun: bool) -> None:
if dryrun:
print(f"Would create a new release on GitHub for tag {new_version}")
return

github_token = ensure_github_token()

response = requests.post(
f"https://api.github.com/repos/{GITHUB_REPO_SLUG}/releases",
headers={"Authorization": f"token {github_token}"},
json={"tag_name": f"v{new_version}", "name": f"v{new_version}", "body": body},
)
response.raise_for_status()


def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Generate and publish a new release of the project."
)
parser.add_argument(
"--patch",
action="store_true",
help="Increment the patch version number (e.g. 1.0.0 -> 1.0.1)",
)
parser.add_argument(
"--minor",
action="store_true",
help="Increment the minor version number (e.g. 1.0.0 -> 1.1.0)",
)
parser.add_argument(
"--major",
action="store_true",
help="Increment the major version number (e.g. 1.0.0 -> 2.0.0)",
)
parser.add_argument(
"--dryrun",
action="store_true",
help="Perform a dry run, making no changes to the repository",
)
return parser.parse_args()


def main() -> None:
set_working_directory()
args = parse_args()
# prechecks
if not args.patch and not args.minor and not args.major:
print("No version increment specified, defaulting to --patch")
args.patch = True
elif sum([args.patch, args.minor, args.major]) > 1:
print("Error: Only one of --patch, --minor, or --major can be specified")
sys.exit(1)
if not args.dryrun:
ensure_clean_working_directory()
ensure_on_main_branch()
ensure_github_token()
if args.dryrun:
print("Running in dry-run mode, no changes will be made to the repository")

# calculate new version
current_version = get_current_version()
print(f"Detected current version: {current_version}")
new_version = get_new_version(current_version, args.patch, args.minor, args.major)
print(f"Calculated new version: {new_version}")

# create and push the new release
write_new_version_to_pyproject(new_version, args.dryrun)
changelog = write_changelog(new_version, args.dryrun)

show_diff()
continue_prompt = input("Continue with release? [Y/n]: ")
if continue_prompt.lower() not in ("y", "yes", ""):
print("Aborting!")
sys.exit(1)

commit_and_push_changes(new_version, args.dryrun)
create_and_push_tag(new_version, args.dryrun)
create_github_release(new_version, changelog, args.dryrun)


if __name__ == "__main__":
main()
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading