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
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
234 lines
10 KiB
Python
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
|