ci: a release publishes a changelog, not an image (318 step 7)
CI / extension-version (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-ml (push) Successful in 4s
Build images / build-agent (push) Successful in 5s
Build images / build-web (push) Successful in 4s
CI / frontend-build (push) Successful in 18s
extension / lint (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m52s
CI / extension-version (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-ml (push) Successful in 4s
Build images / build-agent (push) Successful in 5s
Build images / build-web (push) Successful in 4s
CI / frontend-build (push) Successful in 18s
extension / lint (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m52s
Step 2 took the build consequence away from a `v*` tag — `main` has already built and published the commit by the time anyone tags it, and rebuilding would re-push `:c-<sha>`, which rule 145 forbids even when the source matches. That left the tag with nothing to do at all. This is the job it has instead. Step 6 put the derived version in the Settings footer, so an operator can say WHICH build they are running; this says what is in it that was not in the one they ran last month. Both halves of one question (note #3127 §5). The previous release is found by walking ANCESTRY, not by sorting a list. That is load-bearing here specifically: rule 148 moved the tag shape from `v26.05.22.0` to `v2026.08.28.2208`, and lexicographically `v2026...` sorts BEFORE `v26...` — the third character is `0` against `6`. A sorted implementation would reach back past every new-shape tag to the newest old-shape one and publish months of commits as "changes since", looking entirely correct while doing it. `git describe --exclude` is immune to the shape change, and reachability is the more honest question anyway. The publisher GETs and PATCHes rather than POSTing and recovering the id from a 409 — note #3127 §6.7, which is ThoughtSync #2182's bug. A `v*` tag is created once so the conflict path is rare, but "rare" is how that one survived to be found somewhere else. Cross-checks are reported on the release, not enforced. The tag is already pushed by the time this runs, so failing would leave the operator with a tag, no release, and a red lane to explain it — while the release is still the useful object. It says so at the top when the tag names a version the web image does not report, or when the commit is not on `main` and the `:c-` rollback refs it lists were never published. Nothing runs on a schedule and nothing auto-tags on merge. Release tags are bookmarks (note #3127 §0); FC went twelve weeks without one and nothing was wrong. Also here: - `scripts/` joins the ruff lane. release_notes.py runs only on a tag push, so a syntax error there would otherwise surface at the one moment nobody wants to be debugging a workflow. - version.spec.js reads the workflow directory instead of listing three files by hand. Its own comment says the assertion should survive consumers coming and going; the hardcoded list was the part that could not, and release.yml would have joined the directory without joining the check. Tests build a synthetic history spanning the tag-shape change rather than leaning on this repo's tags, so the span assertion holds whether or not a checkout brought the tags along — a span test that quietly skips is worse than one that fails.
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
"""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=<none>" 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-<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_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=<none>" 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
|
||||
Reference in New Issue
Block a user