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:
@@ -51,7 +51,7 @@ jobs:
|
|||||||
run: python -m pip install --upgrade pip pytest ruff
|
run: python -m pip install --upgrade pip pytest ruff
|
||||||
|
|
||||||
- name: ruff
|
- name: ruff
|
||||||
run: ruff check patch tests
|
run: ruff check patch tests tools
|
||||||
|
|
||||||
- name: pytest
|
- name: pytest
|
||||||
run: pytest tests -v
|
run: pytest tests -v
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -83,6 +83,14 @@
|
|||||||
`.zfs/snapshot/<name>-<timestamp>/` path changes every run, which defeats
|
`.zfs/snapshot/<name>-<timestamp>/` path changes every run, which defeats
|
||||||
restic's parent detection and forces a full re-scan each time.
|
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
|
- **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
|
pytest on Python 3.11/3.12/3.13. Includes tests that `compile()` the
|
||||||
`*_BLOCK` strings — they are Python source appended to live middlewared
|
`*_BLOCK` strings — they are Python source appended to live middlewared
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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} <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))
|
||||||
Reference in New Issue
Block a user