Skip to content

Commit 863d863

Browse files
authored
👷 Automate release preparation (#1988)
1 parent abc73eb commit 863d863

9 files changed

Lines changed: 650 additions & 3 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: sqlmodel/__init__.py
19+
PREPARE_RELEASE_RELEASE_NOTES_FILE: docs/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
@@ -35,7 +35,7 @@ jobs:
3535
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.debug_enabled == 'true' }}
3636
with:
3737
limit-access-to-actor: true
38-
- uses: tiangolo/latest-changes@c9d329cb147f0ddf4fb631214e3f838ff17ccbbd # 0.4.1
38+
- uses: tiangolo/latest-changes@eb3f6e7ff0073896ecb561e774a121de9418fa06 # 0.5.0
3939
with:
4040
token: ${{ secrets.GITHUB_TOKEN }}
4141
latest_changes_file: docs/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: sqlmodel/__init__.py
31+
PREPARE_RELEASE_RELEASE_NOTES_FILE: docs/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.SQLMODEL_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.SQLMODEL_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
workflow_dispatch:
88
inputs:
99
debug_enabled:
@@ -38,6 +38,7 @@ jobs:
3838
# Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum.
3939
# See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837
4040
version: "0.11.4"
41+
enable-cache: false
4142
- name: Build distribution
4243
run: uv build
4344
- name: Publish

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ tests = [
8484
"pytest >=7.0.1",
8585
"ruff >=0.15.6",
8686
"ty>=0.0.25",
87+
"typer >=0.24.1",
8788
"typing-extensions >=4.15.0",
8889
]
8990

scripts/prepare_release.py

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

0 commit comments

Comments
 (0)