From b6b9fd82879e564dad7dc03d26d174846188fdf9 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 28 Aug 2026 20:57:25 -0400 Subject: [PATCH] ci: a release publishes a changelog, not an image (318 step 7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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-`, 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. --- .forgejo/workflows/ci.yml | 5 +- .forgejo/workflows/release.yml | 80 +++++++++ extension/test/version.spec.js | 17 +- scripts/release_notes.py | 312 +++++++++++++++++++++++++++++++++ tests/test_release_notes.py | 149 ++++++++++++++++ 5 files changed, 558 insertions(+), 5 deletions(-) create mode 100644 .forgejo/workflows/release.yml create mode 100644 scripts/release_notes.py create mode 100644 tests/test_release_notes.py diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index e7f71c8..7620eca 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -35,7 +35,10 @@ jobs: - name: Ruff lint # agent/ included so the GPU-agent is linted before its image is built # (build.yml only `docker build`s it — this is where it gets checked). - run: ruff check backend/ tests/ alembic/ agent/ + # scripts/ likewise: release_notes.py runs only on a tag push, so a + # syntax or import error there would otherwise surface at the one + # moment nobody wants to debug a workflow. + run: ruff check backend/ tests/ alembic/ agent/ scripts/ - name: Agent syntax check # The agent's runtime deps (torch/transformers/ultralytics) aren't in the # CI image, so we can't import it — but compileall parses every module, diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml new file mode 100644 index 0000000..6a9bf44 --- /dev/null +++ b/.forgejo/workflows/release.yml @@ -0,0 +1,80 @@ +name: Release + +# A `v*` tag publishes a changelog. It does NOT build anything. +# +# Milestone 318 step 2 removed the tag trigger from build.yml: by the time +# anyone tags a commit, `main` has already built and published it, and a +# rebuild would re-push `:c-` — which rule 145 forbids even when the +# source matches, since image configs carry timestamps and "same source" does +# not mean "same manifest". That left the tag with no consequence at all. +# +# This is the consequence 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). +# +# Nothing here runs on a schedule and nothing auto-tags on merge. Release tags +# are bookmarks — cut one when you will want to point at that day by name, +# otherwise don't (note #3127 §0). FC went twelve weeks between v26.06.04.0 and +# the next one and nothing was wrong. A schedule would turn an optional +# bookmark back into ceremony, which is the thing this milestone is removing. +# +# Cutting the tag is an explicit operator action under rule 2 ("`main` — never +# without explicit request", which since 2026-08-28 covers PR, merge and tag +# alike). This lane only decides what happens once they do. +# +# Requires repo secret RELEASE_TOKEN with the `write:release` scope — the same +# PAT build.yml uses for the ext- XPI asset cache. + +on: + push: + tags: ['v*'] + # So a release body can be regenerated after the fact — the publisher PATCHes + # an existing release rather than falling through on a conflict, so re-running + # this on a tag rewrites the body instead of silently keeping the first one + # (note #3127 §6.7). + workflow_dispatch: + inputs: + tag: + description: 'Tag to (re)publish notes for' + required: true + +jobs: + changelog: + runs-on: python-ci + container: + image: git.fabledsword.com/bvandeusen/ci-python:3.14 + steps: + - uses: actions/checkout@v4 + with: + # Load-bearing twice over: the previous release is found by walking + # ancestry back through the tag graph, and the cross-check against + # the derived web version calls artifacts.sh, which reads commit + # times. A shallow clone would find no previous tag and emit the + # entire history as the changelog — plausible-looking and wrong. + fetch-depth: 0 + ref: ${{ github.event.inputs.tag || github.ref }} + + # The `:c-` rollback refs are only real if `main` built this commit. + # The script checks that against origin/main and downgrades the claim to + # "unverified" when it cannot resolve one; fetching it here means that + # downgrade stays an actual signal instead of firing on every release. + - name: Make main's history resolvable + run: git fetch --no-tags --quiet origin +main:refs/remotes/origin/main || true + + # TAG goes through the environment, not through `${{ }}` inside the + # run block. The value is operator-supplied, and an expression expanded + # into a shell line is expanded BEFORE the shell sees it — there is no + # quoting that makes that safe. On a tag push it is empty and the script + # falls back to GITHUB_REF. + - name: Publish the derived changelog + env: + RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} + TAG: ${{ github.event.inputs.tag }} + run: | + set -eu + if [ -n "${TAG:-}" ]; then + python3 scripts/release_notes.py "$TAG" + else + python3 scripts/release_notes.py + fi diff --git a/extension/test/version.spec.js b/extension/test/version.spec.js index 3b75088..ed8a307 100644 --- a/extension/test/version.spec.js +++ b/extension/test/version.spec.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { readFileSync } from 'node:fs' +import { readdirSync, readFileSync } from 'node:fs' import { execFileSync } from 'node:child_process' import { fileURLToPath } from 'node:url' import path from 'node:path' @@ -107,15 +107,24 @@ describe('consumers delegate rather than keeping their own copy', () => { } }) - const WORKFLOWS = ['ci.yml', 'build.yml', 'extension.yml'] + // Read from disk rather than listed by hand. The point of this assertion is + // that it survives consumers coming and going, and a hardcoded list is the + // one part of it that cannot — release.yml (milestone 318 step 7) would have + // joined the directory without joining the check. + const WORKFLOWS = readdirSync(path.join(EXT_DIR, '..', '.forgejo', 'workflows')).filter((f) => + f.endsWith('.yml') + ) it('no workflow hardcodes the packaged-file set', () => { // ci.yml used to substitute `packaging.sh pathspec` directly, for the // manual-bump guard that milestone 271 step 5 retired. Nothing inlines the // set today, and nothing should start to: a literal :(exclude)extension/... // in a workflow means someone bypassed the shared definition, which is - // exactly the drift #2397 was about. Asserted across all three rather than - // against one named consumer, so it keeps holding as consumers come and go. + // exactly the drift #2397 was about. + expect( + WORKFLOWS.length, + 'no workflows found — the glob is not looking where it thinks' + ).toBeGreaterThan(2) for (const wf of WORKFLOWS) { const text = readText('..', '.forgejo', 'workflows', wf) expect(text, `${wf} inlines an :(exclude) literal`).not.toMatch(/:\(exclude\)extension\//) diff --git a/scripts/release_notes.py b/scripts/release_notes.py new file mode 100644 index 0000000..0511d99 --- /dev/null +++ b/scripts/release_notes.py @@ -0,0 +1,312 @@ +"""Publish a Forgejo release whose body is derived from git, not written by hand. + +Milestone 318 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-`, which rule 145 forbids even when the bytes +match. That left the tag with nothing to do. This gives it the job it has left. + +**The half of the question a version string cannot answer.** Step 6 puts +`2026.08.28.2208` in the Settings footer, so an operator can say which build +they are running. They still cannot say what is in it that was not in the one +they ran last month. A dated release carrying the commits since the previous +one is the object that interprets the identifier (note #3127 §5). + +**Derived, so it cannot drift.** The alternative is a hand-maintained +`CHANGELOG.md`, which goes aspirational the first time someone forgets — and +nothing ever catches it, because there is no second source to disagree with. +Every line below comes out of `git log` at publish time. + +**Optional by construction.** Release tags are bookmarks: cut one when you will +want to point at that day by name, otherwise don't. FC went twelve weeks +without one and nothing was wrong (note #3127 §0). This runs on a tag push and +on nothing else — deliberately no schedule and no auto-tag on merge, either of +which would turn an optional bookmark back into ceremony. + +## Finding the previous release + +`git describe --exclude `, which walks ANCESTRY, not a sorted list. +That is not fussiness: this repo's existing tags are the old `v26.05.22.0` +shape and the next one will be rule 148's `v2026.08.28.2208`. Lexicographically +`v2026...` sorts BEFORE `v26...` — every release from here on would report its +predecessor as itself-or-nothing and emit a changelog covering the entire +history. Ancestry is immune to the shape change, and it is also the more honest +question: "what is in this that was not in the last one" IS a reachability +question. + +## Re-runs update, they do not fall through + +Note #3127 §6.7: a publisher that POSTs and recovers the id from a `409` never +rewrites the body, so a re-run silently keeps the first version. Harmless for a +`v*` tag created once — and wrong the moment anything re-points. This one GETs +first and PATCHes when the release exists, so it is correct either way rather +than correct by luck (ThoughtSync #2182 is the same bug). +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +import urllib.error +import urllib.request + +API = "https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator" + +IMAGES = ( + "git.fabledsword.com/bvandeusen/fabledcurator", + "git.fabledsword.com/bvandeusen/fabledcurator-ml", + "git.fabledsword.com/bvandeusen/fabledcurator-agent", +) + +# Rule 148: `v` + the artifact's own version, zero-padded, no `.N`, no lookup. +RULE_148 = re.compile(r"^v\d{4}\.\d{2}\.\d{2}\.\d{4}$") + +# Past this, the list has stopped being something anyone reads. It is reached +# in exactly one situation — no previous tag is reachable, so the span is the +# whole history — which happens on a genuine first release and on a tag cut +# somewhere `main`'s tags cannot be seen from. Truncating says so; emitting +# 1100 lines would bury the note explaining why there are 1100 of them. +MAX_COMMITS = 200 + + +def git(*args: str) -> str: + return subprocess.run( + ["git", *args], capture_output=True, text=True, check=True + ).stdout.strip() + + +def git_ok(*args: str) -> str | None: + """Run git, returning None instead of raising when it fails. + + Used for the questions that legitimately have no answer — no previous tag, + no local `main` — where the absence is information rather than a fault. + """ + try: + return git(*args) + except subprocess.CalledProcessError: + return None + + +def previous_tag(ref: str, tag: str | None) -> str | None: + """The most recent `v*` tag reachable from `ref`, excluding `tag` itself. + + `--exclude` rather than `^` so this is the same call whether or not + `ref` is the tag being released — and so it does not blow up on a root + commit that has no parent to walk to. + """ + args = ["describe", "--tags", "--abbrev=0", "--match", "v*"] + if tag: + args += ["--exclude", tag] + return git_ok(*args, ref) + + +def commits(previous: str | None, ref: str) -> list[str]: + """The subjects between the previous release and this one. + + `--no-merges` because rule 153 merges `dev` into `main` with a plain merge + commit, so `main`'s first-parent view is a list of "Merge pull request #N" + and nothing else. The work is in the commits under those merges. + """ + span = f"{previous}..{ref}" if previous else ref + out = git("log", "--no-merges", "--format=%s (%h)", span) + return [line for line in out.split("\n") if line.strip()] + + +def truncate(log: list[str]) -> tuple[list[str], str | None]: + if len(log) <= MAX_COMMITS: + return log, None + return log[:MAX_COMMITS], ( + f"{len(log)} commits in this span — more than a changelog is for. " + f"Listing the newest {MAX_COMMITS}. This usually means no previous " + f"`v*` tag was reachable from here." + ) + + +def render(tag: str, sha: str, previous: str | None, log: list[str], notes: list[str]) -> str: + short = sha[:7] + parts = [] + + if notes: + # Anything the derivation could not stand behind goes at the TOP, not + # in a footnote. A release that quietly names a build nobody can find + # is the failure this whole milestone is about. + parts.append("\n".join(f"> **Note:** {n}" for n in notes)) + + parts.append( + f"Built from `{short}`. The rollback unit is the immutable `:c-` tag " + f"(rule 145) — these three move together:\n\n```\n" + + "\n".join(f"{image}:c-{short}" for image in IMAGES) + + "\n```" + ) + + heading = f"## Changes since {previous}" if previous else "## Changes" + if log: + parts.append(heading + "\n\n" + "\n".join(f"- {line}" for line in log)) + else: + parts.append( + heading + + "\n\n_No non-merge commits since the previous release. This tag " + "names the same source under a new name._" + ) + + span = f"{previous}..{tag}" if previous else tag + parts.append( + f"---\n\n_Derived at publish time from `git log --no-merges {span}`. " + f"Nothing here is hand-maintained._" + ) + return "\n\n".join(parts) + + +def cross_checks(tag: str, sha: str) -> list[str]: + """Everything the derivation knows that would make the release a lie. + + Reported rather than enforced. The tag is already pushed by the time this + runs, so failing here would leave the operator with a tag and no release + and nothing but a red lane to explain it — while the release itself is + still the useful object. Say what is wrong, on the release, and publish. + """ + notes = [] + + if not RULE_148.match(tag): + notes.append( + f"`{tag}` is not rule 148's `vYYYY.MM.DD.HHMM` shape. Published " + f"anyway — the old `v26.*` tags predate the rule." + ) + else: + derived = artifact_version("web") + if derived and derived != tag[1:]: + notes.append( + f"This tag names `{tag[1:]}`, but the web image built from " + f"`{sha[:7]}` reports `{derived}`. The Settings footer will not " + f"match this release's name." + ) + + # `:c-` only exists if `main` built this commit. Checking costs one + # git call; claiming it without checking costs a rollback that 404s at the + # moment someone needs it. + main = git_ok("rev-parse", "--verify", "-q", "refs/remotes/origin/main") + if main is None: + notes.append( + "Could not resolve `origin/main` here, so the `:c-` tags above are " + "unverified — they exist only if `main` built this commit." + ) + elif subprocess.run( + ["git", "merge-base", "--is-ancestor", sha, main], capture_output=True + ).returncode != 0: + notes.append( + f"`{sha[:7]}` is not on `main`, so no `:c-{sha[:7]}` images were " + f"ever published. The refs above will not pull." + ) + + return notes + + +def artifact_version(artifact: str) -> str | None: + """What `artifacts.sh` derives for one artifact in the CURRENT checkout. + + It takes no ref because `artifacts.sh` takes none — it walks history from + HEAD. That is right here only because a tag push checks out the tagged + commit; calling this after `--dry-run some-other-ref` would compare the + tag against the working tree, which is why the mismatch note below is + reported and not enforced. + + Returns None rather than raising if the script is missing or unhappy: a + cross-check that cannot run should not take the release down with it. + """ + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + try: + return subprocess.run( + ["sh", os.path.join(root, "scripts", "artifacts.sh"), "version", artifact], + capture_output=True, text=True, check=True, cwd=root, + ).stdout.strip() + except (subprocess.CalledProcessError, OSError): + return None + + +def api(method: str, path: str, token: str, payload: dict | None = None) -> dict | None: + body = json.dumps(payload).encode() if payload is not None else None + req = urllib.request.Request( + API + path, data=body, method=method, + headers={ + "Authorization": "token " + token, + "Content-Type": "application/json", + }, + ) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + return json.load(resp) + except urllib.error.HTTPError as exc: + if exc.code == 404: + return None + sys.exit(f"release: {method} {path} failed with HTTP {exc.code}: {exc.read()!r}") + + +def publish(tag: str, name: str, body: str, token: str) -> None: + existing = api("GET", f"/releases/tags/{tag}", token) + if existing: + api("PATCH", f"/releases/{existing['id']}", token, {"name": name, "body": body}) + print(f"release: updated existing release {existing['id']} for {tag}") + else: + api("POST", "/releases", token, {"tag_name": tag, "name": name, "body": body}) + print(f"release: created release for {tag}") + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument( + "ref", nargs="?", default=None, + help="tag or commit to release. Defaults to GITHUB_REF's tag, else HEAD.", + ) + ap.add_argument( + "--dry-run", action="store_true", + help="render the body to stdout and publish nothing. Needs no token, " + "so it also works as a preview before you decide to cut the tag.", + ) + args = ap.parse_args() + + github_ref = os.environ.get("GITHUB_REF", "") + if args.ref: + ref = args.ref + elif github_ref.startswith("refs/tags/"): + ref = github_ref[len("refs/tags/"):] + else: + ref = "HEAD" + + # A tag only if git knows it as one — `HEAD` and a raw sha are refs to + # release FROM, never the name to exclude or to publish under. + tag = ref if git_ok("rev-parse", "--verify", "-q", f"refs/tags/{ref}") else None + sha = git("rev-parse", ref) + previous = previous_tag(ref, tag) + + print(f"release: ref={ref} sha={sha[:12]} previous={previous or ''}") + + notes = cross_checks(tag, sha) if tag else [ + f"Rendered for `{ref}`, which is not a tag. Nothing was published." + ] + for note in notes: + print(f"release: NOTE {note}") + + log = commits(previous, ref) + print(f"release: {len(log)} non-merge commits in the span") + log, overflow = truncate(log) + if overflow: + print(f"release: NOTE {overflow}") + notes.append(overflow) + body = render(tag or ref, sha, previous, log, notes) + + if args.dry_run or not tag: + print("--- body ---") + print(body) + return + + token = os.environ.get("RELEASE_TOKEN") or os.environ.get("TOKEN") + if not token: + sys.exit("release: no RELEASE_TOKEN in the environment") + publish(tag, f"FabledCurator {tag[1:]}", body, token) + + +if __name__ == "__main__": + main() diff --git a/tests/test_release_notes.py b/tests/test_release_notes.py new file mode 100644 index 0000000..129be14 --- /dev/null +++ b/tests/test_release_notes.py @@ -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=" 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-` 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=" 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