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
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
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_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
|