changelog: release notes from the commit messages, in every format a release needs
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>
This commit is contained in:
1 parent
34af870f6a
commit
3ca302921f
3 files changed
+433
-4
No files matched your search
@@ -0,0 +1,61 @@
|
||||
name: Release notes
|
||||
description: What changed since the previous tag, from the commit messages, in every format a release needs; optionally on the Releases tab
|
||||
inputs:
|
||||
prefix:
|
||||
description: The tag prefix of this kind of release (v, app-v, server-v)
|
||||
default: v
|
||||
tag:
|
||||
description: The tag being released; by default the tag that started the run, or everything since the newest tag
|
||||
default: ''
|
||||
name:
|
||||
description: The product's name in headings; the repository's name by default
|
||||
default: ''
|
||||
strip:
|
||||
description: A regular expression removed from the start of each subject
|
||||
default: ''
|
||||
packager:
|
||||
description: Name <address> written into the deb and rpm changelogs
|
||||
default: Arasaka di Hvozdikov Yevheniy <packages@arasaka.software>
|
||||
history:
|
||||
description: How many releases CHANGELOG.md and the package changelog carry
|
||||
default: '20'
|
||||
output:
|
||||
description: The folder the notes are written to
|
||||
default: release-notes
|
||||
release:
|
||||
description: "'true' also creates or updates this tag's release on the Releases tab with the notes"
|
||||
default: 'false'
|
||||
token:
|
||||
description: A token that may write this repository's releases (only with release)
|
||||
default: ${{ github.token }}
|
||||
outputs:
|
||||
dir:
|
||||
description: The folder with notes.md, notes.txt, CHANGELOG.md, changelog.yml, notes.json and store/en-US.txt
|
||||
value: ${{ steps.notes.outputs.dir }}
|
||||
notes:
|
||||
description: The path of notes.md
|
||||
value: ${{ steps.notes.outputs.notes }}
|
||||
previous:
|
||||
description: The previous tag, empty for a first release
|
||||
value: ${{ steps.notes.outputs.previous }}
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- id: notes
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_TOKEN: ${{ inputs.token }}
|
||||
NOTES_PREFIX: ${{ inputs.prefix }}
|
||||
NOTES_TAG: ${{ inputs.tag }}
|
||||
NOTES_NAME: ${{ inputs.name }}
|
||||
NOTES_STRIP: ${{ inputs.strip }}
|
||||
NOTES_PACKAGER: ${{ inputs.packager }}
|
||||
NOTES_HISTORY: ${{ inputs.history }}
|
||||
NOTES_OUTPUT: ${{ inputs.output }}
|
||||
NOTES_RELEASE: ${{ inputs.release }}
|
||||
run: |
|
||||
args=(--repo . --prefix "$NOTES_PREFIX" --name "$NOTES_NAME" --strip "$NOTES_STRIP" --packager "$NOTES_PACKAGER" \
|
||||
--history "$NOTES_HISTORY" --out "$NOTES_OUTPUT")
|
||||
[ -n "$NOTES_TAG" ] && args+=(--tag "$NOTES_TAG")
|
||||
[ "$NOTES_RELEASE" = "true" ] && args+=(--release)
|
||||
python3 "${{ github.action_path }}/changelog.py" "${args[@]}"
|
||||
@@ -0,0 +1,314 @@
|
||||
#!/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()
|
||||
Reference in new issue
Block a user