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:
thepraandClaude Opus 5.5 committed 2026-11-01 18:05:18 +01:00
1 parent 34af870f6a
commit 3ca302921f
3 files changed
+433 -4

No files matched your search

+58 -4
View File
@@ -1,13 +1,15 @@
# app-stores # app-stores
Two Gitea Actions that publish an app from any project's release workflow: `play` for Google Play and `apple` for Three Gitea Actions for any project's release workflow: `play` publishes to Google Play, `apple` to the App Store,
the App Store. Each is one Python file talking to the store's own web API: Google's Play Developer API and Apple's and `changelog` writes what changed since the previous release from the commit messages, in every format a release
App Store Connect API. Nothing else is needed on the runner beyond Python 3.9 or newer and `openssl`, which every needs. Each is one Python file; the store ones talk to the store's own web API (Google's Play Developer API, Apple's
App Store Connect API). Nothing else is needed on the runner beyond Python 3.9 or newer and `openssl`, which every
Linux and Mac runner has. No fastlane, no Ruby, no service in between. Linux and Mac runner has. No fastlane, no Ruby, no service in between.
```yaml ```yaml
- uses: https://git.thepra.dev/Arasaka/app-stores/play@v1 - uses: https://git.thepra.dev/Arasaka/app-stores/play@v1
- uses: https://git.thepra.dev/Arasaka/app-stores/apple@v1 - uses: https://git.thepra.dev/Arasaka/app-stores/apple@v1
- uses: https://git.thepra.dev/Arasaka/app-stores/changelog@v1
``` ```
> Status: checked against fakes of both stores built from their published API definitions (Google's discovery > Status: checked against fakes of both stores built from their published API definitions (Google's discovery
@@ -148,7 +150,59 @@ Neither store lets an API create an app, so the first steps happen in their cons
6. Create the TestFlight groups. An external group needs the Beta App Review information (contact and notes) 6. Create the TestFlight groups. An external group needs the Beta App Review information (contact and notes)
filled in once. filled in once.
## Notes ## Release notes: `changelog`
**The commit messages are the changelog** (owner, 1 November 2026: every commit between two releases, the whole
message, in every place a release is shown). The action takes the commits since the previous tag of the same kind
(`prefix`: `v`, `app-v`, `server-v`; the previous one is the highest lower version among those tags), leaves out merges
and the `Co-Authored-By`, `Signed-off-by` and Claude attribution lines, and writes, into `output`:
| File | For |
|---|---|
| `notes.md` | "What's new in <name> <version>": each commit's subject as a heading, its body below; the Releases tab, a page |
| `notes.txt` | the same as plain text: Velopack's `--releaseNotes`, an update dialog, F-Droid |
| `CHANGELOG.md` | the last `history` releases, newest first |
| `changelog.yml` | the same releases for nfpm's `changelog:` (chglog), so the deb carries `changelog.Debian.gz` and the rpm its `%changelog`; bodies become one line per paragraph |
| `notes.json` | everything above as data |
| `store/en-US.txt` | the subjects, cut to 500 characters, for the `notes` folder of `play` and `apple` |
With `release: 'true'` it also writes the notes onto this tag's release on the Releases tab: it creates the release
when there is none, and otherwise replaces only what sits between `<!-- changelog -->` and `<!-- /changelog -->`
(adding that block at the end the first time), so a release text written by the project itself stays. Give the job
`permissions: contents: write`.
```yaml
- uses: actions/checkout@v4
- name: Release notes
id: notes
uses: https://git.thepra.dev/Arasaka/app-stores/changelog@v1
with:
prefix: app-v
name: SimpleSchool
packager: Scuola di lingua Cinese di Varese <info@scuolacinese.org>
output: release-notes
release: 'true'
- run: echo "the notes are in ${{ steps.notes.outputs.dir }}, the previous release was ${{ steps.notes.outputs.previous }}"
```
| Input | | |
|---|---|---|
| `prefix` | `v` | the tags of this kind of release |
| `tag` | the tag that started the run | the release being made; empty on a branch, which writes everything since the newest tag as `unreleased` |
| `name` | the repository's name | the product's name in headings |
| `strip` | | a regular expression removed from the start of each subject (me-web? removes its `“me-web?” 0.15.4: `) |
| `packager` | `Arasaka di Hvozdikov Yevheniy <packages@arasaka.software>` | `Name <address>` for the deb and rpm changelogs: the package's own maintainer |
| `history` | `20` | how many releases `CHANGELOG.md` and the package changelog carry |
| `output` | `release-notes` | the folder |
| `release` | `false` | `true` writes the notes onto the Releases tab |
| `token` | the job token | only with `release` |
A shallow checkout is fetched in full, with its tags, before anything is read. A first release lists its newest 100
commits. The same file reads the history from Gitea's API instead of a checkout (`--gitea URL --repository
owner/name`, token in `READ_TOKEN`); that is how `thepra/packages.arasaka.software` writes the notes for every app it
publishes, so the two can never tell a release differently.
## Store notes
One file per language, the file name being the code: Google uses language and region (`en-US`, `it-IT`), Apple One file per language, the file name being the code: Google uses language and region (`en-US`, `it-IT`), Apple
its own list (`en-US`, `it`, `de-DE`). Empty files are skipped. Apple takes no release notes for an app's first its own list (`en-US`, `it`, `de-DE`). Empty files are skipped. Apple takes no release notes for an app's first
+61
View File
@@ -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[@]}"
+314
View File
@@ -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()