Files
hermes-relay/scripts/check-android-release-notes.py
T

222 lines
8.8 KiB
Python

#!/usr/bin/env python3
"""Validate and synchronize Android release-note surfaces.
The newest ``changelog.json`` entry is the complete user-visible source for the
in-app What's New experience. ``--write`` refreshes the legacy text fallback,
Google Play notes, and the matching operator copy in
``docs/play-store-listing.md``. The technical CHANGELOG and GitHub release body
remain separate records, but their version headings are checked so release
prep cannot silently drift.
"""
from __future__ import annotations
import argparse
import json
import pathlib
import re
import sys
import tomllib
from typing import Any
REPO_ROOT = pathlib.Path(__file__).resolve().parents[1]
CHANGELOG_JSON = pathlib.Path("app/src/main/assets/changelog.json")
WHATS_NEW = pathlib.Path("app/src/main/assets/whats_new.txt")
PLAY_NOTES = pathlib.Path("app/src/googlePlay/play/release-notes/en-US/default.txt")
PLAY_LISTING = pathlib.Path("docs/play-store-listing.md")
PLAY_SECTION_RE = re.compile(
r"(## Release Notes\s+Paste into Play Console → \*\*What's new\*\* "
r"\(≤500 characters\):\s+```\s*\n)(.*?)(\n```\s*\n## Category)",
re.DOTALL,
)
CHANGE_KINDS = ("added", "improved", "fixed")
CHANGE_KIND_LABELS = {
"added": "Added",
"improved": "Improved",
"fixed": "Fixed",
}
def _read_text(relative: pathlib.Path) -> str:
return (REPO_ROOT / relative).read_text(encoding="utf-8")
def _android_version() -> str:
versions = tomllib.loads(_read_text(pathlib.Path("gradle/libs.versions.toml"))).get(
"versions", {}
)
return str(versions.get("appVersionName", ""))
def _latest_entry() -> dict[str, Any]:
data = json.loads(_read_text(CHANGELOG_JSON))
if not isinstance(data, dict) or data.get("schema") != 3:
raise ValueError("changelog.json must use schema 3")
versions = data.get("versions")
if not isinstance(versions, list) or not versions or not isinstance(versions[0], dict):
raise ValueError("changelog.json must contain at least one version")
return versions[0]
def validate_curated_entry(entry: dict[str, Any], expected_version: str) -> list[str]:
errors: list[str] = []
version = entry.get("version")
if version != expected_version:
errors.append(
f"latest changelog version is {version!r}, expected Android {expected_version!r}"
)
for field in ("title", "date", "summary"):
if not isinstance(entry.get(field), str) or not entry[field].strip():
errors.append(f"latest changelog entry requires non-empty {field}")
changes = entry.get("changes")
if not isinstance(changes, list) or not changes:
errors.append("latest changelog entry requires a non-empty changes array")
changes = []
seen_ids: set[str] = set()
highlight_count = 0
for index, change in enumerate(changes):
prefix = f"latest changelog change {index + 1}"
if not isinstance(change, dict):
errors.append(f"{prefix} must be an object")
continue
change_id = change.get("id")
if not isinstance(change_id, str) or not change_id.strip():
errors.append(f"{prefix} requires a non-empty id")
elif change_id in seen_ids:
errors.append(f"latest changelog change id {change_id!r} is duplicated")
else:
seen_ids.add(change_id)
if change.get("kind") not in CHANGE_KINDS:
errors.append(f"{prefix} kind must be one of {', '.join(CHANGE_KINDS)}")
for field in ("title", "summary"):
if not isinstance(change.get(field), str) or not change[field].strip():
errors.append(f"{prefix} requires non-empty {field}")
is_highlight = change.get("highlight", False)
if not isinstance(is_highlight, bool):
errors.append(f"{prefix} highlight must be a boolean")
elif is_highlight:
highlight_count += 1
if not 1 <= highlight_count <= 4:
errors.append("latest changelog must select 1-4 highlighted changes")
compatibility = entry.get("compatibility", [])
if not isinstance(compatibility, list):
errors.append("latest changelog compatibility must be an array")
elif any(not isinstance(item, str) or not item.strip() for item in compatibility):
errors.append("latest changelog compatibility items must be non-empty strings")
for legacy_field in ("highlight", "improvements", "toastDigest"):
if legacy_field in entry:
errors.append(f"latest changelog must derive presentation instead of defining {legacy_field}")
play_notes = entry.get("playNotes")
if not isinstance(play_notes, str) or not play_notes.strip():
errors.append("latest changelog entry requires non-empty playNotes")
elif len(render_play_notes(entry)) > 500:
errors.append("rendered Google Play notes exceed 500 characters")
return errors
def render_whats_new(entry: dict[str, Any]) -> str:
lines = [
f"v{entry['version']} - {entry['title']}",
"",
"Summary",
f"* {entry['summary']}",
]
changes = entry["changes"]
highlights = [change for change in changes if change.get("highlight", False)]
if highlights:
lines.extend(("", "Highlights", *(_render_change(change) for change in highlights)))
remaining = [change for change in changes if not change.get("highlight", False)]
for kind in CHANGE_KINDS:
items = [change for change in remaining if change["kind"] == kind]
if items:
lines.extend(("", CHANGE_KIND_LABELS[kind], *(_render_change(change) for change in items)))
compatibility = entry.get("compatibility", [])
if compatibility:
lines.extend(("", "Compatibility", *(f"* {item}" for item in compatibility)))
return "\n".join(lines) + "\n"
def _render_change(change: dict[str, Any]) -> str:
return f"* {change['title']} — {change['summary']}"
def render_play_notes(entry: dict[str, Any]) -> str:
return f"v{entry['version']} - {entry['title']}\n\n{entry['playNotes'].strip()}\n"
def _replace_play_listing(current: str, rendered: str) -> str:
match = PLAY_SECTION_RE.search(current)
if not match:
raise ValueError("docs/play-store-listing.md has no recognized Release Notes block")
return current[: match.start()] + match.group(1) + rendered.rstrip() + match.group(3) + current[match.end() :]
def _check_exact(relative: pathlib.Path, expected: str, errors: list[str]) -> None:
actual = _read_text(relative).replace("\r\n", "\n")
if actual != expected:
errors.append(f"{relative.as_posix()} is stale; run this script with --write")
def _validate_record_versions(version: str, errors: list[str]) -> None:
changelog = _read_text(pathlib.Path("CHANGELOG.md"))
if f"## [Android {version}]" not in changelog:
errors.append(f"CHANGELOG.md has no [Android {version}] release heading")
release_notes = _read_text(pathlib.Path("RELEASE_NOTES.md"))
if not release_notes.startswith(f"# Hermes-Relay Android v{version}\n"):
errors.append(f"RELEASE_NOTES.md does not describe Android v{version}")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--write",
action="store_true",
help="Refresh derived in-app fallback and Play release-note files",
)
args = parser.parse_args()
try:
version = _android_version()
entry = _latest_entry()
errors = validate_curated_entry(entry, version)
if errors:
raise ValueError("\n".join(errors))
whats_new = render_whats_new(entry)
play_notes = render_play_notes(entry)
listing = _replace_play_listing(_read_text(PLAY_LISTING), play_notes)
except (OSError, ValueError, json.JSONDecodeError, tomllib.TOMLDecodeError) as exc:
print(f"Android release-note check failed: {exc}", file=sys.stderr)
return 1
if args.write:
(REPO_ROOT / WHATS_NEW).write_text(whats_new, encoding="utf-8", newline="\n")
(REPO_ROOT / PLAY_NOTES).write_text(play_notes, encoding="utf-8", newline="\n")
(REPO_ROOT / PLAY_LISTING).write_text(listing, encoding="utf-8", newline="\n")
print("Android release-note derivatives refreshed")
return 0
errors = []
_check_exact(WHATS_NEW, whats_new, errors)
_check_exact(PLAY_NOTES, play_notes, errors)
if _read_text(PLAY_LISTING).replace("\r\n", "\n") != listing:
errors.append("docs/play-store-listing.md release notes are stale; run this script with --write")
_validate_record_versions(version, errors)
if errors:
print("Android release-note validation failed:", file=sys.stderr)
for error in errors:
print(f" - {error}", file=sys.stderr)
return 1
print(f"Android release notes are aligned for v{version}")
return 0
if __name__ == "__main__":
raise SystemExit(main())