Repository navigation
fix(release): refuse a release without notes #343
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
|
|
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Exempt every subject that git-cliff drops from the
Failure scenario: this PR squash-merges as Fix options:
Also retitle this PR to a conventional subject, for example |
||
| 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() | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: Use
argparsehere, or check that a value follows--since(not blocking). A trailing--sincewith no value raisesIndexErrorand prints a traceback instead of the usage text.release.shalways passes a value, so only manual runs hit this case.