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
417 lines
17 KiB
Python
417 lines
17 KiB
Python
"""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-<sha>`, 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 <this tag>`, 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.
|
|
|
|
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
|
|
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 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[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.
|
|
|
|
`--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],
|
|
overview: str | None,
|
|
) -> 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))
|
|
|
|
# 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"
|
|
+ "\n".join(f"{image}:c-{short}" for image in IMAGES)
|
|
+ "\n```"
|
|
)
|
|
|
|
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:
|
|
parts.append(
|
|
heading
|
|
+ "\n\n_No non-merge commits since the previous release. This tag "
|
|
"names the same source under a new name._"
|
|
)
|
|
|
|
parts.append(
|
|
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)
|
|
|
|
|
|
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-<sha>` 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 '<none>'}")
|
|
|
|
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}")
|
|
|
|
# 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 ---")
|
|
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()
|