Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,8 @@ jobs:
# Promoting the changelog is part of cutting the release, not a chore to
# remember afterwards: the script fails when `## [Unreleased]` is empty,
# so a release with nothing written down stops here instead of shipping
# release notes that say nothing.
# release notes that say nothing. It also fails when an entry has no bold
# one-line summary, because the release notes are those summaries.
- name: Promote the changelog
env:
VERSION: ${{ steps.version.outputs.version }}
Expand Down
18 changes: 14 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,25 @@ 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. The `## [Unreleased]` section is
what the Release workflow publishes as the GitHub release notes, and a release
with an empty one fails — so entries are added by hand, in the pull request
that earns them. See [Releasing](https://fastapi-cachex.readthedocs.io/en/latest/DEVELOPMENT/#releasing).
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).

Note that 0.3.3 was never released; 0.3.4 follows 0.3.2.

## [Unreleased]

### Changed

- **GitHub release notes list one line per change.** Each changelog entry now
opens with a bold one-line summary. The release page shows only those
summaries with their issue links, grouped as in the changelog, and links to
the full entries on the documentation site. The changelog itself keeps the
details.

## [0.3.7] - 2026-09-25

### Added
Expand Down
9 changes: 5 additions & 4 deletions docs/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,11 @@ Please refer to our [Development Guide](DEVELOPMENT.md) for detailed instruction
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. That section is what the
release notes are built from, and a release refuses to run on an empty one,
so an omission surfaces — but only at release time, and only as "somebody
forgot", never as which PR it was
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
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
Expand Down
35 changes: 29 additions & 6 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,8 +262,9 @@ The workflow runs in this order:
`vX.Y.Z` is already tagged, locally or on the remote, the run stops here.
3. **The changelog.** `scripts/changelog_release.py` renames `## [Unreleased]`
to `## [X.Y.Z] - YYYY-MM-DD`, opens a fresh empty `## [Unreleased]` above
it, rewrites the compare links at the bottom, and writes the promoted
section out to be used as the release body.
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
on the documentation site.
4. **The permanent part**, kept together at the end: commit the version bump
and the promoted changelog, push it, tag, push the tag by refspec, create
the GitHub release from the promoted section, publish to PyPI.
Expand Down Expand Up @@ -295,10 +296,32 @@ 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** 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: the
release body ends with a compare link against the previous tag.
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:
the release body ends with a compare link against the previous tag.

The changelog and the release page serve different readers. The changelog
explains each change in full: what behaviour moved, why, and how to adapt. The
release page is scanned, so it gets one line per change. Every entry therefore
opens with a bold summary, and the release body is just those summaries:

```markdown
- **Add `CacheManager.add()` for store-if-absent writes.** It uses the same
key prefix, JSON encoding and `default_ttl` as `set()`, and runs on the
backend's atomic `set_if_absent` ... ([#65](https://github.com/allen0099/FastAPI-CacheX/issues/65))
```

