"""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. 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 `` and `` 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 `^` 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"(.*?)", 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-` 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}") # 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 `` 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()