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..85fe1f2 --- /dev/null +++ b/scripts/check-release-notes.py @@ -0,0 +1,104 @@ +#!/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 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) + 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) +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)) + 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 in_changelog(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