Files
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

485 lines
18 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import datetime as dt
import re
import sys
from dataclasses import dataclass
from difflib import SequenceMatcher
from pathlib import Path
ROOT_DIR = Path(__file__).resolve().parents[1]
CHANGELOG_PATH = ROOT_DIR / "CHANGELOG.md"
FRAGMENT_DIR = ROOT_DIR / "changes" / "unreleased"
FRONT_MATTER_RE = re.compile(r"\A---\n(.*?)\n---\n?", re.DOTALL)
VERSION_HEADER_RE = re.compile(r"(?m)^## \[([^\]]+)\](?: - .*)?$")
SECTION_HEADER_RE = re.compile(r"(?m)^### ([^\n]+)$")
APP_TAG_RE = re.compile(r"^v\d+\.\d+\.\d+(?:[.-][0-9A-Za-z.-]+)?$")
CJK_RE = re.compile(r"[\u3400-\u9fff]")
TYPE_ORDER = [
"summary",
"added",
"changed",
"deprecated",
"removed",
"fixed",
"security",
"maintenance",
]
TYPE_TITLES = {
"summary": "Summary",
"added": "Added",
"changed": "Changed",
"deprecated": "Deprecated",
"removed": "Removed",
"fixed": "Fixed",
"security": "Security",
"maintenance": "Maintenance",
}
RELEASE_TARGETS = {"app", "plugin"}
MAX_ENTRY_CHARS = 220
MAX_ENTRY_SENTENCES = 2
NEAR_DUPLICATE_THRESHOLD = 0.96
class ChangelogError(RuntimeError):
pass
@dataclass(frozen=True)
class Fragment:
path: Path
release: str
type: str
area: str | None
entries: list[str]
def fail(message: str) -> None:
raise ChangelogError(message)
def relative(path: Path) -> str:
return path.relative_to(ROOT_DIR).as_posix()
def strip_optional_quotes(value: str) -> str:
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
return value[1:-1].strip()
return value
def parse_front_matter(text: str, path: Path) -> tuple[dict[str, str], str]:
match = FRONT_MATTER_RE.match(text)
if not match:
fail(f"{relative(path)} must start with YAML front matter.")
metadata: dict[str, str] = {}
for index, raw_line in enumerate(match.group(1).splitlines(), start=1):
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if ":" not in line:
fail(f"{relative(path)} front matter line {index} must use `key: value`.")
key, value = line.split(":", 1)
key = key.strip()
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]*", key):
fail(f"{relative(path)} front matter key is invalid: {key}")
metadata[key] = strip_optional_quotes(value)
return metadata, text[match.end() :]
def clean_entry(value: str) -> str:
value = re.sub(r"\s+", " ", value).strip()
value = value.removeprefix("- ").strip()
return value
def split_entries(body: str, path: Path) -> list[str]:
lines = body.splitlines()
bullet_entries: list[str] = []
current: list[str] = []
def flush_bullet() -> None:
if not current:
return
entry = clean_entry(" ".join(current))
if entry:
bullet_entries.append(entry)
current.clear()
for raw_line in lines:
line = raw_line.rstrip()
stripped = line.strip()
if stripped.startswith("- "):
flush_bullet()
current.append(stripped[2:])
continue
if current and (not stripped or line.startswith((" ", "\t"))):
if stripped:
current.append(stripped)
continue
if current:
fail(f"{relative(path)} mixes bullet entries with plain text. Use one style.")
if stripped.startswith("#"):
fail(f"{relative(path)} must not include markdown headings.")
flush_bullet()
if bullet_entries:
return bullet_entries
paragraphs: list[str] = []
current_paragraph: list[str] = []
for raw_line in lines:
stripped = raw_line.strip()
if not stripped:
if current_paragraph:
paragraphs.append(clean_entry(" ".join(current_paragraph)))
current_paragraph.clear()
continue
current_paragraph.append(stripped)
if current_paragraph:
paragraphs.append(clean_entry(" ".join(current_paragraph)))
return [paragraph for paragraph in paragraphs if paragraph]
def sentence_count(entry: str) -> int:
return len(re.findall(r"[.!?](?:\s|$)", entry))
def validate_entry(entry: str, path: Path) -> None:
if CJK_RE.search(entry):
fail(f"{relative(path)} entries must be written in English.")
if len(entry) > MAX_ENTRY_CHARS:
fail(
f"{relative(path)} entry is too long ({len(entry)} chars, max {MAX_ENTRY_CHARS}): "
f"{entry}"
)
if sentence_count(entry) > MAX_ENTRY_SENTENCES:
fail(f"{relative(path)} entry should stay concise: {entry}")
if entry.endswith((".", "!", "?")) is False:
fail(f"{relative(path)} entry must end with punctuation: {entry}")
def parse_fragment(path: Path) -> Fragment:
metadata, body = parse_front_matter(path.read_text(encoding="utf-8"), path)
release = metadata.get("release", "").strip().lower()
if release not in RELEASE_TARGETS:
allowed = ", ".join(sorted(RELEASE_TARGETS))
fail(f"{relative(path)} has invalid release `{release}`. Expected one of: {allowed}.")
fragment_type = metadata.get("type", "").strip().lower()
if fragment_type not in TYPE_TITLES:
allowed = ", ".join(TYPE_ORDER)
fail(f"{relative(path)} has invalid type `{fragment_type}`. Expected one of: {allowed}.")
entries = split_entries(body, path)
if not entries:
fail(f"{relative(path)} has no changelog entries.")
if fragment_type == "summary" and len(entries) > 1:
fail(f"{relative(path)} summary fragments must contain one concise paragraph.")
for entry in entries:
validate_entry(entry, path)
area = metadata.get("area") or None
return Fragment(path=path, release=release, type=fragment_type, area=area, entries=entries)
def pending_fragment_paths() -> list[Path]:
if not FRAGMENT_DIR.exists():
return []
return sorted(path for path in FRAGMENT_DIR.glob("*.md") if path.is_file())
def load_fragments(require_pending: bool, release: str | None) -> list[Fragment]:
paths = pending_fragment_paths()
fragments = [parse_fragment(path) for path in paths]
validate_no_near_duplicates(fragments)
if release is not None:
fragments = [fragment for fragment in fragments if fragment.release == release]
if require_pending and not fragments:
scope = f" {release}" if release else ""
fail(f"No pending{scope} changelog fragments found in changes/unreleased/*.md.")
return fragments
def normalized_entry(entry: str) -> str:
return re.sub(r"[^a-z0-9]+", " ", entry.lower()).strip()
def validate_no_near_duplicates(fragments: list[Fragment]) -> None:
seen: list[tuple[str, Fragment, str]] = []
for fragment in fragments:
for entry in fragment.entries:
normalized = normalized_entry(entry)
for previous_normalized, previous_fragment, previous_entry in seen:
if normalized == previous_normalized:
fail(
"Duplicate changelog entries found in "
f"{relative(previous_fragment.path)} and {relative(fragment.path)}: {entry}"
)
score = SequenceMatcher(None, previous_normalized, normalized).ratio()
if score >= NEAR_DUPLICATE_THRESHOLD:
fail(
"Near-duplicate changelog entries found in "
f"{relative(previous_fragment.path)} and {relative(fragment.path)}:\n"
f"- {previous_entry}\n"
f"- {entry}"
)
seen.append((normalized, fragment, entry))
def grouped_entries(fragments: list[Fragment]) -> dict[str, list[str]]:
grouped: dict[str, list[str]] = {entry_type: [] for entry_type in TYPE_ORDER}
seen: set[str] = set()
for fragment in fragments:
for entry in fragment.entries:
key = normalized_entry(entry)
if key in seen:
continue
seen.add(key)
grouped[fragment.type].append(entry)
return {entry_type: entries for entry_type, entries in grouped.items() if entries}
def render_release(title: str, date: str, fragments: list[Fragment]) -> str:
sections = grouped_entries(fragments)
lines = [f"## [{title}] - {date}", ""]
for entry_type in TYPE_ORDER:
entries = sections.get(entry_type)
if not entries:
continue
lines.extend([f"### {TYPE_TITLES[entry_type]}", ""])
if entry_type == "summary":
for entry in entries:
lines.extend([entry, ""])
else:
lines.extend(f"- {entry}" for entry in entries)
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def default_changelog() -> str:
return (
"# Changelog\n\n"
"All notable changes to this project are documented here. The format follows\n"
"[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses\n"
"[Semantic Versioning](https://semver.org/).\n\n"
"Pending release notes live in `changes/unreleased/*.md` and are compiled during\n"
"the app and plugin release processes.\n"
)
def insert_release(content: str, title: str, release_text: str) -> str:
if re.search(rf"(?m)^## \[{re.escape(title)}\](?: - .*)?$", content):
fail(f"CHANGELOG.md already has an entry for {title}.")
stripped = content.rstrip()
match = VERSION_HEADER_RE.search(stripped)
if match:
return stripped[: match.start()].rstrip() + "\n\n" + release_text.rstrip() + "\n\n" + stripped[match.start() :].lstrip() + "\n"
return stripped + "\n\n" + release_text.rstrip() + "\n"
def prepare(release: str, title: str, release_date: str, dry_run: bool) -> None:
fragments = load_fragments(require_pending=True, release=release)
release_text = render_release(title, release_date, fragments)
if dry_run:
sys.stdout.write(release_text)
return
content = CHANGELOG_PATH.read_text(encoding="utf-8") if CHANGELOG_PATH.exists() else default_changelog()
CHANGELOG_PATH.write_text(insert_release(content, title, release_text), encoding="utf-8")
for fragment in fragments:
fragment.path.unlink()
def release_notes_from_match(content: str, matches: list[re.Match[str]], index: int) -> str:
match = matches[index]
start = content.find("\n", match.end())
if start == -1:
fail(f"CHANGELOG.md entry for {match.group(1)} is empty.")
end = matches[index + 1].start() if index + 1 < len(matches) else len(content)
notes = content[start + 1 : end].strip()
if not notes:
fail(f"CHANGELOG.md entry for {match.group(1)} is empty.")
return notes + "\n"
def extract_from_content(content: str, title: str) -> str:
matches = list(VERSION_HEADER_RE.finditer(content))
for index, match in enumerate(matches):
if match.group(1) != title:
continue
return release_notes_from_match(content, matches, index)
fail(f"CHANGELOG.md has no entry for {title}.")
def extract(title: str) -> str:
if not CHANGELOG_PATH.exists():
fail("CHANGELOG.md does not exist.")
return extract_from_content(CHANGELOG_PATH.read_text(encoding="utf-8"), title)
def parsed_release_sections(notes: str) -> dict[str, list[str]]:
title_to_type = {title: entry_type for entry_type, title in TYPE_TITLES.items()}
sections: dict[str, list[str]] = {}
matches = list(SECTION_HEADER_RE.finditer(notes))
for index, match in enumerate(matches):
entry_type = title_to_type.get(match.group(1).strip())
if entry_type is None:
continue
start = notes.find("\n", match.end())
if start == -1:
continue
end = matches[index + 1].start() if index + 1 < len(matches) else len(notes)
body = notes[start + 1 : end].strip()
if not body:
continue
if entry_type == "summary":
entries = [clean_entry(paragraph) for paragraph in re.split(r"\n\s*\n", body)]
else:
entries = [clean_entry(line) for line in body.splitlines() if line.strip().startswith("- ")]
sections.setdefault(entry_type, []).extend(entry for entry in entries if entry)
return sections
def plugin_sections_since_previous_app(content: str, title: str) -> dict[str, list[str]]:
if not APP_TAG_RE.fullmatch(title):
fail(f"Sparkle notes require an app tag, got {title}.")
matches = list(VERSION_HEADER_RE.finditer(content))
target_index = next((index for index, match in enumerate(matches) if match.group(1) == title), None)
if target_index is None:
fail(f"CHANGELOG.md has no entry for {title}.")
grouped: dict[str, list[str]] = {entry_type: [] for entry_type in TYPE_ORDER}
seen: set[str] = set()
for index in range(target_index + 1, len(matches)):
release_title = matches[index].group(1)
if APP_TAG_RE.fullmatch(release_title):
break
if not release_title.startswith("plugins-"):
continue
sections = parsed_release_sections(release_notes_from_match(content, matches, index))
for entry_type, entries in sections.items():
for entry in entries:
key = normalized_entry(entry)
if key in seen:
continue
seen.add(key)
grouped[entry_type].append(entry)
return {entry_type: entries for entry_type, entries in grouped.items() if entries}
def render_note_sections(sections: dict[str, list[str]]) -> str:
lines: list[str] = []
for entry_type in TYPE_ORDER:
entries = sections.get(entry_type)
if not entries:
continue
lines.extend([f"### {TYPE_TITLES[entry_type]}", ""])
if entry_type == "summary":
for entry in entries:
lines.extend([entry, ""])
else:
lines.extend(f"- {entry}" for entry in entries)
lines.append("")
return "\n".join(lines).rstrip()
def compose_sparkle_notes(content: str, title: str, app_notes: str) -> str:
app_notes = app_notes.strip()
if not app_notes:
fail("App release notes are empty.")
parts = ["## App Updates", "", app_notes]
plugin_sections = plugin_sections_since_previous_app(content, title)
if plugin_sections:
parts.extend(["", "## Plugin Updates", "", render_note_sections(plugin_sections)])
return "\n".join(parts).rstrip() + "\n"
def validate(require_pending: bool, release: str | None) -> None:
load_fragments(require_pending=require_pending, release=release)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Manage MacTools changelog fragments.")
subparsers = parser.add_subparsers(dest="command", required=True)
validate_parser = subparsers.add_parser("validate", help="Validate pending changelog fragments.")
validate_parser.add_argument("--release", choices=sorted(RELEASE_TARGETS), help="Validate one release target.")
validate_parser.add_argument(
"--require-pending",
action="store_true",
help="Fail when changes/unreleased contains no markdown fragments.",
)
prepare_parser = subparsers.add_parser("prepare", help="Compile pending fragments into CHANGELOG.md.")
prepare_parser.add_argument("--release", choices=sorted(RELEASE_TARGETS), required=True)
prepare_parser.add_argument("--tag", required=True, help="Release tag, for example v1.0.28 or plugins-1.0.29.")
prepare_parser.add_argument(
"--date",
default=dt.date.today().isoformat(),
help="Release date in YYYY-MM-DD format. Defaults to today.",
)
prepare_parser.add_argument("--dry-run", action="store_true", help="Print the release entry only.")
extract_parser = subparsers.add_parser("extract", help="Extract one version from CHANGELOG.md.")
extract_parser.add_argument("--tag", required=True, help="Release tag, for example v1.0.28 or plugins-1.0.29.")
extract_parser.add_argument("--output", help="Write notes to this file instead of stdout.")
sparkle_parser = subparsers.add_parser(
"compose-sparkle",
help="Compose app and intervening plugin notes for the Sparkle update dialog.",
)
sparkle_parser.add_argument("--tag", required=True, help="App release tag, for example v1.0.31.")
sparkle_parser.add_argument("--app-notes", required=True, help="App-only release notes file.")
sparkle_parser.add_argument("--output", help="Write notes to this file instead of stdout.")
return parser.parse_args()
def main() -> int:
args = parse_args()
try:
if args.command == "validate":
validate(require_pending=args.require_pending, release=args.release)
elif args.command == "prepare":
prepare(args.release, args.tag, args.date, args.dry_run)
elif args.command == "extract":
notes = extract(args.tag)
if args.output:
Path(args.output).write_text(notes, encoding="utf-8")
else:
sys.stdout.write(notes)
elif args.command == "compose-sparkle":
if not CHANGELOG_PATH.exists():
fail("CHANGELOG.md does not exist.")
app_notes_path = Path(args.app_notes)
if not app_notes_path.is_file():
fail(f"App release notes file does not exist: {app_notes_path}")
notes = compose_sparkle_notes(
CHANGELOG_PATH.read_text(encoding="utf-8"),
args.tag,
app_notes_path.read_text(encoding="utf-8"),
)
if args.output:
Path(args.output).write_text(notes, encoding="utf-8")
else:
sys.stdout.write(notes)
else:
fail(f"Unknown command: {args.command}")
except ChangelogError as error:
print(f"[changelog] error: {error}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())