Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions .github/workflows/prepare-release.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
name: Prepare release PR

on:
workflow_dispatch:
inputs:
version:
description: "Release version, e.g. 0.1.1"
required: true
notes:
description: "Markdown changelog notes for this release"
required: true
type: string

permissions:
contents: write
pull-requests: write

concurrency:
group: prepare-release-${{ github.event.inputs.version }}
cancel-in-progress: true

jobs:
prepare:
name: Prepare release bump
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v7
- uses: actions/setup-python@v6
with:
python-version-file: .python-version

- name: Sync workspace
run: uv sync --group dev

- name: Prepare release files
env:
VERSION: ${{ github.event.inputs.version }}
NOTES: ${{ github.event.inputs.notes }}
run: |
uv run python scripts/prepare_release.py "$VERSION" --notes "$NOTES"

- name: Verify release notes are extractable
env:
VERSION: ${{ github.event.inputs.version }}
run: |
uv run python scripts/extract_release_notes.py "$VERSION" --output /tmp/release-notes.md
test -s /tmp/release-notes.md

- name: Create release PR
uses: peter-evans/create-pull-request@v7
with:
branch: release/create-awesome-python-app-${{ github.event.inputs.version }}
title: "chore(release): prepare ${{ github.event.inputs.version }}"
commit-message: "chore(release): prepare ${{ github.event.inputs.version }}"
body: |
## Summary

- bumps `create-python-app-core` and `create-awesome-python-app` to `${{ github.event.inputs.version }}`
- updates the CLI dependency on the matching core version
- prepends release notes to `CHANGELOG.md`

## Release checklist

- [ ] Review generated version/changelog diff
- [ ] Merge this PR
- [ ] Tag `create-awesome-python-app@${{ github.event.inputs.version }}`
- [ ] Confirm PyPI, Docker, Homebrew, AUR, and distribution smoke workflows

