Files
FabledCurator/tests/test_release_notes.py
T
bvandeusenandClaude Opus 5 c0370069e0
Build images / sign-extension (push) Successful in 4s
CI / lint (push) Successful in 4s
CI / extension-version (push) Successful in 5s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 36s
CI / integration (push) Successful in 1m42s
release: the first release describes the product; it has nothing to diff against
Step 7 needs a release that reads as "what is FabledCurator and how do I run
it". What the script would actually have published is "changes since
v26.06.04.0" over 533 commits, truncated to 200 — a release page whose first
screen is the internal build-out that milestone 328 exists to stop shipping,
addressed to a reader who has never seen this project.

Two causes, fixed separately.

**A pre-convention tag is history, not a predecessor.** The 28 `v26.*` tags
were kept when their releases were deleted, so `--match v*` walks ancestry
straight back to one of them. Reachable is not comparable: nobody has run
v26.06.04.0 and its release page no longer exists to compare against. The
match is now `v[0-9][0-9][0-9][0-9].*` — rule 148's shape, which is exactly
the set of tags naming a release a reader could have been running.

**With that narrowed, the first rule-148 tag reaches no predecessor**, and the
old fallback — diff against the whole history — is worse than the problem it
replaced. A release with no predecessor now renders the product overview and
no commit list at all.

The overview is READ OUT OF README.md between `<!-- overview:start -->` and
`<!-- overview:end -->`, not written into the script, for the same reason the
changelog is derived: two hand-maintained descriptions of one product drift
and nothing ever catches it. The release page and the repo front page are one
source. Missing markers are reported as a note and publish anyway, on
cross_checks()'s reasoning — the release is still the useful object.

Every later release goes back to being a changelog, which is what note #3127
§5 says a release is for. MAX_COMMITS still guards the case it now guards:
two real releases far enough apart that the list stops being readable.

Also corrected while marking up the README: "Importing — ingests an existing
library from disk" was still advertising the folder-import feature that
3590c47 documented as deliberately retired. Replaced with what FC actually
does with what arrives — content-hash dedup, sidecar metadata, provenance.

Tests: the two that asserted the old no-predecessor behaviour are rewritten
rather than left; synthetic repos now carry their own copy of the script,
since the overview resolves relative to `__file__` (correct in production,
where release.yml checks out the tag) and would otherwise have every fixture
silently quoting FabledCurator's real README.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TTjbZZ6JirCMSaJzQV1RhA
2026-09-02 12:28:48 -04:00

234 lines
10 KiB
Python

"""What the release changelog promises, and the way it would lie quietly.
A changelog has no consumer that checks it. If it lists the wrong span nothing
fails — the release publishes, reads perfectly, and tells the operator that a
month of work landed in a build that never contained it. That is the same
silent-and-plausible failure class as a revision that identifies the wrong
content (`test_artifact_identity.py` guards the other side of it), so the span
selection is asserted rather than eyeballed.
Everything runs the script the way `release.yml` runs it — as a subprocess,
through `--dry-run`. That is the same code path as a real publish right up to
the HTTP call, so these exercise the interface CI uses instead of a Python
re-implementation of it.
"""
from __future__ import annotations
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
SCRIPT = ROOT / "scripts" / "release_notes.py"
def notes(*args: str, cwd: Path | None = None) -> str:
"""Run the script the way release.yml does.
A synthetic repo runs its OWN copy of the script, because the overview is
read relative to `__file__` rather than to the cwd — which is right in
production (release.yml checks out the tag, so the script IS the tagged
tree's copy) and would otherwise make every synthetic repo silently quote
FabledCurator's real README.
"""
root = cwd or ROOT
script = root / "scripts" / "release_notes.py"
return subprocess.run(
["python3", str(script if script.exists() else SCRIPT), "--dry-run", *args],
capture_output=True, text=True, check=True, cwd=root,
).stdout
OVERVIEW_TEXT = "A synthetic product, described once."
def install_script(repo: Path, *, overview: bool = True) -> None:
"""Give a synthetic repo the script and a README to quote."""
(repo / "scripts").mkdir(exist_ok=True)
(repo / "scripts" / "release_notes.py").write_text(SCRIPT.read_text())
(repo / "scripts" / "artifacts.sh").write_text("#!/bin/sh\nexit 1\n")
readme = "# Synthetic\n\n"
if overview:
readme += f"<!-- overview:start -->\n{OVERVIEW_TEXT}\n<!-- overview:end -->\n"
(repo / "README.md").write_text(readme)
def body_of(out: str) -> str:
assert "--- body ---" in out, f"no body was rendered:\n{out}"
return out.split("--- body ---", 1)[1]
def git(repo: Path, *args: str) -> str:
return subprocess.run(
["git", "-c", "user.email=ci@example.invalid", "-c", "user.name=ci",
"-c", "commit.gpgsign=false", *args],
capture_output=True, text=True, check=True, cwd=repo,
).stdout.strip()
@pytest.fixture
def shaped_history(tmp_path: Path) -> Path:
"""Three releases spanning the rule 148 tag-shape change.
Ancestry order is `v26.06.04.0` → `v2026.08.28.2208` → `v2026.08.29.1000`,
which is the exact arrangement where walking ancestry and sorting a list
disagree — see the test below. Synthetic rather than taken from this repo's
own tags so it holds whether or not CI's checkout brought the tags along:
a span test that quietly skips is the one outcome worse than a failing one.
"""
repo = tmp_path / "shaped"
repo.mkdir()
git(repo, "init", "-q", "-b", "main")
install_script(repo)
for i, tag in enumerate(("v26.06.04.0", "v2026.08.28.2208", "v2026.08.29.1000")):
(repo / "f.txt").write_text(f"{i}\n")
git(repo, "add", "-A")
git(repo, "commit", "-q", "-m", f"work landing in {tag}")
git(repo, "tag", tag)
# One more commit and a merge, so the merge-exclusion test has something to
# exclude that a first-parent listing would otherwise show.
git(repo, "checkout", "-q", "-b", "side")
(repo / "g.txt").write_text("side\n")
git(repo, "add", "g.txt")
git(repo, "commit", "-q", "-m", "feat: work done on the side branch")
git(repo, "checkout", "-q", "main")
git(repo, "merge", "-q", "--no-ff", "side", "-m", "Merge pull request #999 from side")
git(repo, "tag", "v2026.08.30.0900")
return repo
def test_the_previous_release_is_found_by_ancestry_not_by_sorting(shaped_history):
"""The trap this repo is standing in right now.
Rule 148 moved the tag shape from `v26.05.22.0` to `v2026.08.28.2208`.
Lexicographically `v2026...` sorts BEFORE `v26...` — the third character is
`0` against `6` — so a sorted-list implementation reaches back past every
new-shape tag to the newest OLD-shape one and emits months of commits as
"changes since". It looks entirely correct on any repo whose tags share a
single shape, which is every repo until the day the shape changes.
Here, ancestry says `v2026.08.28.2208` and sorting says `v26.06.04.0`.
"""
out = notes("v2026.08.29.1000", cwd=shaped_history)
assert "previous=v2026.08.28.2208" in out
assert "v26.06.04.0" not in out
def test_the_body_names_the_span_it_actually_listed(shaped_history):
"""A body whose heading says "since X" over commits computed from Y is
unfalsifiable from outside — both halves read fine on their own."""
body = body_of(notes("v2026.08.29.1000", cwd=shaped_history))
assert "## Changes since v2026.08.28.2208" in body
assert "v2026.08.28.2208..v2026.08.29.1000" in body
assert "work landing in v2026.08.29.1000" in body
assert "work landing in v2026.08.28.2208" not in body
def test_merges_are_excluded_so_the_list_is_the_work(shaped_history):
"""Rule 153 merges dev into main with a plain merge commit, so `main`'s
first-parent view is nothing but "Merge pull request #N". Including those
would publish a changelog of PR numbers over the actual changes."""
body = body_of(notes("v2026.08.30.0900", cwd=shaped_history))
assert "feat: work done on the side branch" in body
assert "Merge pull request #999" not in body
def test_a_pre_convention_tag_is_history_not_a_predecessor(shaped_history):
"""The defect milestone 328 hit, and the reason the match glob narrowed.
The 28 `v26.*` tags are kept as history while their releases were deleted.
Ancestry alone happily names `v26.06.04.0` as the predecessor of the first
rule-148 tag — and then the body offers "changes since" a release that no
longer exists, over a span (533 commits in the real repo) that is the
internal build-out this milestone exists to stop publishing.
Reachable is not the same as comparable. Only a `vYYYY.` tag names a
release a reader could have been running.
"""
out = notes("v2026.08.28.2208", cwd=shaped_history)
assert "previous=<none>" in out
assert "v26.06.04.0" not in body_of(out)
def test_the_first_release_describes_the_product_instead_of_diffing(shaped_history):
"""No predecessor means nobody reading has run an earlier version, so a
changelog has no referent. The alternative the script used to take — diff
against the whole history, truncated — puts 200 lines of internal build-out
in front of someone meeting the project for the first time."""
body = body_of(notes("v2026.08.28.2208", cwd=shaped_history))
assert OVERVIEW_TEXT in body
assert "## Changes" not in body
assert not [ln for ln in body.split("\n") if ln.startswith("- work landing")]
def test_the_overview_is_readmes_words_not_a_second_copy(shaped_history):
"""Two hand-maintained descriptions of one product drift and nothing
catches it. The release page quotes README.md so there is one source."""
readme = (shaped_history / "README.md").read_text()
assert OVERVIEW_TEXT in readme
assert OVERVIEW_TEXT in body_of(notes("v2026.08.28.2208", cwd=shaped_history))
def test_a_missing_overview_block_is_reported_and_still_publishes(tmp_path):
"""Same reasoning as cross_checks(): the release is the useful object even
when part of the derivation could not run. Say what is missing, publish
anyway — do not leave the operator with a tag and no release."""
repo = tmp_path / "no-markers"
repo.mkdir()
git(repo, "init", "-q", "-b", "main")
install_script(repo, overview=False)
git(repo, "add", "-A")
git(repo, "commit", "-q", "-m", "first")
git(repo, "tag", "v2026.09.01.1200")
out = notes("v2026.09.01.1200", cwd=repo)
assert "No `<!-- overview:start -->` block found" in out
assert "No `<!-- overview:start -->` block found" in body_of(out)
def test_a_non_tag_ref_renders_but_refuses_to_claim_it_published():
"""`--dry-run HEAD` is the operator's preview before deciding to cut a tag
at all. It must not describe itself as a release that happened."""
out = notes("HEAD")
assert "which is not a tag" in out
body_of(out)
def test_the_rollback_refs_name_all_three_images():
"""Rule 145: `:c-<sha>` is the rollback unit, and the three images move
together. A release listing only the web image sends an operator into a
rollback that leaves ml and agent on the newer build — the exact mismatch
build.yml builds all three on every push to avoid."""
body = body_of(notes("HEAD"))
for image in ("fabledcurator", "fabledcurator-ml", "fabledcurator-agent"):
assert f"bvandeusen/{image}:c-" in body, f"{image} missing from the rollback refs"
def test_a_long_span_between_two_releases_is_truncated_and_says_so(tmp_path):
"""The cap is still reachable, just not by the route it used to be.
It no longer fires on "no predecessor" — that renders the overview now.
What it still guards is two real releases far enough apart that the list
stops being something anyone reads, which is the ordinary case for a
project that cuts a bookmark twice a year. The cap is part of the message,
not a silent slice.
"""
repo = tmp_path / "long"
repo.mkdir()
git(repo, "init", "-q", "-b", "main")
install_script(repo)
git(repo, "add", "-A")
git(repo, "commit", "-q", "-m", "scaffold")
git(repo, "tag", "v2026.01.01.0000")
for i in range(205):
git(repo, "commit", "-q", "--allow-empty", "-m", f"fix: change {i}")
git(repo, "tag", "v2026.07.01.0000")
out = notes("v2026.07.01.0000", cwd=repo)
assert "previous=v2026.01.01.0000" in out
body = body_of(out)
listed = [ln for ln in body.split("\n") if ln.startswith("- ")]
assert len(listed) == 200
assert "more than a changelog is for" in body