becomes ``- Add `CacheManager.add()` for store-if-absent writes. ([#65](...))``
under the same `### Added` heading. The issue links are carried over from
anywhere in the entry, and the body ends with a link to the version's section
on the documentation site's changelog page. Write the summary for someone
deciding whether this release matters to them: what changed, in the imperative
or as a plain statement, not how. An entry without one fails the run and is
named in the error, and so does a line in the section that is neither a `###`
heading nor a `- ` entry. `tests/test_changelog_release.py` runs the same check
on the real `CHANGELOG.md`, so the pull request that adds an entry without a
summary fails CI instead of the release.

Two details of the promotion are worth knowing, because both have bitten this
project:
Expand Down
95 changes: 89 additions & 6 deletions scripts/changelog_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,20 @@

`release.yml` calls this so that one manual dispatch does the whole cut: the
heading is renamed, a fresh empty `## [Unreleased]` is opened above it, the
compare links at the bottom are rewritten, and the promoted section is written
out to be used verbatim as the GitHub release body.
compare links at the bottom are rewritten, and a short version of the promoted
section is written out as the GitHub release body.

Two rules drive the implementation:
The changelog keeps the full story of each change; the release page lists one
line per change. Every entry therefore opens with a bold one-line summary::

- **Add `CacheManager.add()` for store-if-absent writes.** It uses the
same key prefix ... ([#65](https://github.com/.../issues/65))

and the release body is those summaries with their issue links, grouped under
the same `###` headings, followed by a link to the full entries on the
documentation site.

Three rules drive the implementation:

* The previous version is **read from the existing headings**, never derived
from the new one. 0.3.3 was never released, so `[0.3.4]` has to compare
Expand All @@ -14,6 +24,8 @@
* An empty `## [Unreleased]` is an error, not an empty release note. The
changelog is maintained 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.

Run it directly to see what a release would produce::

Expand All @@ -36,6 +48,12 @@
)
_LINK = re.compile(r"^\[(?P<label>[^\]]+)\]: (?P<url>\S+)[ \t]*$", re.MULTILINE)
_VERSION = re.compile(r"^\d+\.\d+\.\d+$")
_SUMMARY = re.compile(r"^\*\*(?P<summary>.+?)\*\*", re.DOTALL)
_ISSUE_LINK = re.compile(r"\(\[#\d+\]\([^)\s]+\)(?:, \[#\d+\]\([^)\s]+\))*\)")

# 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/"


class ChangelogError(RuntimeError):
Expand Down Expand Up @@ -134,6 +152,70 @@ def promote(text: str, version: str, date: str) -> tuple[str, str]:
return rewritten, body.strip() + "\n"


def _entries(body: str) -> list[tuple[str, str]]:
"""Split a changelog section into `(### heading, entry text)` pairs.

An entry is a top-level `- ` bullet with everything indented under it,
nested lists included. Entries above the first heading get `""`.
"""
entries: list[tuple[str, list[str]]] = []
heading = ""
for line in body.splitlines():
if line.startswith("### "):
heading = line
elif line.startswith("- "):
entries.append((heading, [line[2:]]))
elif line.strip() and not line[0].isspace():
msg = f"`{line}` is neither a `###` heading nor a `- ` list entry"
raise ChangelogError(msg)
elif entries and entries[-1][0] == heading:
entries[-1][1].append(line.strip())
return [(section, " ".join(filter(None, lines))) for section, lines in entries]


def release_notes(body: str, version: str, date: str) -> str:
"""Shorten a promoted section to one line per entry, for the release page.

Args:
body: The promoted section, as returned by `promote`.
version: The version being released.
date: The release date, `YYYY-MM-DD`.

Returns:
Each entry's bold summary and issue links, under the section's `###`
headings, then a link to the full entries on the documentation site.

Raises:
ChangelogError: A line is neither a heading nor an entry, or an entry
does not open with a bold summary.
"""
lines: list[str] = []
missing: list[str] = []
heading = ""
for section, text in _entries(body):
summary = _SUMMARY.match(text)
if summary is None:
missing.append(text[:70])
continue
if section != heading:
heading = section
lines += ["", heading, ""] if lines else [heading, ""]
links = " ".join(_ISSUE_LINK.findall(text))
line = " ".join(summary["summary"].split())
lines.append(f"- {line} {links}".rstrip())
if missing:
listed = "\n".join(f" - {text}..." for text in missing)
msg = (
"every changelog entry must open with a bold one-line summary, "
"`- **What changed.** Details...`; these do not:\n" + listed
)
raise ChangelogError(msg)

anchor = f"{version.replace('.', '')}-{date}"
lines += ["", f"**Full changelog**: {DOCS_CHANGELOG}#{anchor}"]
return "\n".join(lines) + "\n"


def _today() -> str:
return datetime.datetime.now(tz=datetime.timezone.utc).date().isoformat()

Expand All @@ -151,7 +233,7 @@ def _parse_args(argv: list[str] | None) -> argparse.Namespace:
parser.add_argument(
"--release-notes",
type=Path,
help="write the promoted section here, for use as the release body",
help="write the release body (one line per entry) here",
)
parser.add_argument(
"--dry-run",
Expand All @@ -170,17 +252,18 @@ def main(argv: list[str] | None = None) -> int:
args.version,
args.date,
)
notes = release_notes(body, args.version, args.date)
except (ChangelogError, OSError) as error:
print(f"error: {error}", file=sys.stderr) # noqa: T201
return 1

if args.dry_run:
print(body, end="") # noqa: T201
print(notes, end="") # noqa: T201
return 0

args.changelog.write_text(rewritten, encoding="utf-8")
if args.release_notes is not None:
args.release_notes.write_text(body, encoding="utf-8")
args.release_notes.write_text(notes, encoding="utf-8")
return 0


Expand Down
Loading
Loading