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 automation logic #504

Merged
merged 10 commits into from
Oct 17, 2024
39 changes: 39 additions & 0 deletions release/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Release Instructions

This applies to maintainers preparing a new release.

## Semi-automated release process

### Prerequisites:

* gh cli is installed - see installation instructions [here](https://docs.github.com/en/github-cli/github-cli/quickstart)
sydney-runkle marked this conversation as resolved.
Show resolved Hide resolved
Run gh auth login to authenticate with GitHub, which is needed for the API calls made in the release process.

1. Check out a new branch for the release (perhaps name it `release-v{VERSION}`).
2. Run `uv run release/prepare.py --version {VERSION}` from the root of the repository.
This will:
* Update the version number in the `pyproject.toml` files in the root and in `logfire-api`.
* Add a new section to CHANGELOG.md with a title containing the version number tag and current date.
* Add a line at the end of this section that looks something like [v1.0.1]: https://github.com/pydantic/logfire/compare/v{PREV_VERSION}...v1.0.1 but with the correct version number tags.
3. Curate the changes in CHANGELOG.md:
* Make sure the markdown is valid; in particular, check text that should be in code-blocks is.
* Mark any breaking changes with **Breaking Change:**.
* Deduplicate the packaging entries to include only the most recent version bumps for each package.
4. Run `uv run release/push.py` from the root of the repository. This will:
* Create a PR with the changes you made in the previous steps.
* Add a label to the PR to indicate that it's a release PR.
* Open a draft release on GitHub with the changes you made in the previous steps.
5. Review the PR and merge it.
6. Publish the release and wait for the CI to finish building and publishing the new version.

## Manual release process

If you're doing a release from a branch other than `main`, we'd recommend just going through the release process manually.

1. Update generated stubs just in case it should have been done in a previous PR via `make generate-stubs`.
2. Create a GitHub release draft with a new version number tag, generate release notes, and edit them to exclude irrelevant things like docs updates.
3. Add a new section to CHANGELOG.md with a title containing the version number tag and current date, and copy in the release notes.
4. Add a line to the end of the file that looks something like [v1.0.1]: https://github.com/pydantic/logfire/compare/v1.0.0...v1.0.1 but with the correct version number tags.
5. Update the version number in the two `pyproject.toml` files in the root and in `logfire-api`.
6. Push and merge a PR with these changes.
7. Publish the GitHub release draft and wait for the CI to finish building and publishing the new version.
185 changes: 185 additions & 0 deletions release/prepare.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import re
import subprocess
import sys
from datetime import date
from pathlib import Path

import requests
import toml


def run_command(*args: str) -> str:
"""Run a shell command and return the output."""
p = subprocess.run(args, stdout=subprocess.PIPE, check=True, encoding='utf-8')
return p.stdout.strip()


REPO = 'pydantic/logfire'
CHANGELOG_FILE = 'CHANGELOG.md'
ROOT_PYPROJECT = 'pyproject.toml'
API_PYPROJECT = 'logfire-api/pyproject.toml'
GITHUB_TOKEN = run_command('gh', 'auth', 'token')


def update_version(pyproject_file: str, new_version: str) -> None:
"""Update the version in a given pyproject.toml."""
config = toml.load(pyproject_file)
config['project']['version'] = new_version
with open(pyproject_file, 'w') as f:
toml.dump(config, f)


def generate_stubs() -> None:
"""Run make logic to generate stubs and update __init__.pyi."""
run_command('make', 'run', 'generate-stubs')


def get_last_tag() -> str:
"""Get the latest tag from the Git repository."""
return run_command('git', 'describe', '--tags', '--abbrev=0')


def get_notes(new_version: str) -> str:
"""Generate release notes from GitHub's release notes generator."""
last_tag = get_last_tag()

data = {
'target_committish': 'main',
'previous_tag_name': last_tag,
'tag_name': f'v{new_version}',
}

response = requests.post(
f'https://api.github.com/repos/{REPO}/releases/generate-notes',
headers={
'Accept': 'application/vnd.github+json',
'Authorization': f'Bearer {GITHUB_TOKEN}',
},
json=data,
)
response.raise_for_status()
body = response.json()['body']

# Clean up the release notes
body = re.sub(r'<!--.*?-->\n\n', '', body)
body = re.sub(r'([^\n])(\n#+ .+?\n)', r'\1\n\2', body) # Add blank line before headers
body = re.sub(r'https://github.com/pydantic/logfire/pull/(\d+)', r'[#\1](\0)', body)
body = re.sub(r'\*\*Full Changelog\*\*: .*\n?', '', body)

return body.strip()


def update_history(new_version: str, notes: str) -> None:
"""Update CHANGELOG.md with the new release notes."""
history_path = Path(CHANGELOG_FILE)
history_content = history_path.read_text()

date_today = date.today().strftime('%Y-%m-%d')
title = f'## v{new_version} ({date_today})'
if title in history_content:
print(f'WARNING: {title} already exists in CHANGELOG.md')
sys.exit(1)

new_chunk = f'{title}\n\n{notes}\n\n'
history_path.write_text(new_chunk + history_content)

# Add a comparison link at the end of the file
last_tag = get_last_tag()
compare_link = f'[v{new_version}]: https://github.com/{REPO}/compare/{last_tag}...v{new_version}\n'
with open(history_path, 'a') as f:
f.write(compare_link)


def create_github_release_draft(version: str, release_notes: str):
"""Create a GitHub release draft."""
url = f'https://api.github.com/repos/{REPO}/releases'
headers = {'Authorization': f'token {GITHUB_TOKEN}'}
data = {
'tag_name': f'v{version}',
'name': f'v{version}',
'body': release_notes,
'draft': True,
'prerelease': False,
}
response = requests.post(url, json=data, headers=headers)
response.raise_for_status()
return response.json()['html_url']


def commit_and_push_changes(version: str) -> None:
"""Commit and push changes to a new branch."""
branch_name = f'release/v{version}'
run_command('git', 'checkout', '-b', branch_name)
run_command('git', 'add', '.')
run_command('git', 'commit', '-m', f"'Bump version to v{version}'")
run_command('git', 'push', 'origin', branch_name)


def open_pull_request(version: str):
"""Open a pull request on GitHub."""
url = f'https://api.github.com/repos/{REPO}/pulls'
headers = {'Authorization': f'token {GITHUB_TOKEN}'}
data = {
'title': f'Release v{version}',
'head': f'release/v{version}',
'base': 'main',
'body': f'Bumping version to v{version}.',
}
response = requests.post(url, json=data, headers=headers)
response.raise_for_status()
return response.json()['html_url']


def create_github_release(new_version: str, notes: str):
"""Create a new release on GitHub."""
url = f'https://api.github.com/repos/{REPO}/releases'

data = {
'tag_name': f'v{new_version}',
'name': f'v{new_version}',
'body': notes,
'draft': True,
}

response = requests.post(
url,
headers={
'Authorization': f'Bearer {GITHUB_TOKEN}',
'Accept': 'application/vnd.github+json',
},
json=data,
)
response.raise_for_status()


def main():
"""Automate the release process."""
if len(sys.argv) != 2:
print('Usage: python release.py {VERSION}')
sys.exit(1)

version = sys.argv[1]

update_version(ROOT_PYPROJECT, version)
update_version(API_PYPROJECT, version)
print(f'Updated version to v{version} in both pyproject.toml files.')

generate_stubs()
print('Generated stubs.')

release_notes = get_notes(version)
update_history(version, release_notes)
print('Release notes added to CHANGELOG.md.')

commit_and_push_changes(version)
pr_url = open_pull_request(version)
print(f'Opened PR: {pr_url}')

draft_url = create_github_release_draft(version, release_notes)
print(f'Release draft created: {draft_url}')

print(f'SUCCESS: Completed release process for v{version}')


if __name__ == '__main__':
main()