From a6c015c14122167f3e7da712ab074c66a63d7823 Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:46:30 -0700 Subject: [PATCH 1/2] Release scripts refuse a release without notes 0.42.0 shipped with an empty GitHub Release. Its CHANGELOG section was empty because nothing had landed since the v0.41.0 tag: #340 and #341 merged while the 0.41.0 release PR waited for review, so they were inside that tag but outside its notes, and git-cliff's next run (starting at the tag) never saw them either. scripts/check-release-notes.py checks a version's section. `prepare` runs it after cargo-release and fails on a section with no bullets. `finish` runs it with --since before tagging and fails when a pull request merged since then is missing from the section, with a message to cut the release again from current main. --- .github/workflows/cut-release.yml | 7 ++- scripts/check-release-notes.py | 91 +++++++++++++++++++++++++++++++ scripts/release.sh | 14 +++++ 3 files changed, 110 insertions(+), 2 deletions(-) create mode 100755 scripts/check-release-notes.py diff --git a/.github/workflows/cut-release.yml b/.github/workflows/cut-release.yml index 1a185e8..2fb093e 100644 --- a/.github/workflows/cut-release.yml +++ b/.github/workflows/cut-release.yml @@ -6,12 +6,15 @@ # gh workflow run cut-release.yml -f version=0.40.0 # The `prepare` job runs `scripts/release.sh prepare `: skill # check, release/ branch, cargo-release bump (no publish, no -# tag), changelog validation, pushes the branch and opens the release PR. +# tag), changelog validation, a release-notes check (the new section +# must have bullets), pushes the branch and opens the release PR. # The job then enables auto-merge (squash) on that PR. # 2. The release PR merges on its own once CI and the review gate pass. # 3. The merge fires this workflow again (pull_request closed, head # release/*). The `publish` job runs `scripts/release.sh finish` on main: -# skill check with --require-version, annotated v tag, push. +# skill check with --require-version, a release-notes check (every PR +# merged since the previous tag must be in the section, or the release +# is cut again from current main), annotated v tag, push. # The tag push triggers release.yml (cargo-dist), which builds, publishes # the GitHub Release and the Homebrew formula. # diff --git a/scripts/check-release-notes.py b/scripts/check-release-notes.py new file mode 100755 index 0000000..7a98429 --- /dev/null +++ b/scripts/check-release-notes.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Fail unless CHANGELOG.md carries real release notes for a version. + + scripts/check-release-notes.py [--since ] + +Two checks: + + * The `## []` section exists and has at least one bullet. git-cliff + writes an empty section when nothing landed since the last tag, and an + empty section ships as an empty GitHub Release (0.42.0 did). + * With --since : every pull request merged between and HEAD is + mentioned in the section by its `(#N)` number, release commits aside. + The changelog is generated when the release PR opens; anything merged + while that PR waits for review ends up inside the tag but outside its + notes, and the next release's git-cliff run (which starts at the tag) + never sees it either. The finish step runs this against the previous + tag so such a release is refused before it is tagged. +""" + +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path + +SECTION_START = re.compile(r"^## \[([^\]]+)\]", re.MULTILINE) +RELEASE_COMMIT = re.compile(r"^chore: Release hotdata-cli version") +PR_NUMBER = re.compile(r"\(#(\d+)\)") + + +def section_for(text: str, version: str) -> str | None: + matches = list(SECTION_START.finditer(text)) + for i, m in enumerate(matches): + if m.group(1) == version: + end = matches[i + 1].start() if i + 1 < len(matches) else len(text) + line_end = text.find("\n", m.end()) # skip the rest of the heading (" - ") + return "" if line_end < 0 or line_end >= end else text[line_end + 1:end] + return None + + +def main() -> None: + args = sys.argv[1:] + if not args: + print(__doc__, file=sys.stderr) + sys.exit(2) + version = args[0] + since = args[args.index("--since") + 1] if "--since" in args else None + + body = section_for(Path("CHANGELOG.md").read_text(), version) + if body is None: + print(f"CHANGELOG.md: no section [{version}]", file=sys.stderr) + sys.exit(1) + bullets = [l for l in body.splitlines() if l.lstrip().startswith("- ")] + if not bullets: + print( + f"CHANGELOG.md: section [{version}] has no release notes. Nothing landed " + "since the previous tag, so there is nothing to release.", + file=sys.stderr, + ) + sys.exit(1) + + if since: + subjects = subprocess.check_output( + ["git", "log", "--format=%s", f"{since}..HEAD"], text=True + ).splitlines() + noted = set(PR_NUMBER.findall(body)) + missing = [ + s for s in subjects + if not RELEASE_COMMIT.match(s) + and (m := PR_NUMBER.search(s)) and m.group(1) not in noted + ] + if missing: + print( + f"CHANGELOG.md: section [{version}] misses pull requests merged since {since}:", + file=sys.stderr, + ) + for s in missing: + print(f" {s}", file=sys.stderr) + print( + "These merged after the release PR was opened. Cut the release again " + "from the current main so git-cliff includes them.", + file=sys.stderr, + ) + sys.exit(1) + + print(f"release notes ok: [{version}] has {len(bullets)} bullet(s)") + + +if __name__ == "__main__": + main() diff --git a/scripts/release.sh b/scripts/release.sh index 56aeeb3..cb4ccda 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -63,6 +63,12 @@ case "$COMMAND" in python3 scripts/validate-changelog.py origin/main fi + # A release needs notes: git-cliff writes an empty section when nothing + # landed since the last tag, and that ships as an empty GitHub Release. + echo "" + echo "→ Checking release notes for $VERSION..." + python3 scripts/check-release-notes.py "$VERSION" + echo "" echo "→ Opening pull request..." PR_URL=$(gh pr create \ @@ -105,6 +111,14 @@ case "$COMMAND" in echo "→ Checking agent skills against the code and version..." scripts/check-skills.sh --require-version + # The notes were generated when the release PR opened. Anything merged + # while it waited lands in this tag but in no changelog section (the + # next git-cliff run starts at this tag), so refuse to tag until the + # release is cut again from current main. + PREV_TAG="$(git describe --tags --abbrev=0 --match 'v*' 2>/dev/null || true)" + echo "→ Checking release notes for $VERSION cover everything since ${PREV_TAG:-the start}..." + python3 scripts/check-release-notes.py "$VERSION" ${PREV_TAG:+--since "$PREV_TAG"} + if git rev-parse "$TAG" >/dev/null 2>&1; then echo "error: tag $TAG already exists locally. Delete it or pick a new version." >&2 exit 1 From 233dd82c6b204c0b48b6692d803f86728a71d215 Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:49:08 -0700 Subject: [PATCH 2/2] fix(release): only require notes for commits git-cliff keeps Mirror cliff.toml: a non-conventional subject, docs(changelog), the release and bump-version commits, and merge commits never reach the changelog, so the --since check must not demand them. --- scripts/check-release-notes.py | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/scripts/check-release-notes.py b/scripts/check-release-notes.py index 7a98429..85fe1f2 100755 --- a/scripts/check-release-notes.py +++ b/scripts/check-release-notes.py @@ -8,8 +8,9 @@ * The `## []` section exists and has at least one bullet. git-cliff writes an empty section when nothing landed since the last tag, and an empty section ships as an empty GitHub Release (0.42.0 did). - * With --since : every pull request merged between and HEAD is - mentioned in the section by its `(#N)` number, release commits aside. + * With --since : every pull request merged between and HEAD that + git-cliff would list (a conventional subject that cliff.toml does not + skip) is mentioned in the section by its `(#N)` number. The changelog is generated when the release PR opens; anything merged while that PR waits for review ends up inside the tag but outside its notes, and the next release's git-cliff run (which starts at the tag) @@ -25,9 +26,22 @@ from pathlib import Path SECTION_START = re.compile(r"^## \[([^\]]+)\]", re.MULTILINE) -RELEASE_COMMIT = re.compile(r"^chore: Release hotdata-cli version") PR_NUMBER = re.compile(r"\(#(\d+)\)") +# What git-cliff keeps, mirroring cliff.toml: a conventional subject +# (filter_unconventional = true) that no `skip = true` parser matches. +CONVENTIONAL = re.compile(r"^[a-z]+(\([^)]*\))?!?: ") +SKIPPED = [ + re.compile(r"^docs\(changelog\)"), + re.compile(r"^chore: Release hotdata-cli version"), + re.compile(r"^chore: bump version"), + re.compile(r"^Merge pull request"), +] + + +def in_changelog(subject: str) -> bool: + return bool(CONVENTIONAL.match(subject)) and not any(p.match(subject) for p in SKIPPED) + def section_for(text: str, version: str) -> str | None: matches = list(SECTION_START.finditer(text)) @@ -67,8 +81,7 @@ def main() -> None: noted = set(PR_NUMBER.findall(body)) missing = [ s for s in subjects - if not RELEASE_COMMIT.match(s) - and (m := PR_NUMBER.search(s)) and m.group(1) not in noted + if in_changelog(s) and (m := PR_NUMBER.search(s)) and m.group(1) not in noted ] if missing: print(