Files
FabledCurator/tests/test_release_notes.py
T
bvandeusenandClaude Opus 5 ac70f2aadc
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 18s
extension / lint (push) Successful in 19s
CI / backend-lint-and-test (push) Failing after 32s
Build images / build-web (push) Successful in 1m37s
CI / integration (push) Successful in 2m11s
Build images / smoke-web (push) Successful in 42s
Build images / promote (push) Skipped
refactor: retire the fabledcurator-ml image name entirely (4311)
Operator, 2026-09-23: *"we don't need to keep this as I'll be collapsing my
stack to the same consolidated version that we're building here."*

That was the only thing holding the name up. `fabledcurator-ml` has been the
same bytes as `fabledcurator` since milestone 422 step 6, and 8152684 had
already stopped rebuilding it — this removes the name.

Gone: the whole `build-ml` job (137 more lines), its entry in promote's
`needs` and in promote's tag loop, `ML_PATHS` and the `ml)` case in
artifacts.sh, the `ml` artifact in test_artifact_paths, the image in
release_notes.py and its test, and the README row. `docker-compose.yml`'s
ml-worker service now runs `fabledcurator:latest` with `command:
["ml-worker"]` — the service still exists for anyone who wants lane
separation, it just no longer needs a second image name.

Prose that said "all three images" is now "both", except where it means
three VALUES (revision/version/epoch) or records what happened on 2026-08-30,
when there genuinely were three.

Published tags are left alone. Nothing new goes to that name; what is already
in the registry stays pullable, so a stack that has not moved yet keeps
running rather than losing its image mid-flight.

## A latent trap this made reachable, found by running the script

`artifacts.sh revision ml` did not fail. It printed the usage line to stderr
and answered with **the newest commit in the whole repository** — a
real-looking 12-char sha on stdout, exit 0.

`newest()` inlined the path set as `git log ... -- $(cmd_paths "$1")`, and
`usage` exits from the command SUBSHELL, so the substitution came back empty
and `git log HEAD --` had no pathspec left to filter by. The reuse check
would have compared that answer against a published label, missed, and
rebuilt on every push forever with nothing going red.

It could not be fixed inside `newest`, which was my first attempt: every
caller wraps it in a substitution too, so its `exit` also died in a subshell —
stdout went empty but the status stayed 0. The guard has to run in the main
shell, so it validates the artifact name at dispatch. `newest` keeps a
defensive `|| exit 2` for a future caller that reaches it another way, and
the comment says which of the two is the real check.

Latent while every name callers passed was valid. Removing `ml` from the set
is what made a name that used to work start taking the silent path, so the
test that pins it lands here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
2026-09-23 08:30:47 -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_both_images():
"""Rule 145: `:c-<sha>` is the rollback unit, and both images move
together. A release listing only the web image sends an operator into a
rollback that leaves the agent on the newer build — the exact mismatch
build.yml builds both on every push to avoid."""
body = body_of(notes("HEAD"))
for image in ("fabledcurator", "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