ci: a release publishes a changelog, not an image (318 step 7)
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
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.
This commit is contained in:
@@ -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-<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.
|
||||
|
||||
## 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 `<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.
|
||||
"""
|
||||
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-<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}")
|
||||
|
||||
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()
|
||||
Reference in New Issue
Block a user