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 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238
|
"""
This script automates the release process for a Python package.
It will:
- Add a git tag for the given version.
- Remove the previous dist folder.
- Create a build.
- Ask the user to verify the build.
- Upload the build to PyPI.
- Push all git tags to the remote.
- Create a draft release on GitHub using the version notes in CHANGELOG.md.
Prerequisites:
- This must be run from the root of the repository.
- The repo must have a clean git working tree.
- The user must have the GITHUB_TOKEN environment variable set to a valid GitHub personal access token.
- The user will need credentials for the PyPI repository, which the user will be prompted for during the upload step. The user will need to paste the token manually from a password manager or similar.
- The CHANGELOG.md file must already contain an entry for the version being released.
- Install requirements with: pip install --upgrade --editable '.[release]'
"""
from __future__ import annotations
import os
import re
import subprocess
import sys
from pathlib import Path
import requests
def add_git_tag_for_version(version: str) -> None:
"""Add a git tag for the given version."""
subprocess.run(["git", "tag", "-a", version, "-m", version], check=True)
print(f"Version {version} tag added successfully.")
def remove_previous_dist() -> None:
"""Check for dist folder, and if it exists, remove it."""
subprocess.run(["rm", "-rf", Path("dist")], check=True)
print("Previous dist folder removed successfully.")
def create_build() -> None:
"""Create a build."""
subprocess.run(["python", "-m", "build"], check=True)
print("Build created successfully.")
def verify_build(is_test: str) -> None:
"""Verify the build.
Print the archives in dist/ and ask the user to manually inspect and
confirm they contain the expected files, e.g. source files and test files.
"""
build_files = os.listdir("dist")
if len(build_files) != 2:
print(
"WARNING: dist folder contains incorrect number of files.", file=sys.stderr
)
print("Contents of dist folder:")
subprocess.run(["ls", "-l", Path("dist")], check=True)
print("Contents of tar files in dist folder:")
for build_file in build_files:
subprocess.run(["tar", "tvf", Path("dist") / build_file], check=True)
confirmation = input("Does the build look correct? (y/n): ")
if confirmation == "y":
print("Build verified successfully.")
upload_build_to_pypi(is_test)
push_git_tags()
else:
raise Exception("Could not verify. Build was not uploaded.")
def generate_github_release_notes_body(token: str, version: str) -> str:
"""Generate and grab release notes URL from Github."""
response = requests.post(
"https://api.github.com/repos/john-kurkowski/tldextract/releases/generate-notes",
headers={
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {token}",
"X-GitHub-Api-Version": "2022-11-28",
},
json={"tag_name": version},
)
try:
response.raise_for_status()
except requests.exceptions.HTTPError as err:
print(
f"WARNING: Failed to generate release notes from Github: {err}",
file=sys.stderr,
)
return ""
return str(response.json()["body"])
def get_release_notes_url(body: str) -> str:
"""Parse the release notes content to get the changelog URL."""
url_pattern = re.compile(r"\*\*Full Changelog\*\*: (.*)$")
match = url_pattern.search(body)
if match:
return match.group(1)
else:
print(
"WARNING: Failed to parse release notes URL from GitHub response.",
file=sys.stderr,
)
return ""
def get_changelog_release_notes(release_notes_url: str, version: str) -> str:
"""Get the changelog release notes.
Uses a regex starting on a heading beginning with the version number
literal, and matching until the next heading. Using regex to match markup
is brittle. Consider a Markdown-parsing library instead.
"""
with open("CHANGELOG.md") as file:
changelog_text = file.read()
pattern = re.compile(rf"## {re.escape(version)}[^\n]*(.*?)## ", re.DOTALL)
match = pattern.search(changelog_text)
if match:
return str(match.group(1)).strip()
else:
print(
f"WARNING: Failed to parse changelog release notes. Manually copy this version's notes from the CHANGELOG.md file to {release_notes_url}.",
file=sys.stderr,
)
return ""
def create_release_notes_body(token: str, version: str) -> str:
"""Compile the release notes."""
github_release_body = generate_github_release_notes_body(token, version)
release_notes_url = get_release_notes_url(github_release_body)
changelog_notes = get_changelog_release_notes(release_notes_url, version)
full_release_notes = f"{changelog_notes}\n\n**Full Changelog**: {release_notes_url}"
return full_release_notes
def create_github_release_draft(token: str, version: str) -> None:
"""Create a release on GitHub."""
release_body = create_release_notes_body(token, version)
response = requests.post(
"https://api.github.com/repos/john-kurkowski/tldextract/releases",
headers={
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {token}",
"X-GitHub-Api-Version": "2022-11-28",
},
json={
"tag_name": version,
"name": version,
"body": release_body,
"draft": True,
"prerelease": False,
},
)
try:
response.raise_for_status()
except requests.exceptions.HTTPError as err:
print(
f"WARNING: Failed to create release on Github: {err}",
file=sys.stderr,
)
return
print(f'Release created successfully: {response.json()["html_url"]}')
def upload_build_to_pypi(is_test: str) -> None:
"""Upload the build to PyPI."""
repository: list[str | Path] = (
[] if is_test == "n" else ["--repository", "testpypi"]
)
upload_command = ["twine", "upload", *repository, Path("dist") / "*"]
subprocess.run(
upload_command,
check=True,
)
def push_git_tags() -> None:
"""Push all git tags to the remote."""
subprocess.run(["git", "push", "--tags", "origin", "master"], check=True)
def check_for_clean_working_tree() -> None:
"""Check for a clean git working tree."""
git_status = subprocess.run(
["git", "status", "--porcelain"], capture_output=True, text=True
)
if git_status.stdout:
print(
"Git working tree is not clean. Please commit or stash changes.",
file=sys.stderr,
)
sys.exit(1)
def get_env_github_token() -> str:
"""Check for the GITHUB_TOKEN environment variable."""
github_token = os.environ.get("GITHUB_TOKEN")
if not github_token:
print("GITHUB_TOKEN environment variable not set.", file=sys.stderr)
sys.exit(1)
return github_token
def get_is_test_response() -> str:
"""Ask the user if this is a test release."""
while True:
is_test = input("Is this a test release? (y/n): ")
if is_test in ["y", "n"]:
return is_test
else:
print("Invalid input. Please enter 'y' or 'n.'")
def main() -> None:
"""Run the main program."""
check_for_clean_working_tree()
github_token = get_env_github_token()
is_test = get_is_test_response()
version_number = input("Enter the version number: ")
add_git_tag_for_version(version_number)
remove_previous_dist()
create_build()
verify_build(is_test)
create_github_release_draft(github_token, version_number)
if __name__ == "__main__":
main()
|