Closes #195
19 changes: 11 additions & 8 deletions .github/workflows/publish.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,6 +25,16 @@ jobs:
with:
python-version-file: .python-version
- run: uv sync --group dev
- name: Resolve release version
id: release
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#create-awesome-python-app@}"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Extract release notes
if: startsWith(github.ref, 'refs/tags/')
run: |
uv run python scripts/extract_release_notes.py "${{ steps.release.outputs.version }}" --output release-notes.md
- name: Build packages
run: |
mkdir -p dist-core dist-cli
Expand All@@ -39,14 +49,7 @@ jobs:
uses: softprops/action-gh-release@v3
with:
files: dist/*
body: |
First public release. Install with:

```bash
uvx create-awesome-python-app@0.1.0 --help
```

Catalog: https://github.com/Create-Python-App/cpa-templates
body_path: release-notes.md
- name: Publish create-python-app-core to PyPI
if: startsWith(github.ref, 'refs/tags/')
uses: pypa/gh-action-pypi-publish@release/v1
Expand Down
44 changes: 39 additions & 5 deletions docs/VERSIONING.md
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,43 @@
# Versioning

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

Recommended flow (release-please or manual tags):
## Prepare a release PR

1. Bump `version` in package pyproject.toml files
2. Tag `create-awesome-python-app@X.Y.Z`
3. `publish.yml` builds and publishes to PyPI via OIDC (see #58)
Run the `Prepare release PR` workflow manually with:

- `version`: the next CLI/core version, for example `0.1.1`
- `notes`: markdown release notes to prepend to `CHANGELOG.md`

The workflow runs `scripts/prepare_release.py`, opens a PR, and updates:

- `packages/create-python-app-core/pyproject.toml`
- `packages/create-awesome-python-app/pyproject.toml`
- `create-python-app-core` dependency pin in the CLI package
- both runtime `__version__` files
- `CHANGELOG.md`

Local equivalent:

```bash
uv run python scripts/prepare_release.py 0.1.1 --notes "- Fix release automation."
```

## Publish after merge

After the release-prep PR is merged:

1. Tag `create-awesome-python-app@X.Y.Z`
2. Push the tag
3. `publish.yml` builds and publishes both packages to PyPI via OIDC (see #58)
4. GitHub Release notes are extracted from the matching `CHANGELOG.md` section
5. Distribution workflows update Docker, Homebrew, and AUR

```bash
git tag create-awesome-python-app@X.Y.Z
git push origin create-awesome-python-app@X.Y.Z
```

Before closing the release issue, confirm PyPI, GitHub Release, Docker,
Homebrew, AUR, and `smoke-distribution.yml`.
44 changes: 44 additions & 0 deletions scripts/extract_release_notes.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
#!/usr/bin/env python3
"""Extract a single version section from CHANGELOG.md."""

from __future__ import annotations

import argparse
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]


def extract_notes(version: str) -> str:
lines = (ROOT / "CHANGELOG.md").read_text().splitlines()
heading_prefix = f"## {version}"
start = next(
(idx for idx, line in enumerate(lines) if line.startswith(heading_prefix)),
None,
)
if start is None:
raise SystemExit(f"CHANGELOG.md does not contain notes for {version}")

end = next(
(
idx
for idx, line in enumerate(lines[start + 1 :], start + 1)
if line.startswith("## ")
),
len(lines),
)
return "\n".join(lines[start + 1 : end]).strip() + "\n"


def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("version")
parser.add_argument("--output", default="release-notes.md")
args = parser.parse_args()

Path(args.output).write_text(extract_notes(args.version))
print(f"Wrote release notes for {args.version} to {args.output}")


if __name__ == "__main__":
main()
105 changes: 105 additions & 0 deletions scripts/prepare_release.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Prepare a release bump across the CPA workspace."""

from __future__ import annotations

import argparse
import re
from datetime import UTC, datetime
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]

VERSION_RE = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+(?:[a-zA-Z0-9.-]+)?$")
PROJECT_VERSION_RE = re.compile(r'(?m)^version = "[^"]+"$')
CORE_DEP_RE = re.compile(r'"create-python-app-core>=[^"]+"')
PY_VERSION_RE = re.compile(r'__version__ = "[^"]+"')

VERSION_FILES = [
ROOT / "packages/create-python-app-core/pyproject.toml",
ROOT / "packages/create-awesome-python-app/pyproject.toml",
ROOT / "packages/create-python-app-core/src/create_python_app_core/_version.py",
ROOT
/ "packages/create-awesome-python-app/src"
/ "create_awesome_python_app/__init__.py",
]


def replace_once(path: Path, pattern: re.Pattern[str], replacement: str) -> None:
text = path.read_text()
updated, count = pattern.subn(replacement, text, count=1)
if count != 1:
raise SystemExit(f"Expected one replacement in {path}, got {count}")
path.write_text(updated)


def update_versions(version: str) -> None:
replace_once(
ROOT / "packages/create-python-app-core/pyproject.toml",
PROJECT_VERSION_RE,
f'version = "{version}"',
)
replace_once(
ROOT / "packages/create-awesome-python-app/pyproject.toml",
PROJECT_VERSION_RE,
f'version = "{version}"',
)
replace_once(
ROOT / "packages/create-awesome-python-app/pyproject.toml",
CORE_DEP_RE,
f'"create-python-app-core>={version}"',
)
replace_once(
ROOT / "packages/create-python-app-core/src/create_python_app_core/_version.py",
PY_VERSION_RE,
f'__version__ = "{version}"',
)
replace_once(
ROOT
/ "packages/create-awesome-python-app/src"
/ "create_awesome_python_app/__init__.py",
PY_VERSION_RE,
f'__version__ = "{version}"',
)


def update_changelog(version: str, notes: str) -> None:
changelog = ROOT / "CHANGELOG.md"
text = changelog.read_text()
heading = f"## {version}"
if heading in text:
raise SystemExit(f"CHANGELOG.md already contains {heading}")

today = datetime.now(UTC).date().isoformat()
body = notes.strip() or "- Maintenance release."
section = f"## {version} - {today}\n\n{body}\n\n"
if not text.startswith("# Changelog\n\n"):
raise SystemExit("CHANGELOG.md must start with '# Changelog'")
updated = text.replace("# Changelog\n\n", f"# Changelog\n\n{section}", 1)
changelog.write_text(updated)


def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("version", help="Release version, e.g. 0.1.1")
parser.add_argument(
"--notes",
default="- Maintenance release.",
help="Markdown notes to insert into CHANGELOG.md",
)
args = parser.parse_args()

if not VERSION_RE.match(args.version):
raise SystemExit(f"Invalid version: {args.version}")

for path in VERSION_FILES:
if not path.is_file():
raise SystemExit(f"Missing expected version file: {path}")

update_versions(args.version)
update_changelog(args.version, args.notes)
print(f"Prepared release {args.version}")


if __name__ == "__main__":
main()