]> git.ipfire.org Git - thirdparty/fastapi/fastapi.git/commitdiff
👷 Automate release preparation (#15661)
authorSebastián Ramírez <tiangolo@gmail.com>
Sun, 31 May 2026 16:00:38 +0000 (18:00 +0200)
committerGitHub <noreply@github.com>
Sun, 31 May 2026 16:00:38 +0000 (16:00 +0000)
.github/workflows/create-draft-release.yml [new file with mode: 0644]
.github/workflows/labeler.yml
.github/workflows/latest-changes.yml
.github/workflows/prepare-release.yml [new file with mode: 0644]
.github/workflows/publish.yml
pyproject.toml
scripts/prepare_release.py [new file with mode: 0644]
tests/test_prepare_release.py [new file with mode: 0644]
uv.lock

diff --git a/.github/workflows/create-draft-release.yml b/.github/workflows/create-draft-release.yml
new file mode 100644 (file)
index 0000000..2f61343
--- /dev/null
@@ -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)"
index 5b7524f25efbc49ef9495da412e2214b1383a8e8..803160ef58004c50aa62f29fbfbf938cd5968bdd 100644 (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 }}
index 12bc6768652ff4b296700f36ab6a94407e87337c..92f8f24c942195b76e96e685eaaec389515e4b4e 100644 (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
diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml
new file mode 100644 (file)
index 0000000..73bf4af
--- /dev/null
@@ -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
index 307b3cb3c08c965a0464b258b82af623b5df6e99..57a6af204a5be7311b4a66bd2049e8b70378e34a 100644 (file)
@@ -3,7 +3,7 @@ name: Publish
 on:
   release:
     types:
-      - created
+      - published
 
 permissions: {}
 
index 054e0f21d4d17fb65951808b86f8359e7c0a2566..daa523ce28d8a4228f2053cb25b48b524cffa57c 100644 (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",
diff --git a/scripts/prepare_release.py b/scripts/prepare_release.py
new file mode 100644 (file)
index 0000000..5a1ce90
--- /dev/null
@@ -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()
diff --git a/tests/test_prepare_release.py b/tests/test_prepare_release.py
new file mode 100644 (file)
index 0000000..b46d228
--- /dev/null
@@ -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"
diff --git a/uv.lock b/uv.lock
index c4edc4f8d1cd8c839d98cc7d4c66bd9c19dc6843..d8adf46a579f50ef46a0b780f5ee8975e1eca530 100644 (file)
--- a/uv.lock
+++ b/uv.lock
@@ -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" },