From eb91a337cd243aa20f135b74b0052b11251d3120 Mon Sep 17 00:00:00 2001 From: flan Date: Mon, 13 Jul 2026 15:05:46 +0000 Subject: [PATCH] Automate releases from tags 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. --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 98 +++++++++++++++++++++ CHANGELOG.md | 8 ++ tests/test_release_notes.py | 122 ++++++++++++++++++++++++++ tools/release_notes.py | 157 ++++++++++++++++++++++++++++++++++ 5 files changed, 386 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/release.yml create mode 100644 tests/test_release_notes.py create mode 100644 tools/release_notes.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c71a4c2..be27805 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,7 +51,7 @@ jobs: run: python -m pip install --upgrade pip pytest ruff - name: ruff - run: ruff check patch tests + run: ruff check patch tests tools - name: pytest run: pytest tests -v diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..de7f461 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,98 @@ +name: Release + +# Push a tag, get a release. The body always comes from CHANGELOG.md, so there is +# no second place to write release notes and therefore no second place for them to +# go stale. +# +# git tag -a v0.4.0 -m "v0.4.0" && git push origin v0.4.0 +# +# workflow_dispatch exists to create a release for a tag that already exists +# (backfilling history), since re-pushing an existing tag triggers nothing. + +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + tag: + description: "Existing tag to create a release for (e.g. v0.2.1)" + required: true + type: string + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Resolve tag + id: tag + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT" + else + echo "tag=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" + fi + + - uses: actions/checkout@v4 + with: + ref: ${{ steps.tag.outputs.tag }} + fetch-depth: 0 + + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + + # Never publish a release for code that does not pass its own tests. A + # tagged commit is what people install; it has to be at least as good as + # main. + - name: install dev deps + run: python -m pip install --upgrade pip pytest ruff + + - name: ruff + run: ruff check patch tests tools + + - name: pytest + run: pytest tests -q + + - name: shell syntax + run: | + fail=0 + while IFS= read -r f; do + bash -n "$f" || { echo "::error file=$f::bash syntax error"; fail=1; } + done < <(find . -name '*.sh' -not -path './.git/*') + exit $fail + + # Catches the failure mode this repo actually had: VERSION= drifted to + # three different values across the scripts, and nothing noticed. + - name: version matches tag and CHANGELOG has a section + run: python3 tools/release_notes.py check "${{ steps.tag.outputs.tag }}" + + - name: extract release notes from CHANGELOG + run: | + python3 tools/release_notes.py notes "${{ steps.tag.outputs.tag }}" > /tmp/notes.md + echo "--- release body ---" + cat /tmp/notes.md + + - name: create or update the release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.tag.outputs.tag }} + run: | + # Pre-1.0 and any -rc/-beta suffix ship as prereleases, not "Latest". + prerelease="" + case "$TAG" in + *-rc*|*-beta*|*-alpha*) prerelease="--prerelease" ;; + esac + + if gh release view "$TAG" >/dev/null 2>&1; then + echo "Release $TAG exists — updating notes." + gh release edit "$TAG" --notes-file /tmp/notes.md + else + # shellcheck disable=SC2086 + gh release create "$TAG" \ + --title "$TAG" \ + --notes-file /tmp/notes.md \ + $prerelease + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 32a10b7..d223bc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,6 +83,14 @@ `.zfs/snapshot/-/` path changes every run, which defeats restic's parent detection and forces a full re-scan each time. +- **Automated releases.** Pushing a `v*` tag runs the full test suite and then + cuts a GitHub release whose body is the matching `CHANGELOG.md` section — so + release notes have exactly one source of truth. The workflow refuses to publish + if the tests fail, if the tag does not match the `VERSION=` declared by every + script, or if the CHANGELOG has no section for it. (`VERSION=` had silently + drifted to three different values across the scripts, and nothing noticed.) + `workflow_dispatch` can create a release for an already-existing tag. + - **CI** (GitHub Actions): shellcheck + `bash -n` on every script, ruff, and pytest on Python 3.11/3.12/3.13. Includes tests that `compile()` the `*_BLOCK` strings — they are Python source appended to live middlewared diff --git a/tests/test_release_notes.py b/tests/test_release_notes.py new file mode 100644 index 0000000..65113e8 --- /dev/null +++ b/tests/test_release_notes.py @@ -0,0 +1,122 @@ +"""Tests for the release automation. + +The release workflow refuses to publish unless these hold, so a bad tag fails +loudly in CI instead of shipping a release whose notes are empty, wrong, or whose +scripts announce a different version than the tag. + +That last one is not hypothetical: VERSION= drifted to three different values +across install.sh / uninstall.sh / recover.sh / apply.sh and nothing noticed. +""" + +import os +import sys + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "tools")) + +from release_notes import ( # noqa: E402 + changelog_versions, + check, + extract_notes, + normalise, + script_versions, +) + +REPO = os.path.join(os.path.dirname(__file__), "..") + +SAMPLE = """\ +# Changelog + +## v0.3.0 — 2026-07-13 + +### Added + +- the new thing + +## v0.2.1 — 2026-07-09 + +### Fixed + +- the old thing + +## v0.2.0 — 2026-07-08 + +- first +""" + + +class TestExtractNotes: + def test_returns_only_that_versions_body(self): + body = extract_notes(SAMPLE, "v0.3.0") + assert "the new thing" in body + assert "the old thing" not in body + # The version heading itself is dropped (GitHub renders its own title), + # but sub-headings like "### Added" must survive. + assert not body.startswith("## v") + assert body.startswith("### Added") + + def test_stops_at_the_next_version_heading(self): + body = extract_notes(SAMPLE, "v0.2.1") + assert "the old thing" in body + assert "first" not in body + + def test_last_section_runs_to_end_of_file(self): + assert "first" in extract_notes(SAMPLE, "v0.2.0") + + def test_accepts_the_tag_with_or_without_the_v(self): + assert extract_notes(SAMPLE, "0.3.0") == extract_notes(SAMPLE, "v0.3.0") + + def test_unknown_version_raises_rather_than_returning_empty(self): + # An empty release body is worse than a failed release. + with pytest.raises(KeyError, match="no section"): + extract_notes(SAMPLE, "v9.9.9") + + +class TestChangelogVersions: + def test_lists_versions_newest_first(self): + assert changelog_versions(SAMPLE) == ["0.3.0", "0.2.1", "0.2.0"] + + +class TestAgainstTheRealRepo: + """These run against the actual files, so drift breaks the build.""" + + def test_every_script_declares_a_version(self): + from release_notes import VERSIONED_FILES + + found = script_versions(REPO) + missing = [f for f in VERSIONED_FILES if f not in found] + assert not missing, f"no VERSION= in: {missing}" + + def test_all_scripts_agree_on_the_version(self): + versions = {normalise(v) for v in script_versions(REPO).values()} + assert len(versions) == 1, f"scripts disagree on version: {sorted(versions)}" + + def test_the_current_version_has_a_changelog_section(self): + version = next(iter({normalise(v) for v in script_versions(REPO).values()})) + with open(os.path.join(REPO, "CHANGELOG.md"), encoding="utf-8") as fh: + body = extract_notes(fh.read(), version) + assert body, f"CHANGELOG.md has no content for v{version}" + + def test_the_current_version_is_the_newest_changelog_entry(self): + version = next(iter({normalise(v) for v in script_versions(REPO).values()})) + with open(os.path.join(REPO, "CHANGELOG.md"), encoding="utf-8") as fh: + newest = changelog_versions(fh.read())[0] + assert newest == version, ( + f"scripts say v{version} but the newest CHANGELOG entry is v{newest}" + ) + + def test_check_passes_for_the_current_version(self): + version = next(iter({normalise(v) for v in script_versions(REPO).values()})) + assert check(version, REPO) == [] + + +class TestCheckCatchesMistakes: + def test_reports_a_tag_that_no_script_matches(self): + problems = check("v9.9.9", REPO) + assert problems + assert any("declares VERSION" in p for p in problems) + + def test_reports_a_missing_changelog_section(self): + problems = check("v9.9.9", REPO) + assert any("no section" in p for p in problems) diff --git a/tools/release_notes.py b/tools/release_notes.py new file mode 100644 index 0000000..2c55a47 --- /dev/null +++ b/tools/release_notes.py @@ -0,0 +1,157 @@ +#!/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} ", 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))