The commits since the previous tag of the same kind become notes.md, notes.txt, a CHANGELOG.md of the last releases, an nfpm changelog for the deb and rpm, notes.json and 500-character store notes; with release: 'true' they also go onto the tag's release on the Releases tab, inside a marked block that leaves the rest of the text alone. The same file reads the history from Gitea's API, for the packages publisher. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
315 lines
14 KiB
Python
315 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
import argparse
|
|
import datetime
|
|
import email.utils
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
TRAILER = re.compile(r"^(co-authored-by|signed-off-by|reviewed-by|helped-by|claude-[\w-]+):", re.I)
|
|
GENERATED = re.compile(r"generated with \[?claude code", re.I)
|
|
FIRST = 100
|
|
STORE = 500
|
|
START = "<!-- changelog -->"
|
|
END = "<!-- /changelog -->"
|
|
|
|
|
|
def version_key(text):
|
|
return tuple(int(part) for part in re.findall(r"\d+", text))
|
|
|
|
|
|
def versioned(tag, prefix):
|
|
return tag.startswith(prefix) and re.match(r"\d", tag[len(prefix):]) is not None
|
|
|
|
|
|
def clean(message, strip):
|
|
lines = message.replace("\r", "").split("\n")
|
|
subject = lines[0].strip()
|
|
if strip:
|
|
subject = re.sub(strip, "", subject).strip()
|
|
if subject:
|
|
subject = subject[0].upper() + subject[1:]
|
|
body = [line.rstrip() for line in lines[1:] if not TRAILER.match(line.strip()) and not GENERATED.search(line)]
|
|
while body and not body[0]:
|
|
body.pop(0)
|
|
while body and not body[-1]:
|
|
body.pop()
|
|
return subject, "\n".join(body)
|
|
|
|
|
|
class Git:
|
|
def __init__(self, repo):
|
|
self.repo = repo
|
|
|
|
def run(self, *args):
|
|
return subprocess.run(["git", "-C", str(self.repo), *args], check=True, text=True, capture_output=True).stdout
|
|
|
|
def complete(self):
|
|
shallow = self.run("rev-parse", "--is-shallow-repository").strip() == "true"
|
|
args = ["fetch", "--quiet", "--tags", "--force", "origin"] + (["--unshallow"] if shallow else [])
|
|
done = subprocess.run(["git", "-C", str(self.repo), *args], text=True, capture_output=True)
|
|
if done.returncode != 0:
|
|
print(f"::warning::could not fetch the full history and tags ({done.stderr.strip()[:200]}); using what the checkout has", file=sys.stderr)
|
|
|
|
def tags(self, prefix):
|
|
found = {}
|
|
for line in self.run("tag", "--list", f"{prefix}*", "--format=%(refname:short)").splitlines():
|
|
if versioned(line, prefix):
|
|
found[line] = self.run("log", "-1", "--format=%cI", f"{line}^{{commit}}").strip()
|
|
return found
|
|
|
|
def head_date(self):
|
|
return self.run("log", "-1", "--format=%cI", "HEAD").strip()
|
|
|
|
def commits(self, base, head):
|
|
span = f"{base}..{head}" if base else head
|
|
log = self.run("log", "--no-merges", "--reverse", f"--max-count={FIRST if not base else 100000}", "--format=%H%x1f%cI%x1f%B%x1e", span)
|
|
entries = []
|
|
for entry in log.split("\x1e"):
|
|
entry = entry.strip("\n")
|
|
if entry:
|
|
sha, date, message = entry.split("\x1f", 2)
|
|
entries.append((sha, date, message))
|
|
return entries
|
|
|
|
|
|
class Gitea:
|
|
def __init__(self, url, repository, token):
|
|
self.base = f"{url.rstrip('/')}/api/v1/repos/{repository}"
|
|
self.headers = {"Authorization": f"token {token}"} if token else {}
|
|
|
|
def get(self, path):
|
|
request = urllib.request.Request(f"{self.base}{path}", headers=self.headers)
|
|
with urllib.request.urlopen(request, timeout=60) as answer:
|
|
return json.load(answer)
|
|
|
|
def tags(self, prefix):
|
|
found = {}
|
|
for page in range(1, 40):
|
|
batch = self.get(f"/tags?limit=50&page={page}")
|
|
for tag in batch:
|
|
if versioned(tag["name"], prefix):
|
|
found[tag["name"]] = tag["commit"].get("created") or ""
|
|
if len(batch) < 50:
|
|
break
|
|
return found
|
|
|
|
def commits(self, base, head):
|
|
entries = []
|
|
query = {"sha": head, "limit": 50, "stat": "false", "verification": "false", "files": "false"}
|
|
if base:
|
|
query["not"] = base
|
|
for page in range(1, 200):
|
|
batch = self.get(f"/commits?{urllib.parse.urlencode({**query, 'page': page})}")
|
|
for item in batch:
|
|
if len(item.get("parents") or []) > 1:
|
|
continue
|
|
entries.append((item["sha"], item["commit"]["committer"]["date"], item["commit"]["message"]))
|
|
if len(batch) < 50 or (not base and len(entries) >= FIRST):
|
|
break
|
|
return list(reversed(entries[:FIRST] if not base else entries))
|
|
|
|
|
|
def history(source, prefix, tag, count, strip):
|
|
dates = source.tags(prefix)
|
|
ordered = sorted(dates, key=version_key)
|
|
if tag and tag not in dates:
|
|
raise SystemExit(f"the tag {tag} does not exist")
|
|
if tag:
|
|
newer = ordered.index(tag)
|
|
heads = [(t, t[len(prefix):], dates[t]) for t in reversed(ordered[:newer + 1])]
|
|
else:
|
|
heads = [("HEAD", "unreleased", source.head_date())] + [(t, t[len(prefix):], dates[t]) for t in reversed(ordered)]
|
|
releases = []
|
|
for position, (head, version, date) in enumerate(heads[:count]):
|
|
previous = heads[position + 1][0] if position + 1 < len(heads) else None
|
|
changes = []
|
|
for sha, when, message in source.commits(previous, head):
|
|
subject, body = clean(message, strip)
|
|
if subject:
|
|
changes.append({"sha": sha, "date": when, "subject": subject, "body": body})
|
|
releases.append({"tag": head, "version": version, "date": date, "previous": previous, "changes": changes})
|
|
return releases
|
|
|
|
|
|
def since(release):
|
|
count = len(release["changes"])
|
|
if release["previous"] is None:
|
|
return f"The first release, with {count} change{'s' * (count != 1)}" + (f" (the newest {FIRST})" if count >= FIRST else "") + "."
|
|
return f"{count} change{'s' * (count != 1)} since {release['previous']}."
|
|
|
|
|
|
def markdown(name, release, level=2):
|
|
lines = [f"{'#' * level} What's new in {name} {release['version']}", "", since(release), ""]
|
|
for change in release["changes"]:
|
|
lines += [f"{'#' * (level + 1)} {change['subject']}", ""]
|
|
if change["body"]:
|
|
lines += [change["body"], ""]
|
|
return "\n".join(lines).rstrip() + "\n"
|
|
|
|
|
|
def changelog(name, releases):
|
|
return f"# {name}: changes\n\n" + "\n".join(markdown(name, release, 2) for release in releases)
|
|
|
|
|
|
def text(release):
|
|
parts = []
|
|
for change in release["changes"]:
|
|
parts.append(change["subject"] + (f"\n{change['body']}" if change["body"] else ""))
|
|
return ("\n\n".join(parts) or since(release)) + "\n"
|
|
|
|
|
|
def store(release, limit=STORE):
|
|
lines = []
|
|
for change in release["changes"]:
|
|
line = f"• {change['subject']}"
|
|
if len("\n".join(lines + [line])) > limit - 2:
|
|
lines.append("…")
|
|
break
|
|
lines.append(line)
|
|
return ("\n".join(lines) or since(release))[:limit] + "\n"
|
|
|
|
|
|
def paragraphs(body):
|
|
joined = []
|
|
for block in re.split(r"\n\s*\n", body):
|
|
current = []
|
|
for line in block.splitlines():
|
|
stripped = line.strip()
|
|
if re.match(r"^([-*•]|\d+[.)])\s", stripped) and current:
|
|
joined.append(" ".join(current))
|
|
current = []
|
|
if stripped:
|
|
current.append(stripped)
|
|
if current:
|
|
joined.append(" ".join(current))
|
|
return joined
|
|
|
|
|
|
def iso(date):
|
|
return datetime.datetime.fromisoformat(date.replace("Z", "+00:00")).astimezone(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
|
|
|
|
def chglog(releases, packager):
|
|
out = []
|
|
for release in releases:
|
|
if release["version"] == "unreleased" or not release["changes"]:
|
|
continue
|
|
out += [f"- semver: {json.dumps(release['version'])}", f" date: {json.dumps(iso(release['date']))}",
|
|
f" packager: {json.dumps(packager)}", " deb:", " urgency: medium", " distributions: [stable]", " changes:"]
|
|
for change in release["changes"]:
|
|
note = "\n".join([change["subject"], *paragraphs(change["body"])])
|
|
out += [f" - commit: {json.dumps(change['sha'])}", f" note: {json.dumps(note, ensure_ascii=False)}"]
|
|
return "\n".join(out) + "\n" if out else "[]\n"
|
|
|
|
|
|
def debian(package, revision, releases, packager):
|
|
blocks = []
|
|
for release in releases:
|
|
if release["version"] == "unreleased":
|
|
continue
|
|
version = release["version"] + (f"-{revision}" if revision and "-" not in release["version"] else "")
|
|
lines = [f"{package} ({version}) stable; urgency=medium", ""]
|
|
for change in release["changes"] or [{"subject": since(release), "body": ""}]:
|
|
lines.append(f" * {change['subject']}")
|
|
lines += [f" {paragraph}" for paragraph in paragraphs(change["body"])]
|
|
when = datetime.datetime.fromisoformat(release["date"].replace("Z", "+00:00"))
|
|
lines += ["", f" -- {packager} {email.utils.format_datetime(when)}", ""]
|
|
blocks.append("\n".join(lines))
|
|
return "\n".join(blocks)
|
|
|
|
|
|
def write(out, name, releases, packager):
|
|
out.mkdir(parents=True, exist_ok=True)
|
|
newest = releases[0]
|
|
(out / "notes.md").write_text(markdown(name, newest))
|
|
(out / "notes.txt").write_text(text(newest))
|
|
(out / "CHANGELOG.md").write_text(changelog(name, releases))
|
|
(out / "changelog.yml").write_text(chglog(releases, packager))
|
|
(out / "notes.json").write_text(json.dumps({"name": name, **newest, "history": releases[1:]}, indent=1, ensure_ascii=False) + "\n")
|
|
(out / "store").mkdir(exist_ok=True)
|
|
(out / "store" / "en-US.txt").write_text(store(newest))
|
|
|
|
|
|
def release_page(server, repository, token, tag, title, notes):
|
|
base = f"{server.rstrip('/')}/api/v1/repos/{repository}/releases"
|
|
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
|
|
|
|
def call(method, url, body=None):
|
|
request = urllib.request.Request(url, method=method, headers=headers, data=json.dumps(body).encode() if body is not None else None)
|
|
with urllib.request.urlopen(request, timeout=60) as answer:
|
|
return json.load(answer)
|
|
|
|
block = f"{START}\n{notes.rstrip()}\n{END}"
|
|
try:
|
|
existing = call("GET", f"{base}/tags/{urllib.parse.quote(tag)}")
|
|
except urllib.error.HTTPError as error:
|
|
if error.code != 404:
|
|
raise
|
|
existing = None
|
|
if existing is None:
|
|
made = call("POST", base, {"tag_name": tag, "name": title, "body": block, "draft": False, "prerelease": False})
|
|
return f"created the release {tag} ({made['html_url']})"
|
|
body = existing.get("body") or ""
|
|
if START in body and END in body:
|
|
body = body[:body.index(START)] + block + body[body.index(END) + len(END):]
|
|
else:
|
|
body = (body.rstrip() + "\n\n" + block) if body.strip() else block
|
|
call("PATCH", f"{base}/{existing['id']}", {"body": body})
|
|
return f"updated the notes of the release {tag} ({existing['html_url']})"
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Release notes from the commit messages since the previous tag, in every format a release needs")
|
|
parser.add_argument("--repo", default=".", help="a git checkout (fetched in full when shallow)")
|
|
parser.add_argument("--gitea", help="read the history from this Gitea instead of a checkout")
|
|
parser.add_argument("--repository", default=os.environ.get("GITHUB_REPOSITORY", ""), help="owner/name on Gitea")
|
|
parser.add_argument("--prefix", default="v", help="the tag prefix of this kind of release (v, app-v, server-v)")
|
|
parser.add_argument("--tag", default=os.environ.get("GITHUB_REF_NAME", "") if os.environ.get("GITHUB_REF_TYPE") == "tag" else "",
|
|
help="the tag being released; empty means everything since the newest tag")
|
|
parser.add_argument("--name", default="", help="the product's name in headings; the repository's name by default")
|
|
parser.add_argument("--strip", default="", help="a regular expression removed from the start of each subject")
|
|
parser.add_argument("--packager", default="Arasaka di Hvozdikov Yevheniy <packages@arasaka.software>", help="Name <address> for deb and rpm changelogs")
|
|
parser.add_argument("--history", type=int, default=20, help="how many releases CHANGELOG.md and the package changelog carry")
|
|
parser.add_argument("--out", default="release-notes")
|
|
parser.add_argument("--release", action="store_true", help="also create or update this tag's release on the Releases tab with the notes")
|
|
args = parser.parse_args()
|
|
token = os.environ.get("RELEASE_TOKEN") or os.environ.get("READ_TOKEN", "")
|
|
name = args.name or (args.repository.split("/")[-1] if args.repository else Path(args.repo).resolve().name)
|
|
if args.gitea:
|
|
source = Gitea(args.gitea, args.repository, token)
|
|
else:
|
|
source = Git(args.repo)
|
|
source.complete()
|
|
if args.tag and not versioned(args.tag, args.prefix):
|
|
raise SystemExit(f"{args.tag} is not a {args.prefix}<version> tag")
|
|
releases = history(source, args.prefix, args.tag, max(args.history, 1), args.strip)
|
|
out = Path(args.out)
|
|
write(out, name, releases, args.packager)
|
|
newest = releases[0]
|
|
print(f"{name} {newest['version']}: {since(newest)} Notes in {out}/ (notes.md, notes.txt, CHANGELOG.md, changelog.yml, notes.json, store/en-US.txt)")
|
|
summary = os.environ.get("GITHUB_STEP_SUMMARY")
|
|
if summary:
|
|
with open(summary, "a") as stream:
|
|
stream.write(markdown(name, newest) + "\n")
|
|
output = os.environ.get("GITHUB_OUTPUT")
|
|
if output:
|
|
with open(output, "a") as stream:
|
|
stream.write(f"dir={out.resolve()}\nnotes={(out / 'notes.md').resolve()}\nprevious={newest['previous'] or ''}\n")
|
|
if args.release:
|
|
if newest["version"] == "unreleased":
|
|
print("not a tag: nothing goes on the Releases tab")
|
|
return
|
|
server = args.gitea or os.environ.get("GITHUB_SERVER_URL", "https://git.thepra.dev")
|
|
print(release_page(server, args.repository, token, newest["tag"], f"{name} {newest['version']}", markdown(name, newest)))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|