From 07c5ec5a8da2a2f764b6c5802b826824418aa316 Mon Sep 17 00:00:00 2001 From: Matt Fisher Date: Fri, 2 Oct 2026 17:38:48 +1000 Subject: [PATCH 1/3] Keep scaffolded changelogs as scriv fragments Two PRs that both add to `## [Unreleased]` in CHANGELOG.md conflict on every merge. Scaffolded projects now add one fragment per PR under changelog.d/ (`uv run scriv create`), and `uv run scriv collect` writes them into CHANGELOG.md at release time. - pyproject: scriv in the dev group and [tool.scriv] with Keep a Changelog categories, `## [X.Y.Z] - YYYY-MM-DD` headings, and the version read from __init__.py (library) or pyproject.toml (app). - changelog.d/TEMPLATE.md, and a scriv-insert-here marker in place of `## [Unreleased]`. - CHANGELOG.md is in _skip_if_exists, so copier update leaves a project's changelog alone. - RELEASING.md and the scaffold README describe the flow; template CI collects a fragment in both variants. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/template-ci.yml | 14 ++++++ CHANGELOG.md | 2 + README.md | 6 ++- copier.yml | 6 +++ template/CHANGELOG.md.jinja | 6 +-- template/README.md.jinja | 2 + template/changelog.d/TEMPLATE.md | 49 +++++++++++++++++++ template/pyproject.toml.jinja | 20 ++++++++ ...sh_to_pypi %}RELEASING.md{% endif %}.jinja | 2 +- 9 files changed, 99 insertions(+), 8 deletions(-) create mode 100644 template/changelog.d/TEMPLATE.md diff --git a/.github/workflows/template-ci.yml b/.github/workflows/template-ci.yml index fa9196e..18395bb 100644 --- a/.github/workflows/template-ci.yml +++ b/.github/workflows/template-ci.yml @@ -91,6 +91,20 @@ 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.\n' > "$out/changelog.d/smoke.md" + (cd "$out" && uvx --from 'scriv>=1.8' scriv collect) + grep -q '^## \[0\.1\.0\] - ' "$out/CHANGELOG.md" + grep -q '^- Smoke\.$' "$out/CHANGELOG.md" + test ! -e "$out/changelog.d/smoke.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..9e662b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ 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` no longer touches a project's changelog. **Upgrading an existing project:** `copier update` brings in the config and `changelog.d/TEMPLATE.md` but leaves `CHANGELOG.md` alone. Replace its `## [Unreleased]` heading with `` by hand, and move any unreleased entries into a fragment under `changelog.d/`. - `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..f2f3e67 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. Fragments are collected into `CHANGELOG.md` at release time. + 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..fcfeaa5 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,25 @@ 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 +{% 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 From 399a60eacd31058c90e2a933fe5944bc7afe4fcb Mon Sep 17 00:00:00 2001 From: Matt Fisher Date: Fri, 2 Oct 2026 17:40:27 +1000 Subject: [PATCH 2/3] Render [tool.scriv] with no stray blank line, and a blank line before it Co-Authored-By: Claude Opus 5.5 --- template/pyproject.toml.jinja | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/template/pyproject.toml.jinja b/template/pyproject.toml.jinja index fcfeaa5..4e47d7d 100644 --- a/template/pyproject.toml.jinja +++ b/template/pyproject.toml.jinja @@ -42,6 +42,7 @@ 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. @@ -50,9 +51,7 @@ 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 %} +{% raw %}entry_title_template = "[{{ version }}] - {{ date.strftime('%Y-%m-%d') }}"{% endraw +%} md_header_level = "2" md_html_anchors = false {% if project_kind == "library" %} From 4ed2deab931c74fb18ca64ad951c18d35e8f8f83 Mon Sep 17 00:00:00 2001 From: Matt Fisher Date: Fri, 2 Oct 2026 17:56:00 +1000 Subject: [PATCH 3/3] Collect fragments into tight lists, and close the review's doc gaps - compact_fragments = true: two fragments in one category no longer leave a blank line that makes the list loose and fails mdformat at release. Template CI now collects two same-category fragments and checks the list. - The scaffold README names `scriv collect` for every project kind, not only in RELEASING.md, and says to delete a fragment the change doesn't need. - The upgrade note quotes scriv's error when the marker is missing, says to check that [tool.scriv] version reads the file the build backend reads, and that _skip_if_exists recreates a missing CHANGELOG.md. From the review on #4. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/template-ci.yml | 8 +++++--- CHANGELOG.md | 5 ++++- template/README.md.jinja | 2 +- template/pyproject.toml.jinja | 3 +++ 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/.github/workflows/template-ci.yml b/.github/workflows/template-ci.yml index 18395bb..619d1a7 100644 --- a/.github/workflows/template-ci.yml +++ b/.github/workflows/template-ci.yml @@ -96,11 +96,13 @@ jobs: 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.\n' > "$out/changelog.d/smoke.md" + 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" - grep -q '^- Smoke\.$' "$out/CHANGELOG.md" - test ! -e "$out/changelog.d/smoke.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." diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e662b0..fe81c75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,10 @@ 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` no longer touches a project's changelog. **Upgrading an existing project:** `copier update` brings in the config and `changelog.d/TEMPLATE.md` but leaves `CHANGELOG.md` alone. Replace its `## [Unreleased]` heading with `` by hand, and move any unreleased entries into a fragment under `changelog.d/`. +- `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/template/README.md.jinja b/template/README.md.jinja index f2f3e67..4ef0670 100644 --- a/template/README.md.jinja +++ b/template/README.md.jinja @@ -11,7 +11,7 @@ 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. Fragments are collected into `CHANGELOG.md` at release time. +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/pyproject.toml.jinja b/template/pyproject.toml.jinja index 4e47d7d..b887ba6 100644 --- a/template/pyproject.toml.jinja +++ b/template/pyproject.toml.jinja @@ -54,6 +54,9 @@ 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 %}