Skip to content

Commit c5f8e38

Browse files
chore: automate release preparation
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 26e53c9 commit c5f8e38

5 files changed

Lines changed: 264 additions & 13 deletions

File tree

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
name: Prepare release PR
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
version:
7+
description: "Release version, e.g. 0.1.1"
8+
required: true
9+
notes:
10+
description: "Markdown changelog notes for this release"
11+
required: true
12+
type: string
13+
14+
permissions:
15+
contents: write
16+
pull-requests: write
17+
18+
concurrency:
19+
group: prepare-release-${{ github.event.inputs.version }}
20+
cancel-in-progress: true
21+
22+
jobs:
23+
prepare:
24+
name: Prepare release bump
25+
runs-on: ubuntu-latest
26+
steps:
27+
- uses: actions/checkout@v7
28+
- uses: astral-sh/setup-uv@v7
29+
- uses: actions/setup-python@v6
30+
with:
31+
python-version-file: .python-version
32+
33+
- name: Sync workspace
34+
run: uv sync --group dev
35+
36+
- name: Prepare release files
37+
env:
38+
VERSION: ${{ github.event.inputs.version }}
39+
NOTES: ${{ github.event.inputs.notes }}
40+
run: |
41+
uv run python scripts/prepare_release.py "$VERSION" --notes "$NOTES"
42+
43+
- name: Verify release notes are extractable
44+
env:
45+
VERSION: ${{ github.event.inputs.version }}
46+
run: |
47+
uv run python scripts/extract_release_notes.py "$VERSION" --output /tmp/release-notes.md
48+
test -s /tmp/release-notes.md
49+
50+
- name: Create release PR
51+
uses: peter-evans/create-pull-request@v7
52+
with:
53+
branch: release/create-awesome-python-app-${{ github.event.inputs.version }}
54+
title: "chore(release): prepare ${{ github.event.inputs.version }}"
55+
commit-message: "chore(release): prepare ${{ github.event.inputs.version }}"
56+
body: |
57+
## Summary
58+
59+
- bumps `create-python-app-core` and `create-awesome-python-app` to `${{ github.event.inputs.version }}`
60+
- updates the CLI dependency on the matching core version
61+
- prepends release notes to `CHANGELOG.md`
62+
63+
## Release checklist
64+
65+
- [ ] Review generated version/changelog diff
66+
- [ ] Merge this PR
67+
- [ ] Tag `create-awesome-python-app@${{ github.event.inputs.version }}`
68+
- [ ] Confirm PyPI, Docker, Homebrew, AUR, and distribution smoke workflows
69+
70+
Closes #195

