👷 Automate release preparation (#15661)

This commit is contained in:
Sebastián Ramírez
2026-05-31 18:00:38 +02:00
committed by GitHub
parent ee22a4b8ca
commit b4d58fddee
9 changed files with 666 additions and 3 deletions

View File

@@ -0,0 +1,56 @@
name: Create Draft Release
on:
pull_request:
types:
- closed
permissions: {}
jobs:
create-draft-release:
if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release')
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: write
env:
PREPARE_RELEASE_VERSION_FILE: fastapi/__init__.py
PREPARE_RELEASE_RELEASE_NOTES_FILE: docs/en/docs/release-notes.md
steps:
- name: Dump GitHub context
env:
GITHUB_CONTEXT: ${{ toJson(github) }}
run: echo "$GITHUB_CONTEXT"
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: true
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version-file: ".python-version"
- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
# Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum.
# See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837
version: "0.11.4"
- name: Extract release details
id: release-details
run: |
set -euo pipefail
version="$(uv run python scripts/prepare_release.py current-version)"
uv run python scripts/prepare_release.py release-notes > draft-release-notes.md
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Create draft release
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.release-details.outputs.version }}
run: |
set -euo pipefail
gh release create "$VERSION" \
--draft \
--title "$VERSION" \
--notes-file draft-release-notes.md \
--target "$(git rev-parse HEAD)"

View File

@@ -33,5 +33,5 @@ jobs:
steps:
- uses: agilepathway/label-checker@c3d16ad512e7cea5961df85ff2486bb774caf3c5 # v1.6.65
with:
one_of: breaking,security,feature,bug,refactor,upgrade,docs,lang-all,internal
one_of: breaking,security,feature,bug,refactor,upgrade,docs,lang-all,internal,release
repo_token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -39,7 +39,7 @@ jobs:
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.debug_enabled == 'true' }}
with:
limit-access-to-actor: true
- uses: tiangolo/latest-changes@c9d329cb147f0ddf4fb631214e3f838ff17ccbbd # 0.4.1
- uses: tiangolo/latest-changes@eb3f6e7ff0073896ecb561e774a121de9418fa06 # 0.5.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
latest_changes_file: docs/en/docs/release-notes.md

80
.github/workflows/prepare-release.yml vendored Normal file
View File

@@ -0,0 +1,80 @@
name: Prepare Release
on:
workflow_dispatch:
inputs:
bump:
description: Release bump
required: true
type: choice
options:
- patch
- minor
- major
date:
description: Release date in YYYY-MM-DD format. Defaults to today.
required: false
type: string
permissions: {}
jobs:
prepare-release:
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: write
issues: write
pull-requests: write
env:
PREPARE_RELEASE_VERSION_FILE: fastapi/__init__.py
PREPARE_RELEASE_RELEASE_NOTES_FILE: docs/en/docs/release-notes.md
steps:
- name: Dump GitHub context
env:
GITHUB_CONTEXT: ${{ toJson(github) }}
run: echo "$GITHUB_CONTEXT"
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
token: ${{ secrets.FASTAPI_LATEST_CHANGES }} # zizmor: ignore[secrets-outside-env]
persist-credentials: true
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version-file: ".python-version"
- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
# Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum.
# See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837
version: "0.11.4"
- name: Prepare release
env:
PREPARE_RELEASE_BUMP: ${{ inputs.bump }}
PREPARE_RELEASE_DATE: ${{ inputs.date }}
run: uv run python scripts/prepare_release.py prepare
- name: Get release version
id: release-version
run: |
version="$(uv run python scripts/prepare_release.py current-version)"
echo "$version"
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Create release pull request
env:
GH_TOKEN: ${{ secrets.FASTAPI_LATEST_CHANGES }}
VERSION: ${{ steps.release-version.outputs.version }}
run: |
set -euo pipefail
branch="release-${VERSION}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git switch -c "$branch"
git add $PREPARE_RELEASE_VERSION_FILE $PREPARE_RELEASE_RELEASE_NOTES_FILE
git commit -m "🔖 Release version ${VERSION}"
git push --set-upstream origin "$branch"
gh pr create \
--base master \
--head "$branch" \
--title "🔖 Release version ${VERSION}" \
--body "Prepare release ${VERSION}." \
--label release

