CI / test (push) Successful in 1m42s
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>
104 lines
3.9 KiB
Python
Executable File
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()
|