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.
This commit is contained in:
flan
2026-07-13 15:05:46 +00:00
parent f3ea6b301c
commit eb91a337cd
5 changed files with 386 additions and 1 deletions
+122
View File
@@ -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)