Skip to content

Commit bc92b2f

Browse files
committed
👷 Add release automation
1 parent fd0ca44 commit bc92b2f

7 files changed

Lines changed: 332 additions & 0 deletions

File tree

.github/pr-submit.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
workflows:
22
- .github/workflows/bump-pre-commit-hooks.yml
3+
- .github/workflows/prepare-release.yml
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
name: Create Draft Release
2+
3+
on:
4+
pull_request:
5+
types:
6+
- closed
7+
8+
permissions: {}
9+
10+
jobs:
11+
create-draft-release:
12+
if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release')
13+
runs-on: ubuntu-latest
14+
timeout-minutes: 5
15+
permissions:
16+
contents: write
17+
pull-requests: read
18+
steps:
19+
- name: Dump GitHub context
20+
env:
21+
GITHUB_CONTEXT: ${{ toJson(github) }}
22+
run: echo "$GITHUB_CONTEXT"
23+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
24+
with:
25+
ref: ${{ github.event.repository.default_branch }}
26+
persist-credentials: false
27+
- name: Set up Python
28+
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
29+
with:
30+
python-version-file: ".python-version"
31+
- name: Install uv
32+
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
33+
with:
34+
# Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum.
35+
# See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837
36+
version: "0.11.18"
37+
- name: Extract release details
38+
id: release-details
39+
run: |
40+
set -euo pipefail
41+
version="$(uv run python scripts/prepare_release.py current-version)"
42+
uv run python scripts/prepare_release.py release-notes > draft-release-notes.md
43+
echo "version=$version" >> "$GITHUB_OUTPUT"
44+
- name: Create draft release
45+
env:
46+
GH_TOKEN: ${{ github.token }}
47+
VERSION: ${{ steps.release-details.outputs.version }}
48+
run: |
49+
set -euo pipefail
50+
gh release create "$VERSION" \
51+
--draft \
52+
--title "$VERSION" \
53+
--notes-file draft-release-notes.md \
54+
--target "$(git rev-parse HEAD)"
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
name: Prepare Release
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
bump:
7+
description: Release bump
8+
required: true
9+
type: choice
10+
options:
11+
- patch
12+
- minor
13+
- major
14+
date:
15+
description: Release date in YYYY-MM-DD format. Defaults to today.
16+
required: false
17+
type: string
18+
19+
permissions: {}
20+
21+
jobs:
22+
prepare-release:
23+
runs-on: ubuntu-latest
24+
timeout-minutes: 5
25+
permissions:
26+
contents: read
27+
id-token: write
28+
steps:
29+
- name: Dump GitHub context
30+
env:
31+
GITHUB_CONTEXT: ${{ toJson(github) }}
32+
run: echo "$GITHUB_CONTEXT"
33+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
34+
with:
35+
persist-credentials: false
36+
- name: Set up Python
37+
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
38+
with:
39+
python-version-file: ".python-version"
40+
- name: Install uv
41+
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
42+
with:
43+
# Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum.
44+
# See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837
45+
version: "0.11.18"
46+
- name: Prepare release
47+
env:
48+
BUMP: ${{ inputs.bump }}
49+
RELEASE_DATE: ${{ inputs.date }}
50+
run: uv run python scripts/prepare_release.py prepare "$BUMP" --date "$RELEASE_DATE"
51+
- name: Get release version
52+
id: release-version
53+
run: |
54+
version="$(uv run python scripts/prepare_release.py current-version)"
55+
echo "$version"
56+
echo "version=$version" >> "$GITHUB_OUTPUT"
57+
- name: Get PR Submit token
58+
id: pr-submit
59+
uses: tiangolo/pr-submit@d802fdf59bde80bc3eb8bd3259f4cbeec63de4aa # 0.0.1
60+
- name: Create release pull request
61+
env:
62+
BASE_BRANCH: ${{ github.event.repository.default_branch }}
63+
GH_TOKEN: ${{ steps.pr-submit.outputs.token }}
64+
VERSION: ${{ steps.release-version.outputs.version }}
65+
run: |
66+
set -euo pipefail
67+
branch="release-${VERSION}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
68+
git config user.name "pr-submit[bot]"
69+
git config user.email "pr-submit[bot]@users.noreply.github.com"
70+
git switch -c "$branch"
71+
git add release-notes.md
72+
git commit -m "🔖 Release version ${VERSION}"
73+
gh auth setup-git
74+
git push --set-upstream origin "$branch"
75+
gh label create release \
76+
--description "Release preparation" \
77+
--color ededed \
78+
--force
79+
gh pr create \
80+
--base "$BASE_BRANCH" \
81+
--head "$branch" \
82+
--title "🔖 Release version ${VERSION}" \
83+
--body "Prepare release ${VERSION}." \
84+
--label release

deployment.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,14 @@ There are GitHub Action workflows in the `.github/workflows` directory already c
218218

219219
Both workflows are associated with their respective GitHub Environments, so deployments will be visible in the repository's **Environments** section and will respect any protection rules you configure.
220220

221+
### Prepare a Release
222+
223+
Install the [PR Submit GitHub App](https://github.com/apps/pr-submit) in your repository to enable the **Prepare Release** workflow.
224+
225+
Run the workflow manually from the **Actions** tab and select the version bump. It creates a pull request that updates the release notes. When you merge that pull request, the **Create Draft Release** workflow creates a draft GitHub release with the corresponding release notes.
226+
227+
Review and publish the draft release to trigger the production deployment.
228+
221229
If you need to add extra environments you could use those as a starting point.
222230

223231
## URLs

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
[dependency-groups]
22
dev = [
33
"prek>=0.4.11,<1.0.0",
4+
"typer>=0.20.0,<1.0.0",
45
"zizmor>=1.28.0",
56
]
67

scripts/prepare_release.py

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
"""Prepare a release by updating and extracting release notes."""
2+
3+
import re
4+
from datetime import date
5+
from pathlib import Path
6+
from typing import Annotated, Literal
7+
8+
import typer
9+
10+
RELEASE_NOTES_FILE = Path("release-notes.md")
11+
RELEASE_NOTES_HEADER = "# Release Notes\n\n"
12+
LATEST_CHANGES_HEADER = "## Latest Changes"
13+
VERSION_HEADING_PATTERN = re.compile(r"(?m)^## (\d+\.\d+\.\d+)(?: \([^)]+\))?$")
14+
BumpType = Literal["major", "minor", "patch"]
15+
16+
app = typer.Typer()
17+
18+
19+
def parse_version(version: str) -> tuple[int, int, int]:
20+
if not re.fullmatch(r"\d+\.\d+\.\d+", version):
21+
raise ValueError(f"Invalid version: {version!r}. Expected format: X.Y.Z")
22+
major, minor, patch = version.split(".")
23+
return int(major), int(minor), int(patch)
24+
25+
26+
def get_current_version(content: str, release_notes_file: Path) -> str:
27+
match = VERSION_HEADING_PATTERN.search(content)
28+
if not match:
29+
raise RuntimeError(f"Could not find a version section in {release_notes_file}")
30+
return match.group(1)
31+
32+
33+
def bump_version(version: str, bump: BumpType) -> str:
34+
major, minor, patch = parse_version(version)
35+
if bump == "major":
36+
return f"{major + 1}.0.0"
37+
if bump == "minor":
38+
return f"{major}.{minor + 1}.0"
39+
return f"{major}.{minor}.{patch + 1}"
40+
41+
42+
def update_release_notes(
43+
content: str, version: str, release_date: date, release_notes_file: Path
44+
) -> str:
45+
latest_header = f"{RELEASE_NOTES_HEADER}{LATEST_CHANGES_HEADER}\n"
46+
if not content.startswith(latest_header):
47+
raise RuntimeError(f"{release_notes_file} must start with {latest_header!r}")
48+
if re.search(rf"^## {re.escape(version)}(?: \([^)]+\))?$", content, re.M):
49+
raise RuntimeError(f"Release notes already contain a section for {version}")
50+
51+
current_version = get_current_version(content, release_notes_file)
52+
if parse_version(version) <= parse_version(current_version):
53+
raise RuntimeError(
54+
f"New version {version} must be greater than current version "
55+
f"{current_version}"
56+
)
57+
58+
current_match = VERSION_HEADING_PATTERN.search(content)
59+
assert current_match is not None
60+
latest_changes = content[len(latest_header) : current_match.start()].strip()
61+
if not latest_changes:
62+
raise RuntimeError("The Latest Changes section is empty")
63+
64+
release_header = f"## {version} ({release_date.isoformat()})"
65+
return content.replace(
66+
latest_header,
67+
f"{RELEASE_NOTES_HEADER}{LATEST_CHANGES_HEADER}\n\n{release_header}\n",
68+
1,
69+
)
70+
71+
72+
def get_release_notes_body(content: str, version: str, release_notes_file: Path) -> str:
73+
version_heading = re.compile(rf"(?m)^## {re.escape(version)}(?: \([^)]+\))?$")
74+
match = version_heading.search(content)
75+
if not match:
76+
raise RuntimeError(
77+
f"Could not find release notes section for {version} in "
78+
f"{release_notes_file}"
79+
)
80+
81+
next_match = VERSION_HEADING_PATTERN.search(content, match.end())
82+
end = next_match.start() if next_match else len(content)
83+
body = content[match.end() : end].strip()
84+
if not body:
85+
raise RuntimeError(
86+
f"Release notes section for {version} in {release_notes_file} is empty"
87+
)
88+
return f"{body}\n"
89+
90+
91+
def prepare_release(
92+
bump: BumpType, release_date: date, release_notes_file: Path
93+
) -> str:
94+
content = release_notes_file.read_text(encoding="utf-8")
95+
version = bump_version(get_current_version(content, release_notes_file), bump)
96+
release_notes_file.write_text(
97+
update_release_notes(content, version, release_date, release_notes_file),
98+
encoding="utf-8",
99+
)
100+
return version
101+
102+
103+
@app.command()
104+
def prepare(
105+
bump: Annotated[
106+
BumpType,
107+
typer.Argument(help="The release bump to make: major, minor, or patch."),
108+
],
109+
release_notes_file: Annotated[
110+
Path,
111+
typer.Option(
112+
exists=True,
113+
file_okay=True,
114+
dir_okay=False,
115+
readable=True,
116+
writable=True,
117+
help="Path to the release notes Markdown file.",
118+
),
119+
] = RELEASE_NOTES_FILE,
120+
release_date: Annotated[
121+
str,
122+
typer.Option(
123+
"--date",
124+
help="Release date in YYYY-MM-DD format. Defaults to today.",
125+
),
126+
] = date.today().isoformat(),
127+
) -> None:
128+
parsed_release_date = date.fromisoformat(release_date or date.today().isoformat())
129+
version = prepare_release(bump, parsed_release_date, release_notes_file)
130+
typer.echo(f"Prepared release {version} ({parsed_release_date.isoformat()})")
131+
132+
133+
@app.command()
134+
def current_version(
135+
release_notes_file: Annotated[
136+
Path,
137+
typer.Option(
138+
exists=True,
139+
file_okay=True,
140+
dir_okay=False,
141+
readable=True,
142+
help="Path to the release notes Markdown file.",
143+
),
144+
] = RELEASE_NOTES_FILE,
145+
) -> None:
146+
typer.echo(
147+
get_current_version(
148+
release_notes_file.read_text(encoding="utf-8"), release_notes_file
149+
)
150+
)
151+
152+
153+
@app.command()
154+
def release_notes(
155+
release_notes_file: Annotated[
156+
Path,
157+
typer.Option(
158+
exists=True,
159+
file_okay=True,
160+
dir_okay=False,
161+
readable=True,
162+
help="Path to the release notes Markdown file.",
163+
),
164+
] = RELEASE_NOTES_FILE,
165+
) -> None:
166+
content = release_notes_file.read_text(encoding="utf-8")
167+
version = get_current_version(content, release_notes_file)
168+
typer.echo(
169+
get_release_notes_body(content, version, release_notes_file),
170+
nl=False,
171+
)
172+
173+
174+
if __name__ == "__main__":
175+
app()

0 commit comments

Comments
 (0)