View File

@@ -3,7 +3,7 @@ name: Publish
on:
release:
types:
- created
- published
permissions: {}

View File

@@ -173,6 +173,7 @@ tests = [
"sqlmodel >=0.0.31",
"strawberry-graphql >=0.200.0,<1.0.0",
"ty>=0.0.25",
"typer >=0.24.1",
"a2wsgi >=1.9.0,<=2.0.0",
"pytest-xdist[psutil]>=2.5.0",
"pytest-cov>=4.0.0",

216
scripts/prepare_release.py Normal file
View File

@@ -0,0 +1,216 @@
"""Prepare a release by updating the package version and release notes."""
import re
from datetime import date
from pathlib import Path
from typing import Annotated, Literal
import typer
VERSION_PATTERN = re.compile(r'(?m)^__version__ = "(\d+\.\d+\.\d+)"$')
VERSION_HEADING_PATTERN = re.compile(r"(?m)^## (\d+\.\d+\.\d+)(?: \([^)]+\))?$")
RELEASE_NOTES_HEADER = """---
hide:
- navigation
---
# Release Notes
"""
LATEST_CHANGES_HEADER = "## Latest Changes"
BumpType = Literal["major", "minor", "patch"]
app = typer.Typer()
def parse_version(version: str) -> tuple[int, int, int]:
match = re.fullmatch(r"\d+\.\d+\.\d+", version)
if not match:
raise ValueError(f"Invalid version: {version!r}. Expected format: X.Y.Z")
major, minor, patch = version.split(".")
return int(major), int(minor), int(patch)
def get_current_version(content: str, version_file: Path) -> str:
matches = list(VERSION_PATTERN.finditer(content))
if len(matches) != 1:
raise RuntimeError(
f"Expected exactly one __version__ assignment in {version_file}, "
f"found {len(matches)}"
)
return matches[0].group(1)
def bump_version(version: str, bump: BumpType) -> str:
major, minor, patch = parse_version(version)
if bump == "major":
return f"{major + 1}.0.0"
if bump == "minor":
return f"{major}.{minor + 1}.0"
return f"{major}.{minor}.{patch + 1}"
def update_version_file(content: str, version: str, version_file: Path) -> str:
current_version = get_current_version(content, version_file)
if parse_version(version) <= parse_version(current_version):
raise RuntimeError(
f"New version {version} must be greater than current version {current_version}"
)
return VERSION_PATTERN.sub(f'__version__ = "{version}"', content, count=1)
def update_release_notes(
content: str, version: str, release_date: date, release_notes_file: Path
) -> str:
if not content.startswith(RELEASE_NOTES_HEADER):
raise RuntimeError(
f"{release_notes_file} must start with {RELEASE_NOTES_HEADER!r}"
)
if re.search(rf"^## {re.escape(version)}(?: \([^)]+\))?$", content, re.M):
raise RuntimeError(f"Release notes already contain a section for {version}")
latest_header = f"{RELEASE_NOTES_HEADER}{LATEST_CHANGES_HEADER}\n"
if not content.startswith(latest_header):
raise RuntimeError(f"{release_notes_file} must start with {latest_header!r}")
release_header = f"## {version} ({release_date.isoformat()})"
return content.replace(
latest_header,
f"{RELEASE_NOTES_HEADER}{LATEST_CHANGES_HEADER}\n\n{release_header}\n",
1,
)
def get_release_notes_body(content: str, version: str, release_notes_file: Path) -> str:
version_heading = re.compile(rf"(?m)^## {re.escape(version)}(?: \([^)]+\))?$")
match = version_heading.search(content)
if not match:
raise RuntimeError(
f"Could not find release notes section for {version} in {release_notes_file}"
)
next_match = VERSION_HEADING_PATTERN.search(content, match.end())
end = next_match.start() if next_match else len(content)
body = content[match.end() : end].strip()
if not body:
raise RuntimeError(
f"Release notes section for {version} in {release_notes_file} is empty"
)
return f"{body}\n"
@app.command()
def prepare(
bump: Annotated[
BumpType,
typer.Argument(
envvar="PREPARE_RELEASE_BUMP",
help="The release bump to make: major, minor, or patch.",
),
],
version_file: Annotated[
Path,
typer.Option(
envvar="PREPARE_RELEASE_VERSION_FILE",
exists=True,
file_okay=True,
dir_okay=False,
readable=True,
writable=True,
help="Path to the Python file containing the __version__ assignment.",
),
],
release_notes_file: Annotated[
Path,
typer.Option(
envvar="PREPARE_RELEASE_RELEASE_NOTES_FILE",
exists=True,
file_okay=True,
dir_okay=False,
readable=True,
writable=True,
help="Path to the release notes Markdown file.",
),
],
release_date: Annotated[
str,
typer.Option(
"--date",
envvar="PREPARE_RELEASE_DATE",
help="Release date in YYYY-MM-DD format. Defaults to today.",
),
] = date.today().isoformat(),
) -> None:
parsed_release_date = date.fromisoformat(release_date or date.today().isoformat())
version_file_content = version_file.read_text()
release_notes_content = release_notes_file.read_text()
version = bump_version(
get_current_version(version_file_content, version_file), bump
)
version_file.write_text(
update_version_file(version_file_content, version, version_file)
)
release_notes_file.write_text(
update_release_notes(
release_notes_content, version, parsed_release_date, release_notes_file
)
)
typer.echo(f"Prepared release {version} ({parsed_release_date.isoformat()})")
@app.command()
def current_version(
version_file: Annotated[
Path,
typer.Option(
envvar="PREPARE_RELEASE_VERSION_FILE",
exists=True,
file_okay=True,
dir_okay=False,
readable=True,
help="Path to the Python file containing the __version__ assignment.",
),
],
) -> None:
typer.echo(get_current_version(version_file.read_text(), version_file))
@app.command()
def release_notes(
version_file: Annotated[
Path,
typer.Option(
envvar="PREPARE_RELEASE_VERSION_FILE",
exists=True,
file_okay=True,
dir_okay=False,
readable=True,
help="Path to the Python file containing the __version__ assignment.",
),
],
release_notes_file: Annotated[
Path,
typer.Option(
envvar="PREPARE_RELEASE_RELEASE_NOTES_FILE",
exists=True,
file_okay=True,
dir_okay=False,
readable=True,
help="Path to the release notes Markdown file.",
),
],
) -> None:
version = get_current_version(version_file.read_text(), version_file)
typer.echo(
get_release_notes_body(
release_notes_file.read_text(), version, release_notes_file
),
nl=False,
)
if __name__ == "__main__":
app()

View File

@@ -0,0 +1,307 @@
from datetime import date
from pathlib import Path
import pytest
from typer.testing import CliRunner
from scripts.prepare_release import (
RELEASE_NOTES_HEADER,
BumpType,
app,
bump_version,
get_release_notes_body,
update_release_notes,
update_version_file,
)
runner = CliRunner()
def release_notes_content(body: str) -> str:
return f"{RELEASE_NOTES_HEADER}{body}"
@pytest.mark.parametrize(
("current_version", "bump", "new_version"),
[
("0.136.3", "major", "1.0.0"),
("0.136.3", "minor", "0.137.0"),
("0.136.3", "patch", "0.136.4"),
],
)
def test_bump_version(current_version: str, bump: BumpType, new_version: str) -> None:
assert bump_version(current_version, bump) == new_version
def test_update_version_file() -> None:
content = (
'"""FastAPI framework, high performance, easy to learn, fast to code, '
'ready for production"""\n\n__version__ = "0.136.3"\n'
)
new_content = update_version_file(content, "0.136.4", Path("fastapi/__init__.py"))
assert new_content == (
'"""FastAPI framework, high performance, easy to learn, fast to code, '
'ready for production"""\n\n__version__ = "0.136.4"\n'
)
def test_update_version_file_requires_newer_version() -> None:
content = '__version__ = "0.136.3"\n'
with pytest.raises(RuntimeError, match="must be greater"):
update_version_file(content, "0.136.3", Path("fastapi/__init__.py"))
def test_update_release_notes() -> None:
content = release_notes_content(
"""## Latest Changes
### Fixes
* Fix something.
## 0.136.3 (2026-05-23)
### Fixes
* Previous fix.
"""
)
new_content = update_release_notes(
content, "0.136.4", date(2026, 5, 30), Path("docs/en/docs/release-notes.md")
)
assert new_content == release_notes_content(
"""## Latest Changes
## 0.136.4 (2026-05-30)
### Fixes
* Fix something.
## 0.136.3 (2026-05-23)
### Fixes
* Previous fix.
"""
)
def test_update_release_notes_rejects_existing_version() -> None:
content = release_notes_content(
"""## Latest Changes
## 0.136.4 (2026-05-30)
"""
)
with pytest.raises(RuntimeError, match="already contain"):
update_release_notes(
content, "0.136.4", date(2026, 5, 30), Path("docs/en/docs/release-notes.md")
)
def test_get_release_notes_body_with_dated_heading() -> None:
content = release_notes_content(
"""## Latest Changes
## 0.136.4 (2026-05-30)
### Fixes
* Fix something.
## 0.136.3 (2026-05-23)
### Fixes
* Previous fix.
"""
)
body = get_release_notes_body(
content, "0.136.4", Path("docs/en/docs/release-notes.md")
)
assert body == "### Fixes\n\n* Fix something.\n"
def test_get_release_notes_body_with_plain_heading() -> None:
content = release_notes_content(
"""## Latest Changes
## 0.136.4
### Fixes
* Fix something.
"""
)
body = get_release_notes_body(
content, "0.136.4", Path("docs/en/docs/release-notes.md")
)
assert body == "### Fixes\n\n* Fix something.\n"
def test_get_release_notes_body_allows_non_version_h2_content() -> None:
content = release_notes_content(
"""## Latest Changes
## 0.136.4
## Highlights
* Fix something.
## 0.136.3
* Previous fix.
"""
)
body = get_release_notes_body(
content, "0.136.4", Path("docs/en/docs/release-notes.md")
)
assert body == "## Highlights\n\n* Fix something.\n"
def test_get_release_notes_body_requires_version_section() -> None:
content = release_notes_content("## Latest Changes\n")
with pytest.raises(RuntimeError, match="Could not find"):
get_release_notes_body(
content, "0.136.4", Path("docs/en/docs/release-notes.md")
)
def test_get_release_notes_body_requires_non_empty_section() -> None:
content = release_notes_content(
"""## Latest Changes
## 0.136.4
## 0.136.3
* Previous fix.
"""
)
with pytest.raises(RuntimeError, match="is empty"):
get_release_notes_body(
content, "0.136.4", Path("docs/en/docs/release-notes.md")
)
def test_cli_updates_configured_files(tmp_path: Path) -> None:
version_file = tmp_path / "fastapi" / "__init__.py"
version_file.parent.mkdir()
version_file.write_text('__version__ = "0.136.3"\n')
release_notes_file = tmp_path / "release-notes.md"
release_notes_file.write_text(
release_notes_content(
"""## Latest Changes
### Fixes
* Fix something.
"""
)
)
result = runner.invoke(
app,
[
"prepare",
"patch",
"--version-file",
str(version_file),
"--release-notes-file",
str(release_notes_file),
"--date",
"2026-05-30",
],
)
assert result.exit_code == 0, result.output
assert "Prepared release 0.136.4 (2026-05-30)" in result.output
assert version_file.read_text() == '__version__ = "0.136.4"\n'
assert "## 0.136.4 (2026-05-30)" in release_notes_file.read_text()
def test_cli_accepts_env_vars(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
version_file = tmp_path / "fastapi" / "__init__.py"
version_file.parent.mkdir()
version_file.write_text('__version__ = "0.136.3"\n')
release_notes_file = tmp_path / "docs" / "en" / "docs" / "release-notes.md"
release_notes_file.parent.mkdir(parents=True)
release_notes_file.write_text(release_notes_content("## Latest Changes\n"))
monkeypatch.setenv("PREPARE_RELEASE_BUMP", "minor")
monkeypatch.setenv("PREPARE_RELEASE_VERSION_FILE", str(version_file))
monkeypatch.setenv("PREPARE_RELEASE_RELEASE_NOTES_FILE", str(release_notes_file))
monkeypatch.setenv("PREPARE_RELEASE_DATE", "2026-05-30")
result = runner.invoke(app, ["prepare"])
assert result.exit_code == 0, result.output
assert "Prepared release 0.137.0 (2026-05-30)" in result.output
assert version_file.read_text() == '__version__ = "0.137.0"\n'
assert "## 0.137.0 (2026-05-30)" in release_notes_file.read_text()
def test_cli_prints_current_version(tmp_path: Path) -> None:
version_file = tmp_path / "fastapi" / "__init__.py"
version_file.parent.mkdir()
version_file.write_text('__version__ = "0.136.3"\n')
result = runner.invoke(
app,
[
"current-version",
"--version-file",
str(version_file),
],
)
assert result.exit_code == 0, result.output
assert result.output == "0.136.3\n"
def test_cli_prints_release_notes(tmp_path: Path) -> None:
version_file = tmp_path / "fastapi" / "__init__.py"
version_file.parent.mkdir()
version_file.write_text('__version__ = "0.136.4"\n')
release_notes_file = tmp_path / "release-notes.md"
release_notes_file.write_text(
release_notes_content(
"""## Latest Changes
## 0.136.4 (2026-05-30)
### Fixes
* Fix something.
"""
)
)
result = runner.invoke(
app,
[
"release-notes",
"--version-file",
str(version_file),
"--release-notes-file",
str(release_notes_file),
],
)
assert result.exit_code == 0, result.output
assert result.output == "### Fixes\n\n* Fix something.\n"

3
uv.lock generated
View File

@@ -1236,6 +1236,7 @@ tests = [
{ name = "sqlmodel" },
{ name = "strawberry-graphql" },
{ name = "ty" },
{ name = "typer" },
]
translations = [
{ name = "gitpython" },
@@ -1320,6 +1321,7 @@ dev = [
{ name = "strawberry-graphql", specifier = ">=0.200.0,<1.0.0" },
{ name = "ty", specifier = ">=0.0.25" },
{ name = "typer", specifier = ">=0.21.1" },
{ name = "typer", specifier = ">=0.24.1" },
{ name = "zensical", specifier = ">=0.0.42" },
{ name = "zizmor", specifier = ">=1.23.1" },
]
@@ -1377,6 +1379,7 @@ tests = [
{ name = "sqlmodel", specifier = ">=0.0.31" },
{ name = "strawberry-graphql", specifier = ">=0.200.0,<1.0.0" },
{ name = "ty", specifier = ">=0.0.25" },
{ name = "typer", specifier = ">=0.24.1" },
]
translations = [
{ name = "gitpython", specifier = ">=3.1.46" },