From 8f32f24593e9b1ef7f75b97f143a5ad07020c4d4 Mon Sep 17 00:00:00 2001 From: allen0099 Date: Sun, 27 Sep 2026 12:08:49 +0000 Subject: [PATCH] build: assemble the changelog from per-PR fragments in changelog.d Each pull request adds changelog.d/.
.md instead of editing ## [Unreleased], so parallel pull requests no longer conflict. The release script checks every fragment before writing anything, merges them under their Keep a Changelog headings after any hand-written entry, appends the issue link from the file name, and deletes them; the release job commits the deletions. Closes #213 --- .github/workflows/release.yml | 9 +- CHANGELOG.md | 15 +- CLAUDE.md | 1 + changelog.d/README.md | 41 ++++ docs/CONTRIBUTING.md | 19 +- docs/DEVELOPMENT.md | 69 +++++- i18n/zh-TW/docs/CONTRIBUTING.md | 2 +- scripts/changelog_release.py | 247 ++++++++++++++++++- tests/test_changelog_release.py | 404 +++++++++++++++++++++++++++++++- 9 files changed, 768 insertions(+), 39 deletions(-) create mode 100644 changelog.d/README.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8e87e21..3df0684 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -232,7 +232,7 @@ jobs: echo 'Changes that the release commit would have carried:' echo echo '```' - git --no-pager diff --stat -- pyproject.toml uv.lock CHANGELOG.md + git --no-pager diff --stat -- pyproject.toml uv.lock CHANGELOG.md changelog.d echo '```' echo echo 'Build artifacts:' @@ -305,7 +305,12 @@ jobs: VERSION: ${{ needs.build.outputs.version }} run: | set -euo pipefail - git add pyproject.toml uv.lock CHANGELOG.md + # `build` merged every fragment in changelog.d/ into CHANGELOG.md and + # deleted it, but an artifact cannot carry a deletion. This is the + # same commit, and `build` fails on any file there other than + # fragments, README.md and dotfiles, so delete the same files here. + find changelog.d -maxdepth 1 -type f -name '*.md' ! -name README.md ! -name '.*' -delete + git add pyproject.toml uv.lock CHANGELOG.md changelog.d if git diff --cached --quiet; then echo "Nothing to commit; releasing HEAD as it is." else diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f770a0..34bae94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,15 @@ 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). This file records what changed for users of the library, in particular -behaviour that changed under an unchanged API. Entries are added by hand, in -the pull request that earns them, and each one opens with a bold one-line -summary: `- **What changed.** The details...`. The GitHub release notes list -those summaries and link back here, and a release fails if `## [Unreleased]` -is empty or an entry has no summary. Entries before 0.3.8 predate this format. -See [Releasing](https://fastapi-cachex.readthedocs.io/en/latest/DEVELOPMENT/#releasing). +behaviour that changed under an unchanged API. The pull request that earns an +entry adds it as a fragment in `changelog.d/`, and the release merges the +fragments in here, so `## [Unreleased]` is usually empty between releases. Each +entry opens with a bold one-line summary: `- **What changed.** The details...`. +The GitHub release notes list those summaries and link back here, and a release +fails if there is nothing to release or an entry has no summary. Entries before +0.3.8 predate this format. See +[Changelog fragments](https://fastapi-cachex.readthedocs.io/en/latest/DEVELOPMENT/#changelog-fragments) +and [Releasing](https://fastapi-cachex.readthedocs.io/en/latest/DEVELOPMENT/#releasing). Note that 0.3.3 was never released; 0.3.4 follows 0.3.2. diff --git a/CLAUDE.md b/CLAUDE.md index b2718ff..28428d8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,3 +123,4 @@ Five non-abstract atomic primitives live on the base class with non-atomic fallb - Forward references are mostly quoted annotations with `TYPE_CHECKING` imports; only a couple of modules use `from __future__ import annotations`. - All public functions must have complete type annotations. - Coverage threshold is 90% (enforced by `pytest-cov`). +- Changelog entries go in `changelog.d/.
.md` fragments (bold summary first, no leading `- `, no issue link), not in `CHANGELOG.md`; the release merges them. See `docs/DEVELOPMENT.md#changelog-fragments`. diff --git a/changelog.d/README.md b/changelog.d/README.md new file mode 100644 index 0000000..1d70c4f --- /dev/null +++ b/changelog.d/README.md @@ -0,0 +1,41 @@ +# Changelog fragments + +Pull requests do not edit `CHANGELOG.md`. Each one that changes behaviour, adds +public API, or fixes something a user could have hit adds a fragment here +instead, and the release merges the fragments into `CHANGELOG.md` and deletes +them. Two open pull requests therefore never conflict over the changelog. + +## File name + +`.
.md`, for example `65.added.md`, where `
` is one of +`added`, `changed`, `deprecated`, `removed`, `fixed`, `security`. + +A second entry for the same issue and section goes in `.
.2.md`, +a third in `.
.3.md`, and so on. One file holds one entry. + +## Content + +One changelog entry, opening with a bold one-line summary. Leave out the +leading `- ` and the issue link: the release adds both, the link taken from the +file name. Wrap lines however you like; indent a nested list by two spaces. + +```markdown +**`CacheManager.add()` stores a value only if the key is absent.** It uses the +same key prefix, JSON encoding and `default_ttl` as `set()`. +``` + +becomes, under `### Added`: + +```markdown +- **`CacheManager.add()` stores a value only if the key is absent.** It uses the + same key prefix, JSON encoding and `default_ttl` as `set()`. ([#65](https://github.com/allen0099/FastAPI-CacheX/issues/65)) +``` + +The release notes list only the bold summary, so write it for someone deciding +whether the release matters to them. + +`tests/test_changelog_release.py` checks every fragment here, so a malformed one +(a bad name, an unknown section, no bold summary, a leading `- `, its own issue +link) fails CI in the pull request that adds it. This README and dotfiles are +not fragments. See +[Releasing](https://fastapi-cachex.readthedocs.io/en/latest/DEVELOPMENT/#releasing). diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index cefe2f6..dd86aa3 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -32,13 +32,18 @@ Please refer to our [Development Guide](DEVELOPMENT.md) for detailed instruction 1. Update the matching guide under `docs/` (and the README if the change belongs on the front page) when you change the interface. Only the English pages need updating: the [Traditional Chinese translation](DEVELOPMENT.md#traditional-chinese-translation) is allowed to lag behind them -2. Add an entry to the `## [Unreleased]` section of - [CHANGELOG.md](https://github.com/allen0099/FastAPI-CacheX/blob/master/CHANGELOG.md) if your change alters behaviour, adds public - API, or fixes something a user could have hit. Open the entry with a bold - one-line summary, `- **What changed.** The details...`: the release notes - list only those summaries, and CI fails on an entry without one. A release - also refuses to run on an empty section, so an omission surfaces — but only - at release time, and only as "somebody forgot", never as which PR it was +2. Add a changelog fragment if your change alters behaviour, adds public + API, or fixes something a user could have hit: a file + `changelog.d/.
.md` (section `added`, `changed`, + `deprecated`, `removed`, `fixed` or `security`) holding the entry, opening + with a bold one-line summary, `**What changed.** The details...`, without a + leading `- ` or the issue link — the release adds both. Do not edit + [CHANGELOG.md](https://github.com/allen0099/FastAPI-CacheX/blob/master/CHANGELOG.md) + directly; see [Changelog fragments](DEVELOPMENT.md#changelog-fragments). The + release notes list only the summaries, and CI fails on a malformed fragment. + A release also refuses to run with nothing to release, so an omission + surfaces — but only at release time, and only as "somebody forgot", never as + which PR it was 3. Update the documentation with any new dependencies, features, or changes. New public API needs a docstring and, if it lives in a module not yet covered, an entry under `docs/api/`; check the site with diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 8d419af..00dda63 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -310,7 +310,9 @@ The workflow runs in this order: written. 2. **The version.** `uv version` applies the bump or the exact version. If `vX.Y.Z` is already tagged, locally or on the remote, the run stops here. -3. **The changelog.** `scripts/changelog_release.py` renames `## [Unreleased]` +3. **The changelog.** `scripts/changelog_release.py` merges the fragments in + `changelog.d/` into `## [Unreleased]` (see + [Changelog fragments](#changelog-fragments)), renames it to `## [X.Y.Z] - YYYY-MM-DD`, opens a fresh empty `## [Unreleased]` above it, rewrites the compare links at the bottom, and writes the release body: each entry's bold summary and issue links, and a link to the full entries @@ -318,8 +320,8 @@ The workflow runs in this order: 4. **The build.** `uv build`. The bumped files, the release notes and `dist/` are uploaded as one artifact, which the next two jobs download instead of building anything again. -5. **The permanent part**, kept together at the end: commit the version bump - and the promoted changelog, push it to master, tag, push the tag by refspec, +5. **The permanent part**, kept together at the end: commit the version bump, + the promoted changelog and the removal of the merged fragments, push it to master, tag, push the tag by refspec, create the GitHub release from the promoted section, publish to PyPI. Steps 1–4 are the `build` job, which installs every dev dependency and so gets @@ -342,8 +344,9 @@ of the release path was a release. A dry run may be dispatched on any branch, which is how a change to the workflow itself is rehearsed before it is merged. A dry run answers two questions, and the job summary reports both: whether the -bump and the promotion actually landed in `pyproject.toml`, `uv.lock` and -`CHANGELOG.md` (shown as a `git diff --stat`, staged by nothing), and what +bump and the promotion actually landed in `pyproject.toml`, `uv.lock`, +`CHANGELOG.md` and `changelog.d/` (shown as a `git diff --stat`, staged by +nothing), and what would have been published. The release notes, the built `dist/` and the bumped files are attached to the run as an artifact, because the notes are markdown and reading them in the job summary renders them a second time — which is not what the release page @@ -361,9 +364,9 @@ different path, not of this one. `CHANGELOG.md` used to be maintained entirely by hand and nothing enforced it: the release notes came from `git log --pretty=format:"- %s (%h)"`, so a release happened whether or not anyone had written down what it meant. That is no -longer true. The release body is built from the `## [Unreleased]` section, and -an empty one fails the run — for a hand-maintained file, "nobody wrote it down" -is far more likely than "nothing changed". The commit list has not been lost: +longer true. The release body is built from the `## [Unreleased]` section, +fragments included, and an empty one fails the run — for entries written by +hand, "nobody wrote it down" is far more likely than "nothing changed". The commit list has not been lost: the release body ends with a compare link against the previous tag. The changelog and the release page serve different readers. The changelog @@ -411,6 +414,50 @@ A hand-bump is also what broke the release on 2026-09-05, back when the commit step treated "nothing to commit" as a failure. When a pull request changes behaviour, adds public API, or fixes something a -user could have hit, add the entry to `## [Unreleased]` in the same PR. The -release will fail on an empty section, but it cannot tell you *which* PR forgot -its entry — only that somebody did. +user could have hit, add its entry in the same PR, as a fragment. The release +will fail when there is nothing to release, but it cannot tell you *which* PR +forgot its entry — only that somebody did. + +### Changelog fragments + +Pull requests do not edit `CHANGELOG.md`. When every PR appended to +`## [Unreleased]`, merging one put every other open PR in conflict. Instead, +each PR adds one file per entry to `changelog.d/`: + +- **Name:** `.
.md`, where `
` is `added`, `changed`, + `deprecated`, `removed`, `fixed` or `security`. A second entry for the same + issue and section is `.
.2.md`, then `.3.md`, and so on; a + file holds exactly one entry. +- **Content:** the entry, opening with its bold one-line summary, *without* + the leading `- ` and *without* the issue link. The release adds both, taking + the link from the file name. Line breaks are up to you; indent a nested list + by two spaces. + +`changelog.d/65.added.md`: + +```markdown +**Add `CacheManager.add()` for store-if-absent writes.** It uses the same +key prefix, JSON encoding and `default_ttl` as `set()`. +``` + +is released under `### Added` as: + +```markdown +- **Add `CacheManager.add()` for store-if-absent writes.** It uses the same + key prefix, JSON encoding and `default_ttl` as `set()`. ([#65](https://github.com/allen0099/FastAPI-CacheX/issues/65)) +``` + +At release time `scripts/changelog_release.py` checks every fragment before it +writes anything, merges them into `## [Unreleased]` — sections in Keep a +Changelog order (Added, Changed, Deprecated, Removed, Fixed, Security), each +after any entry already written there by hand, fragments ordered by issue +number — then promotes the section and deletes the fragments. The `release` +job commits the deletions with `CHANGELOG.md`. `changelog.d/README.md` and +dotfiles are not fragments; any other file there that is not a well-formed +fragment (a bad name, an unknown section, no bold summary, a leading `- `, its +own issue link) fails the release with nothing changed. +`test_the_repository_changelog_can_be_released` merges the pending fragments +the same way, so such a file fails CI in the pull request that adds it. + +An entry that belongs to no issue can still be written straight into +`## [Unreleased]` by hand; the release merges it with the fragments. diff --git a/i18n/zh-TW/docs/CONTRIBUTING.md b/i18n/zh-TW/docs/CONTRIBUTING.md index 788717c..69c3aa2 100644 --- a/i18n/zh-TW/docs/CONTRIBUTING.md +++ b/i18n/zh-TW/docs/CONTRIBUTING.md @@ -26,7 +26,7 @@ ## Pull Request 流程 {#pull-request-process} 1. 變更介面時,請更新 `docs/` 底下對應的指南(若變更應出現在首頁,也請更新 README)。只有英文頁面需要更新:[繁體中文翻譯](https://fastapi-cachex.readthedocs.io/en/latest/DEVELOPMENT/#traditional-chinese-translation)(英文)允許落後於英文版。 -2. 若你的變更改變了行為、新增了公開 API,或修正了使用者可能遇到的問題,請在 [CHANGELOG.md](https://github.com/allen0099/FastAPI-CacheX/blob/master/CHANGELOG.md) 的 `## [Unreleased]` 段落新增一筆項目。項目以粗體的一行摘要開頭,寫成 `- **What changed.** The details...`:發行說明只會列出這些摘要,缺少摘要的項目會讓 CI 失敗。發行時若該段落是空的,發行流程也會拒絕執行,因此遺漏終究會被發現,但要到發行時才會發現,而且只會知道「有人忘了寫」,無法得知是哪個 PR。 +2. 若你的變更改變了行為、新增了公開 API,或修正了使用者可能遇到的問題,請新增一個 changelog 片段:檔案 `changelog.d/.
.md`(section 為 `added`、`changed`、`deprecated`、`removed`、`fixed` 或 `security`),內容為該筆項目,以粗體的一行摘要開頭,寫成 `**What changed.** The details...`,不要加上開頭的 `- ` 或 issue 連結——發行時會自動補上。請不要直接編輯 [CHANGELOG.md](https://github.com/allen0099/FastAPI-CacheX/blob/master/CHANGELOG.md),詳見 [Changelog 片段](https://fastapi-cachex.readthedocs.io/en/latest/DEVELOPMENT/#changelog-fragments)(英文)。發行說明只會列出這些摘要,格式錯誤的片段會讓 CI 失敗。沒有任何可發行的項目時,發行流程也會拒絕執行,因此遺漏終究會被發現,但要到發行時才會發現,而且只會知道「有人忘了寫」,無法得知是哪個 PR。 3. 為任何新的依賴、功能或變更更新文件。新的公開 API 需要 docstring;若它位於尚未涵蓋的模組中,還需要在 `docs/api/` 底下新增項目。請以 `uv run zensical build --strict` 檢查網站(見[文件網站](https://fastapi-cachex.readthedocs.io/en/latest/DEVELOPMENT/#documentation-site)(英文))。 4. 取得至少一位其他開發者的同意後,PR 即可合併。 diff --git a/scripts/changelog_release.py b/scripts/changelog_release.py index 9e80795..ad7348b 100644 --- a/scripts/changelog_release.py +++ b/scripts/changelog_release.py @@ -22,11 +22,27 @@ against `v0.3.2`; arithmetic on the version number would silently produce a compare link against a tag that does not exist. * An empty `## [Unreleased]` is an error, not an empty release note. The - changelog is maintained by hand, so "nothing was written down" and "nothing + entries are written by hand, so "nothing was written down" and "nothing changed" look identical from here, and only the former is likely. * An entry without a bold summary is an error too, for the same reason: the release would otherwise publish a line nobody chose. +Entries normally arrive as fragments rather than as edits to `CHANGELOG.md`, +so that parallel pull requests do not conflict on one section. A fragment is +`changelog.d/.
.md` (or `.
..md`, n >= 2, for +a further entry about the same issue and section) holding one entry without +its leading `- ` and without its issue link:: + + **Add `CacheManager.add()` for store-if-absent writes.** It uses the + same key prefix ... + +Before promoting, the fragments are merged into `## [Unreleased]` under their +`###` headings, in Keep a Changelog order and after any entry written there by +hand; each gets its `- ` and its issue link from the file name. They are +deleted once the new changelog is written. Every fragment is checked before +anything is written, so a malformed one fails the release with nothing +changed. `changelog.d/README.md` and dotfiles are not fragments. + Run it directly to see what a release would produce:: uv run python scripts/changelog_release.py --version 0.3.5 --dry-run @@ -35,6 +51,7 @@ from __future__ import annotations import argparse +import dataclasses import datetime import re import sys @@ -51,6 +68,14 @@ _SUMMARY = re.compile(r"^\*\*(?P.+?)\*\*", re.DOTALL) _ISSUE_LINK = re.compile(r"\(\[#\d+\]\([^)\s]+\)(?:, \[#\d+\]\([^)\s]+\))*\)") +# Keep a Changelog's sections, in its order; fragment file names use these. +SECTIONS = ("added", "changed", "deprecated", "removed", "fixed", "security") +FRAGMENTS_DIR = "changelog.d" +FRAGMENTS_README = "README.md" +_FRAGMENT_NAME = re.compile( + r"^(?P[1-9]\d*)\.(?P
[^.]+)(?:\.(?P[2-9]|[1-9]\d+))?\.md$", +) + # Where the full entries are read. `stable` is rebuilt from the tag a release # pushes, and the anchor is the one the site generates for `## [X.Y.Z] - DATE`. DOCS_CHANGELOG = "https://fastapi-cachex.readthedocs.io/en/stable/changelog/" @@ -92,6 +117,198 @@ def _base_url(unreleased_link: re.Match[str]) -> str: return base +def _unreleased_body_end( + released: list[re.Match[str]], links: dict[str, re.Match[str]] +) -> int: + """Return where the `Unreleased` section's body ends.""" + if UNRELEASED not in links: + msg = f"no `[{UNRELEASED}]:` link definition at the bottom of the file" + raise ChangelogError(msg) + return released[0].start() if released else links[UNRELEASED].start() + + +@dataclasses.dataclass(frozen=True) +class Fragment: + """One changelog entry waiting in `changelog.d/`.""" + + path: Path + issue: int + section: str + number: int + text: str + + def entry(self, base: str) -> list[str]: + """Render the fragment as a `- ` list entry with its issue link. + + Args: + base: The repository URL, `https://github.com/OWNER/REPO`. + + Returns: + The entry's lines, continuation lines indented under the bullet. + """ + lines = [line.rstrip() for line in self.text.strip().splitlines()] + link = f"([#{self.issue}]({base}/issues/{self.issue}))" + if lines[-1][0].isspace(): + # Ending in a nested list: a paragraph of its own, not the last item. + lines += ["", link] + else: + lines[-1] += f" {link}" + rest = [f" {line}" if line else "" for line in lines[1:]] + return [f"- {lines[0]}", *rest] + + +def _check_fragment(path: Path) -> Fragment | str: + """Parse one fragment, or return why it cannot be released.""" + name = _FRAGMENT_NAME.match(path.name) + if not path.is_file() or name is None: + return ( + "not a fragment name; expected `.
.md` or " + "`.
..md` (n >= 2)" + ) + section = name["section"] + if section not in SECTIONS: + return f"unknown section `{section}`; use one of {', '.join(SECTIONS)}" + issue = int(name["issue"]) + try: + text = path.read_text(encoding="utf-8").strip() + except UnicodeDecodeError: + return "not UTF-8 text" + problem = _fragment_text_problem(text, issue, section) + if problem is not None: + return problem + return Fragment(path, issue, section, int(name["n"] or 1), text) + + +def _fragment_text_problem(text: str, issue: int, section: str) -> str | None: + """Return why a fragment's text cannot be released, if it cannot.""" + if not text: + return "the file is empty" + if text.startswith(("- ", "* ")): + return "drop the leading `- `; the release adds it" + if _SUMMARY.match(text) is None: + return ( + "must open with a bold one-line summary, `**What changed.** The details...`" + ) + for line in text.splitlines()[1:]: + if line.startswith(("- ", "* ")) or re.match(r"#{1,6} ", line): + return ( + f"`{line}` would start a new entry or heading; write one entry " + f"per file (another goes in `{issue}.{section}.2.md`), and " + "indent a nested list by two spaces" + ) + if re.search(rf"\[#{issue}\]", text): + return f"remove the link to #{issue}; the release appends it from the file name" + return None + + +def read_fragments(directory: Path) -> list[Fragment]: + """Read and check every fragment in `directory`. + + Args: + directory: The fragment directory, normally `changelog.d`. A missing + directory holds no fragments. + + Returns: + The fragments, ordered by section, issue and number. + + Raises: + ChangelogError: A file in the directory, other than its README and + dotfiles, is not a well-formed fragment. Every problem is listed. + """ + if not directory.is_dir(): + return [] + fragments: list[Fragment] = [] + problems: list[str] = [] + for path in sorted(directory.iterdir()): + if path.name == FRAGMENTS_README or path.name.startswith("."): + continue + checked = _check_fragment(path) + if isinstance(checked, str): + problems.append(f" - {path}: {checked}") + else: + fragments.append(checked) + if problems: + msg = ( + f"malformed changelog fragments (see {directory}/{FRAGMENTS_README}):\n" + + "\n".join(problems) + ) + raise ChangelogError(msg) + return sorted( + fragments, + key=lambda fragment: ( + SECTIONS.index(fragment.section), + fragment.issue, + fragment.number, + ), + ) + + +def _trim(lines: list[str]) -> list[str]: + """Drop blank lines at both ends.""" + start = 0 + while start < len(lines) and not lines[start].strip(): + start += 1 + end = len(lines) + while end > start and not lines[end - 1].strip(): + end -= 1 + return lines[start:end] + + +def assemble(text: str, fragments: list[Fragment]) -> str: + """Merge fragments into the `Unreleased` section. + + Sections come out in Keep a Changelog order, followed by any other + hand-written `###` section in its original order. Within a section, the + hand-written entries come first and are kept verbatim, then the fragments + by issue number. + + Args: + text: The full contents of `CHANGELOG.md`. + fragments: The fragments, as returned by `read_fragments`. + + Returns: + The changelog with the fragments in it; unchanged if there are none. + + Raises: + ChangelogError: The `Unreleased` section or its link definition is + missing or malformed. + """ + if not fragments: + return text + unreleased, released = _find_unreleased(text) + links = _links(text) + body_end = _unreleased_body_end(released, links) + base = _base_url(links[UNRELEASED]) + + preamble: list[str] = [] + titles: dict[str, str] = {} + blocks: dict[str, list[str]] = {} + current = preamble + for line in _section_body(text, unreleased, body_end).splitlines(): + if line.startswith("### "): + title = line.removeprefix("### ").strip() + titles.setdefault(title.lower(), title) + current = blocks.setdefault(title.lower(), []) + else: + current.append(line) + blocks = {key: _trim(block) for key, block in blocks.items()} + for fragment in fragments: + titles.setdefault(fragment.section, fragment.section.capitalize()) + blocks.setdefault(fragment.section, []).extend(fragment.entry(base)) + + order = [key for key in SECTIONS if key in blocks] + order += [key for key in blocks if key not in SECTIONS] + parts = ["\n".join(_trim(preamble))] if _trim(preamble) else [] + parts += [f"### {titles[key]}\n\n" + "\n".join(blocks[key]) for key in order] + return ( + text[: unreleased.end()] + + "\n\n" + + "\n\n".join(parts) + + "\n\n" + + text[body_end:] + ) + + def promote(text: str, version: str, date: str) -> tuple[str, str]: """Cut `version` out of the `Unreleased` section. @@ -119,17 +336,15 @@ def promote(text: str, version: str, date: str) -> tuple[str, str]: raise ChangelogError(msg) links = _links(text) - if UNRELEASED not in links: - msg = f"no `[{UNRELEASED}]:` link definition at the bottom of the file" - raise ChangelogError(msg) + body_end = _unreleased_body_end(released, links) base = _base_url(links[UNRELEASED]) - body_end = released[0].start() if released else links[UNRELEASED].start() body = _section_body(text, unreleased, body_end) if not body.strip(): msg = ( - f"`## [{UNRELEASED}]` is empty. Write down what changed before " - f"releasing {version}; see docs/DEVELOPMENT.md#releasing." + f"`## [{UNRELEASED}]` is empty and changelog.d/ has no fragments. " + f"Write down what changed before releasing {version}; see " + "docs/DEVELOPMENT.md#releasing." ) raise ChangelogError(msg) @@ -230,6 +445,14 @@ def _parse_args(argv: list[str] | None) -> argparse.Namespace: default=Path("CHANGELOG.md"), help="path to the changelog", ) + parser.add_argument( + "--fragments", + type=Path, + help=( + "directory of changelog fragments to merge in, then delete " + "(default: changelog.d next to the changelog)" + ), + ) parser.add_argument( "--release-notes", type=Path, @@ -238,7 +461,7 @@ def _parse_args(argv: list[str] | None) -> argparse.Namespace: parser.add_argument( "--dry-run", action="store_true", - help="print the release body instead of writing anything", + help="print the release body instead of writing or deleting anything", ) return parser.parse_args(argv) @@ -246,9 +469,13 @@ def _parse_args(argv: list[str] | None) -> argparse.Namespace: def main(argv: list[str] | None = None) -> int: """Run the command line interface.""" args = _parse_args(argv) + # Everything that can fail runs before anything is written or deleted. try: + fragments = read_fragments( + args.fragments or args.changelog.parent / FRAGMENTS_DIR + ) rewritten, body = promote( - args.changelog.read_text(encoding="utf-8"), + assemble(args.changelog.read_text(encoding="utf-8"), fragments), args.version, args.date, ) @@ -264,6 +491,8 @@ def main(argv: list[str] | None = None) -> int: args.changelog.write_text(rewritten, encoding="utf-8") if args.release_notes is not None: args.release_notes.write_text(notes, encoding="utf-8") + for fragment in fragments: + fragment.path.unlink() return 0 diff --git a/tests/test_changelog_release.py b/tests/test_changelog_release.py index a563fb9..07ce2f9 100644 --- a/tests/test_changelog_release.py +++ b/tests/test_changelog_release.py @@ -8,8 +8,10 @@ import pytest from scripts.changelog_release import ChangelogError +from scripts.changelog_release import assemble from scripts.changelog_release import main from scripts.changelog_release import promote +from scripts.changelog_release import read_fragments from scripts.changelog_release import release_notes BASE = "https://github.com/allen0099/FastAPI-CacheX" @@ -179,8 +181,14 @@ def test_the_repository_changelog_can_be_released(): latest = re.search(r"^## \[(\d+\.\d+\.\d+)\]", text, re.MULTILINE) assert latest is not None - # Right after a release `## [Unreleased]` is empty, which promote() - # rightly refuses. Give it an entry so the rest of the file is still checked. + # The pending fragments go in first, exactly as the release merges them, + # so a malformed fragment fails the pull request that adds it. + fragments = read_fragments(changelog.parent / "changelog.d") + text = assemble(text, fragments) + + # Between releases `## [Unreleased]` is usually empty and every entry + # waits in a fragment. With neither, promote() rightly refuses; give it an + # entry so the rest of the file is still checked. text = re.sub( r"^## \[Unreleased\]\n\s*(?=^## \[)", "## [Unreleased]\n\n### Fixed\n\n- **Placeholder entry.**\n\n", @@ -196,7 +204,11 @@ def test_the_repository_changelog_can_be_released(): assert f"[0.9.9]: {BASE}/compare/v{latest[1]}...v0.9.9" in rewritten assert body.startswith("### ") # Every entry waiting in `Unreleased` has the summary the release page needs. - assert release_notes(body, "0.9.9", "2026-09-14").startswith("### ") + notes = release_notes(body, "0.9.9", "2026-09-14") + assert notes.startswith("### ") + # Every pending fragment reaches the release page with its issue link. + for fragment in fragments: + assert f"[#{fragment.issue}]({BASE}/issues/{fragment.issue})" in notes # Every released heading still has a link definition, and vice versa. headings = { line.removeprefix("## [").split("]")[0] @@ -382,3 +394,389 @@ def test_main_refuses_to_release_an_entry_without_a_summary( assert "bold one-line summary" in capsys.readouterr().err assert "## [0.3.5]" not in changelog.read_text(encoding="utf-8") assert not notes.exists() + + +# Changelog fragments + + +def _fragments(tmp_path: Path, files: dict[str, str]) -> Path: + directory = tmp_path / "changelog.d" + directory.mkdir(exist_ok=True) + for name, text in files.items(): + (directory / name).write_text(text, encoding="utf-8") + return directory + + +def _link(issue: int) -> str: + return f"([#{issue}]({BASE}/issues/{issue}))" + + +def test_fragments_are_ordered_by_section_issue_and_number(tmp_path: Path): + directory = _fragments( + tmp_path, + { + "9.security.md": "**Nine.**", + "65.added.2.md": "**Sixty-five, second.**", + "65.added.md": "**Sixty-five.**", + "7.added.md": "**Seven.**", + "8.fixed.md": "**Eight.**", + "65.added.10.md": "**Sixty-five, tenth.**", + }, + ) + + fragments = read_fragments(directory) + + assert [fragment.path.name for fragment in fragments] == [ + "7.added.md", + "65.added.md", + "65.added.2.md", + "65.added.10.md", + "8.fixed.md", + "9.security.md", + ] + + +def test_a_missing_fragment_directory_holds_no_fragments(tmp_path: Path): + assert read_fragments(tmp_path / "changelog.d") == [] + + +def test_the_readme_and_dotfiles_are_not_fragments(tmp_path: Path): + directory = _fragments( + tmp_path, + {"README.md": "# Not an entry\n", ".gitkeep": "", "1.added.md": "**A.**"}, + ) + + assert [fragment.path.name for fragment in read_fragments(directory)] == [ + "1.added.md" + ] + + +def test_assemble_puts_fragments_under_their_headings_in_keep_a_changelog_order( + tmp_path: Path, +): + empty = CHANGELOG.replace(f"### Added\n\n{ENTRY}\n", "") + directory = _fragments( + tmp_path, + { + "5.security.md": "**Five.** Details.", + "3.fixed.md": "**Three.** Details.", + "4.removed.md": "**Four.**", + "2.changed.md": "**Two.**", + "6.deprecated.md": "**Six.**", + "1.added.md": "**One.**", + }, + ) + + assembled = assemble(empty, read_fragments(directory)) + + assert assembled.split("## [0.3.4]")[0] == ( + "# Changelog\n\n## [Unreleased]\n\n" + f"### Added\n\n- **One.** {_link(1)}\n\n" + f"### Changed\n\n- **Two.** {_link(2)}\n\n" + f"### Deprecated\n\n- **Six.** {_link(6)}\n\n" + f"### Removed\n\n- **Four.** {_link(4)}\n\n" + f"### Fixed\n\n- **Three.** Details. {_link(3)}\n\n" + f"### Security\n\n- **Five.** Details. {_link(5)}\n\n" + ) + # Nothing below the Unreleased section is touched. + assert assembled.split("## [0.3.4]")[1] == empty.split("## [0.3.4]")[1] + + +def test_assemble_indents_the_rest_of_a_multi_line_fragment(tmp_path: Path): + directory = _fragments( + tmp_path, + { + "12.fixed.md": ( + "**Stop `z()` from losing\nentries.** It dropped them.\n\n" + " - nested\n - deeper\n\nMore.\n" + ), + }, + ) + + assembled = assemble(CHANGELOG, read_fragments(directory)) + + assert ( + "### Fixed\n\n- **Stop `z()` from losing\n entries.** It dropped them.\n\n" + f" - nested\n - deeper\n\n More. {_link(12)}\n\n## [0.3.4]" + ) in assembled + _, body = promote(assembled, "0.3.5", "2026-09-14") + assert f"- Stop `z()` from losing entries. {_link(12)}\n" in release_notes( + body, "0.3.5", "2026-09-14" + ) + + +def test_a_fragment_ending_in_a_nested_list_gets_its_link_on_its_own( + tmp_path: Path, +): + directory = _fragments( + tmp_path, {"12.fixed.md": "**X.** Either:\n\n - a\n - b\n"} + ) + + assembled = assemble(CHANGELOG, read_fragments(directory)) + + assert ( + f"- **X.** Either:\n\n - a\n - b\n\n {_link(12)}\n\n## [0.3.4]" + ) in assembled + _, body = promote(assembled, "0.3.5", "2026-09-14") + assert f"- X. {_link(12)}\n" in release_notes(body, "0.3.5", "2026-09-14") + + +def test_assemble_merges_with_entries_written_by_hand(tmp_path: Path): + handwritten = CHANGELOG.replace( + f"### Added\n\n{ENTRY}\n", + "### Documentation\n\n- **Docs.**\n\n" + f"### Added\n\n{ENTRY}\n\n" + "### Fixed\n\n- **By hand.**\n Wrapped. ([#3](x))\n", + ) + directory = _fragments( + tmp_path, + { + "2.added.md": "**From a fragment.**", + "4.fixed.md": "**Fixed by a fragment.**", + "5.changed.md": "**Changed by a fragment.**", + }, + ) + + assembled = assemble(handwritten, read_fragments(directory)) + + assert assembled.split("## [0.3.4]")[0] == ( + "# Changelog\n\n## [Unreleased]\n\n" + f"### Added\n\n{ENTRY}\n- **From a fragment.** {_link(2)}\n\n" + f"### Changed\n\n- **Changed by a fragment.** {_link(5)}\n\n" + "### Fixed\n\n- **By hand.**\n Wrapped. ([#3](x))\n" + f"- **Fixed by a fragment.** {_link(4)}\n\n" + # A heading Keep a Changelog does not define stays, after its sections. + "### Documentation\n\n- **Docs.**\n\n" + ) + + +def test_assemble_keeps_loose_entries_above_the_first_heading(tmp_path: Path): + loose = CHANGELOG.replace( + f"### Added\n\n{ENTRY}\n", "- **Loose.**\n\n### Added\n\n- **Kept.**\n" + ) + directory = _fragments(tmp_path, {"1.added.md": "**New.**"}) + + assembled = assemble(loose, read_fragments(directory)) + + assert ( + "## [Unreleased]\n\n- **Loose.**\n\n" + f"### Added\n\n- **Kept.**\n- **New.** {_link(1)}\n\n## [0.3.4]" + ) in assembled + + +def test_assemble_without_fragments_changes_nothing(): + assert assemble(CHANGELOG, []) == CHANGELOG + + +def test_assemble_takes_the_issue_link_base_from_the_changelog(tmp_path: Path): + fork = CHANGELOG.replace(BASE, "https://github.com/someone/fork") + directory = _fragments(tmp_path, {"7.added.md": "**Seven.**"}) + + assembled = assemble(fork, read_fragments(directory)) + + assert "- **Seven.** ([#7](https://github.com/someone/fork/issues/7))" in assembled + + +def test_fragments_flow_into_the_release_notes(tmp_path: Path): + directory = _fragments( + tmp_path, + { + "20.added.md": "**Twenty.**\nWith details that the notes drop.", + "21.fixed.md": f"**Twenty-one.** See also [#22]({BASE}/pull/22).", + }, + ) + assembled = assemble(CHANGELOG, read_fragments(directory)) + + _, body = promote(assembled, "0.3.5", "2026-09-14") + + assert release_notes(body, "0.3.5", "2026-09-14") == ( + f"### Added\n\n- A thing. ([#1]({BASE}/issues/1))\n" + f"- Twenty. {_link(20)}\n\n" + f"### Fixed\n\n- Twenty-one. {_link(21)}\n\n" + "**Full changelog**: " + "https://fastapi-cachex.readthedocs.io/en/stable/changelog/#035-2026-09-14\n" + ) + + +def test_an_empty_unreleased_section_with_fragments_can_be_released(tmp_path: Path): + empty = CHANGELOG.replace(f"### Added\n\n{ENTRY}\n", "") + directory = _fragments(tmp_path, {"1.fixed.md": "**One.**"}) + + rewritten, body = promote( + assemble(empty, read_fragments(directory)), "0.3.5", "2026-09-14" + ) + + assert body == f"### Fixed\n\n- **One.** {_link(1)}\n" + assert "## [Unreleased]\n\n## [0.3.5] - 2026-09-14\n\n### Fixed\n" in rewritten + + +def test_fragments_need_the_unreleased_link_for_their_issue_links(tmp_path: Path): + without_link = CHANGELOG.replace( + f"[Unreleased]: {BASE}/compare/v0.3.4...HEAD\n", "" + ) + directory = _fragments(tmp_path, {"1.fixed.md": "**One.**"}) + + with pytest.raises(ChangelogError, match="link definition"): + assemble(without_link, read_fragments(directory)) + + +@pytest.mark.parametrize( + ("name", "text", "error"), + [ + ("12.md", "**X.**", "not a fragment name"), + ("12.added.txt", "**X.**", "not a fragment name"), + ("issue-12.added.md", "**X.**", "not a fragment name"), + ("012.added.md", "**X.**", "not a fragment name"), + ("12.added.1.md", "**X.**", "not a fragment name"), + ("12.added.two.md", "**X.**", "not a fragment name"), + ("12.feature.md", "**X.**", "unknown section `feature`"), + ("12.Added.md", "**X.**", "unknown section `Added`"), + ("12.added.md", " \n\n", "the file is empty"), + ("12.added.md", "- **X.** Details.", "drop the leading `- `"), + ("12.added.md", "X. Details.", "bold one-line summary"), + ("12.added.md", "Details. **X.**", "bold one-line summary"), + ("12.added.md", "**X.**\n- **Y.**", "would start a new entry"), + ("12.added.md", "**X.**\n### Fixed", "would start a new entry or heading"), + ("12.added.md", f"**X.** ([#12]({BASE}/issues/12))", "remove the link to #12"), + ("12.added.md", b"**\xff**", "not UTF-8"), + ], +) +def test_a_malformed_fragment_fails_the_release_before_anything_changes( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + name: str, + text: str | bytes, + error: str, +): + changelog = tmp_path / "CHANGELOG.md" + changelog.write_text(CHANGELOG, encoding="utf-8") + directory = _fragments(tmp_path, {"1.added.md": "**Fine.**"}) + bad = directory / name + if isinstance(text, bytes): + bad.write_bytes(text) + else: + bad.write_text(text, encoding="utf-8") + notes = tmp_path / "release-notes.md" + + exit_code = main( + [ + "--version", + "0.3.5", + "--changelog", + str(changelog), + "--release-notes", + str(notes), + ] + ) + + assert exit_code == 1 + stderr = capsys.readouterr().err + assert "malformed changelog fragments" in stderr + assert name in stderr + assert error in stderr + assert changelog.read_text(encoding="utf-8") == CHANGELOG + assert sorted(path.name for path in directory.iterdir()) == sorted( + ["1.added.md", name] + ) + assert not notes.exists() + + +def test_a_directory_in_the_fragment_directory_is_an_error(tmp_path: Path): + directory = _fragments(tmp_path, {}) + (directory / "12.added.md").mkdir() + + with pytest.raises(ChangelogError, match="not a fragment name"): + read_fragments(directory) + + +def test_every_malformed_fragment_is_listed_at_once(tmp_path: Path): + directory = _fragments( + tmp_path, + {"1.feature.md": "**A.**", "2.added.md": "B.", "3.added.md": "**C.**"}, + ) + + with pytest.raises(ChangelogError) as error: + read_fragments(directory) + + assert "1.feature.md" in str(error.value) + assert "2.added.md" in str(error.value) + assert "3.added.md" not in str(error.value) + + +def test_main_merges_the_fragments_and_then_deletes_them(tmp_path: Path): + changelog = tmp_path / "CHANGELOG.md" + changelog.write_text(CHANGELOG, encoding="utf-8") + directory = _fragments( + tmp_path, + { + "README.md": "# Fragments\n", + ".gitkeep": "", + "2.added.md": "**Two.**", + "3.fixed.md": "**Three.**", + }, + ) + notes = tmp_path / "release-notes.md" + + exit_code = main( + [ + "--version", + "0.3.5", + "--date", + "2026-09-14", + "--changelog", + str(changelog), + "--release-notes", + str(notes), + ] + ) + + assert exit_code == 0 + text = changelog.read_text(encoding="utf-8") + assert ( + f"## [0.3.5] - 2026-09-14\n\n### Added\n\n{ENTRY}\n- **Two.** {_link(2)}\n\n" + f"### Fixed\n\n- **Three.** {_link(3)}\n\n## [0.3.4]" + ) in text + assert f"- Two. {_link(2)}" in notes.read_text(encoding="utf-8") + assert f"- Three. {_link(3)}" in notes.read_text(encoding="utf-8") + assert sorted(path.name for path in directory.iterdir()) == [ + ".gitkeep", + "README.md", + ] + + +def test_main_reads_fragments_from_the_given_directory(tmp_path: Path): + changelog = tmp_path / "CHANGELOG.md" + changelog.write_text(CHANGELOG, encoding="utf-8") + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + (elsewhere / "2.added.md").write_text("**Two.**", encoding="utf-8") + + exit_code = main( + [ + "--version", + "0.3.5", + "--changelog", + str(changelog), + "--fragments", + str(elsewhere), + ] + ) + + assert exit_code == 0 + assert f"- **Two.** {_link(2)}" in changelog.read_text(encoding="utf-8") + assert list(elsewhere.iterdir()) == [] + + +def test_main_dry_run_keeps_the_fragments( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +): + changelog = tmp_path / "CHANGELOG.md" + changelog.write_text(CHANGELOG, encoding="utf-8") + directory = _fragments(tmp_path, {"2.added.md": "**Two.**"}) + + exit_code = main(["--version", "0.3.5", "--changelog", str(changelog), "--dry-run"]) + + assert exit_code == 0 + assert f"- Two. {_link(2)}" in capsys.readouterr().out + assert changelog.read_text(encoding="utf-8") == CHANGELOG + assert (directory / "2.added.md").exists()