Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 6 additions & 50 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -234,57 +234,13 @@ jobs:

echo "Generating notes from $PREV to $TAG"

# Collect conventional commit messages, skip chore/ci/deps/merge
FEATURES=""
FIXES=""
PERF=""
OTHER=""
# Categorisation lives in scripts/release_notes.py so it can be
# unit-tested; this body is published verbatim to end users by
# the in-app updater, so development-only commit types (test,
# refactor, chore, ci, build, docs, style) are dropped there.
# stdlib only: this job has no setup-python step.
python3 scripts/release_notes.py "${PREV}..${TAG}" -o release_notes.md

while IFS= read -r line; do
# Skip empty, merge, chore, ci, deps, bump, dependabot
echo "$line" | grep -qiE '^(chore|ci|build|Merge|docs\(deps\))' && continue
echo "$line" | grep -qiE 'dependabot|bump.*from.*to' && continue
echo "$line" | grep -qi '^$' && continue

# Clean up: remove scope prefix for display, capitalize
clean=$(echo "$line" | sed -E 's/^(feat|fix|perf|a11y|docs)(\([^)]*\))?:\s*//')
clean="$(echo "${clean:0:1}" | tr '[:lower:]' '[:upper:]')${clean:1}"

if echo "$line" | grep -qE '^feat'; then
FEATURES="${FEATURES}- ${clean}\n"
elif echo "$line" | grep -qE '^fix'; then
FIXES="${FIXES}- ${clean}\n"
elif echo "$line" | grep -qE '^perf'; then
PERF="${PERF}- ${clean}\n"
elif echo "$line" | grep -qE '^a11y'; then
FIXES="${FIXES}- ${clean}\n"
elif echo "$line" | grep -qE '^docs'; then
: # skip docs-only commits
else
OTHER="${OTHER}- ${clean}\n"
fi
done <<< "$(git log --pretty=format:'%s' "${PREV}..${TAG}" --no-merges)"

BODY=""
if [ -n "$FEATURES" ]; then
BODY="${BODY}## New features\n${FEATURES}\n"
fi
if [ -n "$PERF" ]; then
BODY="${BODY}## Performance\n${PERF}\n"
fi
if [ -n "$FIXES" ]; then
BODY="${BODY}## Fixes & improvements\n${FIXES}\n"
fi
if [ -n "$OTHER" ]; then
BODY="${BODY}## Other\n${OTHER}\n"
fi

if [ -z "$BODY" ]; then
BODY="Bug fixes and improvements."
fi

# Write to file (multiline output)
printf '%b' "$BODY" > release_notes.md
echo "Generated release notes:"
cat release_notes.md

Expand Down
8 changes: 8 additions & 0 deletions app/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,7 @@
"viewer.night_mode": "Night reading mode (invert colors)",
"update.changes": "What's new in this version:",
"update.no_notes": "No release notes available.",
"update.section.security": "## Security",
"update.section.features": "## New features",
"update.section.performance": "## Performance",
"update.section.fixes": "## Fixes & improvements",
Expand Down Expand Up @@ -1036,6 +1037,7 @@
"viewer.night_mode": "Modo noturno (inverter cores)",
"update.changes": "Novidades nesta versão:",
"update.no_notes": "Sem notas de versão disponíveis.",
"update.section.security": "## Segurança",
"update.section.features": "## Novidades",
"update.section.performance": "## Desempenho",
"update.section.fixes": "## Correções e melhorias",
Expand Down Expand Up @@ -1648,6 +1650,7 @@
"viewer.night_mode": "Modo nocturno (invertir colores)",
"update.changes": "Novedades en esta versión:",
"update.no_notes": "Sin notas de versión disponibles.",
"update.section.security": "## Seguridad",
"update.section.features": "## Novedades",
"update.section.performance": "## Rendimiento",
"update.section.fixes": "## Correcciones y mejoras",
Expand Down Expand Up @@ -2260,6 +2263,7 @@
"viewer.night_mode": "Mode nuit (inverser les couleurs)",
"update.changes": "Nouveautés de cette version :",
"update.no_notes": "Aucune note de version disponible.",
"update.section.security": "## Sécurité",
"update.section.features": "## Nouveautés",
"update.section.performance": "## Performance",
"update.section.fixes": "## Corrections et améliorations",
Expand Down Expand Up @@ -2872,6 +2876,7 @@
"viewer.night_mode": "Nachtmodus (Farben invertieren)",
"update.changes": "Neuigkeiten in dieser Version:",
"update.no_notes": "Keine Versionshinweise verfügbar.",
"update.section.security": "## Sicherheit",
"update.section.features": "## Neue Funktionen",
"update.section.performance": "## Leistung",
"update.section.fixes": "## Fehlerbehebungen und Verbesserungen",
Expand Down Expand Up @@ -3484,6 +3489,7 @@
"viewer.night_mode": "夜间阅读模式(反转颜色)",
"update.changes": "此版本的新功能:",
"update.no_notes": "没有可用的版本说明。",
"update.section.security": "## 安全",
"update.section.features": "## 新功能",
"update.section.performance": "## 性能",
"update.section.fixes": "## 修复与改进",
Expand Down Expand Up @@ -4096,6 +4102,7 @@
"viewer.night_mode": "Modalità notturna (inverti colori)",
"update.changes": "Novità in questa versione:",
"update.no_notes": "Nessuna nota di versione disponibile.",
"update.section.security": "## Sicurezza",
"update.section.features": "## Novità",
"update.section.performance": "## Prestazioni",
"update.section.fixes": "## Correzioni e miglioramenti",
Expand Down Expand Up @@ -4708,6 +4715,7 @@
"viewer.night_mode": "Nachtmodus (kleuren inverteren)",
"update.changes": "Wat is er nieuw in deze versie:",
"update.no_notes": "Geen release notes beschikbaar.",
"update.section.security": "## Beveiliging",
"update.section.features": "## Nieuwe functies",
"update.section.performance": "## Prestaties",
"update.section.fixes": "## Fixes en verbeteringen",
Expand Down
6 changes: 5 additions & 1 deletion app/updater.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,12 @@

