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.
123 lines
3.9 KiB
Python
123 lines
3.9 KiB
Python
"""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)
|