release: the first release describes the product; it has nothing to diff against
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
This commit is contained in:
2026-09-02 12:28:48 -04:00
co-authored by Claude Opus 5
parent 3590c478f5
commit c0370069e0
3 changed files with 224 additions and 33 deletions
+118 -14
View File
@@ -33,6 +33,32 @@ 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.
Ancestry alone is not enough, though, and milestone 328 is where that showed.
The 28 `v26.*` tags are still in the repo — the operator kept them as history
when their releases were deleted — so `--match v*` walks straight back to
`v26.06.04.0` and reports 533 commits. That span is not a changelog: nobody has
run `v26.06.04.0`, its release page no longer exists to compare against, and
the 200 lines that survive truncation are precisely the internal build-out that
milestone 328 exists to stop shipping. So the match is `v[0-9][0-9][0-9][0-9].*`
— rule 148's four-digit-year shape — which is exactly the set of tags that name
a release a reader could have been running. A pre-convention tag is history,
not a predecessor.
## The first release has no changelog, and should not pretend to
Once the match is narrowed, the first rule-148 tag reaches no predecessor at
all, and the old fallback — diff against the whole history — is worse than the
problem it replaced. The honest content for a release nobody has a previous
version of is what the thing IS.
So a release with no reachable predecessor renders the product overview instead
of a commit list. It is read out of README.md between `<!-- overview:start -->`
and `<!-- overview:end -->` rather than written here, 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. Every later release goes back to being a changelog, which is what §5 of
note #3127 says a release is for.
## Re-runs update, they do not fall through
Note #3127 §6.7: a publisher that POSTs and recovers the id from a `409` never
@@ -91,18 +117,45 @@ def git_ok(*args: str) -> str | None:
def previous_tag(ref: str, tag: str | None) -> str | None:
"""The most recent `v*` tag reachable from `ref`, excluding `tag` itself.
"""The most recent rule-148 tag reachable from `ref`, excluding `tag` itself.
`--exclude` rather than `<ref>^` 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.
The glob deliberately does NOT match the old `v26.*` tags. They are kept as
history and their releases are gone, so naming one as the predecessor emits
a span nobody can look up. See the module docstring.
"""
args = ["describe", "--tags", "--abbrev=0", "--match", "v*"]
args = ["describe", "--tags", "--abbrev=0", "--match", "v[0-9][0-9][0-9][0-9].*"]
if tag:
args += ["--exclude", tag]
return git_ok(*args, ref)
def product_overview() -> str | None:
"""The product description, lifted verbatim from README.md.
Returns None if the markers are absent or empty — a missing overview is
reported as a note and the release still publishes, on the same reasoning
as cross_checks(): the release is the useful object even when one part of
the derivation could not run.
"""
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
try:
with open(os.path.join(root, "README.md"), encoding="utf-8") as fh:
readme = fh.read()
except OSError:
return None
match = re.search(
r"<!--\s*overview:start\s*-->(.*?)<!--\s*overview:end\s*-->",
readme, re.S,
)
if not match:
return None
return match.group(1).strip() or None
def commits(previous: str | None, ref: str) -> list[str]:
"""The subjects between the previous release and this one.
@@ -125,7 +178,10 @@ def truncate(log: list[str]) -> tuple[list[str], str | None]:
)
def render(tag: str, sha: str, previous: str | None, log: list[str], notes: list[str]) -> str:
def render(
tag: str, sha: str, previous: str | None, log: list[str], notes: list[str],
overview: str | None,
) -> str:
short = sha[:7]
parts = []
@@ -135,6 +191,25 @@ def render(tag: str, sha: str, previous: str | None, log: list[str], notes: list
# is the failure this whole milestone is about.
parts.append("\n".join(f"> **Note:** {n}" for n in notes))
# No predecessor means nobody reading this has run an earlier one, so the
# release describes the product rather than a diff. The overview is
# README.md's own words — see the module docstring on why it is not
# written here.
if previous is None and overview:
parts.append(overview)
parts.append(
"## Installing\n\n"
"```\ncurl -O https://git.fabledsword.com/bvandeusen/FabledCurator/raw/"
f"tag/{tag}/docker-compose.yml\ncurl -O https://git.fabledsword.com/"
f"bvandeusen/FabledCurator/raw/tag/{tag}/.env.example\n"
"mv .env.example .env # then set SECRET_KEY, DB_PASSWORD\n"
"docker compose -f docker-compose.yml up -d\n```\n\n"
"**Read \"Before you expose it\" in the README first.** FabledCurator "
"has no login, and it stores live platform session cookies for "
"accounts that usually have a payment method attached. Bind it to a "
"network you trust."
)
parts.append(
f"Built from `{short}`. The rollback unit is the immutable `:c-` tag "
f"(rule 145) — these three move together:\n\n```\n"
@@ -142,7 +217,19 @@ def render(tag: str, sha: str, previous: str | None, log: list[str], notes: list
+ "\n```"
)
heading = f"## Changes since {previous}" if previous else "## Changes"
if previous is None:
# Deliberately NOT a commit list. The alternative is the whole history
# truncated to MAX_COMMITS, which is 200 lines of internal build-out
# presented to someone who has never seen this project.
parts.append(
"---\n\n_First release under rule 148's `vYYYY.MM.DD.HHMM` shape, so "
"there is no predecessor to diff against and no changelog to derive. "
"The description above is README.md's, quoted at publish time. Later "
"releases carry the commits since the previous one._"
)
return "\n\n".join(parts)
heading = f"## Changes since {previous}"
if log:
parts.append(heading + "\n\n" + "\n".join(f"- {line}" for line in log))
else:
@@ -152,10 +239,9 @@ def render(tag: str, sha: str, previous: str | None, log: list[str], notes: list
"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._"
f"---\n\n_Derived at publish time from "
f"`git log --no-merges {previous}..{tag}`. Nothing here is hand-maintained._"
)
return "\n\n".join(parts)
@@ -289,13 +375,31 @@ def main() -> None:
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)
# A first release renders the overview instead of a changelog, so the
# commit walk is skipped entirely rather than computed and discarded —
# `commits(None, ref)` is the whole history and there is no reason to ask
# for it.
overview = None
log: list[str] = []
if previous is None:
overview = product_overview()
if overview is None:
note = (
"No `<!-- overview:start -->` block found in README.md, so this "
"first release has no product description. Published anyway; add "
"the markers and re-run the workflow to fill it in."
)
print(f"release: NOTE {note}")
notes.append(note)
print("release: no rule-148 predecessor — rendering the product overview")
else:
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, overview)
if args.dry_run or not tag:
print("--- body ---")