diff --git a/CHANGELOG.md b/CHANGELOG.md index 62a0550..c804659 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,36 @@ is deliberate: see [Releasing](docs/releasing.md). Twelve releases were cut on live, every one of those interrupts every user. An alert people learn to ignore is worse than no alert, because one day it carries a security fix. +## Unreleased + +### Fixed + +- **A reboot mid-backup orphaned the entire snapshot tree, permanently.** The sidecar + is the record of which snapshots a run pinned — and it lives in `/run`, which is + **tmpfs**. A reboot (or a crash) between taking the recursive snapshot and cleaning + it up destroyed that record, leaving one snapshot per descendant dataset — **250+ on + a real pool** — with nothing left pointing at them. Nothing would ever have found + them again. + + `gc_stale_snapshots()` is the backstop: it identifies leftovers **by name**, so it + works when the record is gone. It runs at the start of every backup, after the + sidecar reclaim — the recorded path stays authoritative, and the collector only ever + mops up what the record lost. + + Because it deletes data on a *name match* — a weaker claim than a recorded fact — the + selection is a **pure function** with the harshest tests in the suite. A snapshot is + collected only if **all** of these hold: + + | | | + | --- | --- | + | name is exactly `@-` | so `cloud_backup-5` never matches `cloud_backup-50`, an `auto-*` periodic snapshot, or anything a human made | + | it is not the current run's | parent *and* children are excluded | + | **nothing is mounted from it** | an in-flight run pins its own snapshots — this, not the age guard, is what protects a concurrent backup | + | it is **over an hour old** | covers the seconds-long window where a live run has snapshotted but not yet mounted | + + Verified against the real pool: of **4,728** snapshots — including **2,341** periodic + ones — it selects exactly the orphans of the task being run, and nothing else. + ## v0.6.0 — 2026-07-13 ### Added @@ -207,35 +237,7 @@ worse than no alert, because one day it carries a security fix. warning, not a refusal: declining to install over a string we failed to read would be a worse failure than the one being prevented. -- **A stable release may not leave work stranded under `## Unreleased - -### Fixed - -- **A reboot mid-backup orphaned the entire snapshot tree, permanently.** The sidecar - is the record of which snapshots a run pinned — and it lives in `/run`, which is - **tmpfs**. A reboot (or a crash) between taking the recursive snapshot and cleaning - it up destroyed that record, leaving one snapshot per descendant dataset — **250+ on - a real pool** — with nothing left pointing at them. Nothing would ever have found - them again. - - `gc_stale_snapshots()` is the backstop: it identifies leftovers **by name**, so it - works when the record is gone. It runs at the start of every backup, after the - sidecar reclaim — the recorded path stays authoritative, and the collector only ever - mops up what the record lost. - - Because it deletes data on a *name match* — a weaker claim than a recorded fact — the - selection is a **pure function** with the harshest tests in the suite. A snapshot is - collected only if **all** of these hold: - - | | | - | --- | --- | - | name is exactly `@-` | so `cloud_backup-5` never matches `cloud_backup-50`, an `auto-*` periodic snapshot, or anything a human made | - | it is not the current run's | parent *and* children are excluded | - | **nothing is mounted from it** | an in-flight run pins its own snapshots — this, not the age guard, is what protects a concurrent backup | - | it is **over an hour old** | covers the seconds-long window where a live run has snapshotted but not yet mounted | - - Verified against the real pool: of **4,728** snapshots — including **2,341** periodic - ones — it selects exactly the orphans of the task being run, and nothing else.`.** Either it +- **A stable release may not leave work stranded under `## Unreleased`.** Either it is finished and belongs in the release, or the release is premature. Candidates are exempt: an rc may legitimately have work queued behind it. diff --git a/tests/test_release_notes.py b/tests/test_release_notes.py index b4b33b2..428b0fb 100644 --- a/tests/test_release_notes.py +++ b/tests/test_release_notes.py @@ -9,6 +9,7 @@ across install.sh / uninstall.sh / recover.sh / apply.sh and nothing noticed. """ import os +import re import sys import pytest @@ -233,3 +234,60 @@ class TestCandidateNotesResolveToTheBaseVersion: def test_a_genuinely_missing_section_still_raises(self): with pytest.raises(KeyError): extract_notes(self.CHANGELOG, "v9.9.9-rc1") + + +class TestTheChangelogIsStructurallySound: + """The release body IS this file, so a mangled section ships to every user. + + It has been mangled once: an edit matched the literal `## Unreleased` inside a + backticked phrase in a prose bullet and spliced a whole new section into the middle + of it, splitting the sentence in half. + """ + + def changelog(self): + with open(os.path.join(REPO, "CHANGELOG.md"), encoding="utf-8") as fh: + return fh.read() + + def test_no_version_section_is_empty(self): + text = self.changelog() + for v in changelog_versions(text): + assert extract_notes(text, v).strip(), f"v{v} has an empty section" + + def test_versions_are_in_descending_order(self): + from release_notes import version_tuple + versions = changelog_versions(self.changelog()) + assert versions == sorted(versions, key=version_tuple, reverse=True), ( + "CHANGELOG versions are out of order — a section was spliced in wrong" + ) + + def test_headings_are_at_the_start_of_a_line_and_not_inside_prose(self): + # A `### Fixed` that ends up indented under a bullet is a section nobody sees. + for i, line in enumerate(self.changelog().splitlines(), 1): + if line.lstrip().startswith(("## ", "### ")) and line != line.lstrip(): + raise AssertionError( + f"line {i}: heading is indented, so it is inside a list item " + f"rather than being a section: {line!r}" + ) + + def test_every_bullet_that_opens_a_bold_phrase_closes_it(self): + # The splice cut `- **A stable release ... under \`## Unreleased` in half, + # leaving an unterminated ** and a dangling sentence. + # + # A bullet is the `- ` line plus everything up to the next top-level bullet or + # heading -- bold phrases routinely wrap across lines, so a per-line check + # would flag every long bullet in the file. + text = self.changelog() + bullets = re.split(r"^(?=- |#{2,3} )", text, flags=re.M) + bad = [] + for b in bullets: + if not b.startswith("- "): + continue + # Code spans are not markup: `*args, **kwargs` is a literal, not a bold + # phrase, and counting its ** would flag a perfectly well-formed bullet. + prose = re.sub(r"`[^`]*`", "", b) + if prose.count("**") % 2: + bad.append(b.splitlines()[0][:70]) + assert not bad, ( + "unbalanced ** in a bullet — a section was probably spliced into the " + "middle of it:\n " + "\n ".join(bad) + )