CHANGELOG: repair a section spliced into the middle of a bullet, and guard it
CI / shell (shellcheck + syntax) (push) Successful in 10s
CI / python 3.11 (push) Successful in 16s
CI / python 3.12 (push) Successful in 15s
CI / python 3.13 (push) Successful in 14s

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. The release body IS this file, so that would have shipped to every user.

Tests now assert: no empty version section, versions descend, no heading is indented
inside a list item, and every bullet's bold phrases are balanced (ignoring code spans
-- '*args, **kwargs' is a literal, not markup).
This commit is contained in:
2026-07-13 19:59:35 +00:00
parent 0d04c2cd1c
commit 841e0364fd
2 changed files with 89 additions and 29 deletions
+31 -29
View File
@@ -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 `<dataset>@<task>-<YYYYMMDDHHMMSS>` | 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 `<dataset>@<task>-<YYYYMMDDHHMMSS>` | 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.
+58
View File
@@ -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)
)