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.
150 lines
6.5 KiB
Python
150 lines
6.5 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:
|
|
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
|