CHANGELOG: repair a section spliced into the middle of a bullet, and guard it
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:
+31
-29
@@ -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
|
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.
|
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
|
## v0.6.0 — 2026-07-13
|
||||||
### Added
|
### 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
|
warning, not a refusal: declining to install over a string we failed to read would
|
||||||
be a worse failure than the one being prevented.
|
be a worse failure than the one being prevented.
|
||||||
|
|
||||||
- **A stable release may not leave work stranded under `## Unreleased
|
- **A stable release may not leave work stranded under `## Unreleased`.** Either it
|
||||||
|
|
||||||
### 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
|
|
||||||
is finished and belongs in the release, or the release is premature. Candidates
|
is finished and belongs in the release, or the release is premature. Candidates
|
||||||
are exempt: an rc may legitimately have work queued behind it.
|
are exempt: an rc may legitimately have work queued behind it.
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ across install.sh / uninstall.sh / recover.sh / apply.sh and nothing noticed.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -233,3 +234,60 @@ class TestCandidateNotesResolveToTheBaseVersion:
|
|||||||
def test_a_genuinely_missing_section_still_raises(self):
|
def test_a_genuinely_missing_section_still_raises(self):
|
||||||
with pytest.raises(KeyError):
|
with pytest.raises(KeyError):
|
||||||
extract_notes(self.CHANGELOG, "v9.9.9-rc1")
|
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)
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user