Files
Ilker ManapandClaude Opus 5.5 5dac1854c2 Publish small fixes as release candidates of the next version
Every fix so far became its own patch release, and the natural alternative —
v0.10.3-a — sorts before v0.10.3 by SemVer's rules: a hyphen marks a
pre-release, so every tool that orders versions would take the fix for the
older one. A release candidate of the next version, v0.10.4-rc.1, sorts
after v0.10.3 and before v0.10.4, which is what it is.

CI already builds any v* tag. Its release notes now come from a
"## Unreleased" section in CHANGELOG.md, where changes gather until the
version they belong to is released, and the release is marked as a
pre-release. Onboarding, in Go and in the shell script, counts a candidate
as a release and downloads it rather than copying the operator's binary.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 01:00:11 +02:00

116 lines
4.6 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.
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=<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()