Skip to content

build: assemble the changelog from per-PR fragments in changelog.d - #306

Merged
allen0099 merged 1 commit into
masterfrom
build/changelog-fragments
Sep 27, 2026
Merged

allen0099 merged 1 commit into
masterfrom
build/changelog-fragments

Conversation

@allen0099

Copy link
Copy Markdown
Owner

Closes #213

Pull requests stop editing ## [Unreleased]. Each one adds a fragment to changelog.d/, and the release merges the fragments into CHANGELOG.md and deletes them, so parallel PRs no longer conflict over the changelog.

How a contributor writes a fragment

changelog.d/<issue>.<section>.md, where section is added, changed, deprecated, removed, fixed or security. The file holds one entry: bold one-line summary first, without the leading - and without the issue link. The release adds both, taking the link from the file name.

changelog.d/65.added.md:

**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

- **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))

A second entry for the same issue and section goes in 65.added.2.md, then .3.md, and so on.

Design decisions

  • The file has no - and no link. Leaving out the bullet means a contributor cannot get the list marker or the continuation indent wrong; the script adds - and indents continuation lines by two. Leaving out the link keeps it consistent with the file name. Both mistakes are caught: a leading - or a link to the fragment's own issue fails with a message saying what to do. Links to other issues/PRs in the text are fine.
  • One entry per file; more entries use .<n>.md (n >= 2). That is simpler to validate than several entries in one file: a top-level - /* line or a markdown heading after the first line is rejected, pointing at <issue>.<section>.2.md. .1.md is rejected so there is exactly one name for the first entry. Nested lists are fine when indented; if the fragment ends in one, the link goes in its own paragraph instead of onto the last nested item.
  • Merging. Sections come out in Keep a Changelog order (Added, Changed, Deprecated, Removed, Fixed, Security); within a section, hand-written entries first (verbatim), then fragments by issue number and n. Any other hand-written ### section (e.g. Documentation) is kept after those; loose entries above the first heading stay on top. With no fragments the changelog is left byte-for-byte as before.
  • Link base is taken from the [Unreleased] compare link, the same source the compare links use, not hard-coded.
  • Validate everything first. main() reads and checks every fragment, assembles, promotes and builds the release notes before writing anything; only then does it write CHANGELOG.md/the notes and delete the fragments. All malformed fragments are listed in one error. --dry-run deletes nothing.
  • Fragment directory defaults to changelog.d next to --changelog (not the cwd), so the existing tests on temporary changelogs can never touch the repository's fragments. --fragments overrides it. README.md and dotfiles are ignored; anything else that is not a well-formed fragment (including a subdirectory) is an error.
  • Release notes are built from the promoted section exactly as before, so fragment entries flow into them the same way: bold summary plus the appended issue link.
  • CI coverage for fragments. test_the_repository_changelog_can_be_released now merges the real pending fragments before promoting (and only adds its placeholder when there are neither fragments nor hand-written entries), and asserts every fragment's issue link reaches the release notes. A malformed fragment therefore fails CI in the PR that adds it, not at release time.

release.yml (kept minimal; #302's SHA pins should rebase cleanly)

Two lines of behaviour:

  1. release job, "Commit the release": an artifact cannot carry a deletion, so the job deletes the fragments itself before git add and stages changelog.d. It is the same commit build checked out, and build fails on anything in changelog.d/ other than fragments, README.md and dotfiles, so find changelog.d -maxdepth 1 -type f -name '*.md' ! -name README.md ! -name '.*' -delete removes exactly the fragments the script consumed.
  2. Dry-run report: the git diff --stat also lists changelog.d, so a rehearsal shows the fragment deletions the release commit would carry.

Nothing else changed: the dry-run path still runs the script without --dry-run in build, writes nothing permanent, and the release/publish jobs are still skipped.

Docs

  • changelog.d/README.md (kept in git; describes the convention).
  • docs/DEVELOPMENT.md: new "Changelog fragments" section; Releasing steps updated.
  • docs/CONTRIBUTING.md and i18n/zh-TW/docs/CONTRIBUTING.md: PR step 2 now asks for a fragment. There is no zh-TW DEVELOPMENT.md and no PR template in the repo.
  • CHANGELOG.md header paragraph: entries come from fragments, ## [Unreleased] is usually empty between releases.
  • CLAUDE.md: one line on the convention.

No changelog fragment for this PR: it is dev tooling with no user-facing change.

Rehearsal (throwaway clone of this branch)

Three fake merged PRs: 296.fixed.md, 296.fixed.2.md, 297.added.md (ending in a nested list), plus one entry hand-written under ## [Unreleased] / ### Fixed. Then uv version --bump patch and the exact workflow invocation uv run python scripts/changelog_release.py --version "$VERSION" --release-notes release-notes.md (exit 0).

Dry-run diff stat:

 CHANGELOG.md               | 16 +++++++++++++++-
 changelog.d/296.fixed.2.md |  1 -
 changelog.d/296.fixed.md   |  2 --
 changelog.d/297.added.md   |  5 -----
 pyproject.toml             |  2 +-
 uv.lock                    |  2 +-

CHANGELOG.md:

## [Unreleased]

## [0.3.9] - 2026-09-27

### Added

- **`CacheLock.wait()` blocks until a lock is free.** It polls with the same
  backoff as `acquire()`:

    - `timeout=None` waits forever;
    - `timeout=0` returns at once.

  ([#297](https://github.com/allen0099/FastAPI-CacheX/issues/297))

### Fixed

- **Hand-written fix.** Written straight into Unreleased.
- **`MemoryBackend` no longer drops entries during cleanup.** The cleanup task
  compared expiry times against a stale clock. ([#296](https://github.com/allen0099/FastAPI-CacheX/issues/296))
- **`clear_path` escapes the path the same way the key does.** ([#296](https://github.com/allen0099/FastAPI-CacheX/issues/296))

release-notes.md:

### Added

- `CacheLock.wait()` blocks until a lock is free. ([#297](https://github.com/allen0099/FastAPI-CacheX/issues/297))

### Fixed

- Hand-written fix.
- `MemoryBackend` no longer drops entries during cleanup. ([#296](https://github.com/allen0099/FastAPI-CacheX/issues/296))
- `clear_path` escapes the path the same way the key does. ([#296](https://github.com/allen0099/FastAPI-CacheX/issues/296))

**Full changelog**: https://fastapi-cachex.readthedocs.io/en/stable/changelog/#039-2026-09-27

release job simulated on a fresh clone of the same commit with the artifact files copied in, running the new "Commit the release" lines verbatim: the commit chore: release v0.3.9 carries the same six-file stat, and git ls-files changelog.d afterwards is just changelog.d/README.md.

Malformed fragments, same invocation (exit 1, nothing written, fragments kept, no release-notes.md):

error: malformed changelog fragments (see changelog.d/README.md):
  - changelog.d/301.added.md: drop the leading `- `; the release adds it
  - changelog.d/302.feature.md: unknown section `feature`; use one of added, changed, deprecated, removed, fixed, security
  - changelog.d/fix-303.md: not a fragment name; expected `<issue>.<section>.md` or `<issue>.<section>.<n>.md` (n >= 2)

No workflow was dispatched.

Checks

  • uv run pytest: 891 passed, 191 skipped (live Redis/Memcached, no servers started); coverage 94% total, scripts/changelog_release.py 100%
  • uv run ruff check fastapi_cachex tests scripts, ruff format --check: clean
  • uv run mypy fastapi_cachex --strict, mypy tests, mypy scripts: clean
  • uv run pre-commit run --all-files: all passed
  • zensical build --strict (en and zh-TW): no issues

Follow-up (out of scope)

An optional CI check that a PR either adds a changelog.d/ fragment or carries a no-changelog label (the label does not exist yet).

Each pull request adds changelog.d/<issue>.<section>.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
@allen0099 allen0099 added this to the 0.3.9 milestone Sep 27, 2026
@allen0099 allen0099 added enhancement New feature or request github-actions Legacy Dependabot label for Actions update PRs; Renovate uses 'dependencies'. Use 'ci' for CI issues labels Sep 27, 2026
@allen0099
allen0099 merged commit b3b7608 into master Sep 27, 2026
12 checks passed
@allen0099
allen0099 deleted the build/changelog-fragments branch September 27, 2026 12:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request github-actions Legacy Dependabot label for Actions update PRs; Renovate uses 'dependencies'. Use 'ci' for CI issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Use changelog fragments instead of editing CHANGELOG.md in every PR

1 participant