.github/workflows/publish.yml

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,16 @@ jobs:
2525
with:
2626
python-version-file: .python-version
2727
- run: uv sync --group dev
28+
- name: Resolve release version
29+
id: release
30+
run: |
31+
set -euo pipefail
32+
VERSION="${GITHUB_REF_NAME#create-awesome-python-app@}"
33+
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
34+
- name: Extract release notes
35+
if: startsWith(github.ref, 'refs/tags/')
36+
run: |
37+
uv run python scripts/extract_release_notes.py "${{ steps.release.outputs.version }}" --output release-notes.md
2838
- name: Build packages
2939
run: |
3040
mkdir -p dist-core dist-cli
@@ -39,14 +49,7 @@ jobs:
3949
uses: softprops/action-gh-release@v3
4050
with:
4151
files: dist/*
42-
body: |
43-
First public release. Install with:
44-
45-
```bash
46-
uvx create-awesome-python-app@0.1.0 --help
47-
```
48-
49-
Catalog: https://github.com/Create-Python-App/cpa-templates
52+
body_path: release-notes.md
5053
- name: Publish create-python-app-core to PyPI
5154
if: startsWith(github.ref, 'refs/tags/')
5255
uses: pypa/gh-action-pypi-publish@release/v1

docs/VERSIONING.md

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,43 @@
11
# Versioning
22

3-
CPA uses **GitHub Releases + tags** with hatchling package versions.
3+
CPA uses **release-prep PRs + GitHub Releases + tags** with hatchling package
4+
versions.
45

5-
Recommended flow (release-please or manual tags):
6+
## Prepare a release PR
67

7-
1. Bump `version` in package pyproject.toml files
8-
2. Tag `create-awesome-python-app@X.Y.Z`
9-
3. `publish.yml` builds and publishes to PyPI via OIDC (see #58)
8+
Run the `Prepare release PR` workflow manually with:
9+
10+
- `version`: the next CLI/core version, for example `0.1.1`
11+
- `notes`: markdown release notes to prepend to `CHANGELOG.md`
12+
13+
The workflow runs `scripts/prepare_release.py`, opens a PR, and updates:
14+
15+
- `packages/create-python-app-core/pyproject.toml`
16+
- `packages/create-awesome-python-app/pyproject.toml`
17+
- `create-python-app-core` dependency pin in the CLI package
18+
- both runtime `__version__` files
19+
- `CHANGELOG.md`
20+
21+
Local equivalent:
22+
23+
```bash
24+
uv run python scripts/prepare_release.py 0.1.1 --notes "- Fix release automation."
25+
```
26+
27+
## Publish after merge
28+
29+
After the release-prep PR is merged:
30+
31+
1. Tag `create-awesome-python-app@X.Y.Z`
32+
2. Push the tag
33+
3. `publish.yml` builds and publishes both packages to PyPI via OIDC (see #58)
34+
4. GitHub Release notes are extracted from the matching `CHANGELOG.md` section
35+
5. Distribution workflows update Docker, Homebrew, and AUR
36+
37+
```bash
38+
git tag create-awesome-python-app@X.Y.Z
39+
git push origin create-awesome-python-app@X.Y.Z
40+
```
41+
42+
Before closing the release issue, confirm PyPI, GitHub Release, Docker,
43+
Homebrew, AUR, and `smoke-distribution.yml`.

scripts/extract_release_notes.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
#!/usr/bin/env python3
2+
"""Extract a single version section from CHANGELOG.md."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
from pathlib import Path
8+
9+
ROOT = Path(__file__).resolve().parents[1]
10+
11+
12+
def extract_notes(version: str) -> str:
13+
lines = (ROOT / "CHANGELOG.md").read_text().splitlines()
14+
heading_prefix = f"## {version}"
15+
start = next(
16+
(idx for idx, line in enumerate(lines) if line.startswith(heading_prefix)),
17+
None,
18+
)
19+
if start is None:
20+
raise SystemExit(f"CHANGELOG.md does not contain notes for {version}")
21+
22+
end = next(
23+
(
24+
idx
25+
for idx, line in enumerate(lines[start + 1 :], start + 1)
26+
if line.startswith("## ")
27+
),
28+
len(lines),
29+
)
30+
return "\n".join(lines[start + 1 : end]).strip() + "\n"
31+
32+
33+
def main() -> None:
34+
parser = argparse.ArgumentParser()
35+
parser.add_argument("version")
36+
parser.add_argument("--output", default="release-notes.md")
37+
args = parser.parse_args()
38+
39+
Path(args.output).write_text(extract_notes(args.version))
40+
print(f"Wrote release notes for {args.version} to {args.output}")
41+
42+
43+
if __name__ == "__main__":
44+
main()

scripts/prepare_release.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
#!/usr/bin/env python3
2+
"""Prepare a release bump across the CPA workspace."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
import re
8+
from datetime import UTC, datetime
9+
from pathlib import Path
10+
11+
ROOT = Path(__file__).resolve().parents[1]
12+
13+
VERSION_RE = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+(?:[a-zA-Z0-9.-]+)?$")
14+
PROJECT_VERSION_RE = re.compile(r'(?m)^version = "[^"]+"$')
15+
CORE_DEP_RE = re.compile(r'"create-python-app-core>=[^"]+"')
16+
PY_VERSION_RE = re.compile(r'__version__ = "[^"]+"')
17+
18+
VERSION_FILES = [
19+
ROOT / "packages/create-python-app-core/pyproject.toml",
20+
ROOT / "packages/create-awesome-python-app/pyproject.toml",
21+
ROOT / "packages/create-python-app-core/src/create_python_app_core/_version.py",
22+
ROOT / "packages/create-awesome-python-app/src/create_awesome_python_app/__init__.py",
23+
]
24+
25+
26+
def replace_once(path: Path, pattern: re.Pattern[str], replacement: str) -> None:
27+
text = path.read_text()
28+
updated, count = pattern.subn(replacement, text, count=1)
29+
if count != 1:
30+
raise SystemExit(f"Expected one replacement in {path}, got {count}")
31+
path.write_text(updated)
32+
33+
34+
def update_versions(version: str) -> None:
35+
replace_once(
36+
ROOT / "packages/create-python-app-core/pyproject.toml",
37+
PROJECT_VERSION_RE,
38+
f'version = "{version}"',
39+
)
40+
replace_once(
41+
ROOT / "packages/create-awesome-python-app/pyproject.toml",
42+
PROJECT_VERSION_RE,
43+
f'version = "{version}"',
44+
)
45+
replace_once(
46+
ROOT / "packages/create-awesome-python-app/pyproject.toml",
47+
CORE_DEP_RE,
48+
f'"create-python-app-core>={version}"',
49+
)
50+
replace_once(
51+
ROOT / "packages/create-python-app-core/src/create_python_app_core/_version.py",
52+
PY_VERSION_RE,
53+
f'__version__ = "{version}"',
54+
)
55+
replace_once(
56+
ROOT / "packages/create-awesome-python-app/src/create_awesome_python_app/__init__.py",
57+
PY_VERSION_RE,
58+
f'__version__ = "{version}"',
59+
)
60+
61+
62+
def update_changelog(version: str, notes: str) -> None:
63+
changelog = ROOT / "CHANGELOG.md"
64+
text = changelog.read_text()
65+
heading = f"## {version}"
66+
if heading in text:
67+
raise SystemExit(f"CHANGELOG.md already contains {heading}")
68+
69+
today = datetime.now(UTC).date().isoformat()
70+
body = notes.strip() or "- Maintenance release."
71+
section = f"## {version} - {today}\n\n{body}\n\n"
72+
if not text.startswith("# Changelog\n\n"):
73+
raise SystemExit("CHANGELOG.md must start with '# Changelog'")
74+
changelog.write_text(text.replace("# Changelog\n\n", f"# Changelog\n\n{section}", 1))
75+
76+
77+
def main() -> None:
78+
parser = argparse.ArgumentParser()
79+
parser.add_argument("version", help="Release version, e.g. 0.1.1")
80+
parser.add_argument(
81+
"--notes",
82+
default="- Maintenance release.",
83+
help="Markdown notes to insert into CHANGELOG.md",
84+
)
85+
args = parser.parse_args()
86+
87+
if not VERSION_RE.match(args.version):
88+
raise SystemExit(f"Invalid version: {args.version}")
89+
90+
for path in VERSION_FILES:
91+
if not path.is_file():
92+
raise SystemExit(f"Missing expected version file: {path}")
93+
94+
update_versions(args.version)
95+
update_changelog(args.version, args.notes)
96+
print(f"Prepared release {args.version}")
97+
98+
99+
if __name__ == "__main__":
100+
main()

0 commit comments

Comments
 (0)