#!/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. A release candidate — v0.10.4-rc.2 — takes the "## Unreleased" section, which is where changes gather until the version they belong to is released, and is marked as a pre-release. The final v0.10.4 takes its own section once "Unreleased" has been renamed to it. Usage: GITEA_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+|Unreleased)\b.*$", re.M) CANDIDATE = re.compile(r"^v\d+\.\d+\.\d+-rc\.\d+$") 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: section = "Unreleased" if CANDIDATE.match(args.tag) else args.tag if section 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} (looked for {section!r})") notes = {args.tag: notes[section]} else: # Syncing everything: "Unreleased" belongs to whichever candidate is # newest, which only a tag says. notes.pop("Unreleased", None) 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 pre = bool(CANDIDATE.match(tag)) if (rel.get("body") or "").strip() == body.strip() and bool(rel.get("prerelease")) == pre: print(f"same {tag}") continue request("PATCH", f"{base}/{rel['id']}", token, {"body": body, "prerelease": pre}) print(f"set {tag}" + (" (pre-release)" if pre else "")) if __name__ == "__main__": main()