Releases were manual and had drifted: v0.2.0 and v0.2.1 were tagged but never
released, so the releases page jumped v0.1.0 -> v0.3.0 and hid the fix for the
incident that took every app down.
Pushing a v* tag now runs the full suite and cuts a GitHub release whose body is
the matching CHANGELOG.md section -- one source of truth for release notes, so
there is no second place for them to be wrong.
The workflow refuses to publish when:
- the tests, ruff, or bash -n fail (a tagged commit is what people install; it
must be at least as good as main)
- the tag does not match the VERSION= declared by every script
- CHANGELOG.md has no section for the tag, or the section is empty
That version check is not theoretical: VERSION= had drifted to three different
values across install.sh / uninstall.sh / recover.sh / apply.sh and nothing
noticed until this release. tests/test_release_notes.py now asserts the scripts
agree with each other and with the newest CHANGELOG entry, so the drift cannot
come back.
workflow_dispatch takes an existing tag, so releases can be backfilled for tags
that were pushed before this existed.
106 tests, ruff and shellcheck clean.
158 lines
4.5 KiB
Python
158 lines
4.5 KiB
Python
#!/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")
|
|
|
|
# Every script prints a version; they must all agree, and agree with the tag.
|
|
# They drifted to three different values once (0.0.4 / 0.2.1) before anything
|
|
# checked them.
|
|
VERSIONED_FILES = [
|
|
"install.sh",
|
|
"uninstall.sh",
|
|
"recover.sh",
|
|
os.path.join("patch", "apply.sh"),
|
|
]
|
|
|
|
_VERSION_RE = re.compile(r'^VERSION="([^"]+)"', re.M)
|
|
_HEADING_RE = re.compile(r"^##\s+v?(\d+\.\d+\.\d+[^\s]*)", re.M)
|
|
|
|
|
|
def normalise(v: str) -> str:
|
|
return v.strip().lstrip("v")
|
|
|
|
|
|
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.
|
|
|
|
Raises KeyError if the version has no section -- a release with an empty or
|
|
wrong body is worse than a failed release.
|
|
"""
|
|
want = normalise(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()
|
|
|
|
|
|
def check(version: str, root: str = ROOT) -> list[str]:
|
|
"""Every reason this version is not releasable. Empty list means it is."""
|
|
want = normalise(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")
|
|
|
|
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} <version>", file=sys.stderr)
|
|
return 2
|
|
version = argv[2]
|
|
|
|
if cmd == "notes":
|
|
with open(CHANGELOG, 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))
|