From eb58b39ee89b063c340cdf5d2acdf98eb896d51a Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Thu, 3 Sep 2026 23:32:33 +0200 Subject: [PATCH 1/3] chore(changelog): one fragment per PR instead of a bullet in the shared file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The [Unreleased] section is the one place sibling PRs reliably conflict each other — three times in a row on the night of 2026-09-02/03, each a hand-resolved rebase for text neither branch disagreed about. .gitattributes does nothing for it, and a union merge driver would not fix it: GitHub's mergeability check ignores merge drivers, and a branch that MOVES changelog lines comes out of a union rebase with the block duplicated. Fragments remove the shared spot rather than healing it. A PR writes changelog.d/.md in the changelog's own format and touches CHANGELOG.md not at all. tools/changelog is the standard-library tool behind it — `check` (also the CI job "Changelog (fragment)", which refuses a missing fragment AND a bullet written into [Unreleased]), `preview`, and `release`, which folds the fragments newest-first under the new heading, bumps pyproject.toml, uv.lock and app/package.json, repoints the compare links — a step release.md called easy to forget, so it is no longer a step — and deletes the fragments. The exemptions are the ones this repository already had, now enforced rather than remembered: catalogue-only PRs under plots/, the plot pipeline and Dependabot by author, and the skip-changelog label. The release's two aggregate lines stay by hand: they summarize a window, not a PR. Ported from kurrentschrift, where the same tool has run since 2026-08-30. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3 --- .claude/skills/open-pr/SKILL.md | 10 +- .github/copilot-instructions.md | 8 +- .github/pull_request_template.md | 5 +- .github/workflows/ci-changelog.yml | 72 +++++ CHANGELOG.md | 14 +- CLAUDE.md | 6 +- agentic/commands/pull_request.md | 15 +- agentic/commands/release.md | 47 +-- changelog.d/README.md | 57 ++++ changelog.d/changelog-fragments.md | 23 ++ tests/unit/test_agent_instructions.py | 9 +- tests/unit/tools/__init__.py | 0 tests/unit/tools/test_changelog_tool.py | 295 ++++++++++++++++++ tools/__init__.py | 8 + tools/changelog/__init__.py | 386 ++++++++++++++++++++++++ tools/changelog/__main__.py | 122 ++++++++ 16 files changed, 1033 insertions(+), 44 deletions(-) create mode 100644 .github/workflows/ci-changelog.yml create mode 100644 changelog.d/README.md create mode 100644 changelog.d/changelog-fragments.md create mode 100644 tests/unit/tools/__init__.py create mode 100644 tests/unit/tools/test_changelog_tool.py create mode 100644 tools/__init__.py create mode 100644 tools/changelog/__init__.py create mode 100644 tools/changelog/__main__.py diff --git a/.claude/skills/open-pr/SKILL.md b/.claude/skills/open-pr/SKILL.md index 281add364e2..79ee5ea95d7 100644 --- a/.claude/skills/open-pr/SKILL.md +++ b/.claude/skills/open-pr/SKILL.md @@ -76,10 +76,12 @@ git diff --name-only origin/main... A `/verify-*` gate only counts if the **diff's own flow** was driven — rendering a proxy or asserting a 200 is not verification. -**Changelog gate:** every non-exempt PR adds its entries to -`CHANGELOG.md` under `[Unreleased]` before the PR opens (rule + -exemptions in CLAUDE.md; the `/pull_request` command enforces the -same gate and appends the PR number after creation). +**Changelog gate:** every non-exempt PR adds `changelog.d/.md` +before the PR opens and touches `CHANGELOG.md` not at all — that shared +spot is where sibling PRs conflict (rule + exemptions in CLAUDE.md, +format in `changelog.d/README.md`; the `/pull_request` command enforces +the same gate and appends the PR number after creation). Locally: +`uv run python -m tools.changelog check --base origin/main`. Then the local CI equivalents — the same commands the pipeline runs. **Hard gate: do not open the PR while any of these is red.** diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 6df98cf0d21..62626894a18 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -8,15 +8,15 @@ A companion guide `CLAUDE.md` at the repo root carries the shared rules for Clau - **Always write in English** - All output text (code comments, commit messages, PR descriptions, issue comments, documentation) must be in English, even if the user writes in another language. - **Repository prose follows the Google developer documentation style guide** - `docs/`, `README.md`, `agentic/docs/`, changelog entries, and PR/issue text use [Google style](https://developers.google.com/style) (sentence-case headings, second person, numbered procedures); the concrete rules and the house-style exception (`docs/reference/style-guide.md` governs website/brand surfaces) live in the `write-docs` skill (`.claude/skills/write-docs/SKILL.md`). Existing docs migrate on touch, not via bulk rewrites. -- **Changelog and releases** - see [Changelog + releases](#changelog--releases) below: every PR updates `CHANGELOG.md`, a release moves the section and bumps the version files via a PR, and the GitHub release is that section condensed, never copied. When reviewing a non-exempt PR that lacks a changelog entry, flag it. +- **Changelog and releases** - see [Changelog + releases](#changelog--releases) below: every PR adds a fragment under `changelog.d/` and never edits `CHANGELOG.md`, a release folds the fragments and bumps the version files via a PR, and the GitHub release is that section condensed, never copied. When reviewing a non-exempt PR that lacks a fragment — or that writes a bullet into `[Unreleased]` — flag it. - **Never echo secret values into transcripts or logs** - Verify secrets by exit code or metadata, never by printing them. - **Structural fix over symptomatic fix** - When a cheap symptomatic fix and a correct structural fix compete, take the structural one: fix the cause, never mute the alarm. Never modify working code to make a broken test pass — fix the test or flag it. - **Never manually merge a pipeline PR, and never bypass the pipeline** - Specifications and implementations go through the GitHub Actions workflows: `spec-create.yml` writes `plots/{spec-id}/specification.md` and `.yaml`, `impl-merge.yml` merges implementation PRs and creates their `metadata/*.yaml`, promotes the preview images to GCS and sets the `impl:{library}:done` label. Merging one by hand skips all of that and leaves `quality_score: null`, no review data, missing GCS images and the issue open. So: never write those files by hand, never run `gh pr merge` on a spec or implementation PR, and add the `approved` label to the **issue**, never to the PR. The full DON'T/DO table is in `CLAUDE.md`. Ordinary (non-pipeline) PRs are merged by the repository owner, not by an agent. ## Changelog + releases -- **Every PR updates `CHANGELOG.md`** under `[Unreleased]` (Keep-a-Changelog categories, English, bold-titled bullets for headline entries, PR refs — like the existing entries) — that file is how releases get posted; a PR without its entry is incomplete. **Exempt:** the automated plot pipeline's output (spec-create, impl-generate/review/repair/merge, spec auto-polish, daily-regen PRs) and individual Dependabot bumps — those are summarized in aggregate at release time (see `agentic/commands/release.md`). This rule is duplicated in `CLAUDE.md` and `agentic/commands/pull_request.md`; keep all three in sync when changing it. -- **A release** moves `[Unreleased]` under a new version heading (`## [X.Y.Z] — YYYY-MM-DD — `), adds the aggregate lines for the exempt classes (the italic *Catalog* line, the single **Dependencies:** bullet), and bumps the version files (`pyproject.toml` `project.version`, `uv.lock`, `app/package.json`) in the same commit, via a `release/vX.Y.Z` PR. Every bullet already carries its PR reference — `/pull_request` appends it when the PR opens. After the merge the tag goes on the merge commit and the GitHub release is created from the section. +- **Every PR adds `changelog.d/.md`, and NEVER a bullet in `CHANGELOG.md`** — the fragment is a slice of the changelog in the changelog's own format (`### Category` over bold-titled English bullets with PR refs; the rules and an example are in `changelog.d/README.md`). That shared `[Unreleased]` spot is where sibling PRs used to conflict each other — three times in one night on 2026-09-02/03 — and the CI job "Changelog (fragment)" refuses both a missing fragment and a bullet written into `[Unreleased]` directly. Same check locally: `uv run python -m tools.changelog check --base origin/main`. **Exempt:** catalogue-only PRs (everything under `plots/`), the automated plot pipeline (`github-actions[bot]`) and Dependabot — summarized in aggregate at release time (see `agentic/commands/release.md`) — plus any PR labelled `skip-changelog`. This rule is duplicated in `CLAUDE.md` and `agentic/commands/pull_request.md`; keep all three in sync when changing it. +- **A release** runs `uv run python -m tools.changelog release X.Y.Z --title ""`, which folds every fragment under a new version heading (`## [X.Y.Z] — YYYY-MM-DD — `), bumps the version files (`pyproject.toml` `project.version`, `uv.lock`, `app/package.json`), repoints the compare links and deletes the fragments. The two aggregate lines for the exempt classes stay by hand (the italic *Catalog* line, the single **Dependencies:** bullet) because they summarize a window rather than a PR. All of it on a `release/vX.Y.Z` PR. Every bullet already carries its PR reference — `/pull_request` appends it when the PR opens. After the merge the tag goes on the merge commit and the GitHub release is created from the section. - **A GitHub release is that section condensed, never copied:** an intro line (merge count, PR range, link to `CHANGELOG.md`); the section's own `### Added / Changed / Removed / Fixed` headings in the section's order (an empty one is omitted); one bullet per NOTABLE entry — chores, dependency bumps and small fixes are left out, no fixed count — each at most two lines: its bold title, one clause with the essence or the headline number, its PR reference; a compare link (`compare/vPREV...vNEW`) as the last line. Numbers are copied exactly; only PR numbers from the section are cited; the full text lives only in the CHANGELOG — the release page is the index into it. The cut procedure itself is `agentic/commands/release.md`. ## Task Suitability @@ -276,7 +276,7 @@ Before completing any task: 4. Type hints are included for all new functions 5. Docstrings follow Google style for public functions 6. Add integration tests for database-related changes (repositories, models) -7. Non-exempt changes have a `CHANGELOG.md` entry under `[Unreleased]` (see Important Rules) +7. Non-exempt changes carry a `changelog.d/.md` fragment and leave `CHANGELOG.md` alone (see Important Rules) ## Database diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 70a27f9ecc0..15141d022b8 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -17,7 +17,8 @@ Dependabot bumps ignore this template — they are summarized at release time. ## Checklist -- [ ] `CHANGELOG.md` updated under `[Unreleased]` (Keep-a-Changelog categories, - bold-titled bullets, PR ref) — required for every non-pipeline PR +- [ ] `changelog.d/.md` added (Keep-a-Changelog categories, bold-titled + bullets, PR ref — `changelog.d/README.md`), and `CHANGELOG.md` left alone + — required for every non-pipeline PR - [ ] Related documentation updated (e.g. `docs/reference/`, `docs/workflows/`, `docs/contributing.md`) if behavior changed diff --git a/.github/workflows/ci-changelog.yml b/.github/workflows/ci-changelog.yml new file mode 100644 index 00000000000..1a0df6c9d0e --- /dev/null +++ b/.github/workflows/ci-changelog.yml @@ -0,0 +1,72 @@ +# Every PR carries a changelog fragment (changelog.d/.md) instead of a +# bullet in the shared CHANGELOG.md. +# +# That shared spot is where sibling PRs conflicted each other — three times in +# one night on 2026-09-02/03, each a hand-resolved rebase for text neither +# branch disagreed about. The .gitattributes line endings do nothing for it, a +# union merge driver would heal only the local rebase (GitHub's own mergeability +# check ignores merge drivers), and a branch that MOVES changelog lines comes out +# of a union rebase with the block duplicated. Fragments remove the spot instead +# of healing it. The sibling repo kurrentschrift runs the same gate over the same +# tool; keep the two in the same shape. +# +# Only runs on pull_request: a push to main has no base to diff against, and the +# gate is a statement about a PR, not about a commit. + +name: "CI: Changelog" +run-name: "Changelog: ${{ github.ref_name }}" + +on: + pull_request: + branches: + - main + - develop + - 'specification/**' + - 'implementation/**' + +concurrency: + group: ci-changelog-${{ github.ref }} + cancel-in-progress: true + +jobs: + fragment: + name: Changelog (fragment) + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + # The two exempt authors, which are exactly the classes CLAUDE.md already + # excuses and a release summarizes in aggregate: + # + # * github-actions[bot] — the automated plot pipeline (spec-create, + # impl-generate/review/repair/merge, spec auto-polish, daily-regen). Its + # PRs touch only `plots/`, so the tool's own path exemption would pass + # them anyway; skipping by author as well keeps the gate off the hundreds + # of them per window entirely. + # * dependabot[bot] — a bot can neither write a fragment nor reach for the + # label, so the gate would sit red on every Monday batch. A bump that DOES + # deserve a line reaches the changelog through the human PR carrying it. + # + # `skip-changelog` is the human escape hatch, for a PR that truly changes + # nothing worth a line. + if: >- + !contains(github.event.pull_request.labels.*.name, 'skip-changelog') + && github.event.pull_request.user.login != 'dependabot[bot]' + && github.event.pull_request.user.login != 'github-actions[bot]' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The gate diffs against the base branch, which a shallow checkout lacks. + fetch-depth: 0 + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.13' + + - name: Install uv + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + + # `--no-project`: the tool is standard library only, so the gate answers + # in seconds without syncing pandas, scipy and scikit-learn. + - name: Fragment present and well-formed + run: uv run --no-project python -m tools.changelog check --base "origin/${{ github.base_ref }}" diff --git a/CHANGELOG.md b/CHANGELOG.md index 0475c20e969..31f7672d70b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,16 @@ All notable changes to this project are documented here. The format is based on rather than library SemVer: major for milestone releases, minor for feature batches, patch for fix-only (see `agentic/commands/release.md`). -Every non-exempt PR adds its entries under `[Unreleased]`, each bullet with its PR reference. A -release moves that section under a new version heading (`## [X.Y.Z] — YYYY-MM-DD — `) -and bumps `pyproject.toml` (`project.version`), `uv.lock` and `app/package.json` in the same -commit, via a PR (procedure: `agentic/commands/release.md`). After the merge the tag goes on the +**Do not edit this file in a feature PR.** Every non-exempt PR adds one fragment, +`changelog.d/.md`, in this file's own format, each bullet with its PR reference. The shared +`[Unreleased]` spot is where sibling PRs used to conflict each other, so the CI job +"Changelog (fragment)" refuses both a missing fragment and a bullet written in here directly +(format and exemptions: `changelog.d/README.md`). A release runs +`uv run python -m tools.changelog release X.Y.Z --title ""`, which folds every fragment +under a new version heading (`## [X.Y.Z] — YYYY-MM-DD — `), bumps `pyproject.toml` +(`project.version`), `uv.lock` and `app/package.json`, repoints the compare links at the bottom of +this file and deletes the fragments — one commit, via a PR (procedure: +`agentic/commands/release.md`). After the merge the tag goes on the merge commit and the GitHub release is created from the section — condensed, never copied (rule of 2026-08-28): an intro line with the merge count, the PR range and a link to this file; the section's own headings in the section's order (an empty one is omitted); one bullet per NOTABLE diff --git a/CLAUDE.md b/CLAUDE.md index 4eb079957d4..a42be3f9dd9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ A companion guide `.github/copilot-instructions.md` carries the shared rules for - **Always write in English** - All output text (code comments, commit messages, PR descriptions, issue comments, documentation) must be in English, even if the user writes in another language. - **Repository prose follows the Google developer documentation style guide** - `docs/`, `README.md`, `agentic/docs/`, changelog entries, and PR/issue text use [Google style](https://developers.google.com/style) (sentence-case headings, second person, numbered procedures); the concrete rules and the house-style exception (`docs/reference/style-guide.md` governs website/brand surfaces) live in the `write-docs` skill. Existing docs migrate on touch, not via bulk rewrites. - **Update documentation when making changes** - When adding new features, events, or modifying behavior, always check if related documentation needs updating (e.g., `docs/reference/plausible.md` for analytics events, `docs/workflows/` for workflow changes, `docs/contributing.md` for user-facing changes). -- **Changelog and releases** - see [Changelog + releases](#changelog--releases) below: every PR updates `CHANGELOG.md`, a release moves the section and bumps the version files via a PR, and the GitHub release is that section condensed, never copied. +- **Changelog and releases** - see [Changelog + releases](#changelog--releases) below: every PR adds a fragment under `changelog.d/` and never edits `CHANGELOG.md`, a release folds the fragments and bumps the version files via a PR, and the GitHub release is that section condensed, never copied. - **External-system writes need explicit, named authorization** - Merging or closing PRs/issues this session did not create, bulk merges, and label changes on others' PRs are blocked by the permission classifier unless the user named that action; a generic "ok, sounds good" authorizes nothing. The same discipline covers every prod-touching action in interactive sessions — Cloud SQL writes/DDL, GCS production-folder changes, Secret Manager access, Cloud Build config: name the exact action, resource, and id, and ask before acting (the automated `spec-*`/`impl-*` workflows write these by design and are exempt). For any change to `.claude/settings*.json`, use the built-in `/update-config` skill (a Claude Code harness skill, not a repo command) immediately — direct writes are blocked as self-modification and retrying variants just burns round trips. - **Never echo secret values into the transcript** - Verify secrets by exit code or metadata, never by printing them; never create a Secret Manager version via `echo` (the trailing newline corrupts the value). - **Snapshot before destructive prod operations** - Before anything that can overwrite or delete shared prod DB data or GCS production objects (bulk UPDATE/DELETE, a data-rewriting migration, bulk GCS overwrite/delete): take a timestamped backup first (`pg_dump` to a new directory outside the working tree, `gsutil cp` to a backup prefix), sanity-check it (row/object counts — a silent empty snapshot is worse than none because it looks like safety), and never write into, delete, or rename an existing snapshot. @@ -27,8 +27,8 @@ A companion guide `.github/copilot-instructions.md` carries the shared rules for ## Changelog + releases -- **Every PR updates `CHANGELOG.md`** under `[Unreleased]` (Keep-a-Changelog categories, English, bold-titled bullets for headline entries, PR refs — like the existing entries) — that file is how releases get posted; a PR without its entry is incomplete. **Exempt:** the automated plot pipeline's output (spec-create, impl-generate/review/repair/merge, spec auto-polish, daily-regen PRs) and individual Dependabot bumps — those are summarized in aggregate at release time (see `agentic/commands/release.md`). This rule is duplicated in `.github/copilot-instructions.md` and `agentic/commands/pull_request.md`; keep all three in sync when changing it. -- **A release** moves `[Unreleased]` under a new version heading (`## [X.Y.Z] — YYYY-MM-DD — `), adds the aggregate lines for the exempt classes (the italic *Catalog* line, the single **Dependencies:** bullet), and bumps the version files (`pyproject.toml` `project.version`, `uv.lock`, `app/package.json`) in the same commit, via a `release/vX.Y.Z` PR. Every bullet already carries its PR reference — `/pull_request` appends it when the PR opens. After the merge the tag goes on the merge commit and the GitHub release is created from the section. +- **Every PR adds `changelog.d/.md`, and NEVER a bullet in `CHANGELOG.md`** — the fragment is a slice of the changelog in the changelog's own format (`### Category` over bold-titled English bullets with PR refs; the rules and an example are in `changelog.d/README.md`). That shared `[Unreleased]` spot is where sibling PRs used to conflict each other — three times in one night on 2026-09-02/03 — and the CI job "Changelog (fragment)" refuses both a missing fragment and a bullet written into `[Unreleased]` directly. Same check locally: `uv run python -m tools.changelog check --base origin/main`; `… preview` prints the pending section. **Exempt:** catalogue-only PRs (everything under `plots/`), the automated plot pipeline (`github-actions[bot]`: spec-create, impl-generate/review/repair/merge, spec auto-polish, daily-regen) and Dependabot — those are summarized in aggregate at release time (see `agentic/commands/release.md`) — plus any PR labelled `skip-changelog`. This rule is duplicated in `.github/copilot-instructions.md` and `agentic/commands/pull_request.md`; keep all three in sync when changing it. +- **A release** runs `uv run python -m tools.changelog release X.Y.Z --title ""`, which folds every fragment under a new version heading (`## [X.Y.Z] — YYYY-MM-DD — `), bumps the version files (`pyproject.toml` `project.version`, `uv.lock`, `app/package.json`), repoints the compare links and deletes the fragments. The two aggregate lines for the exempt classes stay by hand, because they summarize a window rather than a PR: the italic *Catalog* line and the single **Dependencies:** bullet. All of it on a `release/vX.Y.Z` PR. Every bullet already carries its PR reference — `/pull_request` appends it when the PR opens. After the merge the tag goes on the merge commit and the GitHub release is created from the section. - **A GitHub release is that section condensed, never copied:** an intro line (merge count, PR range, link to `CHANGELOG.md`); the section's own `### Added / Changed / Removed / Fixed` headings in the section's order (an empty one is omitted); one bullet per NOTABLE entry — chores, dependency bumps and small fixes are left out, no fixed count — each at most two lines: its bold title, one clause with the essence or the headline number, its PR reference; a compare link (`compare/vPREV...vNEW`) as the last line. Numbers are copied exactly; only PR numbers from the section are cited; the full text lives only in the CHANGELOG — the release page is the index into it. The cut procedure itself is `agentic/commands/release.md`. ## PR Follow-Through (mandatory after every `gh pr create`) diff --git a/agentic/commands/pull_request.md b/agentic/commands/pull_request.md index ce2b808a4a6..8b1a0a8e700 100644 --- a/agentic/commands/pull_request.md +++ b/agentic/commands/pull_request.md @@ -27,12 +27,15 @@ branch state matches what the PR description claims. 1. Run `git diff origin/main...HEAD --stat` to see changed files summary 2. Run `git log origin/main..HEAD --oneline` to see commits in this branch 3. Run `git branch --show-current` to get the current branch name -4. **Changelog gate** — verify `CHANGELOG.md`'s `[Unreleased]` section covers the notable changes in - this branch (Keep-a-Changelog categories, English, bold-titled bullets for headline entries — - like the existing entries); - add and commit the entry if missing. Exempt: automated pipeline PRs (spec-create, impl-*, - auto-polish, daily-regen) and Dependabot bumps — those are aggregated at release time. This rule - is duplicated in `CLAUDE.md` and `.github/copilot-instructions.md`; keep all three in sync. +4. **Changelog gate** — the branch carries `changelog.d/.md` and does NOT touch + `CHANGELOG.md` (Keep-a-Changelog categories, English, bold-titled bullets — the format and an + example are in `changelog.d/README.md`); add and commit the fragment if missing, and run + `uv run python -m tools.changelog check --base origin/main`, which is the same check the CI job + "Changelog (fragment)" makes. Exempt: catalogue-only PRs (everything under `plots/`), automated + pipeline PRs (`github-actions[bot]`: spec-create, impl-*, auto-polish, daily-regen) and + Dependabot bumps — those are aggregated at release time — plus a PR labelled `skip-changelog`. + This rule is duplicated in `CLAUDE.md` and `.github/copilot-instructions.md`; keep all three in + sync. 5. Run `git push -u origin $(git branch --show-current)` to push the branch (do NOT use `--force` or `--no-verify` unless the user explicitly asks for it) 6. Create the PR — non-draft, ready for review: diff --git a/agentic/commands/release.md b/agentic/commands/release.md index 60489191d6c..50d96819ad1 100644 --- a/agentic/commands/release.md +++ b/agentic/commands/release.md @@ -16,33 +16,40 @@ version: $1 (optional — e.g. `3.1.0`; if omitted, propose one from the `[Unrel precedent), minor for feature batches, patch for fix-only releases. - **Never work on `main` directly** — do the changelog/version edits on a `release/vX.Y.Z` branch and open a PR. -- The release PR should touch exactly four files: `CHANGELOG.md`, `pyproject.toml`, `uv.lock` - (the lock pins the project's own version — v3.0.0 precedent, commit d05e1f2a7) and - `app/package.json` (step 4). Keep the diff tiny and auditable. +- The release PR should touch exactly four files plus the fragments it consumes: `CHANGELOG.md`, + `pyproject.toml`, `uv.lock` (the lock pins the project's own version — v3.0.0 precedent, commit + d05e1f2a7), `app/package.json`, and the deleted `changelog.d/*.md`. Keep the diff tiny and + auditable; `uv run python -m tools.changelog release` produces exactly that set. - Pick a short **codename** (release theme, a few words) — it appears in three synchronized places: the changelog heading, the annotated tag message, and the GitHub release title. ## Run -1. **Verify completeness.** Compare `CHANGELOG.md`'s `[Unreleased]` section against - `git log v..origin/main --oneline --no-merges`, ignoring the exempt classes (spec-create, - impl-generate/review/repair/merge, spec auto-polish, daily-regen commits, individual Dependabot - bumps). Add any missing notable entries first. Run `git fetch origin main` before comparing, and - check the state of any in-flight PRs the release should include yourself - (`gh pr view --json state,mergedAt`) — do not rely on the user to report merge status. -2. **Add the aggregate lines.** Summarize the exempt classes for the release window: +1. **Verify completeness.** Run `uv run python -m tools.changelog preview` — that is the pending + section, every fragment in `changelog.d/` merged with whatever `[Unreleased]` still holds — and + compare it against `git log v..origin/main --oneline --no-merges`, ignoring the exempt + classes (spec-create, impl-generate/review/repair/merge, spec auto-polish, daily-regen commits, + individual Dependabot bumps). A missing entry is added as a fragment in `changelog.d/`, never as + a bullet in `CHANGELOG.md`. Run `git fetch origin main` before comparing, and check the state of + any in-flight PRs the release should include yourself (`gh pr view --json state,mergedAt`) + — do not rely on the user to report merge status. +2. **Cut the section.** `uv run python -m tools.changelog release X.Y.Z --title ""` + (`--dry-run` first prints the section it would write and touches nothing). One command folds + every fragment under `## [X.Y.Z] — YYYY-MM-DD — ` — newest first within a category, by + the commit that added the fragment — leaves an empty `## [Unreleased]` above it, repoints the + compare links at the bottom of the file, bumps `pyproject.toml`, `uv.lock` and + `app/package.json`, and deletes the fragments. `app/package.json` matters as much as the others: + the masthead falls back to it whenever the GitHub releases lookup is unavailable, and + `tests/unit/test_version_sync.py` fails the PR if the two drift. +3. **Add the aggregate lines by hand**, into the section just written. They are the one part the + tool deliberately leaves alone, because they summarize a release window rather than any PR: - An italic `*Catalog: ...*` line at the end of the section (counts of new implementations, regenerations, coverage milestones — query merged impl PRs or `impl:*:done` labels). - - Create or update the single `**Dependencies:**` bullet under `### Changed` grouping the - Dependabot bumps of the window (never one bullet per bump). -3. **Move the section.** Retitle `## [Unreleased]` to `## [X.Y.Z] — YYYY-MM-DD — ` and - recreate an empty `## [Unreleased]` heading above it. At the bottom of the file, repoint the - `[Unreleased]` compare link to `vX.Y.Z...HEAD` and add the new `[X.Y.Z]` compare link — one - link per bracketed heading, this step is easy to forget. -4. **Bump the version** in `pyproject.toml` to `X.Y.Z`, then run `uv lock` so `uv.lock` picks up - the project's own version. Bump `app/package.json` to the same `X.Y.Z` — the masthead falls - back to it whenever the GitHub releases lookup is unavailable, and - `tests/unit/test_version_sync.py` fails the PR if the two drift. + - The single `**Dependencies:**` bullet under `### Changed` grouping the Dependabot bumps of + the window (never one bullet per bump). +4. **Read the diff.** It touches `CHANGELOG.md`, `pyproject.toml`, `uv.lock`, `app/package.json` + and the deleted fragments — nothing else. Run `uv lock` if the lock needs more than its own + version line. 5. **Open the release PR** (`release: vX.Y.Z` title) and follow the standard PR follow-through from `CLAUDE.md`. Ask the user to merge unless explicitly authorized to merge autonomously. 6. **Tag after merge** (on the updated `main`): diff --git a/changelog.d/README.md b/changelog.d/README.md new file mode 100644 index 00000000000..328c017d4c5 --- /dev/null +++ b/changelog.d/README.md @@ -0,0 +1,57 @@ +# changelog.d — one fragment per PR + +Every PR that changes code adds ONE file here instead of editing +`CHANGELOG.md`: `changelog.d/.md`, the slug naming the change (the branch +name minus its prefix does fine — `origin-gate-rest.md`, `csp-hashes.md`). +Nothing else touches `CHANGELOG.md` between releases, so two PRs never meet at +the same line again — the reason this directory exists (three conflicts in one +night on 2026-09-02/03, each a hand-resolved rebase for text neither branch +disagreed about; a union merge driver heals only the local rebase, and GitHub's +own mergeability check ignores merge drivers). + +A fragment is a slice of the changelog in the changelog's own format: + +```markdown +### Added + +- **The thing, named as the reader will meet it.** One clause on what it + does and where, then why it is the right shape — the rationale a diff + cannot carry. Ends with the PR reference once known (#NNNNN). + +### Fixed + +- **What was wrong, as a title.** What it did, what it does now (#NNNNN). +``` + +Rules, all enforced by `uv run python -m tools.changelog check`: + +- Headings are `### Added` · `### Changed` · `### Deprecated` · `### Removed` + · `### Fixed` · `### Security` (Keep a Changelog), each at most once per + fragment; a fragment has at least one bullet. +- A bullet opens with its bold title and wraps with two-space indentation, + exactly like the entries already in `CHANGELOG.md`; English (`CLAUDE.md` + § "Always write in English"), written like the existing entries: what, + where, why. +- Nothing else in the file — no prose above the first heading, no `##`. + +The CI job "Changelog (fragment)" requires a fragment in every PR — except +catalogue-only PRs (everything under `plots/`), PRs labelled `skip-changelog`, +and the two bot authors: the automated plot pipeline (`github-actions[bot]`: +spec-create, impl-generate/review/repair/merge, spec auto-polish, daily-regen) +and Dependabot. Those are exactly the classes `CLAUDE.md` already exempts and +the release summarizes in aggregate. The job also refuses bullets written into +`[Unreleased]` directly. +`uv run python -m tools.changelog check --base origin/main` is the same check +locally; `uv run python -m tools.changelog preview` prints the pending section. + +The release cut, `uv run python -m tools.changelog release X.Y.Z --title "…"`, +folds all fragments under the new version heading — newest first within a +category, by the commit that added the fragment — bumps `pyproject.toml`, +`uv.lock` and `app/package.json`, repoints the compare links at the bottom of +the file, and deletes the fragments. This README stays. + +The two aggregate lines stay by hand: the italic `*Catalog: …*` at the end of +the section and the single `**Dependencies:**` bullet that stands in for the +window's Dependabot bumps. Both summarize a release window rather than a PR, so +they are written at cut time into the section the tool has just laid out +(`agentic/commands/release.md` step 2). diff --git a/changelog.d/changelog-fragments.md b/changelog.d/changelog-fragments.md new file mode 100644 index 00000000000..b3af146bb21 --- /dev/null +++ b/changelog.d/changelog-fragments.md @@ -0,0 +1,23 @@ +### Changed + +- **Every PR now adds a `changelog.d/` fragment instead of a bullet in `CHANGELOG.md`** — the + shared `[Unreleased]` section is the one place sibling PRs reliably conflict each other, and on + the night of 2026-09-02/03 it did so three times in a row: each time a hand-resolved rebase for + text neither branch disagreed about. The `.gitattributes` line-ending rules do nothing for it, and + a union merge driver would not fix it either — GitHub's own mergeability check ignores merge + drivers, and a branch that MOVES changelog lines comes out of a union rebase with the block + duplicated. Fragments remove the shared spot rather than healing it: a PR writes + `changelog.d/.md` in the changelog's own format (`### Category` over bold-titled bullets) + and touches `CHANGELOG.md` not at all, so two PRs never meet at the same line. `tools/changelog` + is the standard-library tool behind it — `check` (also the CI job "Changelog (fragment)", which + refuses a missing fragment AND a bullet written into `[Unreleased]`), `preview`, and `release`, + which folds every fragment under the new version heading newest-first, bumps `pyproject.toml`, + `uv.lock` and `app/package.json`, repoints the compare links at the bottom of the file — a step + `release.md` called easy to forget, so it is no longer a step — and deletes the fragments. The + exemptions are the ones this repository already had, now enforced rather than remembered: + catalogue-only PRs under `plots/`, the automated plot pipeline and Dependabot (both by author, so + the gate never sits red on a bot batch), and the `skip-changelog` label. What the tool + deliberately does NOT touch is the release's two aggregate lines — the italic *Catalog* line and + the single **Dependencies:** bullet — because those summarize a window rather than a PR and are + written at cut time. Ported from the sibling repo kurrentschrift, where the same tool has run + since 2026-08-30. (#PRNUM) diff --git a/tests/unit/test_agent_instructions.py b/tests/unit/test_agent_instructions.py index 44b6a864a58..8ff95ce220e 100644 --- a/tests/unit/test_agent_instructions.py +++ b/tests/unit/test_agent_instructions.py @@ -266,7 +266,14 @@ def test_every_named_skill_exists() -> None: "prose follows the google developer documentation style guide", "docs/reference/style-guide.md", ], - "every PR updates the changelog": ["every pr updates", "[unreleased]", "keep-a-changelog"], + # The keywords carry the PROHIBITION as well as the duty: a fragment is + # added AND `CHANGELOG.md` is left alone. A pin on "changelog.d" alone would + # stay green if a guide were rewritten to allow both. + "every PR adds a changelog fragment, never a CHANGELOG.md bullet": [ + "changelog.d/.md", + "never a bullet in `changelog.md`", + 'changelog (fragment)"', + ], "a release is condensed, never copied": ["condensed, never copied", "agentic/commands/release.md"], "never echo secret values": ["never echo secret"], "structural fix over symptomatic fix": ["structural fix over symptomatic fix"], diff --git a/tests/unit/tools/__init__.py b/tests/unit/tools/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit/tools/test_changelog_tool.py b/tests/unit/tools/test_changelog_tool.py new file mode 100644 index 00000000000..3fb174f38fe --- /dev/null +++ b/tests/unit/tools/test_changelog_tool.py @@ -0,0 +1,295 @@ +"""Unit tests for the changelog fragments tool. + +The gate and the cut are what keep the shared file conflict-free, so every +rule is pinned here: the fragment format (what a stray line does), the merge +order, the cut against a synthetic changelog, the version-line bumps, and the +PR check against a throwaway git repository — the same call the CI job makes. +""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import pytest + +from tools import changelog as cl + + +FRAGMENT = """### Added + +- **A thing.** With a wrapped + continuation line (#1). +- **Another thing.** Short (#1). + +### Fixed + +- **A slip.** Undone (#1). +""" + +CHANGELOG = """# Changelog + +Header prose. + +## [Unreleased] + +### Changed + +- **An old-style entry.** Written before the fragments existed (#0). + +## [0.1.0] — 2026-01-01 — First + +### Added + +- **The beginning.** +""" + + +def test_a_fragment_parses_into_categories_with_wrapped_bullets() -> None: + entries = cl.parse_entries(FRAGMENT, where="f.md") + assert list(entries) == ["Added", "Fixed"] + assert entries["Added"] == [ + "- **A thing.** With a wrapped\n continuation line (#1).", + "- **Another thing.** Short (#1).", + ] + assert entries["Fixed"] == ["- **A slip.** Undone (#1)."] + + +@pytest.mark.parametrize( + ("text", "complaint"), + [ + ("### Broke\n\n- **x.**\n", "f.md:1: unknown category"), + ("- **x.**\n", "f.md:1: a bullet before any"), + ("### Added\n\n- plain bullet\n", "f.md:3: .*bold title"), + ("### Added\n\nprose\n", "f.md:3: stray text"), + ("### Added\n\n- **x.**\n\n### Added\n\n- **y.**\n", "f.md:5: .*twice"), + ], +) +def test_a_malformed_fragment_names_its_line(text: str, complaint: str) -> None: + with pytest.raises(cl.ChangelogError, match=complaint): + cl.parse_entries(text, where="f.md") + + +def test_merge_puts_fragments_above_the_old_section_in_category_order() -> None: + older = cl.Fragment(Path("a.md"), {"Fixed": ["- **old fix.**"], "Added": ["- **old add.**"]}) + newer = cl.Fragment(Path("b.md"), {"Added": ["- **new add.**"]}) + merged = cl.merge({"Added": ["- **file add.**"], "Changed": ["- **file change.**"]}, [newer, older]) + assert list(merged) == ["Added", "Changed", "Fixed"] + assert merged["Added"] == ["- **new add.**", "- **old add.**", "- **file add.**"] + assert cl.render(merged) == ( + "### Added\n\n- **new add.**\n- **old add.**\n- **file add.**\n\n" + "### Changed\n\n- **file change.**\n\n" + "### Fixed\n\n- **old fix.**\n\n" + ) + + +def test_the_cut_empties_unreleased_and_leaves_older_sections_byte_identical() -> None: + fragment = cl.Fragment(Path("f.md"), cl.parse_entries(FRAGMENT, where="f.md")) + text = cl.cut_release(CHANGELOG, [fragment], version="0.2.0", date="2026-08-30", title="Second") + head, heading, rest = text.partition("## [0.2.0] — 2026-08-30 — Second\n\n") + assert heading, text + assert head == "# Changelog\n\nHeader prose.\n\n## [Unreleased]\n\n" + section, _, older = rest.partition("## [0.1.0]") + assert section == ( + "### Added\n\n- **A thing.** With a wrapped\n continuation line (#1).\n- **Another thing.** Short (#1).\n\n" + "### Changed\n\n- **An old-style entry.** Written before the fragments existed (#0).\n\n" + "### Fixed\n\n- **A slip.** Undone (#1).\n\n" + ) + assert "## [0.1.0]" + older == CHANGELOG[CHANGELOG.index("## [0.1.0]") :] + # A second cut of the same text finds nothing pending. + with pytest.raises(cl.ChangelogError, match="nothing to release"): + cl.cut_release(text, [], version="0.3.0", date="2026-08-31", title="Third") + + +@pytest.mark.parametrize( + ("version", "date", "title", "complaint"), + [ + ("0.1.0", "2026-08-30", "t", "not above"), + ("0.0.9", "2026-08-30", "t", "not above"), + ("1.0", "2026-08-30", "t", "MAJOR"), + ("0.2.0", "30.08.2026", "t", "YYYY"), + ("0.2.0", "2026-08-30", " ", "title"), + ], +) +def test_the_cut_refuses_a_bad_heading(version: str, date: str, title: str, complaint: str) -> None: + with pytest.raises(cl.ChangelogError, match=complaint): + cl.cut_release(CHANGELOG, [], version=version, date=date, title=title) + + +PYPROJECT = '[project]\nname = "anyplot"\nversion = "0.1.0"\n' +UV_LOCK = '[[package]]\nname = "other"\nversion = "0.1.0"\n\n[[package]]\nname = "anyplot"\nversion = "0.1.0"\n' +# A dependency pinned at the same version, at the same nesting, is the trap the +# anchor has to survive: only the top-level key is two spaces deep. +PACKAGE_JSON = ( + '{\n "name": "anyplot-website",\n "version": "0.1.0",\n "dependencies": {\n "react": "0.1.0"\n }\n}\n' +) + + +def _write_version_files(root: Path) -> None: + (root / "pyproject.toml").write_text(PYPROJECT, encoding="utf-8") + (root / "uv.lock").write_text(UV_LOCK, encoding="utf-8") + (root / "app").mkdir(exist_ok=True) + (root / "app" / "package.json").write_text(PACKAGE_JSON, encoding="utf-8") + + +def test_the_version_lines_are_bumped_exactly_once_each(tmp_path: Path) -> None: + _write_version_files(tmp_path) + writes = cl.bump_version_files(tmp_path, version="0.2.0", date="2026-08-30") + assert writes[tmp_path / "pyproject.toml"] == PYPROJECT.replace("0.1.0", "0.2.0") + # Only the project's own package block moves, not the other package at the same version. + assert writes[tmp_path / "uv.lock"] == UV_LOCK.replace( + '"anyplot"\nversion = "0.1.0"', '"anyplot"\nversion = "0.2.0"' + ) + # …and in package.json only the top-level key, never the dependency pin. + assert writes[tmp_path / "app" / "package.json"] == PACKAGE_JSON.replace('"version": "0.1.0"', '"version": "0.2.0"') + assert '"react": "0.1.0"' in writes[tmp_path / "app" / "package.json"] + (tmp_path / "pyproject.toml").write_text('name = "anyplot"\n', encoding="utf-8") + with pytest.raises(cl.ChangelogError, match="pyproject.toml: expected exactly one"): + cl.bump_version_files(tmp_path, version="0.2.0", date="2026-08-30") + + +LINKS = ( + "[Unreleased]: https://github.com/MarkusNeusinger/anyplot/compare/v0.1.0...HEAD\n" + "[0.1.0]: https://github.com/MarkusNeusinger/anyplot/releases/tag/v0.1.0\n" +) + + +def test_the_compare_links_follow_the_cut() -> None: + """`release.md` calls this step easy to forget, which is why it is not a step.""" + out = cl.repoint_compare_links(CHANGELOG + "\n" + LINKS, version="0.2.0") + assert "[Unreleased]: https://github.com/MarkusNeusinger/anyplot/compare/v0.2.0...HEAD\n" in out + assert "[0.2.0]: https://github.com/MarkusNeusinger/anyplot/compare/v0.1.0...v0.2.0\n" in out + assert "[0.1.0]: https://github.com/MarkusNeusinger/anyplot/releases/tag/v0.1.0\n" in out + + +def test_a_changelog_without_a_link_block_keeps_none() -> None: + assert cl.repoint_compare_links(CHANGELOG, version="0.2.0") == CHANGELOG + + +# --- the PR gate, against a real (throwaway) repository ---------------------- + + +def _git(root: Path, *args: str, when: str | None = None) -> str: + env = {**os.environ, "GIT_AUTHOR_DATE": when or "", "GIT_COMMITTER_DATE": when or ""} + if when is None: + env.pop("GIT_AUTHOR_DATE"), env.pop("GIT_COMMITTER_DATE") + done = subprocess.run( + ["git", "-c", "user.name=t", "-c", "user.email=t@example.org", "-c", "commit.gpgsign=false", *args], + cwd=root, + env=env, + check=True, + capture_output=True, + text=True, + ) + return done.stdout + + +def _commit_all(root: Path, message: str, when: str | None = None) -> None: + _git(root, "add", "-A") + _git(root, "commit", "-q", "-m", message, when=when) + + +@pytest.fixture +def repo(tmp_path: Path) -> Path: + """A throwaway repository: `main` with a changelog, the fragment README, a source file — and `topic` checked out.""" + _git(tmp_path, "init", "-q", "-b", "main") + (tmp_path / "CHANGELOG.md").write_text(CHANGELOG, encoding="utf-8") + (tmp_path / "changelog.d").mkdir() + (tmp_path / "changelog.d" / "README.md").write_text("# fragments\n", encoding="utf-8") + (tmp_path / "core.py").write_text("x = 1\n", encoding="utf-8") + (tmp_path / "plots" / "scatter-basic").mkdir(parents=True) + (tmp_path / "plots" / "scatter-basic" / "specification.md").write_text("# spec\n", encoding="utf-8") + _write_version_files(tmp_path) + _commit_all(tmp_path, "base") + _git(tmp_path, "checkout", "-q", "-b", "topic") + return tmp_path + + +def test_a_pr_with_a_fragment_passes(repo: Path) -> None: + (repo / "core.py").write_text("x = 2\n", encoding="utf-8") + (repo / "changelog.d" / "topic.md").write_text(FRAGMENT, encoding="utf-8") + _commit_all(repo, "change") + assert cl.check_pr("main", root=repo) == [] + + +def test_a_pr_without_a_fragment_fails_and_names_the_way_out(repo: Path) -> None: + (repo / "core.py").write_text("x = 2\n", encoding="utf-8") + _commit_all(repo, "change") + (problem,) = cl.check_pr("main", root=repo) + assert "no changelog fragment" in problem + assert cl.SKIP_LABEL in problem + + +def test_a_catalogue_only_pr_needs_no_fragment(repo: Path) -> None: + """What the automated plot pipeline produces, and what a release summarizes + in one italic *Catalog* line rather than per PR.""" + (repo / "plots" / "scatter-basic" / "specification.md").write_text("# spec, revised\n", encoding="utf-8") + _commit_all(repo, "spec") + assert cl.check_pr("main", root=repo) == [] + + +def test_a_pipeline_pr_that_strays_outside_plots_still_needs_one(repo: Path) -> None: + """The exemption is the path, not the author — a pipeline PR that edits a + workflow has left the class the *Catalog* line covers.""" + (repo / "plots" / "scatter-basic" / "specification.md").write_text("# spec, revised\n", encoding="utf-8") + (repo / "core.py").write_text("x = 2\n", encoding="utf-8") + _commit_all(repo, "spec and code") + (problem,) = cl.check_pr("main", root=repo) + assert "no changelog fragment" in problem + + +def test_a_branch_with_no_changes_passes(repo: Path) -> None: + assert cl.check_pr("main", root=repo) == [] + + +def test_an_unknown_base_stops_the_gate_instead_of_passing_it(repo: Path) -> None: + """An unfetched base would diff as empty and read as 'nothing changed' — the one silent pass the gate must not have.""" + (repo / "core.py").write_text("x = 2\n", encoding="utf-8") + _commit_all(repo, "change") + with pytest.raises(cl.ChangelogError, match="git diff .*origin/nowhere"): + cl.check_pr("origin/nowhere", root=repo) + + +def test_a_bullet_written_into_unreleased_directly_is_refused(repo: Path) -> None: + """Even next to a proper fragment: the shared spot is what the fragments retire.""" + text = (repo / "CHANGELOG.md").read_text(encoding="utf-8") + text = text.replace("### Changed\n\n", "### Changed\n\n- **Sneaked in.** Not a fragment.\n") + (repo / "CHANGELOG.md").write_text(text, encoding="utf-8") + (repo / "changelog.d" / "topic.md").write_text(FRAGMENT, encoding="utf-8") + _commit_all(repo, "both") + (problem,) = cl.check_pr("main", root=repo) + assert "belongs in a fragment" in problem + assert "Sneaked in" in problem + + +def test_the_cut_orders_fragments_by_their_commit_and_passes_the_gate(repo: Path) -> None: + """Newest first, an uncommitted fragment newest of all; the cut PR itself deletes fragments and passes.""" + _git(repo, "checkout", "-q", "main") # two merged PRs left their fragments on main, in this order + (repo / "changelog.d" / "first.md").write_text("### Added\n\n- **First.**\n", encoding="utf-8") + _commit_all(repo, "first", when="2026-08-30T10:00:00+00:00") + (repo / "changelog.d" / "second.md").write_text("### Added\n\n- **Second.**\n", encoding="utf-8") + _commit_all(repo, "second", when="2026-08-30T10:00:01+00:00") + _git(repo, "checkout", "-q", "-b", "cut") + (repo / "changelog.d" / "third.md").write_text("### Added\n\n- **Third.**\n", encoding="utf-8") + assert [f.path.name for f in cl.load_fragments(repo)] == ["third.md", "second.md", "first.md"] + + release = cl.plan_release(version="0.2.0", date="2026-08-30", title="Cut", root=repo) + assert (repo / "changelog.d" / "first.md").exists(), "planning writes nothing" + cl.apply_release(release) + assert sorted(p.name for p in (repo / "changelog.d").iterdir()) == ["README.md"] + changelog = (repo / "CHANGELOG.md").read_text(encoding="utf-8") + assert "- **Third.**\n- **Second.**\n- **First.**\n\n### Changed\n\n- **An old-style entry.**" in changelog + assert '"version": "0.2.0"' in (repo / "app" / "package.json").read_text(encoding="utf-8") + _commit_all(repo, "release") + assert cl.check_pr("main", root=repo) == [] + with pytest.raises(cl.ChangelogError, match="nothing to release"): + cl.plan_release(version="0.3.0", date="2026-08-31", title="Again", root=repo) + + +def test_a_cut_that_folds_only_old_style_bullets_is_still_a_cut(repo: Path) -> None: + """Before any fragment exists, [Unreleased] alone feeds the cut — no fragment deleted, yet no fragment demanded.""" + release = cl.plan_release(version="0.2.0", date="2026-08-30", title="Cut", root=repo) + cl.apply_release(release) + _commit_all(repo, "release") + assert cl.check_pr("main", root=repo) == [] diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 00000000000..ab278287e01 --- /dev/null +++ b/tools/__init__.py @@ -0,0 +1,8 @@ +"""Developer tooling — not shipped with the API image or the SPA bundle. + +`[tool.setuptools.packages.find]` in pyproject.toml lists `api*`, `core*` and +`automation*`, so nothing here reaches the distribution, and the API image's +root `.dockerignore` is an allowlist that does not name this directory either. +That is the point: a release helper has no business in a container that serves +requests. +""" diff --git a/tools/changelog/__init__.py b/tools/changelog/__init__.py new file mode 100644 index 00000000000..52a0f03c1d4 --- /dev/null +++ b/tools/changelog/__init__.py @@ -0,0 +1,386 @@ +"""Changelog fragments: one file per PR under `changelog.d/`, folded into +`CHANGELOG.md` when a release is cut. + +Why fragments. Every PR adds its bullets under `[Unreleased]` of the one shared +file, so sibling PRs conflict each other exactly there and only there — three +times in one night on 2026-09-02/03, each time a hand-resolved rebase for text +neither branch disagreed about. A union merge driver (`.gitattributes`) heals +the LOCAL rebase for pure additions, but GitHub's own mergeability check ignores +merge drivers, and a branch that MOVES changelog lines comes out of a union +rebase with the block duplicated. Fragments remove the shared spot altogether: a +PR adds `changelog.d/.md` and touches nothing else; the release cut folds +every fragment — plus whatever `[Unreleased]` still holds from before the +fragments existed — under the new version heading, bumps the version files, +repoints the compare links and deletes the fragments. + +A fragment is a slice of the CHANGELOG in the CHANGELOG's own format — +`### ` headings over bold-titled bullets — so the assembled section +reads exactly as if it had been written there, and ONE parser serves the +fragments and the `[Unreleased]` section alike. Standard library only, so the +CI gate runs it without the project's extras. + +What the cut does NOT do is anyplot's two aggregate lines: the italic +`*Catalog: …*` at the end of the section and the single `**Dependencies:**` +bullet that stands in for the window's Dependabot bumps. Both summarize what +happened between releases rather than what any one PR said, so they stay where +`agentic/commands/release.md` puts them — written by hand at cut time, into the +section this tool has just laid out. + +Three verbs (`python -m tools.changelog …`, see `__main__`): + +* `check [--base REF]` — every fragment parses; with `--base`, the diff against + REF must carry a fragment (or be a release cut, or catalogue-only) and must + not add bullets to `[Unreleased]` directly. +* `preview` — the merged `[Unreleased]` as the next cut would write it. +* `release VERSION --title …` — the cut itself. +""" + +from __future__ import annotations + +import re +import subprocess +from dataclasses import dataclass +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +FRAGMENT_DIR_NAME = "changelog.d" +CHANGELOG_NAME = "CHANGELOG.md" + +# Keep a Changelog's own order; the file has used Added, Changed, Removed and +# Fixed so far, and a fragment may only name one of these six. +CATEGORIES = ("Added", "Changed", "Deprecated", "Removed", "Fixed", "Security") + +# What a PR may touch without a fragment: the catalogue itself. The automated +# plot pipeline (spec-create, impl-generate/review/repair/merge, spec +# auto-polish, daily-regen) writes nothing outside `plots/`, and its output is +# the exempt class the release summarizes in one italic *Catalog* line rather +# than per PR — hundreds of them per window. The CI job skips the two bot +# authors as well, so a pipeline PR passes on either count. +EXEMPT_PREFIXES = ("plots/",) + +UNRELEASED_HEADING = "## [Unreleased]" +SKIP_LABEL = "skip-changelog" + +COMPARE_BASE = "https://github.com/MarkusNeusinger/anyplot/compare" + +_VERSION_HEADING = re.compile(r"^## \[(\d+\.\d+\.\d+)\]") +_CATEGORY_LINE = re.compile(r"^### (\S+)\s*$") +_SEMVER = re.compile(r"\d+\.\d+\.\d+") +_DATE = re.compile(r"\d{4}-\d{2}-\d{2}") +_UNRELEASED_LINK = re.compile(r"^\[Unreleased\]: (\S+)/compare/v(\d+\.\d+\.\d+)\.\.\.HEAD$", re.M) + +# One anchored pattern per version line, so a cut that finds anything but +# exactly one match stops instead of guessing. `app/package.json` is bumped in +# lockstep because the masthead falls back to it whenever the GitHub releases +# lookup is unavailable, and tests/unit/test_version_sync.py fails a PR where +# the two drift — the anchor is the two-space-indented top-level key, which no +# dependency line can match. +_VERSION_LINES: tuple[tuple[str, str, str], ...] = ( + ("pyproject.toml", r'^version = "\d+\.\d+\.\d+"$', 'version = "{version}"'), + ("uv.lock", r'^(name = "anyplot"\n)version = "\d+\.\d+\.\d+"$', r'\g<1>version = "{version}"'), + ("app/package.json", r'^ "version": "\d+\.\d+\.\d+",$', ' "version": "{version}",'), +) + +Entries = dict[str, list[str]] + + +class ChangelogError(ValueError): + """A fragment, the changelog or a PR violates the format; the message names where.""" + + +# --- parsing ----------------------------------------------------------------- + + +def parse_entries(text: str, *, where: str) -> Entries: + """Parse `### Category` headings over `- **Title.** …` bullets. + + A bullet keeps its continuation lines (two-space indented, as the existing + entries wrap; sub-bullets are indented the same way). Anything else is an + error naming the line, because a stray line here would silently become + part of a release section nobody proof-reads again. + """ + entries: Entries = {} + current: list[str] | None = None + bullet: list[str] | None = None + + def close() -> None: + nonlocal bullet + if bullet is not None and current is not None: + current.append("\n".join(bullet).rstrip()) + bullet = None + + for n, line in enumerate(text.splitlines(), 1): + if m := _CATEGORY_LINE.match(line): + close() + name = m.group(1) + if name not in CATEGORIES: + raise ChangelogError(f"{where}:{n}: unknown category '{name}' (one of {', '.join(CATEGORIES)})") + if name in entries: + raise ChangelogError(f"{where}:{n}: category '{name}' appears twice") + current = entries[name] = [] + elif line.startswith("- "): + close() + if current is None: + raise ChangelogError(f"{where}:{n}: a bullet before any '### Category' heading") + if not line.startswith("- **"): + raise ChangelogError(f"{where}:{n}: a bullet opens with its bold title: '- **Title.** …'") + bullet = [line] + elif not line.strip(): + if bullet is not None: + bullet.append("") + elif bullet is not None and line.startswith(" "): + bullet.append(line) + else: + raise ChangelogError( + f"{where}:{n}: stray text — only '### Category' headings, '- **…' bullets " + "and their two-space-indented continuation lines belong here" + ) + close() + return entries + + +@dataclass(frozen=True) +class Changelog: + """`CHANGELOG.md` split at its `[Unreleased]` section.""" + + head: str + """Everything up to and including the `## [Unreleased]` line.""" + unreleased: str + """The section body — empty between a cut and the first old-style entry.""" + rest: str + """From the newest version heading to the end, untouched by a cut.""" + + @property + def newest_version(self) -> str | None: + m = _VERSION_HEADING.match(self.rest) + return m.group(1) if m else None + + +def split_changelog(text: str) -> Changelog: + lines = text.splitlines(keepends=True) + start = next((i for i, line in enumerate(lines) if line.rstrip("\n") == UNRELEASED_HEADING), None) + if start is None: + raise ChangelogError(f"{CHANGELOG_NAME}: no '{UNRELEASED_HEADING}' heading") + end = next((i for i in range(start + 1, len(lines)) if lines[i].startswith("## ")), len(lines)) + return Changelog("".join(lines[: start + 1]), "".join(lines[start + 1 : end]), "".join(lines[end:])) + + +# --- fragments --------------------------------------------------------------- + + +@dataclass(frozen=True) +class Fragment: + path: Path + entries: Entries + + +def _git(root: Path, *args: str, required: bool = False) -> str: + """Stdout of a git command in `root`. + + The gate's calls are `required`: a failure there (an unfetched base ref, a + missing merge-base) must stop the check, because an empty diff would + otherwise read as "nothing changed" and let the PR pass. Only the + fragment-date lookup is best-effort and gets the empty string instead. + """ + try: + done = subprocess.run(["git", *args], cwd=root, capture_output=True, text=True, check=False) + except OSError as e: + if required: + raise ChangelogError(f"git {' '.join(args)}: {e}") from e + return "" + if done.returncode != 0: + if required: + raise ChangelogError(f"git {' '.join(args)} failed: {done.stderr.strip() or done.returncode}") + return "" + return done.stdout + + +def _added_at(root: Path, path: Path) -> float: + """Commit time of the commit that added `path`; an uncommitted fragment is the newest of all.""" + stamp = _git(root, "log", "--diff-filter=A", "--format=%ct", "-1", "--", str(path.relative_to(root))).strip() + return float(stamp) if stamp else float("inf") + + +def load_fragments(root: Path = REPO_ROOT) -> list[Fragment]: + """Every `changelog.d/*.md` but the README, newest first. + + Newest first mirrors the rule the shared file had ("a new bullet goes on + top of its category"), so the cut section reads in the same order the + old one did. The order comes from the commit that added the fragment, + not the file name, so a slug never has to encode a date. + """ + directory = root / FRAGMENT_DIR_NAME + fragments: list[Fragment] = [] + for path in sorted(directory.glob("*.md")): + if path.name == "README.md": + continue + where = str(path.relative_to(root)) + entries = parse_entries(path.read_text(encoding="utf-8"), where=where) + if not any(entries.values()): + raise ChangelogError(f"{where}: no bullets") + fragments.append(Fragment(path, entries)) + return sorted(fragments, key=lambda f: (-_added_at(root, f.path), f.path.name)) + + +def merge(existing: Entries, fragments: list[Fragment]) -> Entries: + """Keep a Changelog's category order; within one, the fragments go above what `[Unreleased]` already held.""" + merged: Entries = {} + for category in CATEGORIES: + bullets = [b for f in fragments for b in f.entries.get(category, [])] + existing.get(category, []) + if bullets: + merged[category] = bullets + return merged + + +def render(entries: Entries) -> str: + """The section body as the file writes it: a heading, a blank line, the bullets, a blank line.""" + return "".join(f"### {c}\n\n" + "\n".join(entries[c]) + "\n\n" for c in CATEGORIES if entries.get(c)) + + +def unreleased(root: Path = REPO_ROOT) -> Entries: + """The merged `[Unreleased]` — the section in the file plus every fragment.""" + parts = split_changelog((root / CHANGELOG_NAME).read_text(encoding="utf-8")) + existing = parse_entries(parts.unreleased, where=f"{CHANGELOG_NAME} [Unreleased]") + return merge(existing, load_fragments(root)) + + +# --- the cut ----------------------------------------------------------------- + + +@dataclass(frozen=True) +class Release: + """A planned cut: file contents to write and fragments to delete — nothing touched yet.""" + + version: str + writes: dict[Path, str] + deletes: tuple[Path, ...] + + +def _semver(version: str) -> tuple[int, ...]: + return tuple(int(part) for part in version.split(".")) + + +def repoint_compare_links(text: str, *, version: str) -> str: + """Move `[Unreleased]` to the new tag and add the new version's compare link. + + The link block at the bottom of the file carries one line per bracketed + heading, and `agentic/commands/release.md` calls keeping it in step "easy to + forget" — which is exactly the kind of step a tool should hold instead of a + checklist. A file without an `[Unreleased]:` line keeps none: the block is + optional, and inventing one would be a change the release never asked for. + """ + match = _UNRELEASED_LINK.search(text) + if match is None: + return text + previous = match.group(2) + if previous == version: + raise ChangelogError(f"the [Unreleased] compare link already points at v{version}") + return _UNRELEASED_LINK.sub( + f"[Unreleased]: {COMPARE_BASE}/v{version}...HEAD\n[{version}]: {COMPARE_BASE}/v{previous}...v{version}", + text, + count=1, + ) + + +def cut_release(text: str, fragments: list[Fragment], *, version: str, date: str, title: str) -> str: + """The new `CHANGELOG.md`: `[Unreleased]` emptied, the merged entries under the version heading.""" + if not _SEMVER.fullmatch(version): + raise ChangelogError(f"version '{version}' is not MAJOR.MINOR.PATCH") + if not _DATE.fullmatch(date): + raise ChangelogError(f"date '{date}' is not YYYY-MM-DD") + if not title.strip(): + raise ChangelogError("a release heading carries a title (`## [x.y.z] — date — title`)") + parts = split_changelog(text) + newest = parts.newest_version + if newest and _semver(version) <= _semver(newest): + raise ChangelogError(f"version {version} is not above the newest section, {newest}") + entries = merge(parse_entries(parts.unreleased, where=f"{CHANGELOG_NAME} [Unreleased]"), fragments) + if not entries: + raise ChangelogError("nothing to release: no fragments, and [Unreleased] is empty") + heading = f"## [{version}] — {date} — {title.strip()}\n\n" + return repoint_compare_links(parts.head + "\n" + heading + render(entries) + parts.rest, version=version) + + +def bump_version_files(root: Path, *, version: str, date: str) -> dict[Path, str]: + """New contents of the version-carrying files; exactly one line each, or the cut stops.""" + writes: dict[Path, str] = {} + for name, pattern, replacement in _VERSION_LINES: + path = root / name + text = writes.get(path) or path.read_text(encoding="utf-8") + new, n = re.subn(pattern, replacement.format(version=version, date=date), text, flags=re.M) + if n != 1: + raise ChangelogError(f"{name}: expected exactly one line matching {pattern!r}, found {n}") + writes[path] = new + return writes + + +def plan_release(*, version: str, date: str, title: str, root: Path = REPO_ROOT) -> Release: + changelog = root / CHANGELOG_NAME + fragments = load_fragments(root) + writes = { + changelog: cut_release( + changelog.read_text(encoding="utf-8"), fragments, version=version, date=date, title=title + ) + } + writes.update(bump_version_files(root, version=version, date=date)) + return Release(version, writes, tuple(f.path for f in fragments)) + + +def apply_release(release: Release) -> None: + for path, text in release.writes.items(): + path.write_text(text, encoding="utf-8") + for path in release.deletes: + path.unlink() + + +# --- the PR gate ------------------------------------------------------------- + + +def _changed_files(root: Path, base: str) -> dict[str, str]: + """`path → status letter` for everything HEAD changed since it branched off `base`.""" + out = _git(root, "diff", "--name-status", "--no-renames", f"{base}...HEAD", required=True) + changed: dict[str, str] = {} + for line in out.splitlines(): + status, _, path = line.partition("\t") + if path: + changed[path] = status[:1] + return changed + + +def _bullets(section: str) -> set[str]: + return {b for bullets in parse_entries(section, where=f"{CHANGELOG_NAME} [Unreleased]").values() for b in bullets} + + +def check_pr(base: str, *, root: Path = REPO_ROOT) -> list[str]: + """Why HEAD, as a PR against `base`, fails the fragment rule — empty when it passes. + + Passes when the PR touches `changelog.d/` at all (a fragment added, an old + one corrected), when it is the release cut (a version heading the base + lacks — the cut moves bullets OUT and needs no fragment of its own), or + when everything it touches is exempt (catalogue-only, `plots/`). Fails when + it carries no fragment, and — independently — when it writes bullets into + `[Unreleased]` directly: that is the shared spot the fragments exist to + retire. + """ + changed = _changed_files(root, base) + problems: list[str] = [] + release_cut = False + if changed.get(CHANGELOG_NAME) == "M": + merge_base = _git(root, "merge-base", base, "HEAD", required=True).strip() + before = split_changelog(_git(root, "show", f"{merge_base}:{CHANGELOG_NAME}", required=True)) + after = split_changelog((root / CHANGELOG_NAME).read_text(encoding="utf-8")) + release_cut = after.newest_version != before.newest_version + gained = _bullets(after.unreleased) - _bullets(before.unreleased) + for bullet in sorted(gained): + title = bullet.split("\n", 1)[0][:72] + problems.append(f"{CHANGELOG_NAME} [Unreleased] gained a bullet — it belongs in a fragment: {title}…") + touches_fragments = any(p.startswith(f"{FRAGMENT_DIR_NAME}/") for p in changed) + exempt = bool(changed) and all(p.startswith(EXEMPT_PREFIXES) for p in changed) + if changed and not (touches_fragments or release_cut or exempt): + problems.insert( + 0, + f"no changelog fragment: add {FRAGMENT_DIR_NAME}/.md (format: {FRAGMENT_DIR_NAME}/README.md), " + f"or label the PR '{SKIP_LABEL}' if it truly changes nothing worth a line", + ) + return problems diff --git a/tools/changelog/__main__.py b/tools/changelog/__main__.py new file mode 100644 index 00000000000..e402262e51a --- /dev/null +++ b/tools/changelog/__main__.py @@ -0,0 +1,122 @@ +"""`python -m tools.changelog` — check fragments, preview the pending section, cut a release. + + uv run python -m tools.changelog check # every fragment well-formed + uv run python -m tools.changelog check --base origin/main # …and this branch carries one (the CI gate) + uv run python -m tools.changelog preview # [Unreleased] as the next cut would write it + uv run python -m tools.changelog release 3.3.0 --title "…" [--date YYYY-MM-DD] [--dry-run] + +`release` rewrites CHANGELOG.md — the section, its heading and the compare links +at the bottom — bumps pyproject.toml, uv.lock and app/package.json and deletes +the fragments, in the working tree only. What it deliberately leaves to +`agentic/commands/release.md`: the two aggregate lines (the italic *Catalog* +line and the single **Dependencies:** bullet, which summarize the window rather +than any PR), the release branch and PR, the tag on the merge commit, and the +condensed GitHub release. Nothing here touches a remote, a database or a +deployment. +""" + +from __future__ import annotations + +import argparse +import os +import sys +from datetime import date + +from tools.changelog import ( + CHANGELOG_NAME, + REPO_ROOT, + ChangelogError, + apply_release, + check_pr, + load_fragments, + plan_release, + render, + unreleased, +) + + +def _fail(message: str) -> int: + # GitHub turns the `::error::` line into an annotation on the PR's checks tab. + prefix = "::error::" if os.environ.get("GITHUB_ACTIONS") else "error: " + print(f"{prefix}{message}", file=sys.stderr) + return 1 + + +def cmd_check(args: argparse.Namespace) -> int: + fragments = load_fragments() + print( + f"{len(fragments)} fragment(s) well-formed" + + (": " + ", ".join(f.path.name for f in fragments) if fragments else "") + ) + unreleased() # the section in the file parses too — a malformed seam would break the next cut + if args.base: + problems = check_pr(args.base) + if problems: + for problem in problems: + _fail(problem) + return 1 + print(f"the diff against {args.base} passes the fragment rule") + return 0 + + +def cmd_preview(args: argparse.Namespace) -> int: + entries = unreleased() + if not entries: + print("(nothing pending: no fragments, [Unreleased] empty)") + return 0 + print(render(entries), end="") + return 0 + + +def cmd_release(args: argparse.Namespace) -> int: + release = plan_release(version=args.version, date=args.date, title=args.title) + verb = "would write" if args.dry_run else "wrote" + for path in release.writes: + print(f"{verb} {path.relative_to(REPO_ROOT)}") + for path in release.deletes: + print(f"{'would delete' if args.dry_run else 'deleted'} {path.relative_to(REPO_ROOT)}") + if args.dry_run: + text = release.writes[REPO_ROOT / CHANGELOG_NAME] + start = text.index(f"## [{args.version}]") + end = text.find("\n## [", start + 1) + print("\n" + text[start : end if end > 0 else len(text)].rstrip() + "\n") + return 0 + apply_release(release) + print( + f"\nnext: add the window's aggregate lines by hand (the italic *Catalog* line and the\n" + f"single **Dependencies:** bullet — agentic/commands/release.md step 2), review the diff,\n" + f"then on a release/v{args.version} branch\n" + f" git add -A CHANGELOG.md changelog.d pyproject.toml uv.lock app/package.json\n" + f' git commit -m "release: v{args.version} — {args.title}"\n' + f"after the merge: tag the merge commit v{args.version}, then post the condensed GitHub release." + ) + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="python -m tools.changelog", description=__doc__.split("\n\n")[0]) + sub = parser.add_subparsers(dest="verb", required=True) + + check = sub.add_parser("check", help="every fragment parses; with --base, the branch carries one") + check.add_argument("--base", help="git ref of the PR base (CI: origin/)") + check.set_defaults(run=cmd_check) + + preview = sub.add_parser("preview", help="print [Unreleased] as the next cut would write it") + preview.set_defaults(run=cmd_preview) + + release = sub.add_parser("release", help="cut a release: fold the fragments, bump the version files") + release.add_argument("version", help="MAJOR.MINOR.PATCH, above the newest section") + release.add_argument("--title", required=True, help="the heading's title after the date") + release.add_argument("--date", default=date.today().isoformat(), help="YYYY-MM-DD (default: today)") + release.add_argument("--dry-run", action="store_true", help="print the plan and the new section, write nothing") + release.set_defaults(run=cmd_release) + + args = parser.parse_args(argv) + try: + return args.run(args) + except ChangelogError as e: + return _fail(str(e)) + + +if __name__ == "__main__": + sys.exit(main()) From e39f61d21fef48a3cb50c8f355d2a7d40abdc2af Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Thu, 3 Sep 2026 23:33:21 +0200 Subject: [PATCH 2/3] docs(changelog): PR reference Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3 --- changelog.d/changelog-fragments.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/changelog-fragments.md b/changelog.d/changelog-fragments.md index b3af146bb21..279d9cd9379 100644 --- a/changelog.d/changelog-fragments.md +++ b/changelog.d/changelog-fragments.md @@ -20,4 +20,4 @@ deliberately does NOT touch is the release's two aggregate lines — the italic *Catalog* line and the single **Dependencies:** bullet — because those summarize a window rather than a PR and are written at cut time. Ported from the sibling repo kurrentschrift, where the same tool has run - since 2026-08-30. (#PRNUM) + since 2026-08-30. (#11215) From 9d2a2ad0b502897f70225c50034180e689788243 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Thu, 3 Sep 2026 23:50:27 +0200 Subject: [PATCH 3/3] fix(changelog): five Copilot findings on the fragment gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. The `skip-changelog` escape hatch stayed red after labelling — the default pull_request activity types exclude labeled/unlabeled, so the documented way out did not re-run the check. Types listed explicitly now. 2. `- **unterminated title` parsed as well-formed. The closing `**` cannot be checked on the opening line, because a real title regularly runs onto the continuation line before it, so it is checked when the bullet ends and the error names the line the bullet opened on. Three tests: the wrapped title that must pass, and two unterminated ones that must not. 3. `release` printed "wrote …"/"deleted …" before apply_release() did any I/O, so a failed write left a record of success above the traceback. Apply first, report after; the dry run keeps its "would" wording. 4. agentic/docs/project-guide.md still described the old process end to end — bullets under [Unreleased], the section moved by hand, no app/package.json, the release published verbatim. It now describes the fragment flow and the condensation rule. 5. changelog.d/README.md pointed at release.md step 2 for the aggregate lines, which is the cut; they are step 3. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3 --- .github/workflows/ci-changelog.yml | 6 ++++++ agentic/docs/project-guide.md | 27 ++++++++++++++++--------- changelog.d/README.md | 2 +- tests/unit/tools/test_changelog_tool.py | 15 ++++++++++++++ tools/changelog/__init__.py | 20 +++++++++++++++++- tools/changelog/__main__.py | 18 +++++++++++------ 6 files changed, 71 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci-changelog.yml b/.github/workflows/ci-changelog.yml index 1a0df6c9d0e..55bb709b252 100644 --- a/.github/workflows/ci-changelog.yml +++ b/.github/workflows/ci-changelog.yml @@ -18,6 +18,12 @@ run-name: "Changelog: ${{ github.ref_name }}" on: pull_request: + # `labeled`/`unlabeled` on top of the default three. The `skip-changelog` + # escape hatch is applied AFTER the gate has gone red, and the default + # activity types do not include labelling — so without these the label would + # be documented as the way out while the check stayed red until the next + # push (Copilot review). + types: [opened, synchronize, reopened, labeled, unlabeled] branches: - main - develop diff --git a/agentic/docs/project-guide.md b/agentic/docs/project-guide.md index 41c19456986..c73a889d9f4 100644 --- a/agentic/docs/project-guide.md +++ b/agentic/docs/project-guide.md @@ -1091,19 +1091,28 @@ pytest --pdb # Debug on failure The release notes ARE the changelog — they accumulate PR-by-PR, so cutting a release is pure mechanics (see `agentic/commands/release.md` for the executable flow). -- **`CHANGELOG.md`** (repo root) follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/): - every non-exempt PR adds bold-titled bullets with PR refs under `[Unreleased]`. **Exempt** (would - drown the file): the automated plot pipeline's output (spec-create, impl-generate/review/repair/ +- **`changelog.d/.md`**, one fragment per PR, is what a PR writes — never a bullet in + `CHANGELOG.md` itself, which is the line two sibling PRs then conflict on. A fragment is a slice + of the changelog in the changelog's own format ([Keep a Changelog](https://keepachangelog.com/en/1.1.0/) + categories over bold-titled bullets with PR refs; rules and an example in + `changelog.d/README.md`), and the CI job "Changelog (fragment)" refuses both a missing fragment + and a bullet written into `[Unreleased]`. **Exempt** (would drown the file): catalogue-only PRs + under `plots/`, the automated plot pipeline's output (spec-create, impl-generate/review/repair/ merge, spec auto-polish, daily-regen PRs) and individual Dependabot bumps — these are summarized in aggregate at release time (an italic *Catalog* line at the end of the version section and a - single **Dependencies:** bullet under `### Changed`). + single **Dependencies:** bullet under `### Changed`) — plus any PR labelled `skip-changelog`. - **Versioning** is product communication: major for milestone releases (new language waves, rebrands, breaking URL/schema changes — v2.0.0/v3.0.0 precedent), minor for feature batches, - patch for fix-only. Version lives in `pyproject.toml`. -- **Release flow**: a small `release/vX.Y.Z` PR moves `[Unreleased]` under - `## [X.Y.Z] — YYYY-MM-DD — ` and bumps `pyproject.toml` + `uv.lock`; after merge, an - annotated tag `vX.Y.Z` is pushed and `gh release create` publishes the changelog section - verbatim as the release body, titled `vX.Y.Z — `. + patch for fix-only. Version lives in `pyproject.toml`, mirrored in `uv.lock` and + `app/package.json`. +- **Release flow**: on a small `release/vX.Y.Z` PR, + `uv run python -m tools.changelog release X.Y.Z --title ""` folds every fragment under + `## [X.Y.Z] — YYYY-MM-DD — `, bumps `pyproject.toml`, `uv.lock` and `app/package.json`, + repoints the compare links and deletes the fragments; the two aggregate lines are added by hand + afterwards. After merge, an annotated tag `vX.Y.Z` is pushed and `gh release create` publishes + that section **condensed** — never verbatim (owner rule, 2026-08-28; the v3.1.0 page ran to 640 + lines) — titled `vX.Y.Z — `. The shape of the condensation is in `CLAUDE.md` + § "Changelog + releases". - **Product integration**: the site masthead displays the latest release tag live (`app/src/hooks/useLatestRelease.ts` fetches the GitHub `releases/latest` API with a 1 h localStorage cache) and the About page links to the GitHub releases, so publishing the release diff --git a/changelog.d/README.md b/changelog.d/README.md index 328c017d4c5..3e312946dd4 100644 --- a/changelog.d/README.md +++ b/changelog.d/README.md @@ -54,4 +54,4 @@ The two aggregate lines stay by hand: the italic `*Catalog: …*` at the end of the section and the single `**Dependencies:**` bullet that stands in for the window's Dependabot bumps. Both summarize a release window rather than a PR, so they are written at cut time into the section the tool has just laid out -(`agentic/commands/release.md` step 2). +(`agentic/commands/release.md` step 3 — step 2 is the cut itself). diff --git a/tests/unit/tools/test_changelog_tool.py b/tests/unit/tools/test_changelog_tool.py index 3fb174f38fe..2a736e106a1 100644 --- a/tests/unit/tools/test_changelog_tool.py +++ b/tests/unit/tools/test_changelog_tool.py @@ -46,6 +46,15 @@ """ +def test_a_title_that_closes_on_the_continuation_line_is_accepted() -> None: + """The reason the closing `**` is checked over the whole bullet, not the + opening line: the entries in CHANGELOG.md wrap their titles like this.""" + wrapped = "### Added\n\n- **A title long enough to run\n onto the next line** — and then the body (#1).\n" + assert cl.parse_entries(wrapped, where="f.md")["Added"] == [ + "- **A title long enough to run\n onto the next line** — and then the body (#1)." + ] + + def test_a_fragment_parses_into_categories_with_wrapped_bullets() -> None: entries = cl.parse_entries(FRAGMENT, where="f.md") assert list(entries) == ["Added", "Fixed"] @@ -64,6 +73,12 @@ def test_a_fragment_parses_into_categories_with_wrapped_bullets() -> None: ("### Added\n\n- plain bullet\n", "f.md:3: .*bold title"), ("### Added\n\nprose\n", "f.md:3: stray text"), ("### Added\n\n- **x.**\n\n### Added\n\n- **y.**\n", "f.md:5: .*twice"), + # The bold title is never closed. Not catchable on the opening line — + # a real title regularly runs onto the continuation line before its + # `**` — so this is checked when the bullet ends, and the error names + # the line the bullet OPENED on. + ("### Added\n\n- **unterminated title\n", "f.md:3: .*never closed"), + ("### Added\n\n- **still going\n and going\n", "f.md:3: .*never closed"), ], ) def test_a_malformed_fragment_names_its_line(text: str, complaint: str) -> None: diff --git a/tools/changelog/__init__.py b/tools/changelog/__init__.py index 52a0f03c1d4..645a4505af3 100644 --- a/tools/changelog/__init__.py +++ b/tools/changelog/__init__.py @@ -66,6 +66,9 @@ _VERSION_HEADING = re.compile(r"^## \[(\d+\.\d+\.\d+)\]") _CATEGORY_LINE = re.compile(r"^### (\S+)\s*$") +# The bold title, over the WHOLE bullet: it regularly runs onto the continuation +# line before its closing `**`, so DOTALL and the non-greedy body are both load-bearing. +_BOLD_TITLE = re.compile(r"- \*\*.+?\*\*", re.DOTALL) _SEMVER = re.compile(r"\d+\.\d+\.\d+") _DATE = re.compile(r"\d{4}-\d{2}-\d{2}") _UNRELEASED_LINK = re.compile(r"^\[Unreleased\]: (\S+)/compare/v(\d+\.\d+\.\d+)\.\.\.HEAD$", re.M) @@ -103,11 +106,25 @@ def parse_entries(text: str, *, where: str) -> Entries: entries: Entries = {} current: list[str] | None = None bullet: list[str] | None = None + opened_at = 0 def close() -> None: + """Finish the bullet in hand, checking the one thing only the WHOLE + bullet can answer: that its bold title is closed. + + Not checkable on the opening line — a title regularly runs onto the + continuation line before its `**`, as the entries in CHANGELOG.md do. + Left unchecked, `- **unterminated title` parsed as well-formed and the + release published it unchanged (Copilot review).""" nonlocal bullet if bullet is not None and current is not None: - current.append("\n".join(bullet).rstrip()) + text = "\n".join(bullet).rstrip() + if not _BOLD_TITLE.match(text): + raise ChangelogError( + f"{where}:{opened_at}: the bullet's bold title is never closed — " + "it opens with '- **' and needs the matching '**'" + ) + current.append(text) bullet = None for n, line in enumerate(text.splitlines(), 1): @@ -126,6 +143,7 @@ def close() -> None: if not line.startswith("- **"): raise ChangelogError(f"{where}:{n}: a bullet opens with its bold title: '- **Title.** …'") bullet = [line] + opened_at = n elif not line.strip(): if bullet is not None: bullet.append("") diff --git a/tools/changelog/__main__.py b/tools/changelog/__main__.py index e402262e51a..a0daf550306 100644 --- a/tools/changelog/__main__.py +++ b/tools/changelog/__main__.py @@ -70,21 +70,27 @@ def cmd_preview(args: argparse.Namespace) -> int: def cmd_release(args: argparse.Namespace) -> int: release = plan_release(version=args.version, date=args.date, title=args.title) - verb = "would write" if args.dry_run else "wrote" - for path in release.writes: - print(f"{verb} {path.relative_to(REPO_ROOT)}") - for path in release.deletes: - print(f"{'would delete' if args.dry_run else 'deleted'} {path.relative_to(REPO_ROOT)}") if args.dry_run: + for path in release.writes: + print(f"would write {path.relative_to(REPO_ROOT)}") + for path in release.deletes: + print(f"would delete {path.relative_to(REPO_ROOT)}") text = release.writes[REPO_ROOT / CHANGELOG_NAME] start = text.index(f"## [{args.version}]") end = text.find("\n## [", start + 1) print("\n" + text[start : end if end > 0 else len(text)].rstrip() + "\n") return 0 + # Apply BEFORE reporting. Printing the plan first read as a record of what + # had happened, so a failed write or unlink left a list of "wrote …" lines + # above the traceback that caused them (Copilot review). apply_release(release) + for path in release.writes: + print(f"wrote {path.relative_to(REPO_ROOT)}") + for path in release.deletes: + print(f"deleted {path.relative_to(REPO_ROOT)}") print( f"\nnext: add the window's aggregate lines by hand (the italic *Catalog* line and the\n" - f"single **Dependencies:** bullet — agentic/commands/release.md step 2), review the diff,\n" + f"single **Dependencies:** bullet — agentic/commands/release.md step 3), review the diff,\n" f"then on a release/v{args.version} branch\n" f" git add -A CHANGELOG.md changelog.d pyproject.toml uv.lock app/package.json\n" f' git commit -m "release: v{args.version} — {args.title}"\n'