Raises a real alert in the TrueNAS UI bell -- not a log line nobody reads. On by default, checked once a day. install.sh --no-update-alerts turns it off. It does not nag --------------- A release whose CHANGELOG contains only a "### Docs" section changed no code and raises nothing. Anything else raises INFO; a "### Security" section raises WARNING. The CHANGELOG's own section headings are the signal, and a security fix anywhere in the range escalates the whole span -- a docs-only release sitting on top of a security fix still reports as security rather than hiding it. Why an AlertSource and not midclt ---------------------------------- TrueNAS cannot raise an alert from the CLI. midclt exposes only alert.dismiss, alert.list, alert.list_categories, alert.list_policies and alert.restore -- alert CREATION is internal to middlewared, and none of its ~60 one-shot classes is generic enough to reuse. Registering an AlertSource is the only way. It is also the least invasive thing this patch does. The providers and nested modules both APPEND CODE TO STOCK middleware files; the alert source ADDS ONE FILE and modifies none. It is the native mechanism -- the same one every built-in TrueNAS alert uses -- and TrueNAS polls it itself, so there is no cron job and no systemd timer. - Fail-safe: every error path returns None; it cannot take middlewared down. - Read-only: `git ls-remote` plus an HTTPS fetch of the CHANGELOG. It never writes to .git, so it cannot leave root-owned objects behind the way a `git fetch` from middlewared (running as root) would. - Removed by uninstall.sh (mw_patch.revert_all). - It only tells you; it never updates anything. Verified against the real repo and remote, with middlewared stubbed: on v0.4.1, only a README-only v0.4.2 available -> NO ALERT on v0.4.0, v0.4.1 fixed real bugs -> INFO on v0.3.2, v0.3.3 was the password fix -> SECURITY / WARNING 139 tests, ruff and shellcheck -S style clean.
220 lines
7.2 KiB
Python
220 lines
7.2 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")
|
|
|
|
# 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)
|
|
|
|
|
|
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()
|
|
|
|
|
|
# ── 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."""
|
|
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":
|
|
# An explicit path lets update.sh show the notes from the CHANGELOG of the
|
|
# version it is about to install (`git show <tag>: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))
|