diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..af865b2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,88 @@ +name: Release + +on: + workflow_dispatch: + inputs: + bump: + description: "Which semver part to bump (ignored if version is set)" + type: choice + required: false + default: patch + options: + - patch + - minor + - major + version: + description: "Explicit version to set (X.Y.Z). If set, overrides bump." + type: string + required: false + prerelease: + description: "Mark the GitHub Release as a pre-release" + type: boolean + required: false + default: false + +permissions: + contents: write + +concurrency: + group: release + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Set up Python + run: uv python install 3.11 + + - name: Sync dependencies + run: uv sync --frozen --extra dev + + - name: Configure git author + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Bump version + id: bump + run: | + if [ -n "${{ inputs.version }}" ]; then + NEW_VERSION="$(uv run --active python scripts/bump_version.py --new-version "${{ inputs.version }}")" + else + NEW_VERSION="$(uv run --active python scripts/bump_version.py --bump "${{ inputs.bump }}")" + fi + echo "version=${NEW_VERSION}" >> "${GITHUB_OUTPUT}" + + - name: Commit version bump + run: | + git add pyproject.toml + git commit -m "chore(release): v${{ steps.bump.outputs.version }}" + + - name: Create tag + run: | + git tag "v${{ steps.bump.outputs.version }}" + + - name: Push commit and tag + run: | + git push origin HEAD + git push origin "v${{ steps.bump.outputs.version }}" + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "v${{ steps.bump.outputs.version }}" \ + --title "v${{ steps.bump.outputs.version }}" \ + --generate-notes \ + ${{ inputs.prerelease && '--prerelease' || '' }} diff --git a/README.md b/README.md index 7aa4a4c..c89050e 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,25 @@ Other useful commands: - 🧠 **`just type`**: type check - 🧪 **`just test`**: run template integration tests (renders the template and asserts output) +## Releasing this template 🏷️ + +This repo uses a manually-triggered GitHub Actions workflow to bump the template repo version, tag it, and create a GitHub Release. + +To cut a release: + +1. Go to GitHub Actions → workflow `Release` +2. Click “Run workflow” +3. Choose either: + - `bump`: `patch` / `minor` / `major`, or + - `version`: an explicit `X.Y.Z` (overrides `bump`) + +The workflow will: + +- Update `[project].version` in `pyproject.toml` +- Commit the change +- Create and push a tag `vX.Y.Z` +- Create a GitHub Release with auto-generated release notes + ## FAQ ❓ ### Can Copier be applied over a preexisting project? diff --git a/ROOT_VS_TEMPLATE.md b/ROOT_VS_TEMPLATE.md index 9ad569c..2fef9bc 100644 --- a/ROOT_VS_TEMPLATE.md +++ b/ROOT_VS_TEMPLATE.md @@ -76,24 +76,24 @@ These are **generated-project** assets (or Copier plumbing): ### Same “slot,” different content (noteworthy drift) -1. **GitHub Actions action versions** +1. **GitHub Actions action versions** Root workflows tend to use **newer** pins (e.g. `actions/checkout@v6`, `astral-sh/setup-uv@v7`). Template workflows often use **older** pins (`checkout@v4`, `setup-uv@v5`). Same idea, different freshness. -2. **`lint.yml` vs `lint.yml.jinja`** +2. **`lint.yml` vs `lint.yml.jinja`** Root runs **BasedPyright** and **pre-commit** in the lint job. Template `lint.yml.jinja` stops after **Ruff** (type-checking lives in **`ci.yml.jinja`** as a separate job). So “Lint” is not the same step boundary across root vs generated projects. -3. **Test orchestration** +3. **Test orchestration** Root has a dedicated **`tests.yml`** (pytest, two Python versions). Template embeds tests in **`ci.yml.jinja`** with a **dynamic matrix** from `github_actions_python_versions` in `copier.yml` and adds **coverage** + optional **Codecov**. -4. **`justfile` vs `justfile.jinja`** - - Root `sync`/`update` use `--frozen --extra dev`; template uses extras for **test** and optional **docs**, and template `sync`/`update` omit `--frozen` in the recipes (relying on lockfile from post-gen tasks). - - Template duplicates a section header around the `ci` recipe (“CI (local mirror…)”) compared to root’s cleaner `static_check` + `ci` split. +4. **`justfile` vs `justfile.jinja`** + - Root `sync`/`update` use `--frozen --extra dev`; template uses extras for **test** and optional **docs**, and template `sync`/`update` omit `--frozen` in the recipes (relying on lockfile from post-gen tasks). + - Template duplicates a section header around the `ci` recipe (“CI (local mirror…)”) compared to root’s cleaner `static_check` + `ci` split. - Root `test` targets the repo root; template scopes Ruff/pytest to **`src`** and **`tests/`**. -5. **`.gitignore` vs `.gitignore.jinja`** +5. **`.gitignore` vs `.gitignore.jinja`** Root includes **`.claude/todos/`** (Claude Code local state). Template copy has an stray line **`1`** under “Specific files to ignore” and **omits** `.claude/todos/`. So the copy script has not produced a byte-identical pair. -6. **`pyproject.toml` vs `pyproject.toml.jinja`** +6. **`pyproject.toml` vs `pyproject.toml.jinja`** Root has **no** `[build-system]` / Hatch / package layout; template is a full package with **optional** `docs` extra, **test** extra with coverage tools, and stricter Ruff configuration (extra rules, `src` layout). They should **not** be merged blindly. --- diff --git a/pyproject.toml b/pyproject.toml index 8087eb6..08a0970 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dev = [ "ruff>=0.8.0", "basedpyright>=1.21.0", "pre-commit>=4.0.0", + "typing-extensions>=4.12.0", ] [tool.ruff] diff --git a/scripts/bump_version.py b/scripts/bump_version.py new file mode 100644 index 0000000..adab071 --- /dev/null +++ b/scripts/bump_version.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import argparse +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Literal, Protocol, cast, override + +_VERSION_RE = re.compile(r'^(?P\s*version\s*=\s*")(?P\d+\.\d+\.\d+)(".*)$') + +BumpKind = Literal["patch", "minor", "major"] + + +class _Args(Protocol): + pyproject: str + bump: str | None + new_version: str | None + + +@dataclass(frozen=True) +class Version: + major: int + minor: int + patch: int + + @classmethod + def parse(cls, s: str) -> Version: + parts = s.strip().split(".") + if len(parts) != 3 or any(not p.isdigit() for p in parts): + raise ValueError(f"Invalid version (expected X.Y.Z): {s!r}") + return cls(int(parts[0]), int(parts[1]), int(parts[2])) + + def bumped(self, bump: BumpKind) -> Version: + if bump == "patch": + return Version(self.major, self.minor, self.patch + 1) + if bump == "minor": + return Version(self.major, self.minor + 1, 0) + return Version(self.major + 1, 0, 0) + + @override + def __str__(self) -> str: + return f"{self.major}.{self.minor}.{self.patch}" + + +def _read_project_version(pyproject_path: Path) -> Version: + text = pyproject_path.read_text(encoding="utf-8") + in_project = False + for line in text.splitlines(): + if line.strip() == "[project]": + in_project = True + continue + if in_project and line.startswith("[") and line.strip().endswith("]"): + break + if in_project: + m = _VERSION_RE.match(line) + if m: + return Version.parse(m.group("ver")) + raise RuntimeError(f"Could not find [project] version in {pyproject_path}") + + +def _write_project_version(pyproject_path: Path, new_version: Version) -> None: + text = pyproject_path.read_text(encoding="utf-8") + out_lines: list[str] = [] + in_project = False + replaced = False + + for line in text.splitlines(): + if line.strip() == "[project]": + in_project = True + out_lines.append(line) + continue + if in_project and line.startswith("[") and line.strip().endswith("]"): + in_project = False + if in_project: + m = _VERSION_RE.match(line) + if m: + out_lines.append(f"{m.group('prefix')}{new_version}{m.group(3)}") + replaced = True + continue + out_lines.append(line) + + if not replaced: + raise RuntimeError(f"Could not replace [project] version in {pyproject_path}") + + _ = pyproject_path.write_text("\n".join(out_lines) + "\n", encoding="utf-8") + + +def main() -> int: + parser = argparse.ArgumentParser(description="Bump [project].version in pyproject.toml") + _ = parser.add_argument("--pyproject", default="pyproject.toml", help="Path to pyproject.toml") + _ = parser.add_argument( + "--bump", + choices=("patch", "minor", "major"), + help="Which semver part to bump (ignored if --new-version is provided)", + ) + _ = parser.add_argument("--new-version", help="Explicit version (X.Y.Z)") + args = cast(_Args, cast(object, parser.parse_args())) + + pyproject_path = Path(args.pyproject) + current = _read_project_version(pyproject_path) + + if args.new_version is not None: + new = Version.parse(args.new_version) + else: + if not args.bump: + raise SystemExit("Either --new-version or --bump is required") + new = current.bumped(cast(BumpKind, args.bump)) + + if new == current: + raise SystemExit(f"New version equals current: {current}") + + _write_project_version(pyproject_path, new) + print(str(new)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/uv.lock b/uv.lock index 4091147..73e7ca0 100644 --- a/uv.lock +++ b/uv.lock @@ -546,6 +546,7 @@ dev = [ { name = "pytest" }, { name = "pytest-xdist" }, { name = "ruff" }, + { name = "typing-extensions" }, ] [package.metadata] @@ -557,6 +558,7 @@ requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, { name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=3.5.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8.0" }, + { name = "typing-extensions", marker = "extra == 'dev'", specifier = ">=4.12.0" }, ] provides-extras = ["dev"]