_API_URL = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest"

# Section headings used in auto-generated release notes (build.yml)
# Section headings used in auto-generated release notes, produced by
# scripts/release_notes.py. The match below is a plain string replace,
# so a heading added there without a key here (and in all 8 languages of
# app/translations.json) degrades to English silently.
_SECTION_MAP = {
"## Security": "update.section.security",
"## New features": "update.section.features",
"## Performance": "update.section.performance",
"## Fixes & improvements": "update.section.fixes",
Expand Down
225 changes: 225 additions & 0 deletions scripts/release_notes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
#!/usr/bin/env python3
"""Build the release-notes body from conventional-commit subjects.

Used by the "Generate release notes" step of .github/workflows/build.yml.
The output is published as the GitHub release body, which the in-app
auto-updater downloads and shows to end users (app/updater.py:
_localize_notes translates the "## ..." headings). Anything emitted here
is read by users, not by reviewers, so development-only commit types are
dropped rather than dumped into a catch-all section.

Stdlib only, on purpose: the release job has no setup-python step and
runs this with the runner's system interpreter.
"""

from __future__ import annotations

import argparse
import re
import subprocess
import sys

# Heading text must stay byte-identical to the keys of
# app/updater.py:_SECTION_MAP, otherwise the in-app update dialog shows
# the English heading instead of the translated one (the replace is a
# plain string match, and a miss degrades silently).
HEADING_SECURITY = "## Security"
HEADING_FEATURES = "## New features"
HEADING_PERFORMANCE = "## Performance"
HEADING_FIXES = "## Fixes & improvements"
HEADING_OTHER = "## Other"

FALLBACK_BODY = "Bug fixes and improvements."

# Single source of truth for what each commit type does: a heading to
# publish under, or None to drop. One table instead of a drop-set plus a
# routing map, because the two-structure version grew a branch that
# could never run (a type in the drop-set was re-tested for a scope
# further down) and a test that passed through the wrong arm of the
# chain. Here a type has exactly one entry, so that class of bug cannot
# be expressed.
#
# Dropped types never reach end users: "test"/"refactor" describe
# internal churn ("extract pure dispatcher from TabEditar._run"); docs,
# chore, ci, build, style and seo are repo maintenance. "docs" is
# dropped unconditionally and deliberately: all 44 docs subjects in this
# repo's history are README/website/changelog upkeep, with no
# user-facing change among them. The nearest miss, "docs: document
# bookmarks/TOC and night reading mode", touches only CONTRIBUTING.md,
# README.md and TODO.txt and describes features that already publish
# their own notes via feat:, so publishing it would duplicate.
_TYPE_DESTINATION = {
"security": HEADING_SECURITY,
"feat": HEADING_FEATURES,
"perf": HEADING_PERFORMANCE,
"fix": HEADING_FIXES,
"a11y": HEADING_FIXES,
"i18n": HEADING_FIXES,
"design": HEADING_FIXES,
"chore": None,
"ci": None,
"build": None,
"docs": None,
"test": None,
"refactor": None,
"style": None,
"seo": None,
}

# A recognised type is one the table knows about, whatever its verdict.
# An unrecognised type ("hotfix:") is treated as a fix rather than
# dropped, so a new prefix nobody registered here still reaches users.
_KNOWN_TYPES = frozenset(_TYPE_DESTINATION)

# Section order in the published body. Security first: for a PDF tool it
# is the change users most need to see.
_SECTION_ORDER = (
HEADING_SECURITY,
HEADING_FEATURES,
HEADING_PERFORMANCE,
HEADING_FIXES,
HEADING_OTHER,
)

# "type(scope)!: subject"; scope and the breaking-change "!" are optional.
_CONVENTIONAL_RE = re.compile(
r"^(?P<type>[A-Za-z0-9]+)(?:\((?P<scope>[^)]*)\))?!?:\s*(?P<subject>.*)$"
)

# Machine-generated dependency subjects, matched only against subjects
# with no recognised type. Anchored on purpose: the previous
# "bump.*from.*to" was unanchored and substring-matched, so an ordinary
# sentence that happens to contain those three words ("Bump minimum
# zoom from 50 to 400 percent") was dropped in silence, which is the
# exact failure mode the "## Other" safety net exists to prevent.
#
# "^bumps? <token> from " requires the single-token package/action name
# that dependabot always emits ("Bump actions/checkout from 4 to 5"),
# which prose with a multi-word object does not satisfy. The plural is
# accepted because "Bumps <pkg> from X to Y" is the form dependabot uses
# in PR bodies and in squash-merge subjects.
#
# The bot alternative is anchored too: as a bare substring, "dependabot"
# swallowed ordinary prose that merely mentions it ("Move the dependabot
# config into .github"), the same silent-drop failure mode the "## Other"
# safety net exists to prevent. "\[bot\]" stays unanchored because it is
# a trailing account suffix, not a prefix.
#
# Deliberately NOT applied to a subject that already carries a
# user-facing type: a hand-written "security: bump pillow from 12.2.0
# to 12.3.0" is precisely the note users must see.
_NOISE_RE = re.compile(
r"^(?:chore|build|ci)?\(?deps\)?:|^bumps?\s+\S+\s+from\s|^dependabot|\[bot\]",
re.IGNORECASE,
)
_MERGE_RE = re.compile(r"^(Merge|Revert)\b", re.IGNORECASE)


def classify(subject: str) -> str | None:
"""Return the heading a commit subject belongs under, or None to drop.

"## Other" is reserved for subjects with no recognised type: a
safety net for a commit whose prefix was forgotten, so a real
user-facing change is never silently dropped. A subject is only
dropped from that branch when it is recognisably machine-generated
(see _NOISE_RE), never merely for containing bump-like wording. A
*typed* commit never lands in "## Other", which is what used to
publish raw "Test:" / "Refactor(window):" prefixes to end users.

Evaluation order is significant and each step is disjoint from the
next, so no step can shadow one below it:

1. structural rejects (empty, merge/revert) - never user-facing;
2. recognised type -> whatever _TYPE_DESTINATION says, full stop.
A recognised type is decided by that table alone, so no scope or
wording heuristic can second-guess it (this is what keeps a
hand-written "security(deps): bump ..." published);
3. everything else (unrecognised type, or no type at all) -> drop
if it looks machine-generated, otherwise "## Other".
"""
subject = subject.strip()
if not subject:
return None
if _MERGE_RE.match(subject):
return None

match = _CONVENTIONAL_RE.match(subject)
ctype = (match.group("type") or "").lower() if match else ""

if ctype in _KNOWN_TYPES:
return _TYPE_DESTINATION[ctype]

if _NOISE_RE.search(subject):
return None
# An unrecognised *type* is still a deliberate prefix, so it is a
# fix rather than an unclassified "## Other" line.
return HEADING_FIXES if match else HEADING_OTHER


def clean_subject(subject: str) -> str:
"""Strip any conventional-commit prefix and capitalise the first letter.

The old sed only knew feat|fix|perf|a11y|docs, so every other type
reached users as "Test: ..." / "Refactor(window): ...".
"""
subject = subject.strip()
match = _CONVENTIONAL_RE.match(subject)
if match:
subject = match.group("subject").strip()
if not subject:
return subject
return subject[0].upper() + subject[1:]


def build_notes(subjects) -> str:
"""Render the full release-notes body for an iterable of subjects."""
sections: dict[str, list[str]] = {h: [] for h in _SECTION_ORDER}

for subject in subjects:
heading = classify(subject)
if heading is None:
continue
entry = clean_subject(subject)
if not entry:
continue
bullet = f"- {entry}"
# Squash duplicate subjects (cherry-picks, reverted-then-redone
# work) so the same line is not published twice.
if bullet not in sections[heading]:
sections[heading].append(bullet)

parts = []
for heading in _SECTION_ORDER:
if sections[heading]:
parts.append(heading + "\n" + "\n".join(sections[heading]) + "\n")

if not parts:
return FALLBACK_BODY + "\n"
return "\n".join(parts)


def _git_subjects(rev_range: str) -> list[str]:
out = subprocess.run(
["git", "log", "--pretty=format:%s", "--no-merges", rev_range],
check=True, capture_output=True, text=True, encoding="utf-8",
).stdout
return out.splitlines()


def main(argv=None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("rev_range", help="git revision range, e.g. v1.0.0..v1.1.0")
parser.add_argument("-o", "--output", help="write to this file instead of stdout")
args = parser.parse_args(argv)

body = build_notes(_git_subjects(args.rev_range))
if args.output:
with open(args.output, "w", encoding="utf-8", newline="\n") as fh:
fh.write(body)
else:
sys.stdout.write(body)
return 0


if __name__ == "__main__":
raise SystemExit(main())
Loading