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
7 changes: 5 additions & 2 deletions .github/workflows/cut-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@
# gh workflow run cut-release.yml -f version=0.40.0
# The `prepare` job runs `scripts/release.sh prepare <version>`: skill
# check, release/<version> 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<version> 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<version> tag, push.
# The tag push triggers release.yml (cargo-dist), which builds, publishes
# the GitHub Release and the Homebrew formula.
#
Expand Down
104 changes: 104 additions & 0 deletions scripts/check-release-notes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""Fail unless CHANGELOG.md carries real release notes for a version.

scripts/check-release-notes.py <version> [--since <ref>]

Two checks:

* The `## [<version>]` 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 <ref>: every pull request merged between <ref> 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 (" - <date>")
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Use argparse here, or check that a value follows --since (not blocking). A trailing --since with no value raises IndexError and prints a traceback instead of the usage text. release.sh always passes a value, so only manual runs hit this case.


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
]
Comment on lines +82 to +85

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exempt every subject that git-cliff drops from the --since check, not only release commits. The current check demands subjects that git-cliff never writes, so finish can fail permanently.

cliff.toml drops these commits:

  • non-conventional subjects (filter_unconventional = true, line 42)
  • docs(changelog) (line 68)
  • chore: bump version (line 75)
  • Merge pull request (line 76)

Failure scenario: this PR squash-merges as Release scripts refuse a release without notes (#343). That subject is not conventional, so git-cliff leaves #343 out of the 0.43.0 section. finish then reports #343 as missing and refuses to tag. The error message says to cut the release again from main. A new cut runs the same git-cliff filters, so it omits #343 again, and the release never tags.

Fix options:

  • Generate the expected set with git-cliff itself, for example git cliff <since>..HEAD --context, and compare the PR numbers it emits.
  • Or mirror the filters: require only subjects that match ^\w+(\([^)]*\))?!?: and do not match the skip = true parsers.

Also retitle this PR to a conventional subject, for example ci: release scripts refuse a release without notes. Without that change, #343 is dropped from the next changelog even after the fix.

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()
14 changes: 14 additions & 0 deletions scripts/release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down Expand Up @@ -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
Expand Down
Loading