From 4db4bb12b92441752450e583b78f190c653424d5 Mon Sep 17 00:00:00 2001 From: Sebastiano Date: Wed, 29 Jul 2026 14:39:15 +0200 Subject: [PATCH 01/20] docs(specs): add release, changelog, and SPDX design How frost-planner's release automation, changelog generation and SPDX headers should look once they are ported into this template. --- ...026-07-29-release-changelog-spdx-design.md | 346 ++++++++++++++++++ 1 file changed, 346 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-29-release-changelog-spdx-design.md diff --git a/docs/superpowers/specs/2026-07-29-release-changelog-spdx-design.md b/docs/superpowers/specs/2026-07-29-release-changelog-spdx-design.md new file mode 100644 index 0000000..3e552b6 --- /dev/null +++ b/docs/superpowers/specs/2026-07-29-release-changelog-spdx-design.md @@ -0,0 +1,346 @@ +# Release automation, changelog generation, and SPDX headers + +Date: 2026-07-29 + +## Goal + +Bring three capabilities from +[glacier-project/frost-planner](https://github.com/glacier-project/frost-planner) +into this template: + +1. Tag-triggered GitHub Release publishing. +2. `CHANGELOG.md` generation from git history via git-cliff. +3. SPDX/REUSE licensing headers on source files. + +Because this repository is a *template*, every added file must also survive +`scripts/bootstrap_template.py` — placeholders substituted, copyright holder +rewritten — and be covered by the template smoke test. + +## Decisions + +| Question | Decision | Rationale | +| --- | --- | --- | +| SPDX headers | Auto-insert, then verify | The reference only lints; writing headers automatically removes the manual step. | +| Changelog format | Grouped by conventional-commit type | This repository's history is consistently conventional, unlike the reference's. | +| Release scope | GitHub Release, with a commented PyPI stub | Works with no secrets in every generated repo; PyPI is opt-in. | +| Copyright holder | `the contributors` | Avoids editing a per-author name into every file as contributors change. | +| Entrypoints | Python scripts plus tox envs | Matches this repo's all-Python `scripts/` and tox-driven convention; unit-testable. | +| Commit scope in bullets | Omitted | Keeps bullets short; scope is recoverable from the hash. | +| Root `LICENSE` | Kept alongside `LICENSES/` | GitHub license detection reads the root file. | + +## Divergences from the reference + +These are deliberate improvements, not accidental drift. + +- **`uv.lock` version sync.** `uv.lock` pins the project's own + `version = "0.0.0"`. CI runs `uv run --locked`, so bumping only + `pyproject.toml` would break every workflow on the release commit. The + reference's `release.sh` does not handle this. `release.py` runs `uv lock` + and `uv lock --check`, and commits the lockfile. +- **`git-cliff` as a pinned dependency.** The reference fetches it with + unpinned `uvx`. Here it is a `dev` extra, so releases are reproducible. +- **Release artifacts are smoke-tested.** The release workflow runs + `tox -e build`, reusing the existing `scripts/validate_distribution.py`, + instead of a bare `uv build`. +- **Release notes come from git-cliff `--current`,** not an `awk` scrape of + `CHANGELOG.md`, so notes cannot drift from a mis-parsed heading. +- **Template integration.** No equivalent exists upstream; the reference is + not a template. + +## 1. SPDX / REUSE layer + +New files: + +- `LICENSES/BSD-2-Clause.txt` — license text, copied from the existing root + `LICENSE`. +- `REUSE.toml` — bulk annotations covering every non-Python tracked file. +- `scripts/add_spdx_headers.py` — wraps `reuse annotate`. + +Two pre-commit hooks, in this order: + +```yaml +- repo: local + hooks: + - id: reuse-annotate + name: add SPDX headers + entry: python scripts/add_spdx_headers.py + language: python + additional_dependencies: ['reuse>=6.2,<7'] + types: [python] +- repo: https://github.com/fsfe/reuse-tool + rev: v6.2.0 + hooks: + - id: reuse +``` + +`add_spdx_headers.py` invokes `reuse annotate` over the staged Python files +pre-commit passes as arguments, with: + +``` +--skip-existing --merge-copyrights --skip-unrecognised +-y -c "the contributors" -l BSD-2-Clause +``` + +`reuse` 6.2.0 requires Python >= 3.10, matching this template's +`requires-python = ">=3.10,<4"`. + +### First-commit behavior + +pre-commit fails any hook that modifies files — it compares file hashes and +ignores the hook's exit code. So the first `git commit` on a file with no +header aborts, with the header now written; re-running `git commit` succeeds. +This is the same behavior as `trailing-whitespace`. The benefit is never +hand-writing a header, not an uninterrupted commit. + +`fail_fast: true` is already set globally, so the `reuse` lint hook does not +run in the same pass that `reuse-annotate` modifies files. It runs on the +retry. + +### Header vs. bulk annotation + +Inline headers go in non-empty tracked Python files only: + +``` +project_name/__init__.py tests/test_bootstrap_template.py +project_name/greeter.py tests/test_greeter.py +docs/conf.py tests/test_template_smoke.py +examples/say_hi.py scripts/release.py +scripts/add_spdx_headers.py scripts/gen_changelog.py +scripts/bootstrap_template.py +scripts/update_coverage_readme.py +scripts/validate_distribution.py +``` + +`tests/__init__.py` is 0 bytes and is deliberately excluded: `reuse lint` +skips zero-byte files, so annotating it would add content to an intentionally +empty file for no compliance gain. `add_spdx_headers.py` therefore skips +zero-byte inputs, matching `reuse lint`'s own behavior. + +Everything else is covered by `REUSE.toml`, so non-Python files are never +rewritten. The annotation block covers exactly: + +```toml +path = [ + ".devcontainer/**", + ".env", + ".github/**", + ".gitignore", + ".pre-commit-config.yaml", + ".pylintrc", + ".readthedocs.yaml", + "AGENTS.md", + "CHANGELOG.md", + "CLAUDE.md", + "CONTRIBUTING.md", + "Dockerfile", + "README.md", + "cliff.toml", + "docs/**", + "pyproject.toml", + "tox.ini", + "uv.lock", +] +``` + +`docs/**` covers `docs/*.md` and this `docs/superpowers/specs/` directory. +`AGENTS.md` and `CLAUDE.md` are listed because they are untracked but *not* +gitignored, and `reuse lint` only skips VCS-ignored files — it would otherwise +flag them. + +### Required `.gitignore` additions + +`reuse lint` skips gitignored files, but `.ruff_cache/` and `.mypy_cache/` +are currently **not** ignored, so their contents would fail the lint. Both are +tool caches that belong in `.gitignore` regardless (`.mypy_cache` is a leftover +from before the pyrefly migration in `0383620`). Adding both entries is part of +this work — it is required for the hook to pass, not unrelated cleanup. + +To verify during implementation: whether `reuse lint` ignores the root +`LICENSE` once `LICENSES/BSD-2-Clause.txt` exists. If it does not, add a +`REUSE.toml` entry for it. + +## 2. Changelog layer + +`git-cliff>=2.13,<3` is added to the `dev` extra. It ships platform wheels on +PyPI that bundle the binary, so it resolves into `uv.lock` normally. + +`cliff.toml`: + +```toml +[changelog] +header = "# Changelog\n\n" +body = """ +{% if version %}\ +## {{ version | trim_start_matches(pat="v") }} - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\ +## Unreleased +{% endif %} +{% for group, commits in commits | group_by(attribute="group") %} +### {{ group | striptags | trim | upper_first }} + +{% for commit in commits %}\ +- [{{ commit.id | truncate(length=7, end="") }}] {{ commit.message | upper_first }} +{% endfor %}\ +{% endfor %}\n +""" +trim = true +footer = "" + +[git] +conventional_commits = true +filter_unconventional = false +split_commits = false +protect_breaking_commits = false +filter_commits = false +tag_pattern = "v[0-9]*" +topo_order = false +sort_commits = "oldest" +``` + +Commit parsers, in order — skips first, then type mapping, then a catch-all: + +| Pattern | Group | +| --- | --- | +| `^chore\(release\)` | skipped | +| `^Merge ` | skipped | +| `^feat` | Features | +| `^fix` | Bug Fixes | +| `^perf` | Performance | +| `^refactor` | Refactoring | +| `^docs` | Documentation | +| `^test` | Testing | +| `^(build\|ci)` | Build & CI | +| `^(chore\|style\|format)` | Chores | +| `.*` | Other | + +Group names carry `` numeric prefixes stripped by `striptags`. This +is git-cliff's documented idiom for forcing group order; `group_by` otherwise +sorts alphabetically. + +`filter_unconventional = false` sends unconventional commits to *Other* +rather than dropping them. + +Resulting format: + +```markdown +## 0.1.0 - 2026-07-29 + +### Features + +- [49157ab] add current_python_version function + +### Bug Fixes + +- [a3bd78d] run one tox Python env per matrix job +``` + +`CHANGELOG.md` is currently 0 bytes. `git-cliff --prepend` does not insert the +`[changelog] header`, so the file is seeded with `# Changelog\n\n` as part of +this work. + +## 3. Release layer + +### `scripts/gen_changelog.py` + +Passes its arguments through to `git-cliff --config cliff.toml`. Preview to +stdout by default; `-o CHANGELOG.md` overwrites. + +### `scripts/release.py X.Y.Z [--push] [--no-tag] [--dry-run]` + +Same contract as the reference's `release.sh`, plus lockfile handling: + +1. Validate `X.Y.Z` against `^\d+\.\d+\.\d+([.-].+)?$`; abort if the tag + already exists or the working tree is dirty. +2. Resolve the previous reachable tag with + `git describe --tags --abbrev=0 --match='v[0-9]*' HEAD`. If none, the + changelog covers all history and a warning is printed. +3. Bump `version` in `pyproject.toml`. +4. Run `uv lock`, then `uv lock --check` to confirm the lockfile is in sync. +5. `git-cliff --config cliff.toml --tag vX.Y.Z --prepend CHANGELOG.md`. +6. Commit `pyproject.toml`, `uv.lock`, and `CHANGELOG.md` as + `chore(release): vX.Y.Z`. +7. Unless `--no-tag`, create annotated tag `vX.Y.Z`. +8. With `--push`, `git push origin HEAD --follow-tags`. + +`--dry-run` prints the planned steps and exits without touching the tree, and +so does not require a clean tree. + +### tox envs + +`[testenv:changelog]` and `[testenv:release]`, both with +`runner = uv-venv-lock-runner`, `extras = dev`, and +`allowlist_externals = git, uv`. Appended to the tail of `env_list`. + +``` +uv run tox run -e changelog +uv run tox run -e release -- 0.1.0 --push +``` + +### `.github/workflows/release.yaml` + +Triggered by `push: tags: ['v*']`, with `permissions: contents: write`. Uses +the repository's own `./.github/actions/setup-python-uv` composite action +rather than inlining uv setup, matching the other workflows. + +Steps: + +1. `actions/checkout@v6` with `fetch-depth: 0` — git-cliff needs full history. +2. Verify the tag matches `pyproject.toml`'s version; fail with + `::error::` if not. +3. `uv run --locked --extra dev tox run -e build` — builds sdist and wheel and + import-smoke-tests the wheel via `scripts/validate_distribution.py`. +4. `git-cliff --config cliff.toml --current --strip header -o release-notes.md`; + fail if the result is empty. +5. `softprops/action-gh-release@v2` with `body_path: release-notes.md` and + `dist/*.whl`, `dist/*.tar.gz`. +6. A commented-out `publish` job — `needs: release`, + `environment: pypi`, `permissions: id-token: write`, + `pypa/gh-action-pypi-publish@release/v1` — with a comment explaining that + enabling it requires configuring a PyPI Trusted Publisher. + +## 4. Template integration + +`scripts/bootstrap_template.py` changes: + +- Add `PLACEHOLDER_COPYRIGHT = "the Python Template contributors"` beside the + existing placeholders. +- Add `Path(".github/workflows/release.yaml")` to `WORKFLOW_FILES`, which + feeds both `PACKAGE_FILES` and `REPOSITORY_FILES`. +- Add `cliff.toml`, `REUSE.toml`, `scripts/release.py`, + `scripts/gen_changelog.py`, and `scripts/add_spdx_headers.py` to + `PACKAGE_FILES` so `project_name` is substituted in them. +- Add `update_spdx_copyright()`, which rewrites + `the Python Template contributors` to `the contributors` in + `REUSE.toml` and in every inline header, and call it from `main()`. + +The existing `PYTHON_VERSION_WORKFLOW_FILES` tuple is left unchanged: +`release.yaml` pins no Python version matrix. + +## 5. Testing + +- `tests/test_bootstrap_template.py` — `update_spdx_copyright()` rewrites + `REUSE.toml` and inline headers; the newly added files are covered by + placeholder substitution. +- `tests/test_release.py` — against a temporary git repository: version-format + validation, refusal on an existing tag, refusal on a dirty tree, + `pyproject.toml` bump, `uv.lock` version sync, commit message and tag + creation, and `--dry-run` leaving the tree untouched. +- `tests/test_template_smoke.py` — the generated project has no + `project_name` or `python-template` leftovers in the new files, and its + headers name the generated project. + +Manual verification before handing back: + +- `pre-commit run --all-files` passes on a second run (first run writes + headers). +- `uv run tox run -e changelog` produces sane grouped output against real + history. +- `uv run tox run -e release -- 0.1.0 --dry-run` prints the expected plan. +- `uv lock --check` passes. + +## Out of scope + +- No separate `tox -e license` env. The pre-commit hook plus the existing + `quality.yaml` pre-commit job already run REUSE checks in CI. +- No PyPI publishing enabled by default; the stub is commented out. +- No changes to existing workflows other than adding `release.yaml`. From 1633f496cd3fd10f64a98e63c1b98ab7424b1eb7 Mon Sep 17 00:00:00 2001 From: Sebastiano Date: Wed, 29 Jul 2026 15:17:01 +0200 Subject: [PATCH 02/20] docs(plans): add release, changelog, and SPDX implementation plan Checked the spec's assumptions against the real tools first and fixed a few: reuse ignores the root LICENSE and empty files, and the changelog and release tox envs have to stay out of env_list so a bare `tox` can never cut a release by accident. --- .../2026-07-29-release-changelog-spdx.md | 1616 +++++++++++++++++ ...026-07-29-release-changelog-spdx-design.md | 30 +- 2 files changed, 1642 insertions(+), 4 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-29-release-changelog-spdx.md diff --git a/docs/superpowers/plans/2026-07-29-release-changelog-spdx.md b/docs/superpowers/plans/2026-07-29-release-changelog-spdx.md new file mode 100644 index 0000000..4e32bcb --- /dev/null +++ b/docs/superpowers/plans/2026-07-29-release-changelog-spdx.md @@ -0,0 +1,1616 @@ +# Release Automation, Changelog Generation, and SPDX Headers Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add tag-triggered GitHub Release publishing, git-cliff changelog generation, and auto-inserted SPDX/REUSE headers to this Python template, with all three surviving `scripts/bootstrap_template.py`. + +**Architecture:** Three independent layers. The SPDX layer is two pre-commit hooks (a local fixer, then the upstream `reuse` linter) plus `REUSE.toml` for bulk-annotating non-Python files. The changelog layer is `cliff.toml` plus a thin `gen_changelog.py` wrapper. The release layer is `release.py` (version bump, lockfile sync, changelog prepend, commit, tag) driven locally via tox and consumed in CI by a tag-triggered workflow. A fourth task wires all of it into the template bootstrap script. + +**Tech Stack:** Python 3.10+, uv, tox (`uv-venv-lock-runner`), pre-commit, `git-cliff>=2.13,<3`, `reuse>=6.2,<7`, GitHub Actions. + +**Spec:** `docs/superpowers/specs/2026-07-29-release-changelog-spdx-design.md` + +## Global Constraints + +Every task's requirements implicitly include this section. + +- Copyright holder string is exactly `the Python Template contributors`. License is exactly `BSD-2-Clause`. +- Ruff `line-length = 80`, `target-version = "py310"`. Ruff lint selects `D` (pydocstyle, Google convention) — **every module, function, and class outside `tests/` needs a docstring**. `tests/**/*.py` has `D` ignored. +- Ruff format uses `quote-style = "double"`. +- New scripts go in `scripts/`, are Python (not shell), start with `#!/usr/bin/env python3`, use `from __future__ import annotations`, and end with `raise SystemExit(main())`. Follow the existing style of `scripts/validate_distribution.py` and `scripts/bootstrap_template.py`. +- All new `.py` files must carry the SPDX header shown in Task 1 verbatim, placed after any shebang and before the module docstring. +- `requires-python = ">=3.10,<4"`. `reuse` requires >=3.10, which matches. +- CI invokes tox only as `tox run -e `. **Do not add `changelog` or `release` to `env_list`** — bare `uv run tox` would otherwise fire a release attempt. +- Commit messages are conventional (`feat:`, `fix:`, `docs:`, `build:`, `ci:`, `test:`, `chore:`), because `cliff.toml` parses them into changelog groups. +- Work on branch `feat/release-changelog-spdx`, which already exists and holds the spec. +- pre-commit is **not** installed as a git hook in this clone. Hooks do not run automatically on commit; run them explicitly with `uv run --extra dev pre-commit run --all-files` when a task says to. + +--- + +### Task 1: SPDX / REUSE layer + +**Files:** +- Create: `LICENSES/BSD-2-Clause.txt` +- Create: `REUSE.toml` +- Create: `scripts/add_spdx_headers.py` +- Test: `tests/test_add_spdx_headers.py` +- Modify: `.pre-commit-config.yaml` (append two repos before the trailing `exclude:` block) +- Modify: `.gitignore:51` (add two cache entries) +- Modify: all 12 non-empty tracked `.py` files (headers inserted by the tool, not by hand) + +**Interfaces:** +- Consumes: nothing. +- Produces: `scripts/add_spdx_headers.py` exposing `annotatable(paths: list[str]) -> list[str]` and `build_command(paths: list[str], *, year: str, copyright_holder: str, license_id: str) -> list[str]`. `REUSE.toml` containing the literal string `the Python Template contributors` (Task 5 rewrites it). The header format Task 5's tests assert against. + +- [ ] **Step 1: Create the LICENSES directory** + +REUSE requires the license text under `LICENSES/`. The root `LICENSE` stays exactly where it is — GitHub's license detection reads it, and `reuse lint` ignores it. + +```bash +mkdir -p LICENSES +cp LICENSE LICENSES/BSD-2-Clause.txt +``` + +- [ ] **Step 2: Write the failing test for `add_spdx_headers`** + +Create `tests/test_add_spdx_headers.py`: + +```python +import sys + +from scripts.add_spdx_headers import annotatable, build_command + + +def test_annotatable_keeps_non_empty_files(tmp_path): + populated = tmp_path / "populated.py" + populated.write_text("x = 1\n", encoding="utf-8") + + assert annotatable([str(populated)]) == [str(populated)] + + +def test_annotatable_skips_zero_byte_files(tmp_path): + empty = tmp_path / "__init__.py" + empty.touch() + + assert annotatable([str(empty)]) == [] + + +def test_annotatable_skips_missing_paths(tmp_path): + assert annotatable([str(tmp_path / "absent.py")]) == [] + + +def test_annotatable_skips_directories(tmp_path): + assert annotatable([str(tmp_path)]) == [] + + +def test_build_command_uses_reuse_annotate_with_skip_flags(): + command = build_command( + ["pkg/mod.py"], + year="2026", + copyright_holder="the Python Template contributors", + license_id="BSD-2-Clause", + ) + + assert command[:4] == [sys.executable, "-m", "reuse", "annotate"] + assert "--skip-existing" in command + assert "--merge-copyrights" in command + assert "--skip-unrecognised" in command + assert command[-1] == "pkg/mod.py" + + +def test_build_command_passes_copyright_metadata(): + command = build_command( + ["pkg/mod.py"], + year="2026", + copyright_holder="the Acme contributors", + license_id="BSD-2-Clause", + ) + + assert command[command.index("--year") + 1] == "2026" + assert command[command.index("--copyright") + 1] == "the Acme contributors" + assert command[command.index("--license") + 1] == "BSD-2-Clause" +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `uv run --extra dev pytest tests/test_add_spdx_headers.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'scripts.add_spdx_headers'` + +- [ ] **Step 4: Write `scripts/add_spdx_headers.py`** + +`--skip-existing` makes the hook idempotent; `--skip-unrecognised` stops it from failing on a file type `reuse` has no comment style for. Zero-byte files are filtered out because `reuse lint` ignores them, so annotating `tests/__init__.py` would add content to an intentionally empty file for no compliance gain. + +```python +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause + +"""Insert SPDX licensing headers into Python files that lack them.""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from datetime import date +from pathlib import Path + +COPYRIGHT_HOLDER = "the Python Template contributors" +LICENSE_ID = "BSD-2-Clause" + + +def parse_args() -> argparse.Namespace: + """Parse command-line arguments.""" + parser = argparse.ArgumentParser() + parser.add_argument( + "paths", + nargs="*", + help="Files to annotate. Supplied by pre-commit.", + ) + parser.add_argument( + "--year", + default=str(date.today().year), + help="Copyright year to write. Defaults to the current year.", + ) + parser.add_argument( + "--copyright", + default=COPYRIGHT_HOLDER, + dest="copyright_holder", + help="Copyright holder to write into the header.", + ) + parser.add_argument( + "--license", + default=LICENSE_ID, + dest="license_id", + help="SPDX license identifier to write into the header.", + ) + return parser.parse_args() + + +def annotatable(paths: list[str]) -> list[str]: + """Return the paths reuse can annotate. + + Zero-byte files are skipped because ``reuse lint`` ignores them, so + annotating them would add content to intentionally empty files without + improving compliance. + + Args: + paths: Candidate file paths. + + Returns: + The subset of paths that are non-empty regular files. + """ + keep = [] + for raw in paths: + path = Path(raw) + if path.is_file() and path.stat().st_size > 0: + keep.append(raw) + return keep + + +def build_command( + paths: list[str], + *, + year: str, + copyright_holder: str, + license_id: str, +) -> list[str]: + """Build the ``reuse annotate`` command for the given paths. + + Args: + paths: Files to annotate. + year: Copyright year to write. + copyright_holder: Copyright holder to write. + license_id: SPDX license identifier to write. + + Returns: + The command as an argument list. + """ + return [ + sys.executable, + "-m", + "reuse", + "annotate", + "--skip-existing", + "--merge-copyrights", + "--skip-unrecognised", + "--year", + year, + "--copyright", + copyright_holder, + "--license", + license_id, + *paths, + ] + + +def main() -> int: + """Annotate the requested files and return the reuse exit status.""" + args = parse_args() + paths = annotatable(args.paths) + if not paths: + return 0 + + command = build_command( + paths, + year=args.year, + copyright_holder=args.copyright_holder, + license_id=args.license_id, + ) + return subprocess.run(command, check=False).returncode + + +if __name__ == "__main__": + raise SystemExit(main()) +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `uv run --extra dev pytest tests/test_add_spdx_headers.py -v` +Expected: PASS, 6 tests. + +- [ ] **Step 6: Create `REUSE.toml`** + +This covers every tracked non-Python file. `reuse lint` ignores the root `LICENSE`, `LICENSES/**`, `REUSE.toml` itself, and anything gitignored, so none of those are listed. `AGENTS.md`, `CLAUDE.md`, and `.claude/**` **are** listed: they are untracked but not gitignored, and `reuse lint` only skips VCS-ignored files. `docs/*.md` and `docs/superpowers/**` are used instead of `docs/**` so that `docs/conf.py` is covered by its inline header alone. + +```toml +version = 1 + +[[annotations]] +path = [ + ".claude/**", + ".devcontainer/**", + ".env", + ".github/**", + ".gitignore", + ".pre-commit-config.yaml", + ".pylintrc", + ".readthedocs.yaml", + "AGENTS.md", + "CHANGELOG.md", + "CLAUDE.md", + "CONTRIBUTING.md", + "Dockerfile", + "LICENSES/**", + "README.md", + "cliff.toml", + "docs/*.md", + "docs/superpowers/**", + "pyproject.toml", + "tox.ini", + "uv.lock", +] +precedence = "aggregate" +SPDX-FileCopyrightText = "2026 the Python Template contributors" +SPDX-License-Identifier = "BSD-2-Clause" +``` + +- [ ] **Step 7: Add the missing cache entries to `.gitignore`** + +`.ruff_cache/` and `.mypy_cache/` are currently not ignored, so `reuse lint` would fail on their contents. Insert both after `.pytest_cache/` on line 51, keeping the "Unit test / coverage reports" block alphabetically loose as it already is: + +``` +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +cover/ +``` + +- [ ] **Step 8: Register both pre-commit hooks** + +Append to `.pre-commit-config.yaml` after the `ruff-pre-commit` repo block and before the trailing `# Global file exclusions` / `exclude:` block. Order matters: the fixer runs first, the linter verifies. + +```yaml + # SPDX headers: insert missing ones, then verify REUSE compliance +- repo: local + hooks: + - id: reuse-annotate + name: add SPDX headers + entry: python scripts/add_spdx_headers.py + language: python + additional_dependencies: ['reuse>=6.2,<7'] + types: [python] + +- repo: https://github.com/fsfe/reuse-tool + rev: v6.2.0 + hooks: + - id: reuse +``` + +- [ ] **Step 9: Insert headers into every existing Python file** + +Let the hook do it — do not hand-write headers. The first run reports failure because it modifies files; that is normal pre-commit behavior for a fixing hook. + +Run: `uv run --extra dev pre-commit run reuse-annotate --all-files` +Expected: FAIL, reporting "files were modified by this hook". + +Then confirm the format on a file that has a shebang and one that does not: + +Run: `head -6 scripts/bootstrap_template.py project_name/greeter.py` + +Expected — shebang preserved above the header, `#` separator line present, blank line before the docstring: + +```python +#!/usr/bin/env python3 + +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause + +"""Bootstrap a repository created from this template.""" +``` + +Confirm `tests/__init__.py` is still 0 bytes: + +Run: `wc -c tests/__init__.py` +Expected: `0 tests/__init__.py` + +- [ ] **Step 10: Verify full REUSE compliance and that nothing else broke** + +Run: `uv run --extra dev pre-commit run --all-files` +Expected: PASS on every hook. If `reuse` reports missing licensing information, add the offending path to `REUSE.toml` and re-run. + +Run: `uv run --extra dev pytest tests/ -q` +Expected: PASS. The inserted headers are comments and must not change behavior. + +Run: `uv run --extra dev tox run -e type` +Expected: PASS. + +- [ ] **Step 11: Commit** + +```bash +git add LICENSES REUSE.toml scripts/add_spdx_headers.py \ + tests/test_add_spdx_headers.py .pre-commit-config.yaml .gitignore \ + project_name tests examples scripts docs/conf.py +git commit -m "feat(license): auto-insert and verify SPDX headers" +``` + +--- + +### Task 2: Changelog layer + +**Files:** +- Create: `cliff.toml` +- Create: `scripts/gen_changelog.py` +- Modify: `CHANGELOG.md` (seed; currently 0 bytes) +- Modify: `pyproject.toml` (add `git-cliff` to the `dev` extra) +- Modify: `tox.ini` (add `[testenv:changelog]`, **not** to `env_list`) +- Modify: `uv.lock` (regenerated) +- Modify: `README.md`, `CONTRIBUTING.md` (document the env) + +**Interfaces:** +- Consumes: the SPDX header format from Task 1. +- Produces: `cliff.toml` at the repo root, consumed by Task 3's `release.py` and Task 4's workflow via `--config cliff.toml`. `scripts/gen_changelog.py` passing `sys.argv[1:]` through to `git-cliff`. + +- [ ] **Step 1: Add `git-cliff` to the dev extra** + +It ships platform wheels on PyPI that bundle the Rust binary, so it resolves like any other dependency — no unpinned `uvx` fetch. Add to `[project.optional-dependencies] dev` in `pyproject.toml`, keeping the existing loose alphabetical-ish grouping (place it after `build` and before `hatchling`): + +```toml + "git-cliff>=2.13,<3", +``` + +Then relock: + +```bash +uv lock +``` + +- [ ] **Step 2: Create `cliff.toml`** + +This exact config was validated against this repository's real history — group ordering, the `striptags` prefix trick, and unconventional-commit retention all confirmed working with git-cliff 2.13.1. The trailing `\` on `{% endif %}` suppresses a duplicate blank line under the version heading. + +```toml +# git-cliff configuration. Produces, per release: +# +# ## VERSION - DATE +# +# ### Group +# +# - [shorthash] Subject +# +# Regenerate with `uv run tox run -e changelog -- -o CHANGELOG.md`. + +[changelog] +header = "# Changelog\n\n" +body = """ +{% if version %}\ +## {{ version | trim_start_matches(pat="v") }} - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\ +## Unreleased +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} +### {{ group | striptags | trim | upper_first }} + +{% for commit in commits %}\ +- [{{ commit.id | truncate(length=7, end="") }}] {{ commit.message | upper_first }} +{% endfor %}\ +{% endfor %}\n +""" +trim = true +footer = "" + +[git] +conventional_commits = true +filter_unconventional = false +split_commits = false +protect_breaking_commits = false +filter_commits = false +tag_pattern = "v[0-9]*" +topo_order = false +sort_commits = "oldest" +# Group names carry `` prefixes purely to force ordering; the +# template strips them with `striptags`. Without them `group_by` sorts +# alphabetically. +commit_parsers = [ + { message = "^chore\\(release\\)", skip = true }, + { message = "^Merge ", skip = true }, + { message = "^feat", group = "Features" }, + { message = "^fix", group = "Bug Fixes" }, + { message = "^perf", group = "Performance" }, + { message = "^refactor", group = "Refactoring" }, + { message = "^docs", group = "Documentation" }, + { message = "^test", group = "Testing" }, + { message = "^(build|ci)", group = "Build & CI" }, + { message = "^(chore|style|format)", group = "Chores" }, + { message = ".*", group = "Other" }, +] +``` + +- [ ] **Step 3: Seed `CHANGELOG.md`** + +`git-cliff --prepend` inserts only the rendered body, never the `[changelog] header`. The file is currently 0 bytes, so without seeding the first release would produce a changelog with no `# Changelog` title. + +```bash +printf '# Changelog\n\n' > CHANGELOG.md +``` + +- [ ] **Step 4: Write `scripts/gen_changelog.py`** + +```python +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause + +"""Regenerate CHANGELOG.md from git history using the rules in cliff.toml.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +CONFIG = Path(__file__).resolve().parents[1] / "cliff.toml" + + +def build_command(argv: list[str]) -> list[str]: + """Build the git-cliff command for the given passthrough arguments. + + Args: + argv: Arguments forwarded verbatim to git-cliff. + + Returns: + The command as an argument list. + """ + return [ + sys.executable, + "-m", + "git_cliff", + "--config", + str(CONFIG), + *argv, + ] + + +def main() -> int: + """Run git-cliff and return its exit status.""" + return subprocess.run(build_command(sys.argv[1:]), check=False).returncode + + +if __name__ == "__main__": + raise SystemExit(main()) +``` + +- [ ] **Step 5: Verify the module entry point, and fall back if absent** + +The `git-cliff` wheel may expose only a console script rather than an importable `git_cliff` module. + +Run: `uv run --extra dev python -m git_cliff --version` +Expected: `git-cliff 2.13.x` + +If that fails with `No module named git_cliff`, change `build_command` to invoke the console script from the same environment instead, and keep everything else identical: + +```python +def build_command(argv: list[str]) -> list[str]: + """Build the git-cliff command for the given passthrough arguments. + + Args: + argv: Arguments forwarded verbatim to git-cliff. + + Returns: + The command as an argument list. + """ + executable = Path(sys.executable).parent / "git-cliff" + return [str(executable), "--config", str(CONFIG), *argv] +``` + +- [ ] **Step 6: Add the tox env** + +Add to `tox.ini` after `[testenv:build]`. Do **not** add `changelog` to `env_list` — bare `uv run tox` must not regenerate the changelog. + +```ini +[testenv:changelog] +description = generate the changelog from git history +runner = uv-venv-lock-runner +extras = dev +allowlist_externals = + git +commands = + {envpython} scripts/gen_changelog.py {posargs} +``` + +- [ ] **Step 7: Verify the changelog renders correctly** + +Run: `uv run --extra dev tox run -e changelog -- --tag v0.1.0` + +Expected: grouped output on stdout, with `### Features` before `### Bug Fixes` before `### Refactoring`, exactly one blank line between the `## 0.1.0 - ` heading and the first `###` heading, and early non-conventional commits collected under `### Other`. `CHANGELOG.md` must be unchanged (no `-o` was passed). + +Run: `git diff --stat CHANGELOG.md` +Expected: no output. + +- [ ] **Step 8: Document the env** + +In `README.md`, after the "Build and smoke-test the package artifacts" block (around line 113), and in `CONTRIBUTING.md` after its matching block (around line 56), add: + +````markdown +Preview the generated changelog: + +```bash +uv run tox -e changelog +``` +```` + +Also add a row to the `README.md` repository-layout table (around line 167), matching the existing column alignment: + +``` +| `cliff.toml` | Optional | git-cliff changelog generation rules. | +``` + +And add to the README "Included tools" list: + +```markdown +- [git-cliff](https://git-cliff.org/) for changelog generation +``` + +- [ ] **Step 9: Verify and commit** + +Run: `uv run --extra dev pre-commit run --all-files` +Expected: PASS. `cliff.toml` and `CHANGELOG.md` are both covered by `REUSE.toml`, so `reuse` must stay green. + +```bash +git add cliff.toml CHANGELOG.md scripts/gen_changelog.py pyproject.toml \ + uv.lock tox.ini README.md CONTRIBUTING.md +git commit -m "feat(changelog): generate CHANGELOG.md with git-cliff" +``` + +--- + +### Task 3: Release script + +**Files:** +- Create: `scripts/release.py` +- Test: `tests/test_release.py` +- Modify: `tox.ini` (add `[testenv:release]`, **not** to `env_list`) +- Modify: `README.md`, `CONTRIBUTING.md` + +**Interfaces:** +- Consumes: `cliff.toml` from Task 2, invoked as `git-cliff --config cliff.toml --tag --prepend CHANGELOG.md`. +- Produces: `scripts/release.py` exposing `validate_version(version: str) -> str`, `tag_exists(tag: str, *, runner: Runner = run) -> bool`, `working_tree_dirty(*, runner: Runner = run) -> bool`, `previous_tag(*, runner: Runner = run) -> str | None`, `bump_pyproject(path: Path, version: str) -> None`, `sync_lockfile(*, runner: Runner = run) -> None`, and `changelog_range(prev: str | None) -> list[str]`. The commit subject format `chore(release): vX.Y.Z`, which `cliff.toml` skips via `^chore\(release\)` and Task 4's workflow relies on. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_release.py`. The `runner` seam keeps tests fast and offline — `sync_lockfile` is asserted on the commands it issues rather than by actually resolving dependencies. The git guards run against a real throwaway repo because that is cheap and catches real quoting bugs. + +```python +import subprocess + +import pytest + +from scripts.release import ( + bump_pyproject, + changelog_range, + previous_tag, + sync_lockfile, + tag_exists, + validate_version, + working_tree_dirty, +) + +PYPROJECT = """[project] +name = "project_name" +version = "0.0.0" +description = "A simple template project." +""" + + +class FakeRunner: + """Records commands and replays canned results.""" + + def __init__(self, results=None): + self.commands = [] + self.results = results or {} + + def __call__(self, command, *, check=True, capture=False): + self.commands.append(command) + key = " ".join(command) + result = self.results.get(key) + if result is None: + return subprocess.CompletedProcess(command, 0, "", "") + return result + + +def git_repo(tmp_path): + """Create a throwaway git repo with one commit.""" + subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) + (tmp_path / "file.txt").write_text("hello\n", encoding="utf-8") + subprocess.run(["git", "add", "-A"], cwd=tmp_path, check=True) + subprocess.run( + ["git", "-c", "user.email=a@b", "-c", "user.name=a", + "commit", "-qm", "init"], + cwd=tmp_path, + check=True, + ) + return tmp_path + + +@pytest.mark.parametrize("version", ["0.1.0", "1.2.3", "10.0.1", "1.0.0-rc1"]) +def test_validate_version_accepts_valid_versions(version): + assert validate_version(version) == version + + +@pytest.mark.parametrize("version", ["1.2", "v1.2.3", "abc", "", "1.2.3-"]) +def test_validate_version_rejects_invalid_versions(version): + with pytest.raises(ValueError): + validate_version(version) + + +def test_bump_pyproject_replaces_version(tmp_path): + path = tmp_path / "pyproject.toml" + path.write_text(PYPROJECT, encoding="utf-8") + + bump_pyproject(path, "0.1.0") + + assert 'version = "0.1.0"' in path.read_text(encoding="utf-8") + assert 'version = "0.0.0"' not in path.read_text(encoding="utf-8") + + +def test_bump_pyproject_leaves_other_metadata_alone(tmp_path): + path = tmp_path / "pyproject.toml" + path.write_text(PYPROJECT, encoding="utf-8") + + bump_pyproject(path, "0.1.0") + + content = path.read_text(encoding="utf-8") + assert 'name = "project_name"' in content + assert 'description = "A simple template project."' in content + + +def test_bump_pyproject_raises_without_a_version_line(tmp_path): + path = tmp_path / "pyproject.toml" + path.write_text('[project]\nname = "x"\n', encoding="utf-8") + + with pytest.raises(ValueError): + bump_pyproject(path, "0.1.0") + + +def test_tag_exists_is_false_for_unknown_tag(tmp_path, monkeypatch): + monkeypatch.chdir(git_repo(tmp_path)) + + assert tag_exists("v9.9.9") is False + + +def test_tag_exists_is_true_for_created_tag(tmp_path, monkeypatch): + repo = git_repo(tmp_path) + subprocess.run(["git", "tag", "-a", "v0.1.0", "-m", "v0.1.0"], + cwd=repo, check=True) + monkeypatch.chdir(repo) + + assert tag_exists("v0.1.0") is True + + +def test_working_tree_dirty_is_false_when_clean(tmp_path, monkeypatch): + monkeypatch.chdir(git_repo(tmp_path)) + + assert working_tree_dirty() is False + + +def test_working_tree_dirty_is_true_with_modifications(tmp_path, monkeypatch): + repo = git_repo(tmp_path) + (repo / "file.txt").write_text("changed\n", encoding="utf-8") + monkeypatch.chdir(repo) + + assert working_tree_dirty() is True + + +def test_previous_tag_is_none_without_tags(tmp_path, monkeypatch): + monkeypatch.chdir(git_repo(tmp_path)) + + assert previous_tag() is None + + +def test_previous_tag_returns_most_recent_reachable_tag(tmp_path, monkeypatch): + repo = git_repo(tmp_path) + subprocess.run(["git", "tag", "-a", "v0.1.0", "-m", "v0.1.0"], + cwd=repo, check=True) + monkeypatch.chdir(repo) + + assert previous_tag() == "v0.1.0" + + +def test_sync_lockfile_locks_then_verifies(): + runner = FakeRunner() + + sync_lockfile(runner=runner) + + assert runner.commands == [["uv", "lock"], ["uv", "lock", "--check"]] + + +def test_changelog_range_is_empty_without_a_previous_tag(): + assert changelog_range(None) == [] + + +def test_changelog_range_spans_from_previous_tag_to_head(): + assert changelog_range("v0.1.0") == ["v0.1.0..HEAD"] +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `uv run --extra dev pytest tests/test_release.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'scripts.release'` + +- [ ] **Step 3: Write `scripts/release.py`** + +`sync_lockfile` is the piece the reference repo's `release.sh` is missing. `uv.lock` pins the project's own version, and CI runs `uv run --locked`, so a release that bumps only `pyproject.toml` breaks every workflow on the release commit. + +```python +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause + +"""Prepare a release: bump the version, changelog, commit, and tag. + +The release workflow at ``.github/workflows/release.yaml`` picks up the +pushed tag and publishes a GitHub Release with the matching CHANGELOG +section. + +Usage: + scripts/release.py X.Y.Z # bump + commit + tag (no push) + scripts/release.py X.Y.Z --push # also push branch and tag + scripts/release.py X.Y.Z --dry-run # show what would happen + scripts/release.py X.Y.Z --no-tag # commit only, skip the tag +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from pathlib import Path +from typing import Callable + +REPO_ROOT = Path(__file__).resolve().parents[1] +CONFIG = REPO_ROOT / "cliff.toml" +VERSION_PATTERN = re.compile(r"^\d+\.\d+\.\d+(?:[.-][0-9A-Za-z.-]+)?$") +VERSION_LINE = re.compile(r'^version\s*=\s*"[^"]*"', re.MULTILINE) + +Runner = Callable[..., subprocess.CompletedProcess] + + +def run( + command: list[str], + *, + check: bool = True, + capture: bool = False, +) -> subprocess.CompletedProcess: + """Run a command, optionally capturing its output. + + Args: + command: The command and its arguments. + check: Raise on a non-zero exit status. + capture: Capture stdout and stderr as text. + + Returns: + The completed process. + """ + return subprocess.run( + command, + check=check, + capture_output=capture, + text=True, + ) + + +def parse_args() -> argparse.Namespace: + """Parse command-line arguments.""" + parser = argparse.ArgumentParser() + parser.add_argument("version", help="Release version, as X.Y.Z.") + parser.add_argument( + "--push", + action="store_true", + help="Push the release commit and tag to origin.", + ) + parser.add_argument( + "--no-tag", + action="store_true", + help="Create the release commit without an annotated tag.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print the planned steps without modifying anything.", + ) + return parser.parse_args() + + +def validate_version(version: str) -> str: + """Validate a release version string. + + Args: + version: Candidate version. + + Returns: + The version unchanged. + + Raises: + ValueError: If the version is not of the form X.Y.Z. + """ + if not VERSION_PATTERN.fullmatch(version): + raise ValueError( + f"'{version}' is not a valid version (expected X.Y.Z)." + ) + return version + + +def tag_exists(tag: str, *, runner: Runner = run) -> bool: + """Return whether a git tag already exists. + + Args: + tag: Tag name to look for. + runner: Command runner, injectable for tests. + + Returns: + True if the tag resolves. + """ + result = runner( + ["git", "rev-parse", "--verify", f"refs/tags/{tag}"], + check=False, + capture=True, + ) + return result.returncode == 0 + + +def working_tree_dirty(*, runner: Runner = run) -> bool: + """Return whether the working tree has uncommitted changes. + + Args: + runner: Command runner, injectable for tests. + + Returns: + True if tracked files differ from HEAD. + """ + result = runner( + ["git", "status", "--porcelain", "--untracked-files=no"], + capture=True, + ) + return bool(result.stdout.strip()) + + +def previous_tag(*, runner: Runner = run) -> str | None: + """Return the most recent version tag reachable from HEAD. + + Tags on unrelated branches are skipped automatically. + + Args: + runner: Command runner, injectable for tests. + + Returns: + The tag name, or None when no version tag is reachable. + """ + result = runner( + [ + "git", + "describe", + "--tags", + "--abbrev=0", + "--match=v[0-9]*", + "HEAD", + ], + check=False, + capture=True, + ) + if result.returncode != 0: + return None + return result.stdout.strip() or None + + +def changelog_range(prev: str | None) -> list[str]: + """Return the git-cliff commit range arguments. + + Args: + prev: The previous release tag, if any. + + Returns: + A single-element range list, or an empty list for full history. + """ + if prev is None: + return [] + return [f"{prev}..HEAD"] + + +def bump_pyproject(path: Path, version: str) -> None: + """Rewrite the project version in pyproject.toml. + + Args: + path: Path to pyproject.toml. + version: New version string. + + Raises: + ValueError: If no version line is present. + """ + content = path.read_text(encoding="utf-8") + updated, count = VERSION_LINE.subn( + f'version = "{version}"', + content, + count=1, + ) + if count == 0: + raise ValueError(f"No version line found in {path}.") + path.write_text(updated, encoding="utf-8") + + +def sync_lockfile(*, runner: Runner = run) -> None: + """Refresh uv.lock for the new version and verify it is in sync. + + uv.lock pins the project's own version, and CI runs with ``--locked``, + so the lockfile must be regenerated alongside pyproject.toml. + + Args: + runner: Command runner, injectable for tests. + """ + runner(["uv", "lock"]) + runner(["uv", "lock", "--check"]) + + +def generate_changelog(tag: str, prev: str | None) -> None: + """Prepend the new release section to CHANGELOG.md. + + Args: + tag: The release tag, including the leading 'v'. + prev: The previous release tag, if any. + """ + run( + [ + sys.executable, + str(REPO_ROOT / "scripts" / "gen_changelog.py"), + "--tag", + tag, + *changelog_range(prev), + "--prepend", + "CHANGELOG.md", + ] + ) + + +def main() -> int: + """Run the release preparation process.""" + args = parse_args() + version = validate_version(args.version) + tag = f"v{version}" + + if tag_exists(tag): + raise SystemExit(f"error: tag {tag} already exists") + + prev = previous_tag() + if prev is None: + print( + "warning: no previous tag reachable; the changelog will cover " + "all history", + file=sys.stderr, + ) + else: + print(f"Previous reachable tag: {prev}") + + if args.dry_run: + print(f"[dry-run] bump pyproject.toml to {version}") + print("[dry-run] uv lock && uv lock --check") + print( + "[dry-run] prepend CHANGELOG.md " + f"({prev + '..HEAD' if prev else 'full history'})" + ) + print(f"[dry-run] commit: chore(release): {tag}") + if not args.no_tag: + print(f"[dry-run] create annotated tag {tag}") + if args.push: + print("[dry-run] push branch and tag") + return 0 + + if working_tree_dirty(): + raise SystemExit( + "error: working tree has uncommitted changes; commit or stash " + "them first" + ) + + print(f"Preparing release: {tag}") + bump_pyproject(REPO_ROOT / "pyproject.toml", version) + sync_lockfile() + generate_changelog(tag, prev) + + run(["git", "add", "pyproject.toml", "uv.lock", "CHANGELOG.md"]) + run(["git", "commit", "-m", f"chore(release): {tag}"]) + + if not args.no_tag: + run(["git", "tag", "-a", tag, "-m", tag]) + + if args.push: + run(["git", "push", "origin", "HEAD", "--follow-tags"]) + print(f"\nPushed {tag}. The release workflow will publish it.") + else: + print(f"\nCreated commit and tag {tag} locally.") + print("Push with: git push origin HEAD --follow-tags") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `uv run --extra dev pytest tests/test_release.py -v` +Expected: PASS, 19 tests (the two parametrized cases contribute 4 and 5). + +- [ ] **Step 5: Add the tox env** + +Add to `tox.ini` after `[testenv:changelog]`. Again, **not** in `env_list`. + +```ini +[testenv:release] +description = prepare a release commit and tag +runner = uv-venv-lock-runner +extras = dev +allowlist_externals = + git + uv +commands = + {envpython} scripts/release.py {posargs} +``` + +- [ ] **Step 6: Verify the dry run against the real repository** + +Run: `uv run --extra dev tox run -e release -- 0.1.0 --dry-run` + +Expected: the warning about no reachable tag (this repo has no `v*` tags yet), then the five `[dry-run]` lines. Confirm nothing changed: + +Run: `git status --porcelain` +Expected: no `pyproject.toml`, `uv.lock`, or `CHANGELOG.md` entries. + +Also confirm the guard works: + +Run: `uv run --extra dev tox run -e release -- 1.2 --dry-run` +Expected: FAIL with `'1.2' is not a valid version (expected X.Y.Z).` + +- [ ] **Step 7: Document the env** + +Add to `README.md` and `CONTRIBUTING.md`, after the changelog block from Task 2: + +````markdown +Prepare a release — bumps the version, regenerates the changelog, commits, +and tags: + +```bash +uv run tox -e release -- 0.1.0 +uv run tox -e release -- 0.1.0 --push +``` + +Pushing the tag triggers the `Release` workflow, which builds the +distributions and publishes a GitHub Release. +```` + +- [ ] **Step 8: Verify and commit** + +Run: `uv run --extra dev pre-commit run --all-files` +Expected: PASS. + +Run: `uv run --extra dev pytest tests/ -q` +Expected: PASS. + +```bash +git add scripts/release.py tests/test_release.py tox.ini \ + README.md CONTRIBUTING.md +git commit -m "feat(release): add release preparation script" +``` + +--- + +### Task 4: Release workflow + +**Files:** +- Create: `.github/workflows/release.yaml` + +`README.md` needs no change here: it documents tox envs and the repository +layout, but has no per-workflow list to extend. The release *command* is +documented in Task 3, Step 7. + +**Interfaces:** +- Consumes: `cliff.toml` (Task 2), the `chore(release): vX.Y.Z` commit and `vX.Y.Z` tag convention (Task 3), the existing `./.github/actions/setup-python-uv` composite action, and the existing `[testenv:build]` env which runs `scripts/validate_distribution.py`. +- Produces: `.github/workflows/release.yaml`, which Task 5 adds to `WORKFLOW_FILES`. + +- [ ] **Step 1: Create the workflow** + +Two deliberate improvements over the reference: step 3 runs `tox -e build` rather than a bare `uv build`, reusing the existing `validate_distribution.py` so the published wheel is import-smoke-tested; and step 4 derives notes from git via `git-cliff --current` rather than scraping `CHANGELOG.md` with `awk`, which cannot drift from a mis-parsed heading. + +`fetch-depth: 0` is required — git-cliff needs full history and tags. + +```yaml +name: Release + +on: + push: + tags: + - v* + +permissions: + contents: read + +env: + PYTHON_VERSION: '3.10' + +jobs: + release: + name: Publish GitHub Release + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - uses: ./.github/actions/setup-python-uv + with: + python-version: ${{ env.PYTHON_VERSION }} + # tomllib is 3.11+, and PYTHON_VERSION is 3.10 to match this template's + # floor, so the version is read with a regex instead. The pattern mirrors + # VERSION_LINE in scripts/release.py, which writes this same line. + - name: Verify tag matches the project version + run: | + tag_version="${GITHUB_REF_NAME#v}" + project_version=$(uv run --locked python -c \ + "import pathlib, re; print(re.search(r'(?m)^version\s*=\s*\"([^\"]+)\"', pathlib.Path('pyproject.toml').read_text()).group(1))") + if [ "$tag_version" != "$project_version" ]; then + echo "::error::Tag $GITHUB_REF_NAME does not match pyproject.toml version $project_version" + exit 1 + fi + - name: Build and verify package artifacts + run: uv run --locked --extra dev tox run -e build + - name: Extract release notes + run: | + uv run --locked --extra dev python scripts/gen_changelog.py \ + --current --strip header -o release-notes.md + if [ ! -s release-notes.md ]; then + echo "::error::No changelog content found for $GITHUB_REF_NAME" + exit 1 + fi + cat release-notes.md + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + name: ${{ github.ref_name }} + body_path: release-notes.md + files: | + dist/*.whl + dist/*.tar.gz + draft: false + prerelease: false + + # Publishing to PyPI is opt-in. To enable it: + # 1. Create a PyPI Trusted Publisher for this repository, pointing at + # workflow `release.yaml` and environment `pypi`. + # See https://docs.pypi.org/trusted-publishers/ + # 2. Create a GitHub environment named `pypi`. + # 3. Uncomment the job below. + # No API token is needed; authentication uses OIDC. + # + # publish: + # name: Publish to PyPI + # needs: release + # runs-on: ubuntu-latest + # timeout-minutes: 15 + # environment: pypi + # permissions: + # id-token: write + # steps: + # - uses: actions/checkout@v6 + # - uses: ./.github/actions/setup-python-uv + # with: + # python-version: ${{ env.PYTHON_VERSION }} + # - name: Build distributions + # run: uv run --locked --extra dev tox run -e build + # - name: Publish + # uses: pypa/gh-action-pypi-publish@release/v1 +``` + +- [ ] **Step 2: Verify the workflow parses and matches repo conventions** + +Run: `uv run --extra dev pre-commit run --all-files --files .github/workflows/release.yaml` + +Expected: PASS. `check-yaml` validates syntax and `pretty-format-yaml` enforces 2-space indentation — if it reformats the file, accept its output and re-run. + +Confirm the referenced tox env and composite action exist: + +Run: `uv run --extra dev tox list | grep build && ls .github/actions/setup-python-uv/action.yaml` +Expected: the `build` env is listed and the action file exists. + +- [ ] **Step 3: Verify the release-notes command works locally** + +This is the one workflow step that can be exercised without pushing a tag. `--current` needs a tag to resolve, so create a throwaway one, then delete it. + +```bash +git tag -a v0.0.1-test -m v0.0.1-test +uv run --extra dev python scripts/gen_changelog.py --current --strip header +git tag -d v0.0.1-test +``` + +Expected: a `## 0.0.1-test - ` section with grouped bullets and no `# Changelog` header line. Confirm the tag is gone afterwards with `git tag -l`. + +- [ ] **Step 4: Commit** + +```bash +git add .github/workflows/release.yaml +git commit -m "ci(release): publish GitHub Releases from version tags" +``` + +--- + +### Task 5: Template bootstrap integration + +**Files:** +- Modify: `scripts/bootstrap_template.py` (placeholder constants, `WORKFLOW_FILES`, `PACKAGE_FILES`, new `update_spdx_copyright`, `main`) +- Test: `tests/test_bootstrap_template.py` (extend) + +**Interfaces:** +- Consumes: `REUSE.toml` and the header format (Task 1), `cliff.toml` and `scripts/gen_changelog.py` (Task 2), `scripts/release.py` (Task 3), `.github/workflows/release.yaml` (Task 4). +- Produces: `PLACEHOLDER_COPYRIGHT` and `update_spdx_copyright(paths, *, project_title, dry_run)`, relied on by Task 6's smoke assertions. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_bootstrap_template.py`. Import `update_spdx_copyright` and `PLACEHOLDER_COPYRIGHT` by extending the existing `from scripts.bootstrap_template import (...)` block at the top of the file. + +```python +SPDX_HEADER = ( + "# SPDX-FileCopyrightText: 2026 the Python Template contributors\n" + "#\n" + "# SPDX-License-Identifier: BSD-2-Clause\n" + "\n" + '"""Module."""\n' +) +REUSE_TOML = ( + "version = 1\n" + "\n" + "[[annotations]]\n" + 'path = ["README.md"]\n' + 'precedence = "aggregate"\n' + 'SPDX-FileCopyrightText = "2026 the Python Template contributors"\n' + 'SPDX-License-Identifier = "BSD-2-Clause"\n' +) + + +def test_update_spdx_copyright_rewrites_inline_headers(tmp_path): + module = tmp_path / "mod.py" + module.write_text(SPDX_HEADER, encoding="utf-8") + + update_spdx_copyright( + [module], + project_title="Acme Tool", + dry_run=False, + ) + + content = module.read_text(encoding="utf-8") + assert "2026 the Acme Tool contributors" in content + assert PLACEHOLDER_COPYRIGHT not in content + + +def test_update_spdx_copyright_rewrites_reuse_toml(tmp_path): + reuse_toml = tmp_path / "REUSE.toml" + reuse_toml.write_text(REUSE_TOML, encoding="utf-8") + + update_spdx_copyright( + [reuse_toml], + project_title="Acme Tool", + dry_run=False, + ) + + content = reuse_toml.read_text(encoding="utf-8") + assert 'SPDX-FileCopyrightText = "2026 the Acme Tool contributors"' in content + + +def test_update_spdx_copyright_preserves_license_identifier(tmp_path): + module = tmp_path / "mod.py" + module.write_text(SPDX_HEADER, encoding="utf-8") + + update_spdx_copyright( + [module], + project_title="Acme Tool", + dry_run=False, + ) + + content = module.read_text(encoding="utf-8") + assert "# SPDX-License-Identifier: BSD-2-Clause" in content + + +def test_update_spdx_copyright_dry_run_leaves_files_untouched(tmp_path): + module = tmp_path / "mod.py" + module.write_text(SPDX_HEADER, encoding="utf-8") + + update_spdx_copyright( + [module], + project_title="Acme Tool", + dry_run=True, + ) + + assert module.read_text(encoding="utf-8") == SPDX_HEADER + + +def test_update_spdx_copyright_ignores_missing_files(tmp_path): + update_spdx_copyright( + [tmp_path / "absent.py"], + project_title="Acme Tool", + dry_run=False, + ) + + +def test_release_workflow_is_a_bootstrap_target(): + from scripts.bootstrap_template import WORKFLOW_FILES + + assert Path(".github/workflows/release.yaml") in WORKFLOW_FILES + + +def test_spdx_files_cover_the_new_automation_scripts(): + from scripts.bootstrap_template import SPDX_FILES + + for path in ( + Path("REUSE.toml"), + Path("scripts/release.py"), + Path("scripts/gen_changelog.py"), + Path("scripts/add_spdx_headers.py"), + ): + assert path in SPDX_FILES +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `uv run --extra dev pytest tests/test_bootstrap_template.py -v -k "spdx or workflow_is_a_bootstrap or automation_files"` +Expected: FAIL — `ImportError: cannot import name 'update_spdx_copyright'` + +- [ ] **Step 3: Add the placeholder constant** + +In `scripts/bootstrap_template.py`, after `PLACEHOLDER_DESCRIPTION`: + +```python +PLACEHOLDER_COPYRIGHT = "the Python Template contributors" +``` + +- [ ] **Step 4: Register the new files** + +Add `Path(".github/workflows/release.yaml")` to `WORKFLOW_FILES`, keeping alphabetical order (after `quality.yaml`). It feeds both `PACKAGE_FILES` and `REPOSITORY_FILES` via the existing splat. + +Do **not** add `cliff.toml`, `REUSE.toml`, or the three new scripts to `PACKAGE_FILES`. `PACKAGE_FILES` exists to substitute the literal strings `project_name` and `python-template`, and none of those files contain either — registering them there would be a no-op that misleads the next reader. Their only template-varying content is the SPDX copyright holder, handled by `SPDX_FILES` below. + +Leave `PYTHON_VERSION_WORKFLOW_FILES` unchanged — `release.yaml` has a `PYTHON_VERSION` env var but no version matrix, and `update_ci_workflow` would be a no-op beyond that single substitution. This means the release workflow keeps `PYTHON_VERSION: '3.10'` even when a generated project raises its minimum; that is harmless because the release build is version-independent, and 3.10 is deliberate given the `tomllib` constraint noted in Task 4. + +Add a module-level tuple listing every file carrying the copyright string, after `REPOSITORY_FILES`: + +```python +SPDX_FILES = ( + Path("REUSE.toml"), + Path("docs/conf.py"), + Path("examples/say_hi.py"), + Path("scripts/add_spdx_headers.py"), + Path("scripts/bootstrap_template.py"), + Path("scripts/gen_changelog.py"), + Path("scripts/release.py"), + Path("scripts/update_coverage_readme.py"), + Path("scripts/validate_distribution.py"), + Path("tests/test_add_spdx_headers.py"), + Path("tests/test_bootstrap_template.py"), + Path("tests/test_greeter.py"), + Path("tests/test_release.py"), + Path("tests/test_template_smoke.py"), +) +``` + +The package's own `.py` files are handled separately in Step 6, because the package directory gets renamed. + +- [ ] **Step 5: Write `update_spdx_copyright`** + +Add after `update_readme`: + +```python +def update_spdx_copyright( + paths: list[Path], + *, + project_title: str, + dry_run: bool, +) -> None: + """Rewrite the SPDX copyright holder in the given files. + + Args: + paths: Files that may contain the placeholder copyright holder. + project_title: Human-readable project title. + dry_run: Print planned changes without writing them. + """ + holder = f"the {project_title} contributors" + for path in paths: + replace_text( + path, + {PLACEHOLDER_COPYRIGHT: holder}, + dry_run=dry_run, + ) +``` + +`replace_text` already skips missing files and prints `updated `, which satisfies the missing-file test. + +- [ ] **Step 6: Call it from `main`** + +Insert immediately **before** `rename_package_dir(...)` at the end of `main()`, so the package files are still at their placeholder path when rewritten: + +```python + update_spdx_copyright( + [ + *SPDX_FILES, + *sorted(PACKAGE_DIR.glob("*.py")), + ], + project_title=project_title, + dry_run=args.dry_run, + ) + rename_package_dir(package_name, dry_run=args.dry_run) +``` + +- [ ] **Step 7: Run the tests to verify they pass** + +Run: `uv run --extra dev pytest tests/test_bootstrap_template.py -v` +Expected: PASS, including the pre-existing tests. + +- [ ] **Step 8: Verify bootstrap end-to-end with a dry run** + +Run: `uv run --extra dev python scripts/bootstrap_template.py acme-tool --dry-run` + +Expected: an `updated ` line for each entry in `SPDX_FILES` plus each `project_name/*.py`, alongside the pre-existing output. `cliff.toml` will **not** appear — it carries no copyright header (it is covered by the `REUSE.toml` bulk annotation) and no `project_name` placeholder. `.github/workflows/release.yaml` will also not appear, for the same reason: it is in `WORKFLOW_FILES` for consistency but contains neither placeholder string. Confirm nothing was written: + +Run: `git status --porcelain` +Expected: no modifications. + +- [ ] **Step 9: Verify and commit** + +Run: `uv run --extra dev pre-commit run --all-files` +Expected: PASS. + +```bash +git add scripts/bootstrap_template.py tests/test_bootstrap_template.py +git commit -m "feat(bootstrap): substitute SPDX and release metadata" +``` + +--- + +### Task 6: Template smoke test coverage + +**Files:** +- Modify: `tests/test_template_smoke.py:22-42` (`PLACEHOLDER_CHECK_PATHS`) and `tests/test_template_smoke.py:70-121` (the single smoke test) + +**Interfaces:** +- Consumes: everything from Tasks 1-5, exercised through the real `bootstrap_template.py` run the smoke test already performs. +- Produces: nothing consumed downstream. + +The smoke test is a single test — `test_generated_project_bootstraps_and_builds(tmp_path)` at line 70 — that copytrees the repo, runs `bootstrap_template.py demo-service`, syncs, builds, and finally loops over the `PLACEHOLDER_CHECK_PATHS` tuple (line 22) asserting no leftover placeholders. Extend those two existing structures; do not add a second harness or invent a fixture. + +Because bootstrap is invoked with `demo-service` and no `--project-title`, `resolve_metadata` derives the title `Demo Service`, so the expected copyright holder is exactly `the Demo Service contributors`. + +- [ ] **Step 1: Extend `PLACEHOLDER_CHECK_PATHS`** + +Add these six entries, preserving the tuple's existing alphabetical grouping (workflow paths first, then root and subdirectory files): + +```python + ".github/workflows/release.yaml", + "REUSE.toml", + "cliff.toml", + "scripts/add_spdx_headers.py", + "scripts/gen_changelog.py", + "scripts/release.py", +``` + +- [ ] **Step 2: Add the SPDX assertions to the existing test** + +Append to the end of `test_generated_project_bootstraps_and_builds`, immediately after the existing `for relative_path in PLACEHOLDER_CHECK_PATHS:` loop: + +```python + reuse_toml = (generated_repo / "REUSE.toml").read_text(encoding="utf-8") + assert "the Demo Service contributors" in reuse_toml + assert "the Python Template contributors" not in reuse_toml + + package_modules = sorted( + path + for path in (generated_repo / "demo_service").glob("*.py") + if path.stat().st_size > 0 + ) + assert package_modules + + for module in package_modules: + content = module.read_text(encoding="utf-8") + assert "SPDX-License-Identifier: BSD-2-Clause" in content, str(module) + assert "the Demo Service contributors" in content, str(module) + assert "the Python Template contributors" not in content, str(module) +``` + +- [ ] **Step 3: Run the smoke test** + +The suite is gated behind an environment variable. + +Run: `RUN_TEMPLATE_SMOKE=1 uv run --extra dev --extra docs pytest tests/test_template_smoke.py -v` + +Expected: PASS. If the copyright assertions fail, a path is missing from `SPDX_FILES` in Task 5 — add it there rather than weakening the test. If `uv sync --locked` fails, `uv.lock` was not committed after adding `git-cliff` in Task 2. + +- [ ] **Step 4: Run it the way CI does** + +Run: `uv run --locked --extra dev --extra docs tox run -e template` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add tests/test_template_smoke.py +git commit -m "test(template): assert release and SPDX bootstrap substitution" +``` + +--- + +### Task 7: Full verification + +**Files:** none modified unless a check fails. + +**Interfaces:** +- Consumes: Tasks 1-6. +- Produces: nothing. + +- [ ] **Step 1: Run every quality gate CI runs** + +Run each and confirm PASS: + +```bash +uv run --locked --extra dev pre-commit run --all-files +uv run --locked --extra dev tox run -e type +uv run --locked --extra dev tox run -e coverage +uv run --locked --extra dev tox run -e build +uv lock --check +``` + +`coverage` enforces `fail_under = 90` on the `project_name` package only, so the new scripts do not affect it. + +- [ ] **Step 2: Confirm the release and changelog envs stayed out of the default set** + +Run: `uv run --extra dev tox list --no-desc` + +Expected: `changelog` and `release` appear in the output, but under the "additional environments" section rather than the default list. Cross-check by confirming `env_list` in `tox.ini` contains neither name. + +- [ ] **Step 3: Confirm the changelog covers the release commits themselves** + +Run: `uv run --extra dev tox run -e changelog -- --tag v0.1.0` + +Expected: every commit from this plan appears under the group its type maps to — the three `feat:` commits under `Features`, the `ci:` commit under `Build & CI`, the `test:` commit under `Testing`, and the `docs:` spec and plan commits under `Documentation`. + +- [ ] **Step 4: Report results** + +Report the actual command output for each gate. If any failed, say so with the output rather than describing the work as complete. diff --git a/docs/superpowers/specs/2026-07-29-release-changelog-spdx-design.md b/docs/superpowers/specs/2026-07-29-release-changelog-spdx-design.md index 3e552b6..14a3cf2 100644 --- a/docs/superpowers/specs/2026-07-29-release-changelog-spdx-design.md +++ b/docs/superpowers/specs/2026-07-29-release-changelog-spdx-design.md @@ -155,9 +155,26 @@ tool caches that belong in `.gitignore` regardless (`.mypy_cache` is a leftover from before the pyrefly migration in `0383620`). Adding both entries is part of this work — it is required for the hook to pass, not unrelated cleanup. -To verify during implementation: whether `reuse lint` ignores the root -`LICENSE` once `LICENSES/BSD-2-Clause.txt` exists. If it does not, add a -`REUSE.toml` entry for it. +Verified against `reuse` 6.2.0 in a scratch repository: `reuse lint` ignores +the root `LICENSE`, the `LICENSES/` directory, `REUSE.toml` itself, and +zero-byte files. The root `LICENSE` therefore needs **no** `REUSE.toml` entry, +and the zero-byte skip above is confirmed behavior rather than an assumption. + +`reuse annotate` writes this exact three-line form, preserving any shebang +above it: + +```python +#!/usr/bin/env python3 + +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause +``` + +Note the `#` separator line. The reference repository uses a compact two-line +header, which an older `reuse` produced. This design keeps the tool's native +output rather than post-processing it, so headers stay byte-identical to what +`reuse annotate` regenerates. The difference is cosmetic. ## 2. Changelog layer @@ -269,7 +286,12 @@ so does not require a clean tree. `[testenv:changelog]` and `[testenv:release]`, both with `runner = uv-venv-lock-runner`, `extras = dev`, and -`allowlist_externals = git, uv`. Appended to the tail of `env_list`. +`allowlist_externals = git, uv`. + +Both are deliberately kept **out of** `env_list`. Every CI workflow invokes +tox as `tox run -e `, but a developer running bare `uv run tox` locally +executes everything in `env_list` — which would fire a release attempt. Envs +outside `env_list` remain runnable via `tox run -e release`. ``` uv run tox run -e changelog From 8100d77bc076a19bda4cc77069cec4fdd9f052f9 Mon Sep 17 00:00:00 2001 From: Sebastiano Date: Wed, 29 Jul 2026 15:20:34 +0200 Subject: [PATCH 03/20] docs(plans): require new scripts to be committed executable The shebang hook checks the file mode in the git index, so the new scripts need `git add --chmod=+x`. --- .../plans/2026-07-29-release-changelog-spdx.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/plans/2026-07-29-release-changelog-spdx.md b/docs/superpowers/plans/2026-07-29-release-changelog-spdx.md index 4e32bcb..111a003 100644 --- a/docs/superpowers/plans/2026-07-29-release-changelog-spdx.md +++ b/docs/superpowers/plans/2026-07-29-release-changelog-spdx.md @@ -18,6 +18,7 @@ Every task's requirements implicitly include this section. - Ruff `line-length = 80`, `target-version = "py310"`. Ruff lint selects `D` (pydocstyle, Google convention) — **every module, function, and class outside `tests/` needs a docstring**. `tests/**/*.py` has `D` ignored. - Ruff format uses `quote-style = "double"`. - New scripts go in `scripts/`, are Python (not shell), start with `#!/usr/bin/env python3`, use `from __future__ import annotations`, and end with `raise SystemExit(main())`. Follow the existing style of `scripts/validate_distribution.py` and `scripts/bootstrap_template.py`. +- **Every new script in `scripts/` must be committed executable:** `chmod +x scripts/.py` before `git add`. The `check-shebang-scripts-are-executable` pre-commit hook fails a shebang'd file that is not executable, and it reads the *git index* mode — so `git add --chmod=+x scripts/.py` is the reliable form. All three existing `scripts/*.py` are mode `100755`. (`examples/say_hi.py` is `100644` because it has no shebang; do not add one.) - All new `.py` files must carry the SPDX header shown in Task 1 verbatim, placed after any shebang and before the module docstring. - `requires-python = ">=3.10,<4"`. `reuse` requires >=3.10, which matches. - CI invokes tox only as `tox run -e `. **Do not add `changelog` or `release` to `env_list`** — bare `uv run tox` would otherwise fire a release attempt. @@ -360,12 +361,16 @@ Expected: PASS. - [ ] **Step 11: Commit** ```bash -git add LICENSES REUSE.toml scripts/add_spdx_headers.py \ - tests/test_add_spdx_headers.py .pre-commit-config.yaml .gitignore \ +chmod +x scripts/add_spdx_headers.py +git add --chmod=+x scripts/add_spdx_headers.py +git add LICENSES REUSE.toml tests/test_add_spdx_headers.py \ + .pre-commit-config.yaml .gitignore \ project_name tests examples scripts docs/conf.py git commit -m "feat(license): auto-insert and verify SPDX headers" ``` +Before committing, confirm no unrelated file-mode changes are staged — `git diff --cached --summary` should show `mode change` only for files this task intentionally touched. + --- ### Task 2: Changelog layer @@ -590,7 +595,9 @@ Run: `uv run --extra dev pre-commit run --all-files` Expected: PASS. `cliff.toml` and `CHANGELOG.md` are both covered by `REUSE.toml`, so `reuse` must stay green. ```bash -git add cliff.toml CHANGELOG.md scripts/gen_changelog.py pyproject.toml \ +chmod +x scripts/gen_changelog.py +git add --chmod=+x scripts/gen_changelog.py +git add cliff.toml CHANGELOG.md pyproject.toml \ uv.lock tox.ini README.md CONTRIBUTING.md git commit -m "feat(changelog): generate CHANGELOG.md with git-cliff" ``` @@ -1129,8 +1136,9 @@ Run: `uv run --extra dev pytest tests/ -q` Expected: PASS. ```bash -git add scripts/release.py tests/test_release.py tox.ini \ - README.md CONTRIBUTING.md +chmod +x scripts/release.py +git add --chmod=+x scripts/release.py +git add tests/test_release.py tox.ini README.md CONTRIBUTING.md git commit -m "feat(release): add release preparation script" ``` From 85c3e3b088b381331ffa4e78283a7270d7a00f23 Mon Sep 17 00:00:00 2001 From: Sebastiano Date: Wed, 29 Jul 2026 15:31:12 +0200 Subject: [PATCH 04/20] feat(license): auto-insert and verify SPDX headers A pre-commit hook writes the SPDX header into Python files that are missing one, and REUSE.toml covers everything else in bulk. A second hook fails the commit if anything is left unlicensed. --- .gitignore | 2 + .pre-commit-config.yaml | 15 ++++ LICENSES/BSD-2-Clause.txt | 24 ++++++ REUSE.toml | 29 +++++++ docs/conf.py | 4 + examples/say_hi.py | 4 + project_name/__init__.py | 4 + project_name/greeter.py | 4 + scripts/add_spdx_headers.py | 122 ++++++++++++++++++++++++++++++ scripts/bootstrap_template.py | 5 ++ scripts/update_coverage_readme.py | 5 ++ scripts/validate_distribution.py | 5 ++ tests/test_add_spdx_headers.py | 58 ++++++++++++++ tests/test_bootstrap_template.py | 4 + tests/test_greeter.py | 4 + tests/test_template_smoke.py | 4 + 16 files changed, 293 insertions(+) create mode 100644 LICENSES/BSD-2-Clause.txt create mode 100644 REUSE.toml create mode 100755 scripts/add_spdx_headers.py create mode 100644 tests/test_add_spdx_headers.py diff --git a/.gitignore b/.gitignore index d311a56..43d2d4d 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,8 @@ coverage.xml *.py,cover .hypothesis/ .pytest_cache/ +.ruff_cache/ +.mypy_cache/ cover/ # Translations diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 848f1fa..51d75c7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -58,6 +58,21 @@ repos: - id: ruff-format files: ^((project_name|tests|examples)/.+)?[^/]+\.(py|pyi)$ + # SPDX headers: insert missing ones, then verify REUSE compliance +- repo: local + hooks: + - id: reuse-annotate + name: add SPDX headers + entry: python scripts/add_spdx_headers.py + language: python + additional_dependencies: ['reuse>=6.2,<7'] + types: [python] + +- repo: https://github.com/fsfe/reuse-tool + rev: v6.2.0 + hooks: + - id: reuse + # Global file exclusions exclude: | diff --git a/LICENSES/BSD-2-Clause.txt b/LICENSES/BSD-2-Clause.txt new file mode 100644 index 0000000..fdddb29 --- /dev/null +++ b/LICENSES/BSD-2-Clause.txt @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/REUSE.toml b/REUSE.toml new file mode 100644 index 0000000..c392208 --- /dev/null +++ b/REUSE.toml @@ -0,0 +1,29 @@ +version = 1 + +[[annotations]] +path = [ + ".claude/**", + ".devcontainer/**", + ".env", + ".github/**", + ".gitignore", + ".pre-commit-config.yaml", + ".pylintrc", + ".readthedocs.yaml", + "AGENTS.md", + "CHANGELOG.md", + "CLAUDE.md", + "CONTRIBUTING.md", + "Dockerfile", + "LICENSES/**", + "README.md", + "cliff.toml", + "docs/*.md", + "docs/superpowers/**", + "pyproject.toml", + "tox.ini", + "uv.lock", +] +precedence = "aggregate" +SPDX-FileCopyrightText = "2026 the Python Template contributors" +SPDX-License-Identifier = "BSD-2-Clause" diff --git a/docs/conf.py b/docs/conf.py index 9eaece9..6cd2f74 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause + """Sphinx configuration for the project documentation.""" from __future__ import annotations diff --git a/examples/say_hi.py b/examples/say_hi.py index 1f8b625..478ff67 100644 --- a/examples/say_hi.py +++ b/examples/say_hi.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause + from project_name.greeter import Greeter if __name__ == "__main__": diff --git a/project_name/__init__.py b/project_name/__init__.py index 6f02084..045a84e 100644 --- a/project_name/__init__.py +++ b/project_name/__init__.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause + """Public package interface for the template project.""" from project_name.greeter import Greeter, Language, get_language diff --git a/project_name/greeter.py b/project_name/greeter.py index 7d01f92..44ba165 100644 --- a/project_name/greeter.py +++ b/project_name/greeter.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause + import logging from enum import Enum from typing import ClassVar diff --git a/scripts/add_spdx_headers.py b/scripts/add_spdx_headers.py new file mode 100755 index 0000000..c64ff44 --- /dev/null +++ b/scripts/add_spdx_headers.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause + +"""Insert SPDX licensing headers into Python files that lack them.""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from datetime import date +from pathlib import Path + +COPYRIGHT_HOLDER = "the Python Template contributors" +LICENSE_ID = "BSD-2-Clause" + + +def parse_args() -> argparse.Namespace: + """Parse command-line arguments.""" + parser = argparse.ArgumentParser() + parser.add_argument( + "paths", + nargs="*", + help="Files to annotate. Supplied by pre-commit.", + ) + parser.add_argument( + "--year", + default=str(date.today().year), + help="Copyright year to write. Defaults to the current year.", + ) + parser.add_argument( + "--copyright", + default=COPYRIGHT_HOLDER, + dest="copyright_holder", + help="Copyright holder to write into the header.", + ) + parser.add_argument( + "--license", + default=LICENSE_ID, + dest="license_id", + help="SPDX license identifier to write into the header.", + ) + return parser.parse_args() + + +def annotatable(paths: list[str]) -> list[str]: + """Return the paths reuse can annotate. + + Zero-byte files are skipped because ``reuse lint`` ignores them, so + annotating them would add content to intentionally empty files without + improving compliance. + + Args: + paths: Candidate file paths. + + Returns: + The subset of paths that are non-empty regular files. + """ + keep = [] + for raw in paths: + path = Path(raw) + if path.is_file() and path.stat().st_size > 0: + keep.append(raw) + return keep + + +def build_command( + paths: list[str], + *, + year: str, + copyright_holder: str, + license_id: str, +) -> list[str]: + """Build the ``reuse annotate`` command for the given paths. + + Args: + paths: Files to annotate. + year: Copyright year to write. + copyright_holder: Copyright holder to write. + license_id: SPDX license identifier to write. + + Returns: + The command as an argument list. + """ + return [ + sys.executable, + "-m", + "reuse", + "annotate", + "--skip-existing", + "--merge-copyrights", + "--skip-unrecognised", + "--year", + year, + "--copyright", + copyright_holder, + "--license", + license_id, + *paths, + ] + + +def main() -> int: + """Annotate the requested files and return the reuse exit status.""" + args = parse_args() + paths = annotatable(args.paths) + if not paths: + return 0 + + command = build_command( + paths, + year=args.year, + copyright_holder=args.copyright_holder, + license_id=args.license_id, + ) + return subprocess.run(command, check=False).returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/bootstrap_template.py b/scripts/bootstrap_template.py index 897e521..b264395 100755 --- a/scripts/bootstrap_template.py +++ b/scripts/bootstrap_template.py @@ -1,4 +1,9 @@ #!/usr/bin/env python3 + +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause + """Bootstrap a repository created from this template.""" from __future__ import annotations diff --git a/scripts/update_coverage_readme.py b/scripts/update_coverage_readme.py index 68e6ee3..6f7f0c1 100755 --- a/scripts/update_coverage_readme.py +++ b/scripts/update_coverage_readme.py @@ -1,4 +1,9 @@ #!/usr/bin/env python3 + +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause + """Update the README coverage block from coverage.py XML output.""" from __future__ import annotations diff --git a/scripts/validate_distribution.py b/scripts/validate_distribution.py index e76ded9..d1eece5 100755 --- a/scripts/validate_distribution.py +++ b/scripts/validate_distribution.py @@ -1,4 +1,9 @@ #!/usr/bin/env python3 + +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause + """Build the project distribution and verify the wheel imports cleanly.""" from __future__ import annotations diff --git a/tests/test_add_spdx_headers.py b/tests/test_add_spdx_headers.py new file mode 100644 index 0000000..e2e12c6 --- /dev/null +++ b/tests/test_add_spdx_headers.py @@ -0,0 +1,58 @@ +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause + +import sys +from pathlib import Path + +from scripts.add_spdx_headers import annotatable, build_command + + +def test_annotatable_keeps_non_empty_files(tmp_path: Path) -> None: + populated = tmp_path / "populated.py" + populated.write_text("x = 1\n", encoding="utf-8") + + assert annotatable([str(populated)]) == [str(populated)] + + +def test_annotatable_skips_zero_byte_files(tmp_path: Path) -> None: + empty = tmp_path / "__init__.py" + empty.touch() + + assert annotatable([str(empty)]) == [] + + +def test_annotatable_skips_missing_paths(tmp_path: Path) -> None: + assert annotatable([str(tmp_path / "absent.py")]) == [] + + +def test_annotatable_skips_directories(tmp_path: Path) -> None: + assert annotatable([str(tmp_path)]) == [] + + +def test_build_command_uses_reuse_annotate_with_skip_flags() -> None: + command = build_command( + ["pkg/mod.py"], + year="2026", + copyright_holder="the Python Template contributors", + license_id="BSD-2-Clause", + ) + + assert command[:4] == [sys.executable, "-m", "reuse", "annotate"] + assert "--skip-existing" in command + assert "--merge-copyrights" in command + assert "--skip-unrecognised" in command + assert command[-1] == "pkg/mod.py" + + +def test_build_command_passes_copyright_metadata() -> None: + command = build_command( + ["pkg/mod.py"], + year="2026", + copyright_holder="the Acme contributors", + license_id="BSD-2-Clause", + ) + + assert command[command.index("--year") + 1] == "2026" + assert command[command.index("--copyright") + 1] == "the Acme contributors" + assert command[command.index("--license") + 1] == "BSD-2-Clause" diff --git a/tests/test_bootstrap_template.py b/tests/test_bootstrap_template.py index 84d8f84..c2c7f85 100644 --- a/tests/test_bootstrap_template.py +++ b/tests/test_bootstrap_template.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause + import subprocess import sys from pathlib import Path diff --git a/tests/test_greeter.py b/tests/test_greeter.py index 24763f3..ff37c00 100644 --- a/tests/test_greeter.py +++ b/tests/test_greeter.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause + import pytest from project_name.greeter import Greeter, Language, get_language diff --git a/tests/test_template_smoke.py b/tests/test_template_smoke.py index f832807..fb4e2fc 100644 --- a/tests/test_template_smoke.py +++ b/tests/test_template_smoke.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause + import os import shutil import subprocess From 48534003160877c97d4dc09cf02afa90d77c65ed Mon Sep 17 00:00:00 2001 From: Sebastiano Date: Wed, 29 Jul 2026 15:39:16 +0200 Subject: [PATCH 05/20] docs(plans): fix the license source and reuse override in the plan LICENSE held Unlicense text while pyproject declared BSD-2-Clause. BSD-2-Clause is the right one, so the plan no longer copies the root file. It also now adds the REUSE.toml override that stops reuse reading the SPDX tags quoted in the plan's own code blocks as real annotations. --- .../plans/2026-07-29-release-changelog-spdx.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-07-29-release-changelog-spdx.md b/docs/superpowers/plans/2026-07-29-release-changelog-spdx.md index 111a003..1fde1f9 100644 --- a/docs/superpowers/plans/2026-07-29-release-changelog-spdx.md +++ b/docs/superpowers/plans/2026-07-29-release-changelog-spdx.md @@ -45,10 +45,13 @@ Every task's requirements implicitly include this section. - [ ] **Step 1: Create the LICENSES directory** -REUSE requires the license text under `LICENSES/`. The root `LICENSE` stays exactly where it is — GitHub's license detection reads it, and `reuse lint` ignores it. +REUSE requires the license text under `LICENSES/`. A root `LICENSE` is also kept — GitHub's license detection reads it, and `reuse lint` ignores it. + +**Do not copy the existing root `LICENSE`.** It contains Unlicense text while `pyproject.toml` declares `license = "BSD-2-Clause"` — a pre-existing contradiction in this repository. The project owner ruled that **BSD-2-Clause is correct and the root `LICENSE` file was the mistake**. Write canonical BSD-2-Clause text, with the copyright line `Copyright (c) 2026 the Python Template contributors`, to *both* `LICENSE` and `LICENSES/BSD-2-Clause.txt` with identical content. ```bash mkdir -p LICENSES +# write BSD-2-Clause text to LICENSE, then: cp LICENSE LICENSES/BSD-2-Clause.txt ``` @@ -253,7 +256,17 @@ Expected: PASS, 6 tests. - [ ] **Step 6: Create `REUSE.toml`** -This covers every tracked non-Python file. `reuse lint` ignores the root `LICENSE`, `LICENSES/**`, `REUSE.toml` itself, and anything gitignored, so none of those are listed. `AGENTS.md`, `CLAUDE.md`, and `.claude/**` **are** listed: they are untracked but not gitignored, and `reuse lint` only skips VCS-ignored files. `docs/*.md` and `docs/superpowers/**` are used instead of `docs/**` so that `docs/conf.py` is covered by its inline header alone. +This covers every tracked non-Python file. `reuse lint` ignores the root `LICENSE`, `LICENSES/**`, `REUSE.toml` itself, and anything gitignored, so none of those are listed. `AGENTS.md`, `CLAUDE.md`, and `.claude/**` **are** listed: they are untracked but not gitignored, and `reuse lint` only skips VCS-ignored files. `docs/*.md` is used instead of `docs/**` so that `docs/conf.py` is covered by its inline header alone. + +`docs/superpowers/**` needs a **second** `[[annotations]]` block with `precedence = "override"`. The spec and plan documents quote `SPDX-License-Identifier:` inside fenced code blocks, and `reuse` parses those quotes as real annotations, producing "invalid SPDX License Expression" errors. `override` tells reuse to use the `REUSE.toml` values and ignore anything found inside those files. Verified against reuse 6.2.0: it takes the error count from 3 to 0. + +```toml +[[annotations]] +path = ["docs/superpowers/**"] +precedence = "override" +SPDX-FileCopyrightText = "2026 the Python Template contributors" +SPDX-License-Identifier = "BSD-2-Clause" +``` ```toml version = 1 From ef092a4f199714237b2505b0203fc07a4d92edfb Mon Sep 17 00:00:00 2001 From: Sebastiano Date: Wed, 29 Jul 2026 15:40:35 +0200 Subject: [PATCH 06/20] fix(license): correct root license text and reuse override for plan docs LICENSE carried Unlicense text under a BSD-2-Clause name. Both it and LICENSES/BSD-2-Clause.txt now hold the real BSD-2-Clause text. The plan docs also get their own REUSE.toml block, so the SPDX tags quoted inside them stop being picked up as annotations. --- LICENSE | 40 +++++++++++++++++++-------------------- LICENSES/BSD-2-Clause.txt | 40 +++++++++++++++++++-------------------- REUSE.toml | 10 +++++++++- 3 files changed, 49 insertions(+), 41 deletions(-) diff --git a/LICENSE b/LICENSE index fdddb29..6120396 100644 --- a/LICENSE +++ b/LICENSE @@ -1,24 +1,24 @@ -This is free and unencumbered software released into the public domain. +BSD 2-Clause License -Anyone is free to copy, modify, publish, use, compile, sell, or -distribute this software, either in source code form or as a compiled -binary, for any purpose, commercial or non-commercial, and by any -means. +Copyright (c) 2026 the Python Template contributors -In jurisdictions that recognize copyright laws, the author or authors -of this software dedicate any and all copyright interest in the -software to the public domain. We make this dedication for the benefit -of the public at large and to the detriment of our heirs and -successors. We intend this dedication to be an overt act of -relinquishment in perpetuity of all present and future rights to this -software under copyright law. +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR -OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, -ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -OTHER DEALINGS IN THE SOFTWARE. +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. -For more information, please refer to +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/LICENSES/BSD-2-Clause.txt b/LICENSES/BSD-2-Clause.txt index fdddb29..6120396 100644 --- a/LICENSES/BSD-2-Clause.txt +++ b/LICENSES/BSD-2-Clause.txt @@ -1,24 +1,24 @@ -This is free and unencumbered software released into the public domain. +BSD 2-Clause License -Anyone is free to copy, modify, publish, use, compile, sell, or -distribute this software, either in source code form or as a compiled -binary, for any purpose, commercial or non-commercial, and by any -means. +Copyright (c) 2026 the Python Template contributors -In jurisdictions that recognize copyright laws, the author or authors -of this software dedicate any and all copyright interest in the -software to the public domain. We make this dedication for the benefit -of the public at large and to the detriment of our heirs and -successors. We intend this dedication to be an overt act of -relinquishment in perpetuity of all present and future rights to this -software under copyright law. +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR -OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, -ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -OTHER DEALINGS IN THE SOFTWARE. +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. -For more information, please refer to +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/REUSE.toml b/REUSE.toml index c392208..4bc01d2 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -19,7 +19,6 @@ path = [ "README.md", "cliff.toml", "docs/*.md", - "docs/superpowers/**", "pyproject.toml", "tox.ini", "uv.lock", @@ -27,3 +26,12 @@ path = [ precedence = "aggregate" SPDX-FileCopyrightText = "2026 the Python Template contributors" SPDX-License-Identifier = "BSD-2-Clause" + +# The plan and spec documents quote SPDX tags inside fenced code blocks. +# `override` makes reuse use these values instead of parsing those quotes +# as real annotations. +[[annotations]] +path = ["docs/superpowers/**"] +precedence = "override" +SPDX-FileCopyrightText = "2026 the Python Template contributors" +SPDX-License-Identifier = "BSD-2-Clause" From 1690ab75616241504c2ecf01fb3ec09c77bd52a6 Mon Sep 17 00:00:00 2001 From: Sebastiano Date: Wed, 29 Jul 2026 15:43:10 +0200 Subject: [PATCH 07/20] chore(style): apply mdformat to spec and plan docs They went in unformatted, so pre-commit failed on an otherwise clean tree -- and with fail_fast that killed the run before reuse got to check anything. --- .../2026-07-29-release-changelog-spdx.md | 33 +++++++-- ...026-07-29-release-changelog-spdx-design.md | 72 +++++++++---------- 2 files changed, 62 insertions(+), 43 deletions(-) diff --git a/docs/superpowers/plans/2026-07-29-release-changelog-spdx.md b/docs/superpowers/plans/2026-07-29-release-changelog-spdx.md index 1fde1f9..241ffed 100644 --- a/docs/superpowers/plans/2026-07-29-release-changelog-spdx.md +++ b/docs/superpowers/plans/2026-07-29-release-changelog-spdx.md @@ -26,11 +26,12 @@ Every task's requirements implicitly include this section. - Work on branch `feat/release-changelog-spdx`, which already exists and holds the spec. - pre-commit is **not** installed as a git hook in this clone. Hooks do not run automatically on commit; run them explicitly with `uv run --extra dev pre-commit run --all-files` when a task says to. ---- +______________________________________________________________________ ### Task 1: SPDX / REUSE layer **Files:** + - Create: `LICENSES/BSD-2-Clause.txt` - Create: `REUSE.toml` - Create: `scripts/add_spdx_headers.py` @@ -40,7 +41,9 @@ Every task's requirements implicitly include this section. - Modify: all 12 non-empty tracked `.py` files (headers inserted by the tool, not by hand) **Interfaces:** + - Consumes: nothing. + - Produces: `scripts/add_spdx_headers.py` exposing `annotatable(paths: list[str]) -> list[str]` and `build_command(paths: list[str], *, year: str, copyright_holder: str, license_id: str) -> list[str]`. `REUSE.toml` containing the literal string `the Python Template contributors` (Task 5 rewrites it). The header format Task 5's tests assert against. - [ ] **Step 1: Create the LICENSES directory** @@ -384,11 +387,12 @@ git commit -m "feat(license): auto-insert and verify SPDX headers" Before committing, confirm no unrelated file-mode changes are staged — `git diff --cached --summary` should show `mode change` only for files this task intentionally touched. ---- +______________________________________________________________________ ### Task 2: Changelog layer **Files:** + - Create: `cliff.toml` - Create: `scripts/gen_changelog.py` - Modify: `CHANGELOG.md` (seed; currently 0 bytes) @@ -398,7 +402,9 @@ Before committing, confirm no unrelated file-mode changes are staged — `git di - Modify: `README.md`, `CONTRIBUTING.md` (document the env) **Interfaces:** + - Consumes: the SPDX header format from Task 1. + - Produces: `cliff.toml` at the repo root, consumed by Task 3's `release.py` and Task 4's workflow via `--config cliff.toml`. `scripts/gen_changelog.py` passing `sys.argv[1:]` through to `git-cliff`. - [ ] **Step 1: Add `git-cliff` to the dev extra** @@ -615,18 +621,21 @@ git add cliff.toml CHANGELOG.md pyproject.toml \ git commit -m "feat(changelog): generate CHANGELOG.md with git-cliff" ``` ---- +______________________________________________________________________ ### Task 3: Release script **Files:** + - Create: `scripts/release.py` - Test: `tests/test_release.py` - Modify: `tox.ini` (add `[testenv:release]`, **not** to `env_list`) - Modify: `README.md`, `CONTRIBUTING.md` **Interfaces:** + - Consumes: `cliff.toml` from Task 2, invoked as `git-cliff --config cliff.toml --tag --prepend CHANGELOG.md`. + - Produces: `scripts/release.py` exposing `validate_version(version: str) -> str`, `tag_exists(tag: str, *, runner: Runner = run) -> bool`, `working_tree_dirty(*, runner: Runner = run) -> bool`, `previous_tag(*, runner: Runner = run) -> str | None`, `bump_pyproject(path: Path, version: str) -> None`, `sync_lockfile(*, runner: Runner = run) -> None`, and `changelog_range(prev: str | None) -> list[str]`. The commit subject format `chore(release): vX.Y.Z`, which `cliff.toml` skips via `^chore\(release\)` and Task 4's workflow relies on. - [ ] **Step 1: Write the failing tests** @@ -1155,11 +1164,12 @@ git add tests/test_release.py tox.ini README.md CONTRIBUTING.md git commit -m "feat(release): add release preparation script" ``` ---- +______________________________________________________________________ ### Task 4: Release workflow **Files:** + - Create: `.github/workflows/release.yaml` `README.md` needs no change here: it documents tox envs and the repository @@ -1167,7 +1177,9 @@ layout, but has no per-workflow list to extend. The release *command* is documented in Task 3, Step 7. **Interfaces:** + - Consumes: `cliff.toml` (Task 2), the `chore(release): vX.Y.Z` commit and `vX.Y.Z` tag convention (Task 3), the existing `./.github/actions/setup-python-uv` composite action, and the existing `[testenv:build]` env which runs `scripts/validate_distribution.py`. + - Produces: `.github/workflows/release.yaml`, which Task 5 adds to `WORKFLOW_FILES`. - [ ] **Step 1: Create the workflow** @@ -1295,16 +1307,19 @@ git add .github/workflows/release.yaml git commit -m "ci(release): publish GitHub Releases from version tags" ``` ---- +______________________________________________________________________ ### Task 5: Template bootstrap integration **Files:** + - Modify: `scripts/bootstrap_template.py` (placeholder constants, `WORKFLOW_FILES`, `PACKAGE_FILES`, new `update_spdx_copyright`, `main`) - Test: `tests/test_bootstrap_template.py` (extend) **Interfaces:** + - Consumes: `REUSE.toml` and the header format (Task 1), `cliff.toml` and `scripts/gen_changelog.py` (Task 2), `scripts/release.py` (Task 3), `.github/workflows/release.yaml` (Task 4). + - Produces: `PLACEHOLDER_COPYRIGHT` and `update_spdx_copyright(paths, *, project_title, dry_run)`, relied on by Task 6's smoke assertions. - [ ] **Step 1: Write the failing tests** @@ -1525,14 +1540,16 @@ git add scripts/bootstrap_template.py tests/test_bootstrap_template.py git commit -m "feat(bootstrap): substitute SPDX and release metadata" ``` ---- +______________________________________________________________________ ### Task 6: Template smoke test coverage **Files:** + - Modify: `tests/test_template_smoke.py:22-42` (`PLACEHOLDER_CHECK_PATHS`) and `tests/test_template_smoke.py:70-121` (the single smoke test) **Interfaces:** + - Consumes: everything from Tasks 1-5, exercised through the real `bootstrap_template.py` run the smoke test already performs. - Produces: nothing consumed downstream. @@ -1596,14 +1613,16 @@ git add tests/test_template_smoke.py git commit -m "test(template): assert release and SPDX bootstrap substitution" ``` ---- +______________________________________________________________________ ### Task 7: Full verification **Files:** none modified unless a check fails. **Interfaces:** + - Consumes: Tasks 1-6. + - Produces: nothing. - [ ] **Step 1: Run every quality gate CI runs** diff --git a/docs/superpowers/specs/2026-07-29-release-changelog-spdx-design.md b/docs/superpowers/specs/2026-07-29-release-changelog-spdx-design.md index 14a3cf2..b1f853f 100644 --- a/docs/superpowers/specs/2026-07-29-release-changelog-spdx-design.md +++ b/docs/superpowers/specs/2026-07-29-release-changelog-spdx-design.md @@ -9,8 +9,8 @@ Bring three capabilities from into this template: 1. Tag-triggered GitHub Release publishing. -2. `CHANGELOG.md` generation from git history via git-cliff. -3. SPDX/REUSE licensing headers on source files. +1. `CHANGELOG.md` generation from git history via git-cliff. +1. SPDX/REUSE licensing headers on source files. Because this repository is a *template*, every added file must also survive `scripts/bootstrap_template.py` — placeholders substituted, copyright holder @@ -18,15 +18,15 @@ rewritten — and be covered by the template smoke test. ## Decisions -| Question | Decision | Rationale | -| --- | --- | --- | -| SPDX headers | Auto-insert, then verify | The reference only lints; writing headers automatically removes the manual step. | -| Changelog format | Grouped by conventional-commit type | This repository's history is consistently conventional, unlike the reference's. | -| Release scope | GitHub Release, with a commented PyPI stub | Works with no secrets in every generated repo; PyPI is opt-in. | -| Copyright holder | `the contributors` | Avoids editing a per-author name into every file as contributors change. | -| Entrypoints | Python scripts plus tox envs | Matches this repo's all-Python `scripts/` and tox-driven convention; unit-testable. | -| Commit scope in bullets | Omitted | Keeps bullets short; scope is recoverable from the hash. | -| Root `LICENSE` | Kept alongside `LICENSES/` | GitHub license detection reads the root file. | +| Question | Decision | Rationale | +| ----------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------- | +| SPDX headers | Auto-insert, then verify | The reference only lints; writing headers automatically removes the manual step. | +| Changelog format | Grouped by conventional-commit type | This repository's history is consistently conventional, unlike the reference's. | +| Release scope | GitHub Release, with a commented PyPI stub | Works with no secrets in every generated repo; PyPI is opt-in. | +| Copyright holder | `the contributors` | Avoids editing a per-author name into every file as contributors change. | +| Entrypoints | Python scripts plus tox envs | Matches this repo's all-Python `scripts/` and tox-driven convention; unit-testable. | +| Commit scope in bullets | Omitted | Keeps bullets short; scope is recoverable from the hash. | +| Root `LICENSE` | Kept alongside `LICENSES/` | GitHub license detection reads the root file. | ## Divergences from the reference @@ -216,19 +216,19 @@ sort_commits = "oldest" Commit parsers, in order — skips first, then type mapping, then a catch-all: -| Pattern | Group | -| --- | --- | -| `^chore\(release\)` | skipped | -| `^Merge ` | skipped | -| `^feat` | Features | -| `^fix` | Bug Fixes | -| `^perf` | Performance | -| `^refactor` | Refactoring | -| `^docs` | Documentation | -| `^test` | Testing | -| `^(build\|ci)` | Build & CI | -| `^(chore\|style\|format)` | Chores | -| `.*` | Other | +| Pattern | Group | +| ------------------------- | ------------- | +| `^chore\(release\)` | skipped | +| `^Merge ` | skipped | +| `^feat` | Features | +| `^fix` | Bug Fixes | +| `^perf` | Performance | +| `^refactor` | Refactoring | +| `^docs` | Documentation | +| `^test` | Testing | +| `^(build\|ci)` | Build & CI | +| `^(chore\|style\|format)` | Chores | +| `.*` | Other | Group names carry `` numeric prefixes stripped by `striptags`. This is git-cliff's documented idiom for forcing group order; `group_by` otherwise @@ -268,16 +268,16 @@ Same contract as the reference's `release.sh`, plus lockfile handling: 1. Validate `X.Y.Z` against `^\d+\.\d+\.\d+([.-].+)?$`; abort if the tag already exists or the working tree is dirty. -2. Resolve the previous reachable tag with +1. Resolve the previous reachable tag with `git describe --tags --abbrev=0 --match='v[0-9]*' HEAD`. If none, the changelog covers all history and a warning is printed. -3. Bump `version` in `pyproject.toml`. -4. Run `uv lock`, then `uv lock --check` to confirm the lockfile is in sync. -5. `git-cliff --config cliff.toml --tag vX.Y.Z --prepend CHANGELOG.md`. -6. Commit `pyproject.toml`, `uv.lock`, and `CHANGELOG.md` as +1. Bump `version` in `pyproject.toml`. +1. Run `uv lock`, then `uv lock --check` to confirm the lockfile is in sync. +1. `git-cliff --config cliff.toml --tag vX.Y.Z --prepend CHANGELOG.md`. +1. Commit `pyproject.toml`, `uv.lock`, and `CHANGELOG.md` as `chore(release): vX.Y.Z`. -7. Unless `--no-tag`, create annotated tag `vX.Y.Z`. -8. With `--push`, `git push origin HEAD --follow-tags`. +1. Unless `--no-tag`, create annotated tag `vX.Y.Z`. +1. With `--push`, `git push origin HEAD --follow-tags`. `--dry-run` prints the planned steps and exits without touching the tree, and so does not require a clean tree. @@ -307,15 +307,15 @@ rather than inlining uv setup, matching the other workflows. Steps: 1. `actions/checkout@v6` with `fetch-depth: 0` — git-cliff needs full history. -2. Verify the tag matches `pyproject.toml`'s version; fail with +1. Verify the tag matches `pyproject.toml`'s version; fail with `::error::` if not. -3. `uv run --locked --extra dev tox run -e build` — builds sdist and wheel and +1. `uv run --locked --extra dev tox run -e build` — builds sdist and wheel and import-smoke-tests the wheel via `scripts/validate_distribution.py`. -4. `git-cliff --config cliff.toml --current --strip header -o release-notes.md`; +1. `git-cliff --config cliff.toml --current --strip header -o release-notes.md`; fail if the result is empty. -5. `softprops/action-gh-release@v2` with `body_path: release-notes.md` and +1. `softprops/action-gh-release@v2` with `body_path: release-notes.md` and `dist/*.whl`, `dist/*.tar.gz`. -6. A commented-out `publish` job — `needs: release`, +1. A commented-out `publish` job — `needs: release`, `environment: pypi`, `permissions: id-token: write`, `pypa/gh-action-pypi-publish@release/v1` — with a comment explaining that enabling it requires configuring a PyPI Trusted Publisher. From d99460557d4a40c88ac8764728369f80b251b1f7 Mon Sep 17 00:00:00 2001 From: Sebastiano Date: Wed, 29 Jul 2026 15:53:16 +0200 Subject: [PATCH 08/20] feat(changelog): generate CHANGELOG.md with git-cliff Adds cliff.toml, a small wrapper script and a `tox -e changelog` env. The commit type prefix decides which section an entry lands in. --- CHANGELOG.md | 1 + CONTRIBUTING.md | 6 +++++ README.md | 8 ++++++ cliff.toml | 54 ++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 1 + scripts/gen_changelog.py | 36 +++++++++++++++++++++++++++ tox.ini | 9 +++++++ uv.lock | 22 ++++++++++++++++ 8 files changed, 137 insertions(+) create mode 100644 cliff.toml create mode 100755 scripts/gen_changelog.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e69de29..825c32f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -0,0 +1 @@ +# Changelog diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 325f0e9..17ee579 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,6 +56,12 @@ Build and smoke-test the package artifacts: uv run tox -e build ``` +Preview the generated changelog: + +```bash +uv run tox -e changelog +``` + Build and smoke-test the Docker image: ```bash diff --git a/README.md b/README.md index 98bbc8d..7ddb254 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,12 @@ Build and smoke-test the package artifacts: uv run tox -e build ``` +Preview the generated changelog: + +```bash +uv run tox -e changelog +``` + Build the documentation: ```bash @@ -164,6 +170,7 @@ pull requests. | `scripts/` | Optional | Repository automation scripts, including the bootstrap script. | | `tests/` | Required | Pytest test suite. Mirror the package structure where practical. | | `Dockerfile` | Optional | Container build for running the project example. | +| `cliff.toml` | Optional | git-cliff changelog generation rules. | | `tox.ini` | Required | Local and CI task definitions. | ## Included tools @@ -177,4 +184,5 @@ pull requests. - [MyST Parser](https://myst-parser.readthedocs.io/) for Markdown in Sphinx - [Read the Docs](https://readthedocs.org/) for hosted documentation - [pip-audit](https://pypi.org/project/pip-audit/) for dependency vulnerability checks +- [git-cliff](https://git-cliff.org/) for changelog generation - [GitHub Actions](https://docs.github.com/en/actions) for CI automation diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..660c2f6 --- /dev/null +++ b/cliff.toml @@ -0,0 +1,54 @@ +# git-cliff configuration. Produces, per release: +# +# ## VERSION - DATE +# +# ### Group +# +# - [shorthash] Subject +# +# Regenerate with `uv run tox run -e changelog -- -o CHANGELOG.md`. + +[changelog] +header = "# Changelog\n\n" +body = """ +{% if version %}\ +## {{ version | trim_start_matches(pat="v") }} - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\ +## Unreleased +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} +### {{ group | striptags | trim | upper_first }} + +{% for commit in commits %}\ +- [{{ commit.id | truncate(length=7, end="") }}] {{ commit.message | upper_first }} +{% endfor %}\ +{% endfor %}\n +""" +trim = true +footer = "" + +[git] +conventional_commits = true +filter_unconventional = false +split_commits = false +protect_breaking_commits = false +filter_commits = false +tag_pattern = "v[0-9]*" +topo_order = false +sort_commits = "oldest" +# Group names carry `` prefixes purely to force ordering; the +# template strips them with `striptags`. Without them `group_by` sorts +# alphabetically. +commit_parsers = [ + { message = "^chore\\(release\\)", skip = true }, + { message = "^Merge ", skip = true }, + { message = "^feat", group = "Features" }, + { message = "^fix", group = "Bug Fixes" }, + { message = "^perf", group = "Performance" }, + { message = "^refactor", group = "Refactoring" }, + { message = "^docs", group = "Documentation" }, + { message = "^test", group = "Testing" }, + { message = "^(build|ci)", group = "Build & CI" }, + { message = "^(chore|style|format)", group = "Chores" }, + { message = ".*", group = "Other" }, +] diff --git a/pyproject.toml b/pyproject.toml index 6e7763e..eacf73a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ license = "BSD-2-Clause" [project.optional-dependencies] dev = [ "build>=1.3.0,<2", + "git-cliff>=2.13,<3", "hatchling>=1.29.0,<2", "radon>=6.0.1,<7", "lizard>=1.22.1,<2", diff --git a/scripts/gen_changelog.py b/scripts/gen_changelog.py new file mode 100755 index 0000000..bfd0e18 --- /dev/null +++ b/scripts/gen_changelog.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause + +"""Regenerate CHANGELOG.md from git history using the rules in cliff.toml.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +CONFIG = Path(__file__).resolve().parents[1] / "cliff.toml" + + +def build_command(argv: list[str]) -> list[str]: + """Build the git-cliff command for the given passthrough arguments. + + Args: + argv: Arguments forwarded verbatim to git-cliff. + + Returns: + The command as an argument list. + """ + executable = Path(sys.executable).parent / "git-cliff" + return [str(executable), "--config", str(CONFIG), *argv] + + +def main() -> int: + """Run git-cliff and return its exit status.""" + return subprocess.run(build_command(sys.argv[1:]), check=False).returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tox.ini b/tox.ini index eccdf06..f2609cd 100644 --- a/tox.ini +++ b/tox.ini @@ -58,6 +58,15 @@ extras = dev commands = {envpython} scripts/validate_distribution.py --package project_name --dist-dir dist +[testenv:changelog] +description = generate the changelog from git history +runner = uv-venv-lock-runner +extras = dev +allowlist_externals = + git +commands = + {envpython} scripts/gen_changelog.py {posargs} + [testenv:docs] description = build the documentation site runner = uv-venv-lock-runner diff --git a/uv.lock b/uv.lock index d0d889d..d2c8e3a 100644 --- a/uv.lock +++ b/uv.lock @@ -459,6 +459,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/b2/50e9b292b5cac13e9e81272c7171301abc753a60460d21505b606e15cf21/furo-2025.12.19-py3-none-any.whl", hash = "sha256:bb0ead5309f9500130665a26bee87693c41ce4dbdff864dbfb6b0dae4673d24f", size = 339262, upload-time = "2025-12-19T17:34:38.905Z" }, ] +[[package]] +name = "git-cliff" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/62/57/b12494e2cbc3c9154c942e64659b5aec2b1ce9f12d07f6dc6167e2c63ae5/git_cliff-2.13.1.tar.gz", hash = "sha256:e949ea9c3951ba6037b99eec465162be2584f27f0836ace45f44d6f45650f8c6", size = 113119, upload-time = "2026-04-26T10:33:42.331Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/dd/24768c3c0030710d36706c17b997d06aee27cb76b27ab2abb058ae254175/git_cliff-2.13.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:08a9cb0ec760e165210ed22fefa295b6549a3520b420db995ccbb3620cbb1fbe", size = 7260035, upload-time = "2026-04-26T10:33:16.269Z" }, + { url = "https://files.pythonhosted.org/packages/22/df/842973ead79d27a58cd1eccd167191c0a71513e5c1e9dc30337dafdb7d36/git_cliff-2.13.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e92cf470ecbe73f7d2963dfa80e8961a6b76888d0c19949b0f028a28a0a0470c", size = 6854384, upload-time = "2026-04-26T10:33:18.808Z" }, + { url = "https://files.pythonhosted.org/packages/7c/4d/6d6efa7d61be8632563990ccd402e401695e4a85a0bb1002f28730d03268/git_cliff-2.13.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e8d8e420adf6a36b97e0fbdbf2b07e47712199a9baf3675c9a759430243ea26", size = 7308164, upload-time = "2026-04-26T10:33:20.869Z" }, + { url = "https://files.pythonhosted.org/packages/f0/4a/98b8d2f53a2d0b7d313e98ab363ad7e0d6a514e878a8d678f22402cb0ec7/git_cliff-2.13.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ab059d671565189faa4f3858b2fb42535aa39fe265ad95d84d5c116a2724fc7", size = 7687163, upload-time = "2026-04-26T10:33:23.096Z" }, + { url = "https://files.pythonhosted.org/packages/c7/07/cdd149b3909644aa3f0be7960406d9bbb38598f8618e039ac48cbb43ded6/git_cliff-2.13.1-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:a93db30da45967c42df607fbbc2092111fcd576b0ca9e2fbddd3f653d8c71be7", size = 7317670, upload-time = "2026-04-26T10:33:25.323Z" }, + { url = "https://files.pythonhosted.org/packages/2e/11/6d377a7f3113f6e26d87a28a32013eb05bd62bce176d6f8ee808e4868c1f/git_cliff-2.13.1-py3-none-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:df9f5a2bd16e5225030c9c2362e6ac70b34a906c0fbb83f8cbf5ae46932ba0d2", size = 7502294, upload-time = "2026-04-26T10:33:27.315Z" }, + { url = "https://files.pythonhosted.org/packages/80/6b/e1da9acf3aec99e6600be02b6ce0c9e8bd42d072e3120384514c905231e2/git_cliff-2.13.1-py3-none-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:c12276784d280aa6a7148d3e52ff139e891f4c97720cd9be581b19892ea39fe0", size = 7927258, upload-time = "2026-04-26T10:33:29.554Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ea/9f2188a5e474e5f02193d9c1cdf7028773d483a3f508a1df4f1d93c9cc80/git_cliff-2.13.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:17da93dc605cbc48c762770402067fa726437cedbd62514f9caef6e0ccb58a43", size = 7308153, upload-time = "2026-04-26T10:33:31.589Z" }, + { url = "https://files.pythonhosted.org/packages/b3/70/5e2b2a0e42c07956f911e1eccd6dc6d79b96fc6c8a604a526c1ee0474c84/git_cliff-2.13.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cd4a08cf3f638ec71d2ed451aa8673bef99e1107a36366153db97ca18d981655", size = 7502287, upload-time = "2026-04-26T10:33:33.894Z" }, + { url = "https://files.pythonhosted.org/packages/05/50/cc4c1d3d360621c0235d66d2c74472d9993d8aadf23ffa311eaf29d7a3aa/git_cliff-2.13.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c5bb87f6e1db18be09e50c9d59234dc949713e20c2700e94eca378d22ad79719", size = 7927253, upload-time = "2026-04-26T10:33:36.205Z" }, + { url = "https://files.pythonhosted.org/packages/86/5d/717d30f37dad65a6cc5220b04a3ef1bc44c31fb56d0ce2895114e159df4f/git_cliff-2.13.1-py3-none-win32.whl", hash = "sha256:c8878972e0a6c26d9137fc406a611116239333578d95ac05064d2807920bd83c", size = 6718261, upload-time = "2026-04-26T10:33:38.1Z" }, + { url = "https://files.pythonhosted.org/packages/99/b2/99fac50978b9a90bfec0f1b89354a667ec83f4990301f6c708abce05484e/git_cliff-2.13.1-py3-none-win_amd64.whl", hash = "sha256:856d831a0bede9c258229dbd4d4c2b1c0810d8fce3d3882729669e8dc09c72bf", size = 7714969, upload-time = "2026-04-26T10:33:40.163Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -911,6 +931,7 @@ source = { editable = "." } [package.optional-dependencies] dev = [ { name = "build" }, + { name = "git-cliff" }, { name = "hatchling" }, { name = "lizard" }, { name = "pip-audit" }, @@ -939,6 +960,7 @@ docs = [ requires-dist = [ { name = "build", marker = "extra == 'dev'", specifier = ">=1.3.0,<2" }, { name = "furo", marker = "extra == 'docs'", specifier = ">=2024.8.6,<2027" }, + { name = "git-cliff", marker = "extra == 'dev'", specifier = ">=2.13,<3" }, { name = "hatchling", marker = "extra == 'dev'", specifier = ">=1.29.0,<2" }, { name = "lizard", marker = "extra == 'dev'", specifier = ">=1.22.1,<2" }, { name = "myst-parser", marker = "extra == 'docs'", specifier = ">=3,<5" }, From b318ef9fe237e74f4c0d38e44c8b0c21c34a101e Mon Sep 17 00:00:00 2001 From: Sebastiano Date: Wed, 29 Jul 2026 15:57:39 +0200 Subject: [PATCH 09/20] fix(plans): fix changelog generation for the very first release git-cliff refuses --prepend without a commit range, which is exactly the no-previous-tag case, so release.py would have failed on the first release it ever ran. It uses -o for a full render instead, which also writes the `# Changelog` header. --- .../2026-07-29-release-changelog-spdx.md | 60 +++++++++++++++++-- 1 file changed, 54 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/plans/2026-07-29-release-changelog-spdx.md b/docs/superpowers/plans/2026-07-29-release-changelog-spdx.md index 241ffed..2888b60 100644 --- a/docs/superpowers/plans/2026-07-29-release-changelog-spdx.md +++ b/docs/superpowers/plans/2026-07-29-release-changelog-spdx.md @@ -490,6 +490,14 @@ commit_parsers = [ printf '# Changelog\n\n' > CHANGELOG.md ``` +The `end-of-file-fixer` pre-commit hook will trim this to a single trailing newline (`# Changelog\n`), and that is fine and expected — **do not fight the hook.** The reason it is safe is behavioral, and was verified against git-cliff 2.13.1: + +- git-cliff recognises its configured header only when the file's `# Changelog` is followed by a blank line. Against the trimmed one-newline seed, `--prepend` does not match the header and instead emits a **duplicate** `# Changelog` at the bottom of the file. +- That state is never reached, because `generate_changelog` in Task 3 uses `-o` (full regeneration, which emits the header itself) whenever there is no previous tag — i.e. for the first release. The result is `# Changelog\n\n## 0.1.0 …`, whose header *is* followed by a blank line. +- Every subsequent release therefore prepends correctly, inserting the new section between the header and the previous release's section. + +Do not add a prose preamble under the heading to force the blank line: `--prepend` inserts immediately after the header, so the preamble would sink below the newest release section on the first prepend. Header plus version sections only. + - [ ] **Step 4: Write `scripts/gen_changelog.py`** ```python @@ -636,7 +644,7 @@ ______________________________________________________________________ - Consumes: `cliff.toml` from Task 2, invoked as `git-cliff --config cliff.toml --tag --prepend CHANGELOG.md`. -- Produces: `scripts/release.py` exposing `validate_version(version: str) -> str`, `tag_exists(tag: str, *, runner: Runner = run) -> bool`, `working_tree_dirty(*, runner: Runner = run) -> bool`, `previous_tag(*, runner: Runner = run) -> str | None`, `bump_pyproject(path: Path, version: str) -> None`, `sync_lockfile(*, runner: Runner = run) -> None`, and `changelog_range(prev: str | None) -> list[str]`. The commit subject format `chore(release): vX.Y.Z`, which `cliff.toml` skips via `^chore\(release\)` and Task 4's workflow relies on. +- Produces: `scripts/release.py` exposing `validate_version(version: str) -> str`, `tag_exists(tag: str, *, runner: Runner = run) -> bool`, `working_tree_dirty(*, runner: Runner = run) -> bool`, `previous_tag(*, runner: Runner = run) -> str | None`, `bump_pyproject(path: Path, version: str) -> None`, `sync_lockfile(*, runner: Runner = run) -> None`, `changelog_range(prev: str | None) -> list[str]`, and `generate_changelog(tag: str, prev: str | None, *, runner: Runner = run) -> None`. The commit subject format `chore(release): vX.Y.Z`, which `cliff.toml` skips via `^chore\(release\)` and Task 4's workflow relies on. - [ ] **Step 1: Write the failing tests** @@ -650,6 +658,7 @@ import pytest from scripts.release import ( bump_pyproject, changelog_range, + generate_changelog, previous_tag, sync_lockfile, tag_exists, @@ -792,6 +801,28 @@ def test_changelog_range_is_empty_without_a_previous_tag(): def test_changelog_range_spans_from_previous_tag_to_head(): assert changelog_range("v0.1.0") == ["v0.1.0..HEAD"] + + +def test_generate_changelog_regenerates_when_there_is_no_previous_tag(): + runner = FakeRunner() + + generate_changelog("v0.1.0", None, runner=runner) + + command = runner.commands[0] + assert "-o" in command + assert "--prepend" not in command + assert command[command.index("-o") + 1] == "CHANGELOG.md" + + +def test_generate_changelog_prepends_when_a_previous_tag_exists(): + runner = FakeRunner() + + generate_changelog("v0.1.0", "v0.0.9", runner=runner) + + command = runner.commands[0] + assert "--prepend" in command + assert "-o" not in command + assert "v0.0.9..HEAD" in command ``` - [ ] **Step 2: Run the tests to verify they fail** @@ -1014,17 +1045,34 @@ def sync_lockfile(*, runner: Runner = run) -> None: runner(["uv", "lock", "--check"]) -def generate_changelog(tag: str, prev: str | None) -> None: - """Prepend the new release section to CHANGELOG.md. +def generate_changelog( + tag: str, + prev: str | None, + *, + runner: Runner = run, +) -> None: + """Write the new release section into CHANGELOG.md. + + With no previous tag the file is regenerated with ``-o``, because + git-cliff refuses ``--prepend`` unless a range, ``-u``, or ``-l`` is + given, and because only a full render emits the ``# Changelog`` + header. Once one release exists the file starts with that header + followed by a blank line, which git-cliff recognises, so later + releases are prepended above the previous section. Args: tag: The release tag, including the leading 'v'. prev: The previous release tag, if any. + runner: Command runner, injectable for tests. """ - run( + script = str(REPO_ROOT / "scripts" / "gen_changelog.py") + if prev is None: + runner([sys.executable, script, "--tag", tag, "-o", "CHANGELOG.md"]) + return + runner( [ sys.executable, - str(REPO_ROOT / "scripts" / "gen_changelog.py"), + script, "--tag", tag, *changelog_range(prev), @@ -1100,7 +1148,7 @@ if __name__ == "__main__": - [ ] **Step 4: Run the tests to verify they pass** Run: `uv run --extra dev pytest tests/test_release.py -v` -Expected: PASS, 19 tests (the two parametrized cases contribute 4 and 5). +Expected: PASS, 21 tests (the two parametrized cases contribute 4 and 5). - [ ] **Step 5: Add the tox env** From a374951d5dc799b4a597ea5d7d9227acf3814fcb Mon Sep 17 00:00:00 2001 From: Sebastiano Date: Wed, 29 Jul 2026 16:02:53 +0200 Subject: [PATCH 10/20] docs(changelog): document the --prepend duplicate-header caveat A bare `# Changelog` line does not match the configured header, so --prepend adds a second one at the bottom rather than recognising it. Noted in cliff.toml for anyone running git-cliff by hand. --- cliff.toml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/cliff.toml b/cliff.toml index 660c2f6..def11d4 100644 --- a/cliff.toml +++ b/cliff.toml @@ -7,6 +7,15 @@ # - [shorthash] Subject # # Regenerate with `uv run tox run -e changelog -- -o CHANGELOG.md`. +# +# --prepend caveat: git-cliff only recognises the `# Changelog` header +# above when it is followed by a blank line. CHANGELOG.md is committed as +# a bare `# Changelog\n` (a pre-commit hook strips the blank line), so +# `--prepend` against it does not match the header and duplicates +# `# Changelog` at the bottom of the file instead of leaving it alone. Use +# `-o` for a full regeneration, not `--prepend`, against the committed +# file. `--prepend` also requires a commit range (or `-u`/`-l`), so the +# first release must use `-o` regardless. [changelog] header = "# Changelog\n\n" From a0422df2661953ebd8541d9471f663a8b0d323bb Mon Sep 17 00:00:00 2001 From: Sebastiano Date: Wed, 29 Jul 2026 23:55:08 +0200 Subject: [PATCH 11/20] feat(release): add release preparation script One command to bump the version, refresh the lockfile and changelog, then commit and tag. --dry-run shows what it would do, and it refuses to run on a dirty tree or over an existing tag. --- CONTRIBUTING.md | 11 ++ README.md | 11 ++ scripts/release.py | 308 ++++++++++++++++++++++++++++++++++++++++++ tests/test_release.py | 213 +++++++++++++++++++++++++++++ tox.ini | 10 ++ 5 files changed, 553 insertions(+) create mode 100755 scripts/release.py create mode 100644 tests/test_release.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 17ee579..7baff8f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -62,6 +62,17 @@ Preview the generated changelog: uv run tox -e changelog ``` +Prepare a release — bumps the version, regenerates the changelog, commits, +and tags: + +```bash +uv run tox -e release -- 0.1.0 +uv run tox -e release -- 0.1.0 --push +``` + +Pushing the tag triggers the `Release` workflow, which builds the +distributions and publishes a GitHub Release. + Build and smoke-test the Docker image: ```bash diff --git a/README.md b/README.md index 7ddb254..81a59a9 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,17 @@ Preview the generated changelog: uv run tox -e changelog ``` +Prepare a release — bumps the version, regenerates the changelog, commits, +and tags: + +```bash +uv run tox -e release -- 0.1.0 +uv run tox -e release -- 0.1.0 --push +``` + +Pushing the tag triggers the `Release` workflow, which builds the +distributions and publishes a GitHub Release. + Build the documentation: ```bash diff --git a/scripts/release.py b/scripts/release.py new file mode 100755 index 0000000..987f7c8 --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause + +"""Prepare a release: bump the version, changelog, commit, and tag. + +The release workflow at ``.github/workflows/release.yaml`` picks up the +pushed tag and publishes a GitHub Release with the matching CHANGELOG +section. + +Usage: + scripts/release.py X.Y.Z # bump + commit + tag (no push) + scripts/release.py X.Y.Z --push # also push branch and tag + scripts/release.py X.Y.Z --dry-run # show what would happen + scripts/release.py X.Y.Z --no-tag # commit only, skip the tag +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from collections.abc import Callable +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +CONFIG = REPO_ROOT / "cliff.toml" +VERSION_PATTERN = re.compile(r"^\d+\.\d+\.\d+(?:[.-][0-9A-Za-z.-]+)?$") +VERSION_LINE = re.compile(r'^version\s*=\s*"[^"]*"', re.MULTILINE) + +Runner = Callable[..., subprocess.CompletedProcess[str]] + + +def run( + command: list[str], + *, + check: bool = True, + capture: bool = False, +) -> subprocess.CompletedProcess[str]: + """Run a command, optionally capturing its output. + + Args: + command: The command and its arguments. + check: Raise on a non-zero exit status. + capture: Capture stdout and stderr as text. + + Returns: + The completed process. + """ + return subprocess.run( + command, + check=check, + capture_output=capture, + text=True, + ) + + +def parse_args() -> argparse.Namespace: + """Parse command-line arguments.""" + parser = argparse.ArgumentParser() + parser.add_argument("version", help="Release version, as X.Y.Z.") + parser.add_argument( + "--push", + action="store_true", + help="Push the release commit and tag to origin.", + ) + parser.add_argument( + "--no-tag", + action="store_true", + help="Create the release commit without an annotated tag.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print the planned steps without modifying anything.", + ) + return parser.parse_args() + + +def validate_version(version: str) -> str: + """Validate a release version string. + + Args: + version: Candidate version. + + Returns: + The version unchanged. + + Raises: + ValueError: If the version is not of the form X.Y.Z. + """ + if not VERSION_PATTERN.fullmatch(version): + raise ValueError( + f"'{version}' is not a valid version (expected X.Y.Z)." + ) + return version + + +def tag_exists(tag: str, *, runner: Runner = run) -> bool: + """Return whether a git tag already exists. + + Args: + tag: Tag name to look for. + runner: Command runner, injectable for tests. + + Returns: + True if the tag resolves. + """ + result = runner( + ["git", "rev-parse", "--verify", f"refs/tags/{tag}"], + check=False, + capture=True, + ) + return result.returncode == 0 + + +def working_tree_dirty(*, runner: Runner = run) -> bool: + """Return whether the working tree has uncommitted changes. + + Args: + runner: Command runner, injectable for tests. + + Returns: + True if tracked files differ from HEAD. + """ + result = runner( + ["git", "status", "--porcelain", "--untracked-files=no"], + capture=True, + ) + return bool(result.stdout.strip()) + + +def previous_tag(*, runner: Runner = run) -> str | None: + """Return the most recent version tag reachable from HEAD. + + Tags on unrelated branches are skipped automatically. + + Args: + runner: Command runner, injectable for tests. + + Returns: + The tag name, or None when no version tag is reachable. + """ + result = runner( + [ + "git", + "describe", + "--tags", + "--abbrev=0", + "--match=v[0-9]*", + "HEAD", + ], + check=False, + capture=True, + ) + if result.returncode != 0: + return None + return result.stdout.strip() or None + + +def changelog_range(prev: str | None) -> list[str]: + """Return the git-cliff commit range arguments. + + Args: + prev: The previous release tag, if any. + + Returns: + A single-element range list, or an empty list for full history. + """ + if prev is None: + return [] + return [f"{prev}..HEAD"] + + +def bump_pyproject(path: Path, version: str) -> None: + """Rewrite the project version in pyproject.toml. + + Args: + path: Path to pyproject.toml. + version: New version string. + + Raises: + ValueError: If no version line is present. + """ + content = path.read_text(encoding="utf-8") + updated, count = VERSION_LINE.subn( + f'version = "{version}"', + content, + count=1, + ) + if count == 0: + raise ValueError(f"No version line found in {path}.") + path.write_text(updated, encoding="utf-8") + + +def sync_lockfile(*, runner: Runner = run) -> None: + """Refresh uv.lock for the new version and verify it is in sync. + + uv.lock pins the project's own version, and CI runs with ``--locked``, + so the lockfile must be regenerated alongside pyproject.toml. + + Args: + runner: Command runner, injectable for tests. + """ + runner(["uv", "lock"]) + runner(["uv", "lock", "--check"]) + + +def generate_changelog( + tag: str, + prev: str | None, + *, + runner: Runner = run, +) -> None: + """Write the new release section into CHANGELOG.md. + + With no previous tag the file is regenerated with ``-o``, because + git-cliff refuses ``--prepend`` unless a range, ``-u``, or ``-l`` is + given, and because only a full render emits the ``# Changelog`` + header. Once one release exists the file starts with that header + followed by a blank line, which git-cliff recognises, so later + releases are prepended above the previous section. + + Args: + tag: The release tag, including the leading 'v'. + prev: The previous release tag, if any. + runner: Command runner, injectable for tests. + """ + script = str(REPO_ROOT / "scripts" / "gen_changelog.py") + if prev is None: + runner([sys.executable, script, "--tag", tag, "-o", "CHANGELOG.md"]) + return + runner( + [ + sys.executable, + script, + "--tag", + tag, + *changelog_range(prev), + "--prepend", + "CHANGELOG.md", + ] + ) + + +def main() -> int: + """Run the release preparation process.""" + args = parse_args() + version = validate_version(args.version) + tag = f"v{version}" + + if tag_exists(tag): + raise SystemExit(f"error: tag {tag} already exists") + + prev = previous_tag() + if prev is None: + print( + "warning: no previous tag reachable; the changelog will cover " + "all history", + file=sys.stderr, + ) + else: + print(f"Previous reachable tag: {prev}") + + if args.dry_run: + print(f"[dry-run] bump pyproject.toml to {version}") + print("[dry-run] uv lock && uv lock --check") + print( + "[dry-run] prepend CHANGELOG.md " + f"({prev + '..HEAD' if prev else 'full history'})" + ) + print(f"[dry-run] commit: chore(release): {tag}") + if not args.no_tag: + print(f"[dry-run] create annotated tag {tag}") + if args.push: + print("[dry-run] push branch and tag") + return 0 + + if working_tree_dirty(): + raise SystemExit( + "error: working tree has uncommitted changes; commit or stash " + "them first" + ) + + print(f"Preparing release: {tag}") + bump_pyproject(REPO_ROOT / "pyproject.toml", version) + sync_lockfile() + generate_changelog(tag, prev) + + run(["git", "add", "pyproject.toml", "uv.lock", "CHANGELOG.md"]) + run(["git", "commit", "-m", f"chore(release): {tag}"]) + + if not args.no_tag: + run(["git", "tag", "-a", tag, "-m", tag]) + + if args.push: + run(["git", "push", "origin", "HEAD", "--follow-tags"]) + print(f"\nPushed {tag}. The release workflow will publish it.") + else: + print(f"\nCreated commit and tag {tag} locally.") + print("Push with: git push origin HEAD --follow-tags") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_release.py b/tests/test_release.py new file mode 100644 index 0000000..3cc8eb0 --- /dev/null +++ b/tests/test_release.py @@ -0,0 +1,213 @@ +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause + +from __future__ import annotations + +import subprocess +from typing import TYPE_CHECKING + +import pytest + +from scripts.release import ( + bump_pyproject, + changelog_range, + generate_changelog, + previous_tag, + sync_lockfile, + tag_exists, + validate_version, + working_tree_dirty, +) + +if TYPE_CHECKING: + from pathlib import Path + +PYPROJECT = """[project] +name = "project_name" +version = "0.0.0" +description = "A simple template project." +""" + + +class FakeRunner: + """Records commands and replays canned results.""" + + def __init__( + self, + results: dict[str, subprocess.CompletedProcess[str]] | None = None, + ) -> None: + self.commands: list[list[str]] = [] + self.results = results or {} + + def __call__( + self, + command: list[str], + *, + check: bool = True, + capture: bool = False, + ) -> subprocess.CompletedProcess[str]: + self.commands.append(command) + key = " ".join(command) + result = self.results.get(key) + if result is None: + return subprocess.CompletedProcess(command, 0, "", "") + return result + + +def git_repo(tmp_path: Path) -> Path: + """Create a throwaway git repo with one commit.""" + subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) + (tmp_path / "file.txt").write_text("hello\n", encoding="utf-8") + subprocess.run(["git", "add", "-A"], cwd=tmp_path, check=True) + subprocess.run( + [ + "git", + "-c", + "user.email=a@b", + "-c", + "user.name=a", + "commit", + "-qm", + "init", + ], + cwd=tmp_path, + check=True, + ) + return tmp_path + + +@pytest.mark.parametrize("version", ["0.1.0", "1.2.3", "10.0.1", "1.0.0-rc1"]) +def test_validate_version_accepts_valid_versions(version: str) -> None: + assert validate_version(version) == version + + +@pytest.mark.parametrize("version", ["1.2", "v1.2.3", "abc", "", "1.2.3-"]) +def test_validate_version_rejects_invalid_versions(version: str) -> None: + with pytest.raises(ValueError): + validate_version(version) + + +def test_bump_pyproject_replaces_version(tmp_path: Path) -> None: + path = tmp_path / "pyproject.toml" + path.write_text(PYPROJECT, encoding="utf-8") + + bump_pyproject(path, "0.1.0") + + assert 'version = "0.1.0"' in path.read_text(encoding="utf-8") + assert 'version = "0.0.0"' not in path.read_text(encoding="utf-8") + + +def test_bump_pyproject_leaves_other_metadata_alone(tmp_path: Path) -> None: + path = tmp_path / "pyproject.toml" + path.write_text(PYPROJECT, encoding="utf-8") + + bump_pyproject(path, "0.1.0") + + content = path.read_text(encoding="utf-8") + assert 'name = "project_name"' in content + assert 'description = "A simple template project."' in content + + +def test_bump_pyproject_raises_without_a_version_line(tmp_path: Path) -> None: + path = tmp_path / "pyproject.toml" + path.write_text('[project]\nname = "x"\n', encoding="utf-8") + + with pytest.raises(ValueError): + bump_pyproject(path, "0.1.0") + + +def test_tag_exists_is_false_for_unknown_tag( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(git_repo(tmp_path)) + + assert tag_exists("v9.9.9") is False + + +def test_tag_exists_is_true_for_created_tag( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + repo = git_repo(tmp_path) + subprocess.run( + ["git", "tag", "-a", "v0.1.0", "-m", "v0.1.0"], cwd=repo, check=True + ) + monkeypatch.chdir(repo) + + assert tag_exists("v0.1.0") is True + + +def test_working_tree_dirty_is_false_when_clean( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(git_repo(tmp_path)) + + assert working_tree_dirty() is False + + +def test_working_tree_dirty_is_true_with_modifications( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + repo = git_repo(tmp_path) + (repo / "file.txt").write_text("changed\n", encoding="utf-8") + monkeypatch.chdir(repo) + + assert working_tree_dirty() is True + + +def test_previous_tag_is_none_without_tags( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(git_repo(tmp_path)) + + assert previous_tag() is None + + +def test_previous_tag_returns_most_recent_reachable_tag( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + repo = git_repo(tmp_path) + subprocess.run( + ["git", "tag", "-a", "v0.1.0", "-m", "v0.1.0"], cwd=repo, check=True + ) + monkeypatch.chdir(repo) + + assert previous_tag() == "v0.1.0" + + +def test_sync_lockfile_locks_then_verifies() -> None: + runner = FakeRunner() + + sync_lockfile(runner=runner) + + assert runner.commands == [["uv", "lock"], ["uv", "lock", "--check"]] + + +def test_changelog_range_is_empty_without_a_previous_tag() -> None: + assert changelog_range(None) == [] + + +def test_changelog_range_spans_from_previous_tag_to_head() -> None: + assert changelog_range("v0.1.0") == ["v0.1.0..HEAD"] + + +def test_generate_changelog_regenerates_when_there_is_no_previous_tag() -> None: + runner = FakeRunner() + + generate_changelog("v0.1.0", None, runner=runner) + + command = runner.commands[0] + assert "-o" in command + assert "--prepend" not in command + assert command[command.index("-o") + 1] == "CHANGELOG.md" + + +def test_generate_changelog_prepends_when_a_previous_tag_exists() -> None: + runner = FakeRunner() + + generate_changelog("v0.1.0", "v0.0.9", runner=runner) + + command = runner.commands[0] + assert "--prepend" in command + assert "-o" not in command + assert "v0.0.9..HEAD" in command diff --git a/tox.ini b/tox.ini index f2609cd..e9ad31b 100644 --- a/tox.ini +++ b/tox.ini @@ -67,6 +67,16 @@ allowlist_externals = commands = {envpython} scripts/gen_changelog.py {posargs} +[testenv:release] +description = prepare a release commit and tag +runner = uv-venv-lock-runner +extras = dev +allowlist_externals = + git + uv +commands = + {envpython} scripts/release.py {posargs} + [testenv:docs] description = build the documentation site runner = uv-venv-lock-runner From 06bab37adf0968f38a0c0f7d9f673a0a46126975 Mon Sep 17 00:00:00 2001 From: Sebastiano Date: Wed, 29 Jul 2026 23:56:15 +0200 Subject: [PATCH 12/20] ci(release): publish GitHub Releases from version tags Pushing a v* tag builds the distributions, pulls that version's section out of the changelog for the release notes, and publishes the release with the wheel and sdist attached. PyPI publishing is there but left commented out. --- .github/workflows/release.yaml | 86 ++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 .github/workflows/release.yaml diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..3bb5550 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,86 @@ +name: Release + +on: + push: + tags: + - v* + +permissions: + contents: read + +env: + PYTHON_VERSION: '3.10' + +jobs: + release: + name: Publish GitHub Release + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - uses: ./.github/actions/setup-python-uv + with: + python-version: ${{ env.PYTHON_VERSION }} + # tomllib is 3.11+, and PYTHON_VERSION is 3.10 to match this template's + # floor, so the version is read with a regex instead. The pattern mirrors + # VERSION_LINE in scripts/release.py, which writes this same line. + - name: Verify tag matches the project version + run: | + tag_version="${GITHUB_REF_NAME#v}" + project_version=$(uv run --locked python -c \ + "import pathlib, re; print(re.search(r'(?m)^version\s*=\s*\"([^\"]+)\"', pathlib.Path('pyproject.toml').read_text()).group(1))") + if [ "$tag_version" != "$project_version" ]; then + echo "::error::Tag $GITHUB_REF_NAME does not match pyproject.toml version $project_version" + exit 1 + fi + - name: Build and verify package artifacts + run: uv run --locked --extra dev tox run -e build + - name: Extract release notes + run: | + uv run --locked --extra dev python scripts/gen_changelog.py \ + --current --strip header -o release-notes.md + if [ ! -s release-notes.md ]; then + echo "::error::No changelog content found for $GITHUB_REF_NAME" + exit 1 + fi + cat release-notes.md + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + name: ${{ github.ref_name }} + body_path: release-notes.md + files: | + dist/*.whl + dist/*.tar.gz + draft: false + prerelease: false + + # Publishing to PyPI is opt-in. To enable it: + # 1. Create a PyPI Trusted Publisher for this repository, pointing at + # workflow `release.yaml` and environment `pypi`. + # See https://docs.pypi.org/trusted-publishers/ + # 2. Create a GitHub environment named `pypi`. + # 3. Uncomment the job below. + # No API token is needed; authentication uses OIDC. + # + # publish: + # name: Publish to PyPI + # needs: release + # runs-on: ubuntu-latest + # timeout-minutes: 15 + # environment: pypi + # permissions: + # id-token: write + # steps: + # - uses: actions/checkout@v6 + # - uses: ./.github/actions/setup-python-uv + # with: + # python-version: ${{ env.PYTHON_VERSION }} + # - name: Build distributions + # run: uv run --locked --extra dev tox run -e build + # - name: Publish + # uses: pypa/gh-action-pypi-publish@release/v1 From a8292bc4cc0daa27b5f4f2689b2db276f137fbcf Mon Sep 17 00:00:00 2001 From: Sebastiano Date: Wed, 29 Jul 2026 23:59:04 +0200 Subject: [PATCH 13/20] feat(bootstrap): substitute SPDX and release metadata New projects get their own copyright holder written into every file that carries one, and the release workflow is now part of what the bootstrap script knows about. --- scripts/bootstrap_template.py | 50 ++++++++++++++ tests/test_bootstrap_template.py | 114 +++++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+) diff --git a/scripts/bootstrap_template.py b/scripts/bootstrap_template.py index b264395..2c1abf1 100755 --- a/scripts/bootstrap_template.py +++ b/scripts/bootstrap_template.py @@ -18,6 +18,7 @@ PLACEHOLDER_AUTHOR = "Mario Potato" PLACEHOLDER_AUTHOR_EMAIL = "mario.potato@univr.it" PLACEHOLDER_DESCRIPTION = "A simple template project." +PLACEHOLDER_COPYRIGHT = "the Python Template contributors" SUPPORTED_PYTHON_VERSIONS = ("3.10", "3.11", "3.12", "3.13", "3.14") WORKFLOW_FILES = ( @@ -26,6 +27,7 @@ Path(".github/workflows/documentation.yaml"), Path(".github/workflows/package.yaml"), Path(".github/workflows/quality.yaml"), + Path(".github/workflows/release.yaml"), Path(".github/workflows/security.yaml"), Path(".github/workflows/template-smoke.yaml"), Path(".github/workflows/tests.yaml"), @@ -60,6 +62,24 @@ Path("README.md"), *WORKFLOW_FILES, ) +# Files carrying the placeholder SPDX copyright holder. The package's own +# modules are added at call time, because the package directory is renamed. +SPDX_FILES = ( + Path("REUSE.toml"), + Path("docs/conf.py"), + Path("examples/say_hi.py"), + Path("scripts/add_spdx_headers.py"), + Path("scripts/bootstrap_template.py"), + Path("scripts/gen_changelog.py"), + Path("scripts/release.py"), + Path("scripts/update_coverage_readme.py"), + Path("scripts/validate_distribution.py"), + Path("tests/test_add_spdx_headers.py"), + Path("tests/test_bootstrap_template.py"), + Path("tests/test_greeter.py"), + Path("tests/test_release.py"), + Path("tests/test_template_smoke.py"), +) DOCS_INDEX = Path("docs/index.md") DOCS_CONF = Path("docs/conf.py") PACKAGE_DIR = Path(PLACEHOLDER_PACKAGE) @@ -517,6 +537,28 @@ def update_readme( path.write_text(updated, encoding="utf-8") +def update_spdx_copyright( + paths: list[Path], + *, + project_title: str, + dry_run: bool, +) -> None: + """Rewrite the SPDX copyright holder in the given files. + + Args: + paths: Files that may contain the placeholder copyright holder. + project_title: Human-readable project title. + dry_run: Print planned changes without writing them. + """ + holder = f"the {project_title} contributors" + for path in paths: + replace_text( + path, + {PLACEHOLDER_COPYRIGHT: holder}, + dry_run=dry_run, + ) + + def rename_package_dir(package_name: str, *, dry_run: bool) -> None: """Rename the placeholder package directory.""" target_dir = Path(package_name) @@ -613,6 +655,14 @@ def main() -> int: minimum_python_version=minimum_python_version, dry_run=args.dry_run, ) + update_spdx_copyright( + [ + *SPDX_FILES, + *sorted(PACKAGE_DIR.glob("*.py")), + ], + project_title=project_title, + dry_run=args.dry_run, + ) rename_package_dir(package_name, dry_run=args.dry_run) return 0 diff --git a/tests/test_bootstrap_template.py b/tests/test_bootstrap_template.py index c2c7f85..2a07b5f 100644 --- a/tests/test_bootstrap_template.py +++ b/tests/test_bootstrap_template.py @@ -9,11 +9,13 @@ import pytest from scripts.bootstrap_template import ( + PLACEHOLDER_COPYRIGHT, format_tox_env, normalize_distribution_name, normalize_minimum_python_version, parse_args, resolve_metadata, + update_spdx_copyright, versions_from_minimum, ) @@ -400,3 +402,115 @@ def test_bootstrap_template_updates_minimum_python_version( assert "project_name" not in docker_ci assert 'name = "demo-service"' in uv_lock assert f'requires-python = ">={minimum_python_version}, <4"' in uv_lock + + +# These fixtures quote SPDX tags as data. The markers stop `reuse lint` from +# parsing them as annotations on this file. +# REUSE-IgnoreStart +SPDX_HEADER = ( + "# SPDX-FileCopyrightText: 2026 the Python Template contributors\n" + "#\n" + "# SPDX-License-Identifier: BSD-2-Clause\n" + "\n" + '"""Module."""\n' +) +REUSE_TOML = ( + "version = 1\n" + "\n" + "[[annotations]]\n" + 'path = ["README.md"]\n' + 'precedence = "aggregate"\n' + 'SPDX-FileCopyrightText = "2026 the Python Template contributors"\n' + 'SPDX-License-Identifier = "BSD-2-Clause"\n' +) +# REUSE-IgnoreEnd + + +def test_update_spdx_copyright_rewrites_inline_headers(tmp_path: Path) -> None: + module = tmp_path / "mod.py" + module.write_text(SPDX_HEADER, encoding="utf-8") + + update_spdx_copyright( + [module], + project_title="Acme Tool", + dry_run=False, + ) + + content = module.read_text(encoding="utf-8") + assert "2026 the Acme Tool contributors" in content + assert PLACEHOLDER_COPYRIGHT not in content + + +def test_update_spdx_copyright_rewrites_reuse_toml(tmp_path: Path) -> None: + reuse_toml = tmp_path / "REUSE.toml" + reuse_toml.write_text(REUSE_TOML, encoding="utf-8") + + update_spdx_copyright( + [reuse_toml], + project_title="Acme Tool", + dry_run=False, + ) + + content = reuse_toml.read_text(encoding="utf-8") + assert ( + 'SPDX-FileCopyrightText = "2026 the Acme Tool contributors"' in content + ) + + +def test_update_spdx_copyright_preserves_license_identifier( + tmp_path: Path, +) -> None: + module = tmp_path / "mod.py" + module.write_text(SPDX_HEADER, encoding="utf-8") + + update_spdx_copyright( + [module], + project_title="Acme Tool", + dry_run=False, + ) + + content = module.read_text(encoding="utf-8") + # REUSE-IgnoreStart + assert "# SPDX-License-Identifier: BSD-2-Clause" in content + # REUSE-IgnoreEnd + + +def test_update_spdx_copyright_dry_run_leaves_files_untouched( + tmp_path: Path, +) -> None: + module = tmp_path / "mod.py" + module.write_text(SPDX_HEADER, encoding="utf-8") + + update_spdx_copyright( + [module], + project_title="Acme Tool", + dry_run=True, + ) + + assert module.read_text(encoding="utf-8") == SPDX_HEADER + + +def test_update_spdx_copyright_ignores_missing_files(tmp_path: Path) -> None: + update_spdx_copyright( + [tmp_path / "absent.py"], + project_title="Acme Tool", + dry_run=False, + ) + + +def test_release_workflow_is_a_bootstrap_target() -> None: + from scripts.bootstrap_template import WORKFLOW_FILES + + assert Path(".github/workflows/release.yaml") in WORKFLOW_FILES + + +def test_spdx_files_cover_the_new_automation_scripts() -> None: + from scripts.bootstrap_template import SPDX_FILES + + for path in ( + Path("REUSE.toml"), + Path("scripts/release.py"), + Path("scripts/gen_changelog.py"), + Path("scripts/add_spdx_headers.py"), + ): + assert path in SPDX_FILES From bb1191bcd095bc37b142d99480d77de3caa3c8a0 Mon Sep 17 00:00:00 2001 From: Sebastiano Date: Thu, 30 Jul 2026 00:01:21 +0200 Subject: [PATCH 14/20] fix(docs): exclude development plans from the sphinx build The plan and spec docs under docs/superpowers/ are in no toctree and quote code sphinx cannot highlight, and with -W that is a build failure. It was breaking tox -e docs, the docs workflow and the template smoke test. --- docs/conf.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 6cd2f74..de5b39e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -30,7 +30,15 @@ ".md": "markdown", ".rst": "restructuredtext", } -exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] +# `superpowers/` holds development plans and design notes, not published +# documentation. They are not in any toctree and quote code that the +# highlighter cannot lex, both of which are errors under `sphinx -W`. +exclude_patterns = [ + "_build", + "Thumbs.db", + ".DS_Store", + "superpowers/**", +] html_theme = "furo" html_title = "project_name documentation" From e305fc9b358ad22e7c6be689a6641e3664708097 Mon Sep 17 00:00:00 2001 From: Sebastiano Date: Thu, 30 Jul 2026 00:01:38 +0200 Subject: [PATCH 15/20] test(template): assert release and SPDX bootstrap substitution The smoke test now checks that a generated project has no placeholders left in any of the new files, and that its package modules carry the new project's copyright holder rather than the template's. --- tests/test_template_smoke.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/test_template_smoke.py b/tests/test_template_smoke.py index fb4e2fc..3b9eb8e 100644 --- a/tests/test_template_smoke.py +++ b/tests/test_template_smoke.py @@ -30,10 +30,13 @@ ".github/workflows/documentation.yaml", ".github/workflows/package.yaml", ".github/workflows/quality.yaml", + ".github/workflows/release.yaml", ".github/workflows/security.yaml", ".github/workflows/template-smoke.yaml", ".github/workflows/tests.yaml", "README.md", + "REUSE.toml", + "cliff.toml", "docs/api.md", "docs/conf.py", "docs/documentation.md", @@ -41,6 +44,9 @@ "docs/template.md", "docs/usage.md", "pyproject.toml", + "scripts/add_spdx_headers.py", + "scripts/gen_changelog.py", + "scripts/release.py", "tox.ini", ) @@ -124,3 +130,22 @@ def test_generated_project_bootstraps_and_builds(tmp_path: Path) -> None: content = (generated_repo / relative_path).read_text(encoding="utf-8") assert "project_name" not in content, relative_path assert "python-template" not in content, relative_path + + reuse_toml = (generated_repo / "REUSE.toml").read_text(encoding="utf-8") + assert "the Demo Service contributors" in reuse_toml + assert "the Python Template contributors" not in reuse_toml + + package_modules = sorted( + path + for path in (generated_repo / "demo_service").glob("*.py") + if path.stat().st_size > 0 + ) + assert package_modules + + for module in package_modules: + content = module.read_text(encoding="utf-8") + # REUSE-IgnoreStart + assert "SPDX-License-Identifier: BSD-2-Clause" in content, str(module) + # REUSE-IgnoreEnd + assert "the Demo Service contributors" in content, str(module) + assert "the Python Template contributors" not in content, str(module) From a211f2da3baba2e42cf370d2d8c2015013aaa842 Mon Sep 17 00:00:00 2001 From: Sebastiano Date: Thu, 30 Jul 2026 09:53:13 +0200 Subject: [PATCH 16/20] docs: document the commit, license header, and release workflow Walks through the three pieces of automation in the order you meet them: how to commit, how license headers get added for you, and how to cut a release. Also fixes CONTRIBUTING.md, which still told people to edit CHANGELOG.md by hand. --- CONTRIBUTING.md | 18 +++++- README.md | 161 +++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 161 insertions(+), 18 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7baff8f..bc8fc60 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -128,6 +128,18 @@ A good pull request should include: ## Release notes -Update `CHANGELOG.md` for changes that affect generated projects, supported -Python versions, dependency management, CI behavior, documentation publishing, -or the public template workflow. +`CHANGELOG.md` is generated from commit messages by git-cliff — do not edit it +by hand. What lands in it is decided by the Conventional Commits prefix on each +commit, so write commit subjects that read as release notes on their own, +especially for changes that affect generated projects, supported Python +versions, dependency management, CI behavior, documentation publishing, or the +public template workflow. + +Preview the result before opening a pull request: + +```bash +uv run tox -e changelog +``` + +The prefix-to-section mapping and the full release procedure are documented in +the "Commit, license, and release workflow" section of `README.md`. diff --git a/README.md b/README.md index 81a59a9..9398106 100644 --- a/README.md +++ b/README.md @@ -113,38 +113,165 @@ Build and smoke-test the package artifacts: uv run tox -e build ``` -Preview the generated changelog: +Build the documentation: ```bash -uv run tox -e changelog +uv run tox -e docs ``` -Prepare a release — bumps the version, regenerates the changelog, commits, -and tags: +Build and smoke-test the Docker image: ```bash -uv run tox -e release -- 0.1.0 -uv run tox -e release -- 0.1.0 --push +docker build --pull -t python-template:ci . +docker run --rm python-template:ci uv run python -c "import project_name" ``` -Pushing the tag triggers the `Release` workflow, which builds the -distributions and publishes a GitHub Release. +See `CONTRIBUTING.md` for the full local verification workflow and the +generated-project smoke test. + +## Commit, license, and release workflow -Build the documentation: +Three pieces of automation depend on each other, in this order: commit +messages feed the changelog, license headers are added while you commit, and +the release command turns both into a tagged GitHub Release. + +### 1. Commit + +Install the git hooks once per clone. Without this, the hooks only run when you +invoke them by hand: ```bash -uv run tox -e docs +uv run pre-commit install ``` -Build and smoke-test the Docker image: +From then on every `git commit` formats and lints the staged files, inserts +missing license headers, and verifies REUSE compliance. Run the same hooks +across the whole repository at any time: ```bash -docker build --pull -t python-template:ci . -docker run --rm python-template:ci uv run python -c "import project_name" +uv run pre-commit run --all-files ``` -See `CONTRIBUTING.md` for the full local verification workflow and the -generated-project smoke test. +If a hook rewrites a file, the commit is aborted with the fix already applied +to your working tree — stage it and commit again: + +```bash +git add -A +git commit -m "feat(greeter): add localized greetings" +``` + +**Commit messages must follow the Conventional Commits format**, because +`cliff.toml` derives the changelog from the message prefix. Use +`type: subject` or `type(scope): subject`: + +| Prefix | Changelog section | +| ----------------------------- | ----------------- | +| `feat:` | Features | +| `fix:` | Bug Fixes | +| `perf:` | Performance | +| `refactor:` | Refactoring | +| `docs:` | Documentation | +| `test:` | Testing | +| `build:`, `ci:` | Build & CI | +| `chore:`, `style:`, `format:` | Chores | +| anything else | Other | + +Only the subject after the prefix reaches the changelog, so write the subject +as a standalone sentence. Merge commits and `chore(release):` commits are +skipped, which keeps release commits out of their own changelog. + +### 2. License headers + +Headers are appended automatically — you do not normally run anything. The +`add SPDX headers` hook calls `scripts/add_spdx_headers.py` on every staged +Python file, which shells out to `reuse annotate` and writes: + +```python +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause +``` + +Existing headers are left alone, and zero-byte files (such as empty +`__init__.py`) are skipped. The `reuse lint` hook then fails the commit if any +file still lacks copyright or license information. + +To annotate or check files outside a commit, drive the hooks directly: + +```bash +uv run pre-commit run reuse-annotate --files project_name/greeter.py +uv run pre-commit run reuse --all-files +``` + +`reuse` itself is installed only inside that hook's environment, not as a +project dependency, so calling the script directly needs the tool supplied. +Do that when you need a different year or copyright holder than the defaults: + +```bash +uv run --with 'reuse>=6.2,<7' python scripts/add_spdx_headers.py \ + --year 2027 --copyright "Your Name" project_name/greeter.py +``` + +Non-Python files are covered in bulk by `REUSE.toml` instead of an in-file +header, which is why config files, workflows, and Markdown carry no comment +block. Add new paths to the `path` list there rather than editing those files. +The license text itself lives in `LICENSES/BSD-2-Clause.txt`; REUSE requires +every identifier used anywhere in the repository to have a matching file in +that directory. When test fixtures or docs need to quote an SPDX tag as data, +wrap the region in `REUSE-IgnoreStart` / `REUSE-IgnoreEnd` comments so it is +not mistaken for a real annotation. + +### 3. Release and changelog + +Preview what the changelog would contain, without writing anything: + +```bash +uv run tox -e changelog # unreleased commits +uv run tox -e changelog -- --tag v0.1.0 # as it would look tagged v0.1.0 +uv run tox -e changelog -- -o CHANGELOG.md # regenerate the file in place +``` + +`CHANGELOG.md` is generated, never hand-edited. Fix a wrong entry by amending +the commit message it came from and regenerating. + +Then cut the release. Start with a dry run, which prints the planned steps and +touches nothing: + +```bash +uv run tox -e release -- 0.1.0 --dry-run +``` + +When it looks right, run it for real from a clean working tree: + +```bash +uv run tox -e release -- 0.1.0 # bump, changelog, commit, tag +uv run tox -e release -- 0.1.0 --push # ...and push the branch and tag +``` + +The release command refuses to run if the version is not `X.Y.Z`, if the tag +already exists, or if the working tree has uncommitted changes. Otherwise it: + +1. bumps `version` in `pyproject.toml` +1. runs `uv lock` and `uv lock --check`, so the lockfile matches the new + version and CI's `--locked` installs keep working +1. writes the new section into `CHANGELOG.md` — a full render for the first + release, prepended above the previous section afterwards +1. commits the three files as `chore(release): v0.1.0` +1. creates the annotated tag `v0.1.0` + +Add `--no-tag` to produce the commit without a tag. Nothing is pushed unless +you pass `--push`; otherwise push when ready: + +```bash +git push origin HEAD --follow-tags +``` + +Pushing a `v*` tag triggers the `Release` workflow, which verifies the tag +matches the `pyproject.toml` version, builds and smoke-tests the +distributions with `tox -e build`, extracts that version's changelog section +as the release notes, and publishes a GitHub Release with the wheel and sdist +attached. Publishing to PyPI is opt-in — see the commented `publish` job in +`.github/workflows/release.yaml` for the Trusted Publisher setup. ## Documentation @@ -175,12 +302,14 @@ pull requests. | Path | Status | Purpose | | --------------- | -------- | ------------------------------------------------------------------------- | | `.github/` | Optional | GitHub Actions, Dependabot, and repository guidance. | +| `LICENSES/` | Required | Full text of every SPDX license used, as REUSE requires. | | `docs/` | Required | Sphinx documentation, including manual pages and generated API reference. | | `examples/` | Optional | Runnable examples for users and contributors. | | `project_name/` | Required | Source package. The bootstrap script renames this directory. | | `scripts/` | Optional | Repository automation scripts, including the bootstrap script. | | `tests/` | Required | Pytest test suite. Mirror the package structure where practical. | | `Dockerfile` | Optional | Container build for running the project example. | +| `REUSE.toml` | Optional | Bulk SPDX annotations for files that carry no in-file header. | | `cliff.toml` | Optional | git-cliff changelog generation rules. | | `tox.ini` | Required | Local and CI task definitions. | @@ -196,4 +325,6 @@ pull requests. - [Read the Docs](https://readthedocs.org/) for hosted documentation - [pip-audit](https://pypi.org/project/pip-audit/) for dependency vulnerability checks - [git-cliff](https://git-cliff.org/) for changelog generation +- [REUSE](https://reuse.software/) for SPDX license headers and compliance +- [pre-commit](https://pre-commit.com/) for the commit-time hook suite - [GitHub Actions](https://docs.github.com/en/actions) for CI automation From e6a4053a75454422a47e2f9812c9a8342f0c9c25 Mon Sep 17 00:00:00 2001 From: Sebastiano Date: Wed, 5 Aug 2026 14:58:41 +0200 Subject: [PATCH 17/20] ci: fix CI errors --- scripts/bootstrap_template.py | 1 + scripts/gen_changelog.py | 32 +++++++++++++++- scripts/release.py | 7 +++- tests/test_gen_changelog.py | 71 +++++++++++++++++++++++++++++++++++ tests/test_release.py | 54 ++++++++++++++++---------- 5 files changed, 143 insertions(+), 22 deletions(-) create mode 100644 tests/test_gen_changelog.py diff --git a/scripts/bootstrap_template.py b/scripts/bootstrap_template.py index 2c1abf1..b9f22b6 100755 --- a/scripts/bootstrap_template.py +++ b/scripts/bootstrap_template.py @@ -76,6 +76,7 @@ Path("scripts/validate_distribution.py"), Path("tests/test_add_spdx_headers.py"), Path("tests/test_bootstrap_template.py"), + Path("tests/test_gen_changelog.py"), Path("tests/test_greeter.py"), Path("tests/test_release.py"), Path("tests/test_template_smoke.py"), diff --git a/scripts/gen_changelog.py b/scripts/gen_changelog.py index bfd0e18..53f573c 100755 --- a/scripts/gen_changelog.py +++ b/scripts/gen_changelog.py @@ -7,6 +7,7 @@ from __future__ import annotations +import shutil import subprocess import sys from pathlib import Path @@ -14,6 +15,34 @@ CONFIG = Path(__file__).resolve().parents[1] / "cliff.toml" +def git_cliff_executable() -> str: + """Locate the git-cliff binary. + + git-cliff ships as a dev dependency, so it normally sits next to the + running interpreter. The PATH is searched as a fallback for the case + where this script is run with an interpreter from outside that + environment. + + Returns: + The path to the git-cliff binary. + + Raises: + SystemExit: If no git-cliff binary can be found. + """ + candidate = Path(sys.executable).parent / "git-cliff" + for path in (candidate, candidate.with_suffix(".exe")): + if path.exists(): + return str(path) + + found = shutil.which("git-cliff") + if found is None: + raise SystemExit( + "error: git-cliff not found; install the dev extra with " + "'uv sync --extra dev'" + ) + return found + + def build_command(argv: list[str]) -> list[str]: """Build the git-cliff command for the given passthrough arguments. @@ -23,8 +52,7 @@ def build_command(argv: list[str]) -> list[str]: Returns: The command as an argument list. """ - executable = Path(sys.executable).parent / "git-cliff" - return [str(executable), "--config", str(CONFIG), *argv] + return [git_cliff_executable(), "--config", str(CONFIG), *argv] def main() -> int: diff --git a/scripts/release.py b/scripts/release.py index 987f7c8..685bd0c 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -19,6 +19,7 @@ from __future__ import annotations import argparse +import os import re import subprocess import sys @@ -26,7 +27,6 @@ from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] -CONFIG = REPO_ROOT / "cliff.toml" VERSION_PATTERN = re.compile(r"^\d+\.\d+\.\d+(?:[.-][0-9A-Za-z.-]+)?$") VERSION_LINE = re.compile(r'^version\s*=\s*"[^"]*"', re.MULTILINE) @@ -248,6 +248,11 @@ def generate_changelog( def main() -> int: """Run the release preparation process.""" args = parse_args() + # git, git-cliff, and uv all resolve paths against the current + # directory. Anchoring to the repository root keeps a release invoked + # from a subdirectory from writing the changelog somewhere else and + # then aborting with the version bump already applied. + os.chdir(REPO_ROOT) version = validate_version(args.version) tag = f"v{version}" diff --git a/tests/test_gen_changelog.py b/tests/test_gen_changelog.py new file mode 100644 index 0000000..0e276b6 --- /dev/null +++ b/tests/test_gen_changelog.py @@ -0,0 +1,71 @@ +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause + +from __future__ import annotations + +import sys +from typing import TYPE_CHECKING + +import pytest + +from scripts.gen_changelog import CONFIG, build_command, git_cliff_executable + +if TYPE_CHECKING: + from pathlib import Path + + +def test_build_command_passes_the_config_and_forwards_arguments() -> None: + command = build_command(["--tag", "v0.1.0", "--prepend", "CHANGELOG.md"]) + + assert command[1:] == [ + "--config", + str(CONFIG), + "--tag", + "v0.1.0", + "--prepend", + "CHANGELOG.md", + ] + + +def test_git_cliff_executable_prefers_the_interpreter_environment( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + binary = tmp_path / "git-cliff" + binary.touch() + monkeypatch.setattr(sys, "executable", str(tmp_path / "python")) + + assert git_cliff_executable() == str(binary) + + +def test_git_cliff_executable_finds_the_windows_binary( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + binary = tmp_path / "git-cliff.exe" + binary.touch() + monkeypatch.setattr(sys, "executable", str(tmp_path / "python.exe")) + + assert git_cliff_executable() == str(binary) + + +def test_git_cliff_executable_falls_back_to_the_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + binary = tmp_path / "elsewhere" / "git-cliff" + binary.parent.mkdir() + binary.touch() + binary.chmod(0o755) + monkeypatch.setattr(sys, "executable", str(tmp_path / "python")) + monkeypatch.setenv("PATH", str(binary.parent)) + + assert git_cliff_executable() == str(binary) + + +def test_git_cliff_executable_reports_a_missing_binary( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(sys, "executable", str(tmp_path / "python")) + monkeypatch.setenv("PATH", str(tmp_path / "empty")) + + with pytest.raises(SystemExit, match="git-cliff"): + git_cliff_executable() diff --git a/tests/test_release.py b/tests/test_release.py index 3cc8eb0..c096601 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -5,14 +5,17 @@ from __future__ import annotations import subprocess -from typing import TYPE_CHECKING +import sys +from pathlib import Path import pytest from scripts.release import ( + REPO_ROOT, bump_pyproject, changelog_range, generate_changelog, + main, previous_tag, sync_lockfile, tag_exists, @@ -20,9 +23,6 @@ working_tree_dirty, ) -if TYPE_CHECKING: - from pathlib import Path - PYPROJECT = """[project] name = "project_name" version = "0.0.0" @@ -55,25 +55,30 @@ def __call__( return result +REPO_CONFIG = { + # Written to the repo config rather than passed per command, so callers + # can commit and tag without a global git identity. + "user.email": "a@b", + "user.name": "a", + # Ambient signing settings would otherwise make commits and tags here + # depend on the contributor's keys being present and unlocked. + "commit.gpgsign": "false", + "tag.gpgSign": "false", +} + + def git_repo(tmp_path: Path) -> Path: - """Create a throwaway git repo with one commit.""" + """Create a throwaway git repo with one commit. + + The repo is configured to be independent of the ambient git config, so + the tests behave the same for every contributor and on CI. + """ subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) + for key, value in REPO_CONFIG.items(): + subprocess.run(["git", "config", key, value], cwd=tmp_path, check=True) (tmp_path / "file.txt").write_text("hello\n", encoding="utf-8") subprocess.run(["git", "add", "-A"], cwd=tmp_path, check=True) - subprocess.run( - [ - "git", - "-c", - "user.email=a@b", - "-c", - "user.name=a", - "commit", - "-qm", - "init", - ], - cwd=tmp_path, - check=True, - ) + subprocess.run(["git", "commit", "-qm", "init"], cwd=tmp_path, check=True) return tmp_path @@ -175,6 +180,17 @@ def test_previous_tag_returns_most_recent_reachable_tag( assert previous_tag() == "v0.1.0" +def test_main_operates_from_the_repository_root( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The release runs against the repo regardless of the caller's cwd.""" + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(sys, "argv", ["release.py", "99.0.0", "--dry-run"]) + + assert main() == 0 + assert Path.cwd() == REPO_ROOT + + def test_sync_lockfile_locks_then_verifies() -> None: runner = FakeRunner() From ccaa38092ae8b7846721000008a1064a1d9941f9 Mon Sep 17 00:00:00 2001 From: Sebastiano Date: Wed, 5 Aug 2026 14:59:04 +0200 Subject: [PATCH 18/20] feat(dependabot): update package ecosystem from pip to uv --- .github/dependabot.yml | 5 +- uv.lock | 138 ++++++++++++++++++++++------------------- 2 files changed, 79 insertions(+), 64 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index e5374c7..361fa2b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,6 +1,9 @@ version: 2 updates: -- package-ecosystem: pip +# The `uv` ecosystem understands pyproject.toml *and* uv.lock. The `pip` +# ecosystem does not update uv.lock, so transitive pins there (which CI +# installs, because every job runs with --locked) would never be bumped. +- package-ecosystem: uv directory: / schedule: interval: weekly diff --git a/uv.lock b/uv.lock index d2c8e3a..631763e 100644 --- a/uv.lock +++ b/uv.lock @@ -241,14 +241,14 @@ wheels = [ [[package]] name = "click" -version = "8.3.1" +version = "8.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, ] [[package]] @@ -721,63 +721,75 @@ wheels = [ [[package]] name = "msgpack" -version = "1.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f5/a2/3b68a9e769db68668b25c6108444a35f9bd163bb848c0650d516761a59c0/msgpack-1.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0051fffef5a37ca2cd16978ae4f0aef92f164df86823871b5162812bebecd8e2", size = 81318, upload-time = "2025-10-08T09:14:38.722Z" }, - { url = "https://files.pythonhosted.org/packages/5b/e1/2b720cc341325c00be44e1ed59e7cfeae2678329fbf5aa68f5bda57fe728/msgpack-1.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a605409040f2da88676e9c9e5853b3449ba8011973616189ea5ee55ddbc5bc87", size = 83786, upload-time = "2025-10-08T09:14:40.082Z" }, - { url = "https://files.pythonhosted.org/packages/71/e5/c2241de64bfceac456b140737812a2ab310b10538a7b34a1d393b748e095/msgpack-1.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b696e83c9f1532b4af884045ba7f3aa741a63b2bc22617293a2c6a7c645f251", size = 398240, upload-time = "2025-10-08T09:14:41.151Z" }, - { url = "https://files.pythonhosted.org/packages/b7/09/2a06956383c0fdebaef5aa9246e2356776f12ea6f2a44bd1368abf0e46c4/msgpack-1.1.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:365c0bbe981a27d8932da71af63ef86acc59ed5c01ad929e09a0b88c6294e28a", size = 406070, upload-time = "2025-10-08T09:14:42.821Z" }, - { url = "https://files.pythonhosted.org/packages/0e/74/2957703f0e1ef20637d6aead4fbb314330c26f39aa046b348c7edcf6ca6b/msgpack-1.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:41d1a5d875680166d3ac5c38573896453bbbea7092936d2e107214daf43b1d4f", size = 393403, upload-time = "2025-10-08T09:14:44.38Z" }, - { url = "https://files.pythonhosted.org/packages/a5/09/3bfc12aa90f77b37322fc33e7a8a7c29ba7c8edeadfa27664451801b9860/msgpack-1.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:354e81bcdebaab427c3df4281187edc765d5d76bfb3a7c125af9da7a27e8458f", size = 398947, upload-time = "2025-10-08T09:14:45.56Z" }, - { url = "https://files.pythonhosted.org/packages/4b/4f/05fcebd3b4977cb3d840f7ef6b77c51f8582086de5e642f3fefee35c86fc/msgpack-1.1.2-cp310-cp310-win32.whl", hash = "sha256:e64c8d2f5e5d5fda7b842f55dec6133260ea8f53c4257d64494c534f306bf7a9", size = 64769, upload-time = "2025-10-08T09:14:47.334Z" }, - { url = "https://files.pythonhosted.org/packages/d0/3e/b4547e3a34210956382eed1c85935fff7e0f9b98be3106b3745d7dec9c5e/msgpack-1.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:db6192777d943bdaaafb6ba66d44bf65aa0e9c5616fa1d2da9bb08828c6b39aa", size = 71293, upload-time = "2025-10-08T09:14:48.665Z" }, - { url = "https://files.pythonhosted.org/packages/2c/97/560d11202bcd537abca693fd85d81cebe2107ba17301de42b01ac1677b69/msgpack-1.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2e86a607e558d22985d856948c12a3fa7b42efad264dca8a3ebbcfa2735d786c", size = 82271, upload-time = "2025-10-08T09:14:49.967Z" }, - { url = "https://files.pythonhosted.org/packages/83/04/28a41024ccbd67467380b6fb440ae916c1e4f25e2cd4c63abe6835ac566e/msgpack-1.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:283ae72fc89da59aa004ba147e8fc2f766647b1251500182fac0350d8af299c0", size = 84914, upload-time = "2025-10-08T09:14:50.958Z" }, - { url = "https://files.pythonhosted.org/packages/71/46/b817349db6886d79e57a966346cf0902a426375aadc1e8e7a86a75e22f19/msgpack-1.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61c8aa3bd513d87c72ed0b37b53dd5c5a0f58f2ff9f26e1555d3bd7948fb7296", size = 416962, upload-time = "2025-10-08T09:14:51.997Z" }, - { url = "https://files.pythonhosted.org/packages/da/e0/6cc2e852837cd6086fe7d8406af4294e66827a60a4cf60b86575a4a65ca8/msgpack-1.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:454e29e186285d2ebe65be34629fa0e8605202c60fbc7c4c650ccd41870896ef", size = 426183, upload-time = "2025-10-08T09:14:53.477Z" }, - { url = "https://files.pythonhosted.org/packages/25/98/6a19f030b3d2ea906696cedd1eb251708e50a5891d0978b012cb6107234c/msgpack-1.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7bc8813f88417599564fafa59fd6f95be417179f76b40325b500b3c98409757c", size = 411454, upload-time = "2025-10-08T09:14:54.648Z" }, - { url = "https://files.pythonhosted.org/packages/b7/cd/9098fcb6adb32187a70b7ecaabf6339da50553351558f37600e53a4a2a23/msgpack-1.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bafca952dc13907bdfdedfc6a5f579bf4f292bdd506fadb38389afa3ac5b208e", size = 422341, upload-time = "2025-10-08T09:14:56.328Z" }, - { url = "https://files.pythonhosted.org/packages/e6/ae/270cecbcf36c1dc85ec086b33a51a4d7d08fc4f404bdbc15b582255d05ff/msgpack-1.1.2-cp311-cp311-win32.whl", hash = "sha256:602b6740e95ffc55bfb078172d279de3773d7b7db1f703b2f1323566b878b90e", size = 64747, upload-time = "2025-10-08T09:14:57.882Z" }, - { url = "https://files.pythonhosted.org/packages/2a/79/309d0e637f6f37e83c711f547308b91af02b72d2326ddd860b966080ef29/msgpack-1.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:d198d275222dc54244bf3327eb8cbe00307d220241d9cec4d306d49a44e85f68", size = 71633, upload-time = "2025-10-08T09:14:59.177Z" }, - { url = "https://files.pythonhosted.org/packages/73/4d/7c4e2b3d9b1106cd0aa6cb56cc57c6267f59fa8bfab7d91df5adc802c847/msgpack-1.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:86f8136dfa5c116365a8a651a7d7484b65b13339731dd6faebb9a0242151c406", size = 64755, upload-time = "2025-10-08T09:15:00.48Z" }, - { url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939, upload-time = "2025-10-08T09:15:01.472Z" }, - { url = "https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064, upload-time = "2025-10-08T09:15:03.764Z" }, - { url = "https://files.pythonhosted.org/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131, upload-time = "2025-10-08T09:15:05.136Z" }, - { url = "https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556, upload-time = "2025-10-08T09:15:06.837Z" }, - { url = "https://files.pythonhosted.org/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920, upload-time = "2025-10-08T09:15:08.179Z" }, - { url = "https://files.pythonhosted.org/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013, upload-time = "2025-10-08T09:15:09.83Z" }, - { url = "https://files.pythonhosted.org/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096, upload-time = "2025-10-08T09:15:11.11Z" }, - { url = "https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708, upload-time = "2025-10-08T09:15:12.554Z" }, - { url = "https://files.pythonhosted.org/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119, upload-time = "2025-10-08T09:15:13.589Z" }, - { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" }, - { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" }, - { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" }, - { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" }, - { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" }, - { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" }, - { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" }, - { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" }, - { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" }, - { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" }, - { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" }, - { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" }, - { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" }, - { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" }, - { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" }, - { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" }, - { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" }, - { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" }, - { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" }, - { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" }, - { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" }, - { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" }, - { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" }, - { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" }, - { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/f9/c0a1c127f9049db9155afc316952ea571720dd01833ff5e4d7e8e6352dbb/msgpack-1.2.1.tar.gz", hash = "sha256:04c721c2c7448767e9e3f2520a475663d8ee0f09c31890f6d2bd70fd636a9647", size = 183960, upload-time = "2026-06-18T16:13:52.594Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/16/f70100614b69feb3ade7285f08c9c52d6cda0a5c03f3f5e2facd63acb211/msgpack-1.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8c7b398c56ff125feae96c2737abfec5595f1fa0aa186df60c56040b8accb95c", size = 82926, upload-time = "2026-06-18T16:12:31.531Z" }, + { url = "https://files.pythonhosted.org/packages/e4/3c/08ecd5cdfe4e2de43aec79062028ad0f7b2d9b1fea5430068c198ba570da/msgpack-1.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1548006a91aa93c5da81f3bdcebc1a0d10cea2d25969754fbe848da622b2b895", size = 82730, upload-time = "2026-06-18T16:12:32.894Z" }, + { url = "https://files.pythonhosted.org/packages/19/9f/a70c9cb1a04ecc134005149367dcfe35d167284e8f65035a1e4156ad17b5/msgpack-1.2.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1dabedcd0f23559f3596428c6589c1cd8c6eaed3a0d720795b07b0225d769203", size = 400729, upload-time = "2026-06-18T16:12:34.052Z" }, + { url = "https://files.pythonhosted.org/packages/fa/7f/5ce020168cf0439041526e95aa068c722c016aee21624e331aeabeee2e8e/msgpack-1.2.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83efa1c898e0fc5380fc0cabbf75164c52e3b5cbb45973710d75821928380c73", size = 407625, upload-time = "2026-06-18T16:12:35.239Z" }, + { url = "https://files.pythonhosted.org/packages/79/70/fb7668ce0386819303047057aef6fc1da73b584291d9cff82b821744e2ef/msgpack-1.2.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01e2dd6c9b19d333a00282330cc8a73d38d8dabc306dc5b42cd668c3ac82e833", size = 377891, upload-time = "2026-06-18T16:12:36.684Z" }, + { url = "https://files.pythonhosted.org/packages/3d/dc/9ebe654a73c3aed2e40aa6b52e3c2a02b5f53ef0085fa235a45d5b367f87/msgpack-1.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:350cb813d0af6e65d2f7ef0d729f7ff5be5a8bce03665892f43e5883d4ecc1b8", size = 391987, upload-time = "2026-06-18T16:12:37.839Z" }, + { url = "https://files.pythonhosted.org/packages/42/eb/b67cf64218a2fa25e1c671fe1d3dbb06cbeb973e71bc4b822da079862d0b/msgpack-1.2.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ee1d9ed27d0497b848923746cf762ed2e7db24f4be7eec8e5cbe8c766aa707b7", size = 374603, upload-time = "2026-06-18T16:12:39.221Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2e/9ee200cde32fd1a0101b4006202fde554c1860adfb9bf7bff31ea4c08df8/msgpack-1.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:633727297ed063441fd1cda2288865487f33ad14eeb8831afb5f0c396a62cfce", size = 405121, upload-time = "2026-06-18T16:12:40.524Z" }, + { url = "https://files.pythonhosted.org/packages/43/b6/f10117be7ca7a51e8feed699a907b8e663a8cd66e115ae6b4fb30cc7945c/msgpack-1.2.1-cp310-cp310-win32.whl", hash = "sha256:298872ecf9e61950f1c6af4ca969b859ee91783bb920ef6e6172697d0c8aad74", size = 64088, upload-time = "2026-06-18T16:12:41.762Z" }, + { url = "https://files.pythonhosted.org/packages/ba/93/89976c696fb0224662239d952c47b4d1661b34d79a332ef5584facaa8579/msgpack-1.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:2ff164c1b0bcb740b073b99e945234d0212852fa378e44a208c425379140dbeb", size = 70113, upload-time = "2026-06-18T16:12:42.78Z" }, + { url = "https://files.pythonhosted.org/packages/f4/6b/e9b1cdc042c4458801d2545ed782a95f3d6ba8e270cce8745b8603c7f748/msgpack-1.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:29a3f6e9667868429d8240dfd063ea5ffdc1321c13d783aa23827a38de0dcb22", size = 82812, upload-time = "2026-06-18T16:12:45.022Z" }, + { url = "https://files.pythonhosted.org/packages/0c/3a/dd518a1bf78ed1e9ad8afe57307c079a00eafe4b3068932a27ca1ea56b4f/msgpack-1.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:aded5bdf32609dc7987a49bbbd15a8ef096193f96dd8bbeb791de729e650acf5", size = 82739, upload-time = "2026-06-18T16:12:46.025Z" }, + { url = "https://files.pythonhosted.org/packages/70/e0/7ba9e1542bf0771a27b8b37c1316e3f95ae9d748fd765284655c476ad4ef/msgpack-1.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:146ee4e9ce80b365c6d4c47073da9da7bcec473e58194ceee5dd7620ace77e06", size = 414233, upload-time = "2026-06-18T16:12:47.029Z" }, + { url = "https://files.pythonhosted.org/packages/03/8d/671d81534ea0e2b0e8a121be100020da09eb78861fe3aa8f3ef7dcd3bed1/msgpack-1.2.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a28d076ca7c82b9c8728ad90b7147489449557038bed50e4241eb832395169b4", size = 423843, upload-time = "2026-06-18T16:12:48.19Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b6/e5c737515ed1f166664b87601b532f58cbb73d8aa6a90b99f7c2c5037e8e/msgpack-1.2.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7d31c0ac0c640f877804c67cb2bc9f4e23dc2db97e96c2e67fa27d38283b41f8", size = 390772, upload-time = "2026-06-18T16:12:49.624Z" }, + { url = "https://files.pythonhosted.org/packages/a8/46/62ed8c2e87d7021eab19921594d961ef3aa3794eec76c716dc30f3bfd433/msgpack-1.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8ff92d7feeaf5bc26c51495b69e2f99ed97ab79346fb6555f44be7dd2ac6503b", size = 409559, upload-time = "2026-06-18T16:12:50.936Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/59aa3887b860bbf43532835e192b1c388a17590d6068ae4f8b2bc74c906e/msgpack-1.2.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:779197a6513bab3c3632265e3d0f7cb3227e62510841a6f34f1eaa37efbb345e", size = 387838, upload-time = "2026-06-18T16:12:52.161Z" }, + { url = "https://files.pythonhosted.org/packages/09/11/f8563e471093420cf6478cb3271a0175d8402b82d879783d4035d2d03360/msgpack-1.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:67f6dd22fa72a93752643f07889796d62739a13415ee630169a8ce764f86cf9f", size = 421732, upload-time = "2026-06-18T16:12:53.556Z" }, + { url = "https://files.pythonhosted.org/packages/57/cf/e673683c4c6c90c1022b24c65af4b03eda72b182a1176ef6449069d66acc/msgpack-1.2.1-cp311-cp311-win32.whl", hash = "sha256:91054a783328e0ea7954b8771095705c8d2243b814743fbaadf14552c9c52c5d", size = 64091, upload-time = "2026-06-18T16:12:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/3f/07/ca212739d179f9083bff2c7c08c24101c3555a334fadc2b876b18768a3ae/msgpack-1.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2eda0b7ebb1283a98d3e4492ac933c8af6aff59fd3df1c3ed024f536af4b1dc8", size = 70462, upload-time = "2026-06-18T16:12:55.898Z" }, + { url = "https://files.pythonhosted.org/packages/6d/be/6798347b425e26f35db82e69dd83c09716c856a3714e7bffc4c0860fd830/msgpack-1.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6ee967f7c7e1df2890c671ff2ee51a28ded0efc95da3e507176dee881ce36c66", size = 65059, upload-time = "2026-06-18T16:12:57.053Z" }, + { url = "https://files.pythonhosted.org/packages/bc/dd/9e8cbd8f5582ca4b590336f2b91ee5662f6a6ca562b565abaf696a0f81ff/msgpack-1.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2ef59c659f289eddf8aa6623823f19fa2f40a4029266889eac7a2505dd210c35", size = 83531, upload-time = "2026-06-18T16:12:58.249Z" }, + { url = "https://files.pythonhosted.org/packages/50/2e/ebdb85a8da151397a2790363676b7ed7c125924fe618e4c6d8befb0cc62c/msgpack-1.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3567748a5107cb40cdf66a275430c2f87c07777698f4bfd25c35f44d533258c", size = 82657, upload-time = "2026-06-18T16:12:59.396Z" }, + { url = "https://files.pythonhosted.org/packages/26/aa/753ad8b007b464e1d8aa0c8e650b9c5f4f725e658fc5ac8a7635c55b7f6e/msgpack-1.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60926b75d00c8e816ef98f3034f484a8bc64242d66839cef4cf7e503142316a0", size = 410634, upload-time = "2026-06-18T16:13:00.383Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/6adabd4f6d5e686f97dd02ce7fce3fe4cf672cbac36b8f67ff4040e8ad8b/msgpack-1.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:020e881a764b20d8d7ca1a54fc01b8175519d108e3c3f194fddc200bda95951a", size = 419989, upload-time = "2026-06-18T16:13:01.776Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cc/85039b7b0eb168aaad7383a23c97e291a11f08351cb45a606ce865e4e3f1/msgpack-1.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4202c74688ca06591f78cb18988228bd4cca2cc75d57b60008372892d2f1e6e6", size = 377544, upload-time = "2026-06-18T16:13:03.637Z" }, + { url = "https://files.pythonhosted.org/packages/ed/bf/35963899493b32030c85fc513b723ae66144ac70c11ebc52e889e16e3d99/msgpack-1.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8b267ce94efb76fbd1b3373511420074ee3187f0f7811bf394531de13294735a", size = 400842, upload-time = "2026-06-18T16:13:05.012Z" }, + { url = "https://files.pythonhosted.org/packages/a6/df/8e2ac970c8f99264cd9997d1c73df5466bc19da3301d7dc5500862a9b089/msgpack-1.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f1d0f8f98ade9634e01fb704a408f9336c0a8f1117b369f5db83dc7551d8b1", size = 374108, upload-time = "2026-06-18T16:13:06.232Z" }, + { url = "https://files.pythonhosted.org/packages/17/dd/fa8bd265110dfa51c20cb529f9e6d240a16fafe7e645004c6af2d01353ba/msgpack-1.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f02cf17a6ca1abe29b5f980644f7551f94d71f2011509b26d8625ce038f0df64", size = 414939, upload-time = "2026-06-18T16:13:07.478Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b9/8377a5ad8953fc0437c70cc98d9ae29f27fe5ac5109fbec0812085865735/msgpack-1.2.1-cp312-cp312-win32.whl", hash = "sha256:0c0d9802354507bcba62af19c17918e3eb437cc25e6f50657d511b5856a77aac", size = 64504, upload-time = "2026-06-18T16:13:08.822Z" }, + { url = "https://files.pythonhosted.org/packages/57/7f/ce1e377df7e62461fefd9eb23bfb93a4a523f40a517b377b8f844d836828/msgpack-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c24aa15d5963051e1a5c62b12c50cd705992502b5ec1f3bece6046f33c9fc24", size = 71421, upload-time = "2026-06-18T16:13:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/8f/32/ebfe84c9929f08f188d56c7a2fd913406a9ddad76a634697c1c43b8112e6/msgpack-1.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:4227224aaec8f7fbcbfbd4272319347b2bb4030366502600f8c45588c5187b07", size = 64775, upload-time = "2026-06-18T16:13:11.056Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ac/dcddcab6f6c20ecb387ca5e980371cdb3f87ff69aeca388be97eebc4c074/msgpack-1.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a70e3cf2804a300d921bb0940426e35f4e489a23adfb77a808892241db0a064", size = 83151, upload-time = "2026-06-18T16:13:12.173Z" }, + { url = "https://files.pythonhosted.org/packages/64/71/fbcfa83a1d6a9c6091942d1cfd070962244664b87427a9a49a6897b1b219/msgpack-1.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:491cc39455ca765fad51fb451bf2915eb2cf41192ab5801ce8d67c1d614fe056", size = 82351, upload-time = "2026-06-18T16:13:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/e3/10/ddf7b06db879e8792d13934ddda09ff20bd2a583fd84c9b59aae9b0e650b/msgpack-1.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f310233ef7fb9c14e201c93639fe5f5260b005f56f0b29048e999c30935596cc", size = 407518, upload-time = "2026-06-18T16:13:14.233Z" }, + { url = "https://files.pythonhosted.org/packages/79/d3/36a46a8ed992b781acbc05928bd5bee3c810cb0c3563bf81a7b0c04a1a76/msgpack-1.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787c9bebb5833e8f6fc8abca3c0597683d8d87f56a8842b6b89c75a5f3176e2d", size = 416405, upload-time = "2026-06-18T16:13:15.435Z" }, + { url = "https://files.pythonhosted.org/packages/f9/84/e8e9598b557c0ba6ddae901a73780a4c75ac667dddf59414b1e56a42fb34/msgpack-1.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc871b997a9370d855b7394465f2f350e847a5b806dd38dcc9c989e7d87da155", size = 376257, upload-time = "2026-06-18T16:13:17.022Z" }, + { url = "https://files.pythonhosted.org/packages/40/16/738fe6d875ad7e2a9429c165322a4ec088f4f273cdfae63d96a89c467961/msgpack-1.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:85f57e960d877f2977f6430896191b04a21f8901b3b4baf2e4604329f4db5402", size = 397469, upload-time = "2026-06-18T16:13:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/ca/be/6d5952df75a7f24f35833af764c3a6860780364cb3a0030beb8099e1b2b4/msgpack-1.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1233ee2dd0cefba127583de50ea654677277047d238303521db35def3d7b2e7c", size = 372802, upload-time = "2026-06-18T16:13:19.685Z" }, + { url = "https://files.pythonhosted.org/packages/e1/39/e2ef7dbf0473bcb8dc7c50bf782a892d67414877b63e47fc88eb189ef5e6/msgpack-1.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3dc2feb0876209d9c38aa56cb1de169bd6c4348f1aa48271f241226590993e6", size = 411273, upload-time = "2026-06-18T16:13:21.028Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c5/133f4512a56e983a93445c836c9d94d88f3bc2e0980ff4b9e577bd8416ce/msgpack-1.2.1-cp313-cp313-win32.whl", hash = "sha256:6d09badf350af2be9d189184e04e64cf54ad93569ab3d96fca58bd3e84aad707", size = 64471, upload-time = "2026-06-18T16:13:22.293Z" }, + { url = "https://files.pythonhosted.org/packages/e2/98/577e10b055096a7dd40732358cabaf7180a20c79ed1dcdbb618e4b9deac7/msgpack-1.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:33f14fba63278b714efe6ad07e50ea5f03d91537aa6a1c5f1ceca4cf44013ca9", size = 71274, upload-time = "2026-06-18T16:13:23.455Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ee/0c0048e7cfbef23c6a94791b8959ab28155232e7956de8a305b5ff588f05/msgpack-1.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc5febcd4c99effbc02b528e49d6fd0760b2b7d48c05239e345a5fa6e743d9a", size = 64795, upload-time = "2026-06-18T16:13:24.687Z" }, + { url = "https://files.pythonhosted.org/packages/77/58/cce442852c6b9e1639c7c8ac8fd9143121cb32dab0f308df4d1426a8eb9c/msgpack-1.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:05f340e47e7e47d2da8db9b53e1bb1d294369e9ef45a747441309f6650b8351d", size = 83610, upload-time = "2026-06-18T16:13:25.724Z" }, + { url = "https://files.pythonhosted.org/packages/60/5c/15b4c7a0182f75ffa90751958ba36a9c01cafee367d49a3edc10ed140b01/msgpack-1.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:810b916696c86ef0deb3b74588480224df4c1b071136c34183e4a2a4284d7ac7", size = 83138, upload-time = "2026-06-18T16:13:26.781Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/99e58722feaffc5f2fbcc0c8c0d1451ab9f84097f7af87291b46af2390f4/msgpack-1.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca0dacff965c47afdc3749a8469d7302a8f801d6a28758d55120d75e66ce6889", size = 406090, upload-time = "2026-06-18T16:13:28.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/03/8c63e8cf52958534ef688625965ab04c269a6cadd8caef16758b380a821a/msgpack-1.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e2bf9280bceb5efca998435904b5d3e9fdbcc11d90dc9df30aec7973252b720", size = 412106, upload-time = "2026-06-18T16:13:29.427Z" }, + { url = "https://files.pythonhosted.org/packages/63/d2/155d9e71b40e41fd934bc0c48b9b2770f22263e1ac20aad8e29fdca7be3f/msgpack-1.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6c4be5d1c02a42b066ca6ddb71adf36432868fdcdb6ee87e634e86e0674190", size = 374851, upload-time = "2026-06-18T16:13:30.631Z" }, + { url = "https://files.pythonhosted.org/packages/98/48/deaf2326262a8d5ea3295ce9649912ecd3f551ba7ec8e33c665d2ba583f3/msgpack-1.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec0e675d59150a6269ddc9139087c722292664a37d071a849c05c473350f1f2d", size = 396168, upload-time = "2026-06-18T16:13:31.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/2a/b4410f906c2ec0008f1608d3ab5143afc3ad3f4e6da0fed3ea2231d0bef4/msgpack-1.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:dd3bfe82d53edfe4b7fc9a7ec9761e23a7a5b1dac22264505af428253c29ed24", size = 371959, upload-time = "2026-06-18T16:13:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/59/86/1edc67270099a528fa2093ea60fe191233cd238e4bd30cfacf7db79fc959/msgpack-1.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5ad5467fc3f68b5468e06c5f788d712e9f8ffc8b0cd1bcb160c105c1ee92dae7", size = 408457, upload-time = "2026-06-18T16:13:34.567Z" }, + { url = "https://files.pythonhosted.org/packages/82/90/8b630fef07d8c5ab457b71ff2c217910c83d333c7a68472c186e87cc504a/msgpack-1.2.1-cp314-cp314-win32.whl", hash = "sha256:98b58bdb89c46190e4609bb36abe17c6d4105ad13f9c5f8f6f64d320f8ced3fb", size = 65942, upload-time = "2026-06-18T16:13:36.056Z" }, + { url = "https://files.pythonhosted.org/packages/16/f1/467b81e98b24dd3885d7b1857728797b4ffc76a7a7483af4fb321a07de3c/msgpack-1.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:74847557e28ce71bd3c438a447ca90e4b507e997ddbdef8a12a7b283b86c156b", size = 72627, upload-time = "2026-06-18T16:13:37.079Z" }, + { url = "https://files.pythonhosted.org/packages/a7/1d/5d8c4c89985feb6acefb82a09e501c60392261856d2408d20bfe4f0360b1/msgpack-1.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:b50b727bd652bdc37d950336c848ef20ec54a4cafc38dce19b1cd86ad625d0f7", size = 66908, upload-time = "2026-06-18T16:13:38.23Z" }, + { url = "https://files.pythonhosted.org/packages/1b/02/ad2afb678b4de94496cd432b581759b756a92c1192d8c767edd6b132efdc/msgpack-1.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8d00f177ca88a77c1cf848d204a38f249751650b601cb6532acc68805d8a8273", size = 86000, upload-time = "2026-06-18T16:13:39.44Z" }, + { url = "https://files.pythonhosted.org/packages/54/74/0b797484013128837f3b1cbb6cea019277c4de4e377dc512b4d9a0f92940/msgpack-1.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5bb9c386f0a329c035ddbab4b72d1028bf9627add8dda41070288563d57ed1b1", size = 86544, upload-time = "2026-06-18T16:13:40.447Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b4/b774d7eb95561739907fec675582f83203cf41c597a418c2589b4bfb8e9d/msgpack-1.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20466cca18c49c7292a8984bc15d65857b171e7264bdcb5f96baf8be238791fc", size = 427661, upload-time = "2026-06-18T16:13:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f9/3243191dc9937e00756c8bc1b0272fed8f23758e43df2a3b46f533e5090f/msgpack-1.2.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:196300e7e5d6e74d50f1607ab9c06c4a1484c383cd22defd727902591f7e8dde", size = 426375, upload-time = "2026-06-18T16:13:42.936Z" }, + { url = "https://files.pythonhosted.org/packages/23/c7/1693111db9944ba4ad4b67a1e788400d78a0b6af7a6523dc7e4e58f8274b/msgpack-1.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575957e79cd51903a4e8495a242442949641e08f1efd5197b43bebd3ea7682b4", size = 380495, upload-time = "2026-06-18T16:13:44.306Z" }, + { url = "https://files.pythonhosted.org/packages/3e/2b/92f86956a0c13e8662f7e2ad630c4eb4db07497b967589bd5245e018b2c1/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8c2ed1e48cc0f460bf3c7780e7137ff21a4e18433451916f2442c1b21036cd7d", size = 410897, upload-time = "2026-06-18T16:13:45.629Z" }, + { url = "https://files.pythonhosted.org/packages/da/ea/1479f72d200313a76fc2f823a79d1e07ed052ab7b8a0280640aa7b95de42/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5f6277e5f783c36786a145e0247fc189a03f35f84b251646e53592d2bc12b355", size = 378519, upload-time = "2026-06-18T16:13:46.998Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4d/fa006060ffa1011d32bfae826fe766fe73e02982183601633b7121058ab3/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9389552ecf4784886345ead0647e4edc96bee37cbab05b75540f542f766c48c", size = 419815, upload-time = "2026-06-18T16:13:48.205Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/aab6c946570496b78e67804721f3d5e2d62a93081b9b37df77764ef56347/msgpack-1.2.1-cp314-cp314t-win32.whl", hash = "sha256:c1c79a604a2969a868a78b6ebd27a887e00c624f14f66b3038e0590cb23332d1", size = 70914, upload-time = "2026-06-18T16:13:49.385Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/e608956488a2af014cfe6e3d665e090b8ee42aa14b07f8f95b8880d66b09/msgpack-1.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f12038a35fabd52e56a3547bab42401af49a45caa6dd00b34c44de235bc93ee2", size = 77999, upload-time = "2026-06-18T16:13:50.467Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8a/27e2e57055176e366a46b85d02d68e7a5bcfbdd8474c9706375d965f24d3/msgpack-1.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0adcf06ffde0777c0e1a9b771a2b1c4226ba1bbf748c8efcc02fcdeca3299107", size = 71160, upload-time = "2026-06-18T16:13:51.498Z" }, ] [[package]] @@ -1498,15 +1510,15 @@ wheels = [ [[package]] name = "starlette" -version = "1.2.1" +version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/25/44/ec35f1b6e83094b997da438a02c8c9b0ade2b1e84cfc48bd4656780760a6/starlette-1.2.1.tar.gz", hash = "sha256:9b9b5ebb992e67d6093741e63c2f59e4f6fff986f81163c087867bd7b924b3f6", size = 2701854, upload-time = "2026-05-31T01:07:51.847Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/16/dc49e4b9d348b0f0567a67613a946f4e26b3299408ccf78df318c51a2aec/starlette-1.4.0.tar.gz", hash = "sha256:ecf3a067176d63c6412f98c660dab3355b525584d3725c0c40a4519d33ec2130", size = 2708995, upload-time = "2026-08-05T09:36:43.693Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/54/196d0c1db10af76baa4f64894448505d60d3cdf70ef92cbb35f46a4e4c71/starlette-1.2.1-py3-none-any.whl", hash = "sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89", size = 73350, upload-time = "2026-05-31T01:07:50.09Z" }, + { url = "https://files.pythonhosted.org/packages/be/f6/7916cd7717e196bba370c0f17abb30466014ce29094665c3581e51a8c24b/starlette-1.4.0-py3-none-any.whl", hash = "sha256:cacd43738e07b834844e2c8e97b898b40089d74f2678234b0a3e93cd3d515813", size = 74021, upload-time = "2026-08-05T09:36:41.944Z" }, ] [[package]] From 6d3bec7ae14f66e460ff016f6dc96dc76c8684ce Mon Sep 17 00:00:00 2001 From: Sebastiano Date: Wed, 5 Aug 2026 15:06:48 +0200 Subject: [PATCH 19/20] fix(dependabot): fix time format in configurationpre --- .github/dependabot.yml | 6 +- .pre-commit-config.yaml | 6 +- scripts/check_yaml_sexagesimal.py | 108 +++++++++++++++++++++++++++ tests/test_check_yaml_sexagesimal.py | 106 ++++++++++++++++++++++++++ 4 files changed, 222 insertions(+), 4 deletions(-) create mode 100755 scripts/check_yaml_sexagesimal.py create mode 100644 tests/test_check_yaml_sexagesimal.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 361fa2b..4034eba 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,7 +8,7 @@ updates: schedule: interval: weekly day: monday - time: 06:00 + time: '06:00' timezone: Europe/Rome open-pull-requests-limit: 10 commit-message: @@ -24,7 +24,7 @@ updates: schedule: interval: weekly day: monday - time: 06:30 + time: '06:30' timezone: Europe/Rome open-pull-requests-limit: 5 commit-message: @@ -40,7 +40,7 @@ updates: schedule: interval: weekly day: monday - time: 07:00 + time: '07:00' timezone: Europe/Rome open-pull-requests-limit: 5 commit-message: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 51d75c7..6de3df8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,8 +40,12 @@ repos: - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks rev: v2.16.0 hooks: + # --preserve-quotes matters: ruamel follows YAML 1.2, where a bare 06:00 + # is a string, so without it the formatter strips "redundant" quotes that + # YAML 1.1 parsers (Ruby's Psych, used by Dependabot) need in order to + # read the value as a string instead of the integer 21600. - id: pretty-format-yaml - args: [--autofix, --indent, '2'] + args: [--autofix, --indent, '2', --preserve-quotes] # Check for common spelling mistakes - repo: https://github.com/codespell-project/codespell rev: v2.4.2 diff --git a/scripts/check_yaml_sexagesimal.py b/scripts/check_yaml_sexagesimal.py new file mode 100755 index 0000000..31f9812 --- /dev/null +++ b/scripts/check_yaml_sexagesimal.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause + +"""Flag unquoted HH:MM values in YAML files. + +YAML 1.1 reads a bare ``06:00`` as a sexagesimal integer, so Ruby's Psych +parser -- which GitHub's Dependabot uses -- sees ``21600`` where a string +was intended and rejects the file. Python parsers follow YAML 1.2 and read +the same scalar as a string, so neither the ``check-yaml`` hook nor JSON +Schema validation can catch this: they never see a number. + +The scan is line based rather than parser based for that reason. Quoting +the value fixes it, and ``pretty-format-yaml`` needs ``--preserve-quotes`` +so it does not strip the quotes back off. +""" + +from __future__ import annotations + +import argparse +import re +from pathlib import Path + +# Mirrors the sexagesimal pattern in Ruby's Psych scalar scanner. +SEXAGESIMAL = re.compile(r"[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+") + + +def sexagesimal_to_int(value: str) -> int: + """Convert a sexagesimal scalar the way Ruby's Psych parser does. + + Args: + value: A scalar such as ``06:00``. + + Returns: + The integer a YAML 1.1 parser produces for that scalar. + """ + parts = value.replace("_", "").split(":") + return sum( + int(part) * 60 ** abs(index - 2) for index, part in enumerate(parts) + ) + + +def bare_sexagesimal_values(content: str) -> list[tuple[int, str]]: + """Find unquoted sexagesimal scalars in YAML content. + + Args: + content: The YAML document text. + + Returns: + A list of (line number, value) pairs, in file order. + """ + findings: list[tuple[int, str]] = [] + for number, raw_line in enumerate(content.splitlines(), start=1): + line = raw_line.split(" #", 1)[0].strip() + if not line or line.startswith("#"): + continue + if line.startswith("- "): + line = line[2:].strip() + + _, separator, value = line.partition(": ") + candidate = value.strip() if separator else line + if SEXAGESIMAL.fullmatch(candidate): + findings.append((number, candidate)) + return findings + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + """Parse command-line arguments. + + Args: + argv: Arguments to parse, or None to read them from the command + line. + + Returns: + The parsed arguments. + """ + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("paths", nargs="*", help="YAML files to check.") + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + """Report every unquoted sexagesimal value in the given files. + + Args: + argv: Arguments to parse, or None to read them from the command + line. + + Returns: + 1 if any file contains an unquoted sexagesimal value, else 0. + """ + args = parse_args(argv) + found = False + for path in args.paths: + content = Path(path).read_text(encoding="utf-8") + for number, value in bare_sexagesimal_values(content): + print( + f"{path}:{number}: {value} is read as the integer " + f"{sexagesimal_to_int(value)} by YAML 1.1 parsers such as " + f"Dependabot's; quote it as '{value}'" + ) + found = True + return 1 if found else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_check_yaml_sexagesimal.py b/tests/test_check_yaml_sexagesimal.py new file mode 100644 index 0000000..f65cea3 --- /dev/null +++ b/tests/test_check_yaml_sexagesimal.py @@ -0,0 +1,106 @@ +# SPDX-FileCopyrightText: 2026 the Python Template contributors +# +# SPDX-License-Identifier: BSD-2-Clause + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from scripts.check_yaml_sexagesimal import ( + bare_sexagesimal_values, + main, + sexagesimal_to_int, +) + +if TYPE_CHECKING: + from pathlib import Path + +DEPENDABOT = """version: 2 +updates: +- package-ecosystem: uv + directory: / + schedule: + interval: weekly + day: monday + time: 06:00 + timezone: Europe/Rome +""" + + +@pytest.mark.parametrize( + ("value", "expected"), + [("06:00", 21600), ("06:30", 23400), ("07:00", 25200), ("1:02:03", 3723)], +) +def test_sexagesimal_to_int_matches_the_ruby_result( + value: str, expected: int +) -> None: + assert sexagesimal_to_int(value) == expected + + +def test_bare_sexagesimal_values_flags_an_unquoted_time() -> None: + assert bare_sexagesimal_values(DEPENDABOT) == [(8, "06:00")] + + +def test_bare_sexagesimal_values_accepts_a_quoted_time() -> None: + assert bare_sexagesimal_values(DEPENDABOT.replace("06:00", "'06:00'")) == [] + + +def test_bare_sexagesimal_values_flags_a_sequence_item() -> None: + assert bare_sexagesimal_values("times:\n- 06:00\n") == [(2, "06:00")] + + +def test_bare_sexagesimal_values_reports_every_line() -> None: + content = "a: 06:00\nb: 06:30\nc: '07:00'\n" + + assert bare_sexagesimal_values(content) == [(1, "06:00"), (2, "06:30")] + + +@pytest.mark.parametrize( + "content", + [ + "version: 2\n", + "timeout-minutes: 20\n", + "timezone: Europe/Rome\n", + "python-version: '3.10'\n", + "cron: '0 6 * * 1'\n", + "url: http://example.com:8080\n", + "# time: 06:00\n", + "schedule:\n", + # 60 is out of range, so a YAML 1.1 parser leaves it a string. + "time: 06:60\n", + ], +) +def test_bare_sexagesimal_values_ignores_unambiguous_scalars( + content: str, +) -> None: + assert bare_sexagesimal_values(content) == [] + + +def test_bare_sexagesimal_values_ignores_a_trailing_comment() -> None: + assert bare_sexagesimal_values("time: '06:00' # daily\n") == [] + + +def test_main_reports_the_offending_file_and_line( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + path = tmp_path / "dependabot.yml" + path.write_text(DEPENDABOT, encoding="utf-8") + + assert main([str(path)]) == 1 + + output = capsys.readouterr().out + assert f"{path}:8" in output + assert "21600" in output + assert "'06:00'" in output + + +def test_main_accepts_a_quoted_file(tmp_path: Path) -> None: + path = tmp_path / "dependabot.yml" + path.write_text( + DEPENDABOT.replace("06:00", "'06:00'"), + encoding="utf-8", + ) + + assert main([str(path)]) == 0 From e483e23b4e694003499fb5a87e3bb97e42f65e09 Mon Sep 17 00:00:00 2001 From: Sebastiano Date: Wed, 5 Aug 2026 15:09:27 +0200 Subject: [PATCH 20/20] feat(pre-commit): add check for unquoted YAML clock values and update SPDX files --- .pre-commit-config.yaml | 12 ++++++++++++ scripts/bootstrap_template.py | 2 ++ 2 files changed, 14 insertions(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6de3df8..4bea083 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -46,6 +46,18 @@ repos: # read the value as a string instead of the integer 21600. - id: pretty-format-yaml args: [--autofix, --indent, '2', --preserve-quotes] + + # Runs after the formatter, because a bare HH:MM is a string to every + # Python YAML parser: check-yaml and JSON Schema validation both accept + # the value Dependabot rejects, so it needs a dedicated check. +- repo: local + hooks: + - id: check-yaml-sexagesimal + name: check for unquoted YAML clock values + entry: python scripts/check_yaml_sexagesimal.py + language: python + types: [yaml] + # Check for common spelling mistakes - repo: https://github.com/codespell-project/codespell rev: v2.4.2 diff --git a/scripts/bootstrap_template.py b/scripts/bootstrap_template.py index b9f22b6..0c3025a 100755 --- a/scripts/bootstrap_template.py +++ b/scripts/bootstrap_template.py @@ -70,12 +70,14 @@ Path("examples/say_hi.py"), Path("scripts/add_spdx_headers.py"), Path("scripts/bootstrap_template.py"), + Path("scripts/check_yaml_sexagesimal.py"), Path("scripts/gen_changelog.py"), Path("scripts/release.py"), Path("scripts/update_coverage_readme.py"), Path("scripts/validate_distribution.py"), Path("tests/test_add_spdx_headers.py"), Path("tests/test_bootstrap_template.py"), + Path("tests/test_check_yaml_sexagesimal.py"), Path("tests/test_gen_changelog.py"), Path("tests/test_greeter.py"), Path("tests/test_release.py"),