1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167
|
"""Handles creating a release PR."""
from __future__ import annotations
from pathlib import Path
from subprocess import CalledProcessError, check_call, run
from git import Commit, Head, Remote, Repo, TagReference
from packaging.version import Version
ROOT_SRC_DIR = Path(__file__).parents[1]
def cleanup_failed_release( # noqa: PLR0913
repo: Repo,
upstream: Remote,
version: Version,
release_branch: Head,
original_main_sha: str,
*,
release_created: bool,
tag_pushed: bool,
main_pushed: bool,
) -> None:
print("Release failed! Cleaning up...") # noqa: T201
if release_created:
print(f"Deleting GitHub release {version}") # noqa: T201
try:
check_call(["gh", "release", "delete", str(version), "--yes"], cwd=str(ROOT_SRC_DIR)) # noqa: S607
except Exception as cleanup_error: # noqa: BLE001
print(f"Warning: Failed to delete GitHub release: {cleanup_error}") # noqa: T201
if tag_pushed:
print(f"Deleting remote tag {version}") # noqa: T201
try:
repo.git.push(upstream.name, f":refs/tags/{version}", "--no-verify")
except Exception as cleanup_error: # noqa: BLE001
print(f"Warning: Failed to delete remote tag: {cleanup_error}") # noqa: T201
if main_pushed:
print(f"Reverting main to {original_main_sha[:8]}") # noqa: T201
try:
repo.git.push(upstream.name, f"{original_main_sha}:main", "-f", "--no-verify")
except Exception as cleanup_error: # noqa: BLE001
print(f"Warning: Failed to revert main: {cleanup_error}") # noqa: T201
print("Deleting remote release branch") # noqa: T201
try:
repo.git.push(upstream.name, f":{release_branch}", "--no-verify")
except Exception as cleanup_error: # noqa: BLE001
print(f"Warning: Failed to delete remote branch: {cleanup_error}") # noqa: T201
def main(version_str: str) -> None:
version = Version(version_str)
repo = Repo(str(ROOT_SRC_DIR))
if repo.is_dirty():
msg = "Current repository is dirty. Please commit any changes and try again."
raise RuntimeError(msg)
upstream, release_branch = create_release_branch(repo, version)
main_pushed = False
tag_pushed = False
release_created = False
original_main_sha = upstream.refs.main.commit.hexsha
try:
release_commit = release_changelog(repo, version)
tag = tag_release_commit(release_commit, repo, version)
print("push release commit") # noqa: T201
repo.git.push(upstream.name, f"{release_branch}:main", "-f")
main_pushed = True
print("push release tag") # noqa: T201
repo.git.push(upstream.name, tag, "-f")
tag_pushed = True
create_github_release(version)
release_created = True
print("checkout main to new release and delete release branch") # noqa: T201
repo.heads.main.checkout()
repo.delete_head(release_branch, force=True)
print("delete remote release branch") # noqa: T201
repo.git.push(upstream.name, f":{release_branch}", "--no-verify")
upstream.fetch()
repo.git.reset("--hard", f"{upstream.name}/main")
print("All done! ✨ 🍰 ✨") # noqa: T201
except Exception:
cleanup_failed_release(
repo,
upstream,
version,
release_branch,
original_main_sha,
release_created=release_created,
tag_pushed=tag_pushed,
main_pushed=main_pushed,
)
raise
def create_release_branch(repo: Repo, version: Version) -> tuple[Remote, Head]:
print("create release branch from upstream main") # noqa: T201
upstream = get_upstream(repo)
upstream.fetch()
branch_name = f"release-{version}"
release_branch = repo.create_head(branch_name, upstream.refs.main, force=True)
upstream.push(refspec=f"{branch_name}:{branch_name}", force=True)
release_branch.set_tracking_branch(repo.refs[f"{upstream.name}/{branch_name}"]) # ty: ignore[invalid-argument-type] # gitpython types Reference broadly
release_branch.checkout()
return upstream, release_branch
def get_upstream(repo: Repo) -> Remote:
for remote in repo.remotes:
if any("tox-dev/tox" in url for url in remote.urls):
return remote
msg = "could not find tox-dev/tox remote"
raise RuntimeError(msg)
def release_changelog(repo: Repo, version: Version) -> Commit:
print("generate release commit") # noqa: T201
check_call(["towncrier", "build", "--yes", "--version", version.public], cwd=str(ROOT_SRC_DIR)) # noqa: S607
print("format changelog with pre-commit") # noqa: T201
changelog_path = ROOT_SRC_DIR / "docs" / "changelog.rst"
try:
check_call(["pre-commit", "run", "--files", str(changelog_path)], cwd=str(ROOT_SRC_DIR)) # noqa: S607
except CalledProcessError:
print("pre-commit made formatting changes, staging them") # noqa: T201
repo.index.add([str(changelog_path)])
return repo.index.commit(f"release {version}")
def tag_release_commit(release_commit: Commit, repo: Repo, version: Version) -> TagReference:
print("tag release commit") # noqa: T201
existing_tags = [x.name for x in repo.tags]
if version in existing_tags:
print(f"delete existing tag {version}") # noqa: T201
repo.delete_tag(version) # ty: ignore[invalid-argument-type] # Version has __str__, gitpython uses it
print(f"create tag {version}") # noqa: T201
return repo.create_tag(version, ref=release_commit, force=True) # ty: ignore[invalid-argument-type] # Version has __str__, gitpython uses it
def create_github_release(version: Version) -> None:
print("create github release") # noqa: T201
version_str = str(version)
try:
result = run(
["gh", "release", "create", version_str, "--title", f"v{version_str}", "--generate-notes"], # noqa: S607
cwd=str(ROOT_SRC_DIR),
capture_output=True,
text=True,
check=True,
)
if result.stdout:
print(result.stdout) # noqa: T201
except CalledProcessError as e:
print(f"gh release create failed with exit code {e.returncode}") # noqa: T201
if e.stdout:
print(f"stdout: {e.stdout}") # noqa: T201
if e.stderr:
print(f"stderr: {e.stderr}") # noqa: T201
raise
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(prog="release")
parser.add_argument("--version", required=True)
options = parser.parse_args()
main(options.version)
|