"""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"\n{OVERVIEW_TEXT}\n\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=" 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 `` block found" in out assert "No `` 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-` 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