diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 0000000..51feb09 --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -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 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0506b06..b54850b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -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 @@ -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 diff --git a/docs/VERSIONING.md b/docs/VERSIONING.md index 4429a0d..52fa752 100644 --- a/docs/VERSIONING.md +++ b/docs/VERSIONING.md @@ -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`. diff --git a/scripts/extract_release_notes.py b/scripts/extract_release_notes.py new file mode 100644 index 0000000..ea25ab9 --- /dev/null +++ b/scripts/extract_release_notes.py @@ -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() diff --git a/scripts/prepare_release.py b/scripts/prepare_release.py new file mode 100644 index 0000000..d934c2f --- /dev/null +++ b/scripts/prepare_release.py @@ -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()