Files
AnyPS5/tools/prepare_release_notes.py
T
Dean a7380a2c5f Merge pull request #13184 from rooklz/fix/prepare-release-notes-symlink-loop
fix: refuse symlink-loop release note outputs without traceback
2026-11-03 06:28:28 +13:00

114 lines
4.4 KiB
Python

import argparse
import json
import os
import re
import secrets
import stat
import sys
from pathlib import Path
BODY_LIMIT = 125000
def resolve_output_path(path):
path = Path(path)
try:
resolved = Path(os.path.realpath(path))
except OSError as error:
raise ValueError(str(error)) from error
if resolved.is_symlink():
try:
resolved.stat()
except OSError as error:
raise ValueError(f"{path}: release note output is a symbolic link loop: {error}") from error
raise ValueError(f"{path}: release note output is a symbolic link loop")
return resolved
def release_body(notes):
if len(notes) <= BODY_LIMIT:
return notes
return re.sub(
r"^## What's Changed[^\S\r\n]*\r?\n.*?(?=^#{1,2} |^\*\*Full Changelog\*\*:|\Z)",
"",
notes,
count=1,
flags=re.MULTILINE | re.DOTALL,
)
def prepare_notes(source, body, full, check=False):
paths = (source, body, full)
for index, first in enumerate(paths):
for second in paths[index + 1:]:
if os.path.realpath(first) == os.path.realpath(second) or (first.exists() and second.exists() and first.samefile(second)):
raise ValueError("Release note input and outputs must be distinct files")
try:
payload = json.loads(source.read_text(encoding="utf-8-sig"))
except RecursionError as error:
raise ValueError("Generated release notes response is too deeply nested") from error
if not isinstance(payload, dict) or "body" not in payload:
raise ValueError("Generated release notes response must be a JSON object with a body field")
notes = payload["body"]
if not isinstance(notes, str):
raise ValueError("Generated release notes body must be a string")
body_text = release_body(notes)
if len(body_text) > BODY_LIMIT:
raise ValueError(f"Release notes body exceeds {BODY_LIMIT} characters after removing What's Changed")
if check:
try:
return body.read_bytes() == body_text.encode("utf-8") and full.read_bytes() == notes.encode("utf-8")
except FileNotFoundError:
return False
for destination in (body, full):
target = resolve_output_path(destination)
try:
target.stat()
except FileNotFoundError:
pass
if target.is_dir():
raise IsADirectoryError(str(destination))
if os.name == "nt" and target.exists() and target.stat().st_file_attributes & stat.FILE_ATTRIBUTE_READONLY:
raise PermissionError(f"Release note destination is read-only: {destination}")
staged = []
try:
for destination, text in ((full, notes), (body, body_text)):
target = resolve_output_path(destination)
target.parent.mkdir(parents=True, exist_ok=True)
temporary = target.parent / (".release-notes-" + secrets.token_hex(16))
with temporary.open("xb") as output:
staged.append((temporary, target))
if target.exists():
temporary.chmod(stat.S_IMODE(target.stat().st_mode))
output.write(text.encode("utf-8"))
for temporary, target in staged:
os.replace(temporary, target)
finally:
for temporary, _ in staged:
temporary.unlink(missing_ok=True)
def main(argv=None):
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="backslashreplace")
if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(encoding="utf-8", errors="backslashreplace")
parser = argparse.ArgumentParser(description="Prepare size-safe GitHub release notes")
parser.add_argument("--input", type=Path, required=True)
parser.add_argument("--body", type=Path, required=True)
parser.add_argument("--full", type=Path, required=True)
parser.add_argument("--check", action="store_true", help="fail with status 1 when outputs differ; never create or modify files")
args = parser.parse_args(argv)
try:
matched = prepare_notes(args.input, args.body, args.full, args.check)
if args.check and not matched:
print("Release note outputs are missing or stale", file=sys.stderr)
return 1
except (OSError, ValueError) as error:
parser.error(str(error))
return 0
if __name__ == "__main__":
sys.exit(main())