Skip to content

Commit

Permalink
Automate release process (#325)
Browse files Browse the repository at this point in the history
* Add script to interactively release the current work tree, tagged, to PyPI and GitHub
* Add release-only dependencies
* Typecheck scripts folder
    * The folder is not a package, so tell mypy `--explicit-package-bases`
* Test script happy path
    * Add snapshot library dependency
        * To unit test network and subprocess input, and verbose CLI output
    * Skip test on Windows
        * Snapshot paths are platform-dependent. Maintainers don't yet release using Windows, besides.
  • Loading branch information
ekcorso committed Mar 15, 2024
1 parent 9f16a0c commit a2eb188
Show file tree
Hide file tree
Showing 5 changed files with 584 additions and 1 deletion.
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ dependencies = [
]

[project.optional-dependencies]
release = [
"build",
"twine",
]
testing = [
"black",
"mypy",
Expand All @@ -49,6 +53,7 @@ testing = [
"pytest-mock",
"responses",
"ruff",
"syrupy",
"tox",
"types-filelock",
"types-requests",
Expand Down Expand Up @@ -79,6 +84,7 @@ write_to = "tldextract/_version.py"
version = {attr = "setuptools_scm.get_version"}

[tool.mypy]
explicit_package_bases = true
strict = true

[tool.pytest.ini_options]
Expand Down
238 changes: 238 additions & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,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()

0 comments on commit a2eb188

Please sign in to comment.