diff --git a/.github/workflows/template-ci.yml b/.github/workflows/template-ci.yml index fa9196e..619d1a7 100644 --- a/.github/workflows/template-ci.yml +++ b/.github/workflows/template-ci.yml @@ -91,6 +91,22 @@ jobs: test ! -e /tmp/out-app/.github/workflows/publish.yml echo "Both variants rendered and validated." + for kind in app library; do + out="/tmp/out-$kind" + test -f "$out/changelog.d/TEMPLATE.md" + grep -q '' "$out/CHANGELOG.md" + ! grep -q '## \[Unreleased\]' "$out/CHANGELOG.md" || exit 1 + printf '### Added\n\n- Smoke one.\n' > "$out/changelog.d/smoke1.md" + printf '### Added\n\n- Smoke two.\n' > "$out/changelog.d/smoke2.md" + (cd "$out" && uvx --from 'scriv>=1.8' scriv collect) + grep -q '^## \[0\.1\.0\] - ' "$out/CHANGELOG.md" + # two fragments in one category must collect into one tight list + grep -A1 '^- Smoke one\.$' "$out/CHANGELOG.md" | grep -q '^- Smoke two\.$' + test ! -e "$out/changelog.d/smoke1.md" + test -f "$out/changelog.d/TEMPLATE.md" + done + echo "scriv collects fragments into CHANGELOG.md in both variants." + # use_typos defaults on, and answering no drops the hook uvx copier copy --trust --defaults --vcs-ref=HEAD \ --data project_name="sample-no-typos" \ diff --git a/CHANGELOG.md b/CHANGELOG.md index d8c91b3..fe81c75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ Entries for 1.0.0 through 1.5.2 were backfilled from git history after the fact, ### Added +- Scaffolded projects keep their changelog as [scriv](https://scriv.readthedocs.io/) fragments: each PR adds a file under `changelog.d/` (`uv run scriv create`) instead of editing `CHANGELOG.md`, and `uv run scriv collect` writes them into `CHANGELOG.md` at release time. Two PRs that both edited `## [Unreleased]` conflicted on every merge; fragments are separate files, so they never do. The scaffold gains `[tool.scriv]` in `pyproject.toml` (Keep a Changelog categories, `## [X.Y.Z] - YYYY-MM-DD` headings, the version read from `__init__.py` for libraries or `pyproject.toml` for apps), `scriv` in the dev group, `changelog.d/TEMPLATE.md`, and a `` marker in place of `## [Unreleased]`. `RELEASING.md` and the README say how to use them. +- `CHANGELOG.md` is listed in `_skip_if_exists`, so `copier update` leaves an existing changelog alone. It does create one if the file is missing, so a project that keeps its changelog elsewhere gets a new `CHANGELOG.md` proposed on each update. +- **Upgrading an existing project:** `copier update` brings in the config and `changelog.d/TEMPLATE.md` but not the marker. By hand: + - Replace the `## [Unreleased]` heading in `CHANGELOG.md` with ``, and move any unreleased entries into a fragment under `changelog.d/`. Skipping this makes `scriv collect` fail with `Entry 'Changelog' is not a valid version!`; its hint about `scriv-end-here` is not the fix. + - Check that `[tool.scriv] version` reads the file your build backend reads. Libraries get `src//__init__.py`, which is right for the template's hatchling setup. A project that moved to a static `version` in `pyproject.toml` (for example, uv_build) needs `literal: pyproject.toml: project.version`, or collect writes a stale version. - `python-ci.yml` and `node-ci.yml` take an `lfs` input, passed through to `actions/checkout`. It defaults to `false`, because an LFS pull costs bandwidth against the account quota on every run and most projects have nothing in LFS. Turn it on for a repo whose tests read LFS-tracked fixtures: without it the checkout produces pointer files, and the failure surfaces as whatever the reading library says about malformed input — `FzErrorFormat: no objects found` from PyMuPDF, in the case that prompted this — with nothing anywhere in the output mentioning LFS. - Repo settings as code, opt-in via `use_repo_settings` (default off, so `copier update --defaults` leaves existing projects alone): the scaffold diff --git a/README.md b/README.md index edc52cf..4a61e44 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,10 @@ standard so the repos don't drift: - Optional TypeScript/JavaScript side: **Biome** (lint + format) in the pre-commit stack, plus a shared **`node-ci`** workflow for type-check and build -- **Keep a Changelog** `CHANGELOG.md`; libraries also get PyPI trusted - publishing (`publish.yml` + `RELEASING.md`) +- **Keep a Changelog** `CHANGELOG.md`, written from per-PR fragments in + `changelog.d/` by [scriv](https://scriv.readthedocs.io/), so concurrent PRs + never conflict on it; libraries also get PyPI trusted publishing + (`publish.yml` + `RELEASING.md`) - A Claude Code `SessionStart` hook that pre-warms the toolchain. With a frontend it also points corepack at `registry.npmjs.org`, since some sandbox egress proxies block `repo.yarnpkg.com` and corepack then fails before Yarn or diff --git a/copier.yml b/copier.yml index 9471a8d..0db8af0 100644 --- a/copier.yml +++ b/copier.yml @@ -1,6 +1,12 @@ _subdirectory: template _min_copier_version: "9.0.0" +# A project's changelog is its own once scaffolded. Without this, every +# `copier update` that touched CHANGELOG.md.jinja would 3-way merge the +# template's few lines into a long project history and conflict. +_skip_if_exists: + - CHANGELOG.md + # Trim block-tag newlines so conditionals don't leave stray blank lines in # rendered text/markdown (keeps mdformat happy on a fresh scaffold). _envops: diff --git a/template/CHANGELOG.md.jinja b/template/CHANGELOG.md.jinja index e7956e2..bb1683e 100644 --- a/template/CHANGELOG.md.jinja +++ b/template/CHANGELOG.md.jinja @@ -4,8 +4,4 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] - -### Added - -- Initial project scaffold. + diff --git a/template/README.md.jinja b/template/README.md.jinja index b14ad9c..4ef0670 100644 --- a/template/README.md.jinja +++ b/template/README.md.jinja @@ -11,6 +11,8 @@ uv run pytest uv run basedpyright src ``` +Each pull request adds a changelog fragment rather than editing `CHANGELOG.md`, so concurrent PRs don't conflict. Run `uv run scriv create`, uncomment the sections that apply in the new file under `changelog.d/`, and commit it with the change, or delete it if the change needs no entry. At release time, bump the version{% if project_kind == 'app' %} in `pyproject.toml`{% endif %} and run `uv run scriv collect`, which writes the fragments into `CHANGELOG.md` under the new version and deletes them. + Linting (ruff, [zizmor](https://docs.zizmor.sh/), mdformat) runs via [pre-commit](https://pre-commit.com); CI runs the same stack plus basedpyright and pytest via the shared [`python-ci`](https://github.com/{{ github_owner }}/python-project-template) reusable workflow. {% if publish_to_pypi %} diff --git a/template/changelog.d/TEMPLATE.md b/template/changelog.d/TEMPLATE.md new file mode 100644 index 0000000..64e9ea6 --- /dev/null +++ b/template/changelog.d/TEMPLATE.md @@ -0,0 +1,49 @@ + + + + + + + + + + + + + diff --git a/template/pyproject.toml.jinja b/template/pyproject.toml.jinja index 7004ab1..b887ba6 100644 --- a/template/pyproject.toml.jinja +++ b/template/pyproject.toml.jinja @@ -29,6 +29,7 @@ dev = [ "pytest-cov>=7.0.0", "basedpyright>=1.39", "pre-commit>=4.0.0", + "scriv>=1.8", ] {% if project_kind == "library" -%} @@ -41,6 +42,27 @@ packages = ["src/{{ package_name }}"] [tool.uv] package = false {% endif %} + +[tool.scriv] +# One fragment per pull request in changelog.d/, collected into CHANGELOG.md +# at release time, so concurrent PRs never edit the same lines of the changelog. +format = "md" +fragment_directory = "changelog.d" +categories = ["Added", "Changed", "Deprecated", "Removed", "Fixed", "Security"] +skip_fragments = "[A-Z]*" # TEMPLATE.md +new_fragment_template = "file: TEMPLATE.md" +{% raw %}entry_title_template = "[{{ version }}] - {{ date.strftime('%Y-%m-%d') }}"{% endraw +%} +md_header_level = "2" +md_html_anchors = false +# Without this, two fragments in one category leave a blank line between them, +# which makes the list loose and fails mdformat on the release commit. +compact_fragments = true +{% if project_kind == "library" %} +version = "literal: src/{{ package_name }}/__init__.py: __version__" +{% else %} +version = "literal: pyproject.toml: project.version" +{% endif %} + [tool.ruff] line-length = 100 src = ["src"] diff --git a/template/{% if publish_to_pypi %}RELEASING.md{% endif %}.jinja b/template/{% if publish_to_pypi %}RELEASING.md{% endif %}.jinja index f20a950..9f92b0d 100644 --- a/template/{% if publish_to_pypi %}RELEASING.md{% endif %}.jinja +++ b/template/{% if publish_to_pypi %}RELEASING.md{% endif %}.jinja @@ -17,7 +17,7 @@ The first tagged release creates the PyPI project and converts the pending publi ## Each release 1. Bump `__version__` in `src/{{ package_name }}/__init__.py` (single source of truth — `pyproject.toml` reads it). -2. Move the `## [Unreleased]` entries in `CHANGELOG.md` under a new `## [X.Y.Z] - YYYY-MM-DD` heading. +2. Run `uv run scriv collect`. It reads the new version, writes the fragments in `changelog.d/` into `CHANGELOG.md` under `## [X.Y.Z] - YYYY-MM-DD`, and deletes them. 3. Commit, then tag and push: ```bash git tag vX.Y.Z