#!/usr/bin/env python3 """Extract one version's section from CHANGELOG.md, and check version consistency. Used by .github/workflows/release.yml so a release's body is always the changelog entry -- there is no second place to write release notes, and therefore no second place for them to be wrong. python3 tools/release_notes.py notes v0.3.0 # -> the section body python3 tools/release_notes.py version # -> version per the scripts python3 tools/release_notes.py check v0.3.0 # -> exit 1 on any mismatch """ from __future__ import annotations import os import re import sys ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) CHANGELOG = os.path.join(ROOT, "CHANGELOG.md") # Everything that announces a version must agree with everything else. They drifted # to three different values once (0.0.4 / 0.2.1) before anything checked them -- # and create_task.py's __version__ then sat at 0.2.0 through three more releases, # because the first version of this check only looked at VERSION= in shell scripts. VERSIONED_FILES = [ "install.sh", "uninstall.sh", "recover.sh", os.path.join("patch", "apply.sh"), os.path.join("patch", "create_task.py"), # exposes `--version` to users "update.sh", ] # `VERSION="x"` (shell) or `__version__ = "x"` (python). _VERSION_RE = re.compile(r'^(?:VERSION=|__version__\s*=\s*)"([^"]+)"', re.M) _HEADING_RE = re.compile(r"^##\s+v?(\d+\.\d+\.\d+[^\s]*)", re.M) #: Work in progress lives here until a release promotes it. Batching through this #: section is what stops "tag, find bug, tag again" from becoming twelve releases. UNRELEASED = "Unreleased" _UNRELEASED_RE = re.compile(r"^##\s+Unreleased\s*$", re.M | re.I) _RC_RE = re.compile(r"-(rc|beta|alpha)\d*$", re.I) def normalise(v: str) -> str: return v.strip().lstrip("v") def is_prerelease(tag: str) -> bool: """True for v1.2.3-rc1 / -beta / -alpha. Those never reach users.""" return bool(_RC_RE.search(tag.strip())) def base_version(tag: str) -> str: """v1.2.3-rc2 -> 1.2.3""" return _RC_RE.sub("", normalise(tag)) def unreleased_body(text: str) -> str: """Content under `## Unreleased`, or "" if the section is absent/empty.""" m = _UNRELEASED_RE.search(text) if not m: return "" rest = text[m.end():] nxt = _HEADING_RE.search(rest) return (rest[:nxt.start()] if nxt else rest).strip() def promote(text: str, version: str, date: str) -> str: """Rename `## Unreleased` to `## vX.Y.Z — date`. Refuses if the section is missing or empty: a release with nothing in it is a release nobody needed, and cutting one only trains people to ignore alerts. """ if not unreleased_body(text): raise ValueError( "CHANGELOG.md has no `## Unreleased` content — nothing to release. " "Add your changes there first." ) m = _UNRELEASED_RE.search(text) return text[:m.start()] + f"## v{normalise(version)} — {date}" + text[m.end():] def script_versions(root: str = ROOT) -> dict[str, str]: """VERSION= as declared by each script.""" found = {} for rel in VERSIONED_FILES: path = os.path.join(root, rel) try: with open(path, encoding="utf-8") as fh: m = _VERSION_RE.search(fh.read()) except OSError: continue if m: found[rel] = m.group(1) return found def changelog_versions(text: str) -> list[str]: """Versions with a section in the changelog, newest first.""" return [normalise(v) for v in _HEADING_RE.findall(text)] def extract_notes(text: str, version: str) -> str: """The body of one version's section, without its heading. A release candidate resolves to its BASE version: v0.6.0-rc2 ships the same code as v0.6.0 and therefore the same notes, and the CHANGELOG only ever has the one section. Without this, the release workflow cut the tag, passed every gate, and then died extracting the body -- so the candidate existed but was never published. Raises KeyError if the version has no section -- a release with an empty or wrong body is worse than a failed release. """ want = base_version(version) lines = text.splitlines() start = None for i, line in enumerate(lines): m = _HEADING_RE.match(line) if m and normalise(m.group(1)) == want: start = i + 1 break if start is None: raise KeyError(f"CHANGELOG.md has no section for v{want}") end = len(lines) for i in range(start, len(lines)): if _HEADING_RE.match(lines[i]): end = i break return "\n".join(lines[start:end]).strip() # ── significance ────────────────────────────────────────────────────────────── # Used by the TrueNAS update alert to decide whether a release is worth bothering # anyone about. The CHANGELOG's own section headings are the signal: a release that # only has "### Docs" changed no code, and nobody should get an alert for a README. _SECTION_RE = re.compile(r"^###\s+(.+?)\s*$", re.M) #: Headings that mean "nothing about the running system changed". QUIET_SECTIONS = {"docs", "documentation"} def version_tuple(v: str) -> tuple: """Sortable version. Pre-release suffixes are dropped, not ranked.""" return tuple(int(x) for x in normalise(v).split("-")[0].split(".")) def section_headings(body: str) -> list[str]: """The `### ...` headings inside one version's body, lowercased.""" return [h.strip().lower() for h in _SECTION_RE.findall(body)] def significance(text: str, current: str, latest: str): """How much does upgrading `current` -> `latest` actually matter? Returns ``(level, versions, headings)`` where level is one of: "security" a release in the range has a Security section -> alert loudly "notable" something about the system changed -> alert quietly "docs" only documentation changed -> DO NOT alert Considers every release in the range, not just the newest: a docs-only v0.4.2 on top of a security-fixing v0.4.1 must still be reported as security. """ cur, lat = version_tuple(current), version_tuple(latest) versions = [ v for v in changelog_versions(text) if cur < version_tuple(v) <= lat ] headings = [] for v in versions: try: headings.extend(section_headings(extract_notes(text, v))) except KeyError: continue if any(h.startswith("security") for h in headings): return "security", versions, headings if [h for h in headings if h not in QUIET_SECTIONS]: return "notable", versions, headings return "docs", versions, headings def check(version: str, root: str = ROOT) -> list[str]: """Every reason this version is not releasable. Empty list means it is. `version` may be a release candidate (v1.2.3-rc2); the scripts and CHANGELOG are checked against its BASE version, since an rc ships the same code. """ want = base_version(version) problems = [] versions = script_versions(root) for rel, got in sorted(versions.items()): if normalise(got) != want: problems.append(f"{rel} declares VERSION={got!r}, tag is v{want}") missing = [r for r in VERSIONED_FILES if r not in versions] for rel in missing: problems.append(f"{rel} has no VERSION= line") try: with open(os.path.join(root, "CHANGELOG.md"), encoding="utf-8") as fh: text = fh.read() except OSError as e: problems.append(f"cannot read CHANGELOG.md: {e}") return problems try: body = extract_notes(text, want) except KeyError as e: problems.append(str(e)) else: if not body: problems.append(f"CHANGELOG.md section for v{want} is empty") # A stable release must not leave work stranded under `## Unreleased`. If it is # finished enough to ship, it belongs in the release; if it is not, the release # is premature. (An rc may legitimately have more work queued behind it.) if not is_prerelease(version) and unreleased_body(text): problems.append( "CHANGELOG.md still has content under `## Unreleased` — either include " "it in this release, or do not cut the release yet" ) return problems def main(argv): if len(argv) < 2: print(__doc__, file=sys.stderr) return 2 cmd = argv[1] if cmd == "version": versions = set(map(normalise, script_versions().values())) if len(versions) != 1: print(f"scripts disagree on version: {sorted(versions)}", file=sys.stderr) return 1 print(versions.pop()) return 0 if len(argv) < 3: print(f"usage: {argv[0]} {cmd} ", file=sys.stderr) return 2 version = argv[2] if cmd == "notes": # An explicit path lets update.sh show the notes from the CHANGELOG of the # version it is about to install (`git show :CHANGELOG.md`), not the # one already checked out. path = argv[3] if len(argv) > 3 else CHANGELOG with open(path, encoding="utf-8") as fh: print(extract_notes(fh.read(), version)) return 0 if cmd == "check": problems = check(version) for p in problems: print(f"::error::{p}") if problems: return 1 print(f"v{normalise(version)} is consistent across scripts and CHANGELOG") return 0 print(f"unknown command: {cmd}", file=sys.stderr) return 2 if __name__ == "__main__": sys.exit(main(sys.argv))