Files
llmgw/scripts/gitea/release_notes.py
T
Ilker ManapandClaude Opus 5.5 fa7f90880d Put the changelog on the release page
Every release on Gitea was a list of binaries and nothing else. Gitea makes
an empty release for each pushed tag, and the workflow only ever attached
files to it, so what had changed was written in CHANGELOG.md and nowhere a
release was looked at.

A job after the build now copies the tag's section of the changelog onto its
release, and fails if there is none — a version nobody described should say
so where it is seen. Run by hand, it syncs every version the changelog has,
which is how the releases made before this get their notes. The changelog
stays the one place notes are written; this copies it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 10:32:58 +02:00

104 lines
3.9 KiB
Python
Executable File

#!/usr/bin/env python3
"""Put each version's CHANGELOG.md section on its Gitea release.
Gitea materialises a release for every pushed tag and leaves its body empty,
and the release workflow only ever attached binaries — so every release page
was a list of files with nothing saying what had changed. The changelog is
where that is written, and it stays the one place it is written: this copies
it, it does not replace it.
Idempotent: a release whose body already matches is left alone, a version with
no release is skipped, and nothing is created or deleted.
Usage:
GITEA_TOKEN=<token> scripts/gitea/release_notes.py [--tag v0.8.1] [--dry-run]
Without --tag, every version in the changelog is synced. The release workflow
runs it with the tag it just built; running the workflow by hand runs it for
all of them, which is how releases made before this existed get their notes.
"""
import argparse
import json
import os
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
DEFAULT_INSTANCE = "https://gitea.mynodes.xyz"
DEFAULT_REPO = "ilker/llmgw"
# "## v0.8.1 — 2026-09-28". The dash is whatever was typed; the version is what
# matters.
HEADING = re.compile(r"^## (v\d+\.\d+\.\d+)\b.*$", re.M)
def sections(changelog):
"""Map each version to the text under its heading, up to the next one."""
out = {}
marks = list(HEADING.finditer(changelog))
for i, m in enumerate(marks):
end = marks[i + 1].start() if i + 1 < len(marks) else len(changelog)
out[m.group(1)] = changelog[m.end():end].strip() + "\n"
return out
def request(method, url, token, payload=None):
data = json.dumps(payload).encode() if payload is not None else None
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Authorization", f"token {token}")
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=30) as resp:
body = resp.read()
return json.loads(body) if body else None
except urllib.error.HTTPError as e:
if e.code == 404:
return None
detail = e.read().decode(errors="replace")[:300]
raise SystemExit(f"{method} {url} failed: {e.code} {detail}") from e
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--instance", default=os.environ.get("GITEA_INSTANCE", DEFAULT_INSTANCE))
ap.add_argument("--repo", default=os.environ.get("GITEA_REPO", DEFAULT_REPO))
ap.add_argument("--changelog", default="CHANGELOG.md")
ap.add_argument("--tag", help="sync only this version")
ap.add_argument("--dry-run", action="store_true", help="show what would change")
args = ap.parse_args()
token = os.environ.get("GITEA_TOKEN", "")
if not token and not args.dry_run:
sys.exit("GITEA_TOKEN is not set (use --dry-run to preview)")
with open(args.changelog, encoding="utf-8") as f:
notes = sections(f.read())
if args.tag:
if args.tag not in notes:
# A tag with no section is a release somebody forgot to describe.
# Failing says so where it is seen; an empty page says nothing.
sys.exit(f"{args.changelog} has no section for {args.tag}")
notes = {args.tag: notes[args.tag]}
base = f"{args.instance.rstrip('/')}/api/v1/repos/{args.repo}/releases"
for tag, body in notes.items():
if args.dry_run:
print(f"would set {tag} ({len(body.splitlines())} lines)")
continue
rel = request("GET", f"{base}/tags/{urllib.parse.quote(tag)}", token)
if rel is None:
print(f"skip {tag} (no release)")
continue
if (rel.get("body") or "").strip() == body.strip():
print(f"same {tag}")
continue
request("PATCH", f"{base}/{rel['id']}", token, {"body": body})
print(f"set {tag}")
if __name__ == "__main__":
main()