Skip to content

Commit 4d1c45d

Browse files
committed
feat: releases workflow
1 parent 7717a63 commit 4d1c45d

3 files changed

Lines changed: 256 additions & 0 deletions

File tree

.github/workflows/release.yml

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
branches:
6+
- releases
7+
8+
permissions:
9+
contents: write
10+
id-token: write
11+
12+
jobs:
13+
release:
14+
name: Build and publish
15+
runs-on: ubuntu-latest
16+
environment: pypi
17+
18+
steps:
19+
- name: Check out repository
20+
uses: actions/checkout@v4
21+
with:
22+
fetch-depth: 0
23+
24+
- name: Install uv
25+
uses: astral-sh/setup-uv@v5
26+
27+
- name: Set up Python
28+
uses: actions/setup-python@v5
29+
with:
30+
python-version-file: .python-version
31+
32+
- name: Sync packages
33+
run: uv sync --locked --all-packages
34+
35+
- name: Resolve release version
36+
run: |
37+
python - <<'PY'
38+
import os
39+
import re
40+
import sys
41+
import tomllib
42+
from pathlib import Path
43+
44+
with open("pyproject.toml", "rb") as file:
45+
version = tomllib.load(file)["project"]["version"]
46+
47+
if not re.fullmatch(r"\d+\.\d+\.\d+((a|b|rc)\d+)?", version):
48+
print(f"Unsupported release version: {version!r}")
49+
print("Expected X.Y.Z, X.Y.ZaN, X.Y.ZbN, or X.Y.ZrcN.")
50+
sys.exit(1)
51+
52+
tag = f"v{version}"
53+
github_env = Path(os.environ["GITHUB_ENV"])
54+
with github_env.open("a") as env:
55+
env.write(f"RELEASE_VERSION={version}\n")
56+
env.write(f"TAG_NAME={tag}\n")
57+
PY
58+
59+
- name: Verify release tag is new
60+
run: |
61+
if git rev-parse --verify --quiet "refs/tags/$TAG_NAME"; then
62+
echo "Tag $TAG_NAME already exists."
63+
exit 1
64+
fi
65+
if git ls-remote --exit-code --tags origin "refs/tags/$TAG_NAME" >/dev/null 2>&1; then
66+
echo "Tag $TAG_NAME already exists on origin."
67+
exit 1
68+
fi
69+
70+
- name: Check import sorting
71+
run: uv run ruff check --select I .
72+
73+
- name: Check formatting
74+
run: uv run ruff format --check .
75+
76+
- name: Type check
77+
run: uv run basedpyright .
78+
79+
- name: Contract tests
80+
run: uv run pytest tests/test_contracts.py -n auto
81+
82+
- name: Build distributions
83+
run: uv build
84+
85+
- name: Check distributions
86+
run: uv run twine check dist/*
87+
88+
- name: Create release tag
89+
run: |
90+
git config user.name "github-actions[bot]"
91+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
92+
git tag -a "$TAG_NAME" -m "Release $TAG_NAME" "$GITHUB_SHA"
93+
git push origin "$TAG_NAME"
94+
95+
- name: Upload distributions
96+
uses: actions/upload-artifact@v4
97+
with:
98+
name: dist
99+
path: dist/*
100+
if-no-files-found: error
101+
102+
- name: Publish to PyPI
103+
uses: pypa/gh-action-pypi-publish@release/v1
104+
105+
- name: Create GitHub release
106+
env:
107+
GH_TOKEN: ${{ github.token }}
108+
run: gh release create "$TAG_NAME" dist/* --generate-notes --title "$TAG_NAME"

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,30 @@ result = generate_client(
7777

7878
Invalid extension outputs fail fast with explicit diagnostics.
7979

80+
## Releases
81+
82+
Releases are published from the protected `releases` branch. The package version is set manually in `pyproject.toml`, and pushing a release commit to `releases` triggers the GitHub Actions release workflow. The workflow creates the matching `vX.Y.Z` tag after checks pass.
83+
84+
Before the first release, configure PyPI Trusted Publishing for this repository:
85+
86+
- PyPI project: `openapi-python`
87+
- GitHub workflow: `release.yml`
88+
- GitHub environment: `pypi`
89+
90+
The GitHub `pypi` environment should be limited to deployments from the `releases` branch.
91+
92+
Release steps:
93+
94+
```bash
95+
# 1. Update project.version in pyproject.toml, then commit that change.
96+
uv run python scripts/release.py --version 0.1.0
97+
98+
# 2. If checks pass, push the current commit to the releases branch.
99+
uv run python scripts/release.py --version 0.1.0 --push-release-branch
100+
```
101+
102+
The release workflow verifies that the version tag does not already exist, runs checks, builds the distributions, validates them with `twine`, creates the release tag, publishes to PyPI, and creates a GitHub Release with generated notes.
103+
80104
## Transport Decoupling
81105

82106
Generated clients expose a transport protocol. You can plug in your own transport while keeping route-level typing guarantees.

scripts/release.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
import re
5+
import shutil
6+
import subprocess
7+
import sys
8+
from pathlib import Path
9+
10+
import tomllib
11+
12+
ROOT = Path(__file__).resolve().parents[1]
13+
14+
15+
def run(
16+
command: list[str], *, capture: bool = False
17+
) -> subprocess.CompletedProcess[str]:
18+
print("+", " ".join(command))
19+
return subprocess.run(
20+
command,
21+
cwd=ROOT,
22+
check=True,
23+
text=True,
24+
stdout=subprocess.PIPE if capture else None,
25+
stderr=subprocess.PIPE if capture else None,
26+
)
27+
28+
29+
def project_version() -> str:
30+
with (ROOT / "pyproject.toml").open("rb") as file:
31+
return tomllib.load(file)["project"]["version"]
32+
33+
34+
def require_supported_version(version: str) -> None:
35+
if not re.fullmatch(r"\d+\.\d+\.\d+((a|b|rc)\d+)?", version):
36+
print(f"Unsupported release version: {version!r}")
37+
print("Expected X.Y.Z, X.Y.ZaN, X.Y.ZbN, or X.Y.ZrcN.")
38+
sys.exit(1)
39+
40+
41+
def require_clean_worktree() -> None:
42+
status = run(["git", "status", "--porcelain"], capture=True).stdout.strip()
43+
if status:
44+
print(
45+
"Release requires a clean git worktree. Commit or stash these changes first:"
46+
)
47+
print(status)
48+
sys.exit(1)
49+
50+
51+
def require_tag_available(tag: str) -> None:
52+
local = subprocess.run(
53+
["git", "rev-parse", "--verify", "--quiet", f"refs/tags/{tag}"],
54+
cwd=ROOT,
55+
text=True,
56+
stdout=subprocess.DEVNULL,
57+
stderr=subprocess.DEVNULL,
58+
)
59+
if local.returncode == 0:
60+
print(f"Tag {tag} already exists locally.")
61+
sys.exit(1)
62+
63+
remote = subprocess.run(
64+
["git", "ls-remote", "--exit-code", "--tags", "origin", f"refs/tags/{tag}"],
65+
cwd=ROOT,
66+
text=True,
67+
stdout=subprocess.DEVNULL,
68+
stderr=subprocess.DEVNULL,
69+
)
70+
if remote.returncode == 0:
71+
print(f"Tag {tag} already exists on origin.")
72+
sys.exit(1)
73+
74+
75+
def main() -> None:
76+
parser = argparse.ArgumentParser(
77+
description="Prepare and optionally publish from the releases branch."
78+
)
79+
parser.add_argument(
80+
"--version",
81+
help="Expected version. Defaults to the version in pyproject.toml.",
82+
)
83+
parser.add_argument(
84+
"--push-release-branch",
85+
action="store_true",
86+
help="Push the current commit to origin/releases after release checks pass.",
87+
)
88+
args = parser.parse_args()
89+
90+
version = project_version()
91+
require_supported_version(version)
92+
if args.version and args.version != version:
93+
print(
94+
f"Expected version {args.version}, but pyproject.toml contains {version}."
95+
)
96+
sys.exit(1)
97+
98+
tag = f"v{version}"
99+
100+
require_clean_worktree()
101+
require_tag_available(tag)
102+
103+
dist = ROOT / "dist"
104+
if dist.exists():
105+
shutil.rmtree(dist)
106+
107+
run(["uv", "run", "ruff", "check", "--select", "I", "."])
108+
run(["uv", "run", "ruff", "format", "--check", "."])
109+
run(["uv", "run", "basedpyright", "."])
110+
run(["uv", "run", "pytest", "tests/test_contracts.py", "-n", "auto"])
111+
run(["uv", "build"])
112+
run(["uv", "run", "twine", "check", *sorted(str(path) for path in dist.iterdir())])
113+
114+
if args.push_release_branch:
115+
run(["git", "push", "origin", "HEAD:releases"])
116+
print("Pushed current commit to origin/releases.")
117+
print("GitHub Actions will create the release tag and publish after approval.")
118+
else:
119+
print(f"Release checks passed for {tag}.")
120+
print("Publish with: git push origin HEAD:releases")
121+
122+
123+
if __name__ == "__main__":
124+
main()

0 commit comments

Comments
 (0)