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>
116 lines
4.6 KiB
Python
Executable File
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()
|