Skip to content

Commit 0bcc232

Browse files
chore: automate release preparation (#200)
* chore: automate release preparation Co-authored-by: Cursor <cursoragent@cursor.com> * fix: wrap long lines in prepare_release.py for ruff E501 Co-authored-by: Cursor <cursoragent@cursor.com> * style: ruff-format prepare_release.py Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent c501ae0 commit 0bcc232

5 files changed

Lines changed: 269 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: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
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
23+
/ "packages/create-awesome-python-app/src"
24+
/ "create_awesome_python_app/__init__.py",
25+
]
26+
27+
28+
def replace_once(path: Path, pattern: re.Pattern[str], replacement: str) -> None:
29+
text = path.read_text()
30+
updated, count = pattern.subn(replacement, text, count=1)
31+
if count != 1:
32+
raise SystemExit(f"Expected one replacement in {path}, got {count}")
33+
path.write_text(updated)
34+
35+
36+
def update_versions(version: str) -> None:
37+
replace_once(
38+
ROOT / "packages/create-python-app-core/pyproject.toml",
39+
PROJECT_VERSION_RE,
40+
f'version = "{version}"',
41+
)
42+
replace_once(
43+
ROOT / "packages/create-awesome-python-app/pyproject.toml",
44+
PROJECT_VERSION_RE,
45+
f'version = "{version}"',
46+
)
47+
replace_once(
48+
ROOT / "packages/create-awesome-python-app/pyproject.toml",
49+
CORE_DEP_RE,
50+
f'"create-python-app-core>={version}"',
51+
)
52+
replace_once(
53+
ROOT / "packages/create-python-app-core/src/create_python_app_core/_version.py",
54+
PY_VERSION_RE,
55+
f'__version__ = "{version}"',
56+
)
57+
replace_once(
58+
ROOT
59+
/ "packages/create-awesome-python-app/src"
60+
/ "create_awesome_python_app/__init__.py",
61+
PY_VERSION_RE,
62+
f'__version__ = "{version}"',
63+
)
64+
65+
66+
def update_changelog(version: str, notes: str) -> None:
67+
changelog = ROOT / "CHANGELOG.md"
68+
text = changelog.read_text()
69+
heading = f"## {version}"
70+
if heading in text:
71+
raise SystemExit(f"CHANGELOG.md already contains {heading}")
72+
73+
today = datetime.now(UTC).date().isoformat()
74+
body = notes.strip() or "- Maintenance release."
75+
section = f"## {version} - {today}\n\n{body}\n\n"
76+
if not text.startswith("# Changelog\n\n"):
77+
raise SystemExit("CHANGELOG.md must start with '# Changelog'")
78+
updated = text.replace("# Changelog\n\n", f"# Changelog\n\n{section}", 1)
79+
changelog.write_text(updated)
80+
81+
82+
def main() -> None:
83+
parser = argparse.ArgumentParser()
84+
parser.add_argument("version", help="Release version, e.g. 0.1.1")
85+
parser.add_argument(
86+
"--notes",
87+
default="- Maintenance release.",
88+
help="Markdown notes to insert into CHANGELOG.md",
89+
)
90+
args = parser.parse_args()
91+
92+
if not VERSION_RE.match(args.version):
93+
raise SystemExit(f"Invalid version: {args.version}")
94+
95+
for path in VERSION_FILES:
96+
if not path.is_file():
97+
raise SystemExit(f"Missing expected version file: {path}")
98+
99+
update_versions(args.version)
100+
update_changelog(args.version, args.notes)
101+
print(f"Prepared release {args.version}")
102+
103+
104+
if __name__ == "__main__":
105+
main()

0 commit comments

Comments
 (0)