Skip to content

Commit 145bb7b

Browse files
authored
👷 Automate release preparation (#420)
1 parent db5b240 commit 145bb7b

8 files changed

Lines changed: 653 additions & 5 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
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+
env:
18+
PREPARE_RELEASE_VERSION_FILE: src/fastapi_cli/__init__.py
19+
PREPARE_RELEASE_RELEASE_NOTES_FILE: release-notes.md
20+
steps:
21+
- name: Dump GitHub context
22+
env:
23+
GITHUB_CONTEXT: ${{ toJson(github) }}
24+
run: echo "$GITHUB_CONTEXT"
25+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
26+
with:
27+
ref: ${{ github.event.repository.default_branch }}
28+
persist-credentials: true
29+
- name: Set up Python
30+
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
31+
with:
32+
python-version-file: ".python-version"
33+
- name: Install uv
34+
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
35+
with:
36+
# Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum.
37+
# See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837
38+
version: "0.11.4"
39+
- name: Extract release details
40+
id: release-details
41+
run: |
42+
set -euo pipefail
43+
version="$(uv run python scripts/prepare_release.py current-version)"
44+
uv run python scripts/prepare_release.py release-notes > draft-release-notes.md
45+
echo "version=$version" >> "$GITHUB_OUTPUT"
46+
- name: Create draft release
47+
env:
48+
GH_TOKEN: ${{ github.token }}
49+
VERSION: ${{ steps.release-details.outputs.version }}
50+
run: |
51+
set -euo pipefail
52+
gh release create "$VERSION" \
53+
--draft \
54+
--title "$VERSION" \
55+
--notes-file draft-release-notes.md \
56+
--target "$(git rev-parse HEAD)"

.github/workflows/labeler.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,5 +33,5 @@ jobs:
3333
steps:
3434
- uses: agilepathway/label-checker@c3d16ad512e7cea5961df85ff2486bb774caf3c5 # v1.6.65
3535
with:
36-
one_of: breaking,security,feature,bug,refactor,upgrade,docs,lang-all,internal
36+
one_of: breaking,security,feature,bug,refactor,upgrade,docs,lang-all,internal,release
3737
repo_token: ${{ secrets.GITHUB_TOKEN }}

.github/workflows/latest-changes.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ jobs:
3939
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.debug_enabled == 'true' }}
4040
with:
4141
limit-access-to-actor: true
42-
- uses: tiangolo/latest-changes@c9d329cb147f0ddf4fb631214e3f838ff17ccbbd # 0.4.1
42+
- uses: tiangolo/latest-changes@eb3f6e7ff0073896ecb561e774a121de9418fa06 # 0.5.0
4343
with:
4444
token: ${{ secrets.GITHUB_TOKEN }}
4545
latest_changes_file: release-notes.md
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
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: write
27+
issues: write
28+
pull-requests: write
29+
env:
30+
PREPARE_RELEASE_VERSION_FILE: src/fastapi_cli/__init__.py
31+
PREPARE_RELEASE_RELEASE_NOTES_FILE: release-notes.md
32+
steps:
33+
- name: Dump GitHub context
34+
env:
35+
GITHUB_CONTEXT: ${{ toJson(github) }}
36+
run: echo "$GITHUB_CONTEXT"
37+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
38+
with:
39+
token: ${{ secrets.LATEST_CHANGES }} # zizmor: ignore[secrets-outside-env]
40+
persist-credentials: true
41+
- name: Set up Python
42+
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
43+
with:
44+
python-version-file: ".python-version"
45+
- name: Install uv
46+
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
47+
with:
48+
# Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum.
49+
# See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837
50+
version: "0.11.4"
51+
- name: Prepare release
52+
env:
53+
PREPARE_RELEASE_BUMP: ${{ inputs.bump }}
54+
PREPARE_RELEASE_DATE: ${{ inputs.date }}
55+
run: uv run python scripts/prepare_release.py prepare
56+
- name: Get release version
57+
id: release-version
58+
run: |
59+
version="$(uv run python scripts/prepare_release.py current-version)"
60+
echo "$version"
61+
echo "version=$version" >> "$GITHUB_OUTPUT"
62+
- name: Create release pull request
63+
env:
64+
GH_TOKEN: ${{ secrets.LATEST_CHANGES }}
65+
VERSION: ${{ steps.release-version.outputs.version }}
66+
run: |
67+
set -euo pipefail
68+
branch="release-${VERSION}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
69+
git config user.name "github-actions[bot]"
70+
git config user.email "github-actions[bot]@users.noreply.github.com"
71+
git switch -c "$branch"
72+
git add $PREPARE_RELEASE_VERSION_FILE $PREPARE_RELEASE_RELEASE_NOTES_FILE
73+
git commit -m "🔖 Release version ${VERSION}"
74+
git push --set-upstream origin "$branch"
75+
gh pr create \
76+
--base main \
77+
--head "$branch" \
78+
--title "🔖 Release version ${VERSION}" \
79+
--body "Prepare release ${VERSION}." \
80+
--label release

.github/workflows/publish.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ name: Publish
33
on:
44
release:
55
types:
6-
- created
6+
- published
77

88
permissions: {}
99

@@ -32,6 +32,7 @@ jobs:
3232
# Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum.
3333
# See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837
3434
version: "0.11.4"
35+
enable-cache: false
3536
- name: Build distribution
3637
run: uv build
3738
- name: Publish

scripts/prepare_release.py

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

0 commit comments

Comments
 (0)