"""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: return subprocess.run( ["python3", str(SCRIPT), "--dry-run", *args], capture_output=True, text=True, check=True, cwd=cwd or ROOT, ).stdout 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") 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", "f.txt") 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_the_first_release_still_renders_with_nothing_behind_it(shaped_history): """No previous tag is reachable from the oldest one. That is a real state, not an error, and it must not take the release down with it.""" out = notes("v26.06.04.0", cwd=shaped_history) assert "previous=" in out assert "## Changes" 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_an_unbounded_span_is_truncated_and_says_so(): """With no reachable previous tag the span is the whole history. Emitting eleven hundred lines would bury the one line explaining why there are eleven hundred of them, so the cap is part of the message, not a silent slice.""" out = notes("HEAD") if "previous=" not in out: pytest.skip("a previous tag is reachable from HEAD in this checkout") 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