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
88 changes: 88 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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' || '' }}
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
16 changes: 8 additions & 8 deletions ROOT_VS_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
118 changes: 118 additions & 0 deletions scripts/bump_version.py
Original file line number Diff line number Diff line change
@@ -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<prefix>\s*version\s*=\s*")(?P<ver>\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())
2 changes: 2 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading