diff --git a/scripts/make-manifest.py b/scripts/make-manifest.py new file mode 100755 index 00000000..f7ef19df --- /dev/null +++ b/scripts/make-manifest.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +"""Walk a client directory and emit a release manifest. + +Produces canonical-form JSON matching docs/update-manifest.md. Does not sign — +pair with a separate signer that reads the manifest bytes verbatim and emits +a detached Ed25519 signature. + +Usage: + make-manifest.py --source /path/to/client --version 2026.04.14-1 \\ + [--previous 2026.04.13-3] [--notes "bugfixes"] \\ + [--launcher Metin2Launcher.exe] [--out manifest.json] + +The launcher path is treated specially: its entry appears at the top level +under "launcher", not inside "files". Defaults to "Metin2Launcher.exe"; if +that file does not exist in the source tree, the script refuses to run. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import sys +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path + + +# Files and directories that must never appear in a release manifest. +# These are runtime artifacts, build outputs, or developer state. +EXCLUDE_DIRS = { + ".git", + ".vs", + ".updates", + "log", + "__pycache__", +} +EXCLUDE_FILES = { + ".gitignore", + ".gitattributes", + "desktop.ini", + "Thumbs.db", + ".DS_Store", +} +EXCLUDE_SUFFIXES = { + ".pdb", # debug symbols + ".ilk", # MSVC incremental link + ".old", # rollback copies written by the launcher + ".log", # runtime logs, not release content + ".dxvk-cache", # DXVK shader cache, per-machine + ".swp", + ".tmp", +} + + +@dataclass +class FileEntry: + path: str + sha256: str + size: int + platform: str = "all" + required: bool = True + executable: bool = False + + def to_dict(self) -> dict: + out = { + "path": self.path, + "sha256": self.sha256, + "size": self.size, + } + if self.platform != "all": + out["platform"] = self.platform + if not self.required: + out["required"] = False + if self.executable: + out["executable"] = True + return out + + +def sha256_file(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1 << 20), b""): + h.update(chunk) + return h.hexdigest() + + +def should_skip(rel_path: Path) -> bool: + for part in rel_path.parts: + if part in EXCLUDE_DIRS: + return True + if rel_path.name in EXCLUDE_FILES: + return True + if rel_path.suffix in EXCLUDE_SUFFIXES: + return True + return False + + +def classify_platform(rel_path: Path) -> str: + """Very simple platform inference. Extend as native Linux build lands.""" + suffix = rel_path.suffix.lower() + if suffix in {".exe", ".dll"}: + return "windows" + return "all" + + +def walk_client(source: Path, launcher_rel: Path) -> tuple[FileEntry, list[FileEntry]]: + launcher_entry: FileEntry | None = None + files: list[FileEntry] = [] + + for path in sorted(source.rglob("*")): + if not path.is_file(): + continue + rel = path.relative_to(source) + if should_skip(rel): + continue + + entry = FileEntry( + path=rel.as_posix(), + sha256=sha256_file(path), + size=path.stat().st_size, + platform=classify_platform(rel), + ) + + if rel == launcher_rel: + launcher_entry = entry + else: + files.append(entry) + + if launcher_entry is None: + raise SystemExit( + f"error: launcher file {launcher_rel} not found under {source}. " + f"pass --launcher if the launcher is named differently, or create it." + ) + + files.sort(key=lambda e: e.path) + return launcher_entry, files + + +def build_manifest( + version: str, + created_at: str, + previous: str | None, + notes: str | None, + min_launcher_version: str | None, + launcher: FileEntry, + files: list[FileEntry], +) -> dict: + """Assemble the manifest dict in canonical key order.""" + manifest: dict = {"version": version, "created_at": created_at} + if previous is not None: + manifest["previous"] = previous + if notes is not None: + manifest["notes"] = notes + if min_launcher_version is not None: + manifest["min_launcher_version"] = min_launcher_version + manifest["launcher"] = launcher.to_dict() + manifest["files"] = [f.to_dict() for f in files] + return manifest + + +def canonical_json(manifest: dict) -> bytes: + text = json.dumps(manifest, indent=2, ensure_ascii=False) + return (text + "\n").encode("utf-8") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--source", required=True, type=Path, + help="Path to the client root directory") + parser.add_argument("--version", required=True, + help="Release version, e.g. 2026.04.14-1") + parser.add_argument("--previous", + help="Previous release version, if any") + parser.add_argument("--notes", + help="Release notes (Markdown allowed)") + parser.add_argument("--min-launcher-version", + help="Minimum launcher version that can apply this manifest") + parser.add_argument("--launcher", default="Metin2Launcher.exe", + help="Launcher filename relative to source (default: Metin2Launcher.exe)") + parser.add_argument("--out", type=Path, default=Path("manifest.json"), + help="Output file path (default: ./manifest.json)") + parser.add_argument("--created-at", + help="Override created_at timestamp (default: now, UTC). Useful for reproducible test runs.") + args = parser.parse_args() + + source: Path = args.source.resolve() + if not source.is_dir(): + print(f"error: {source} is not a directory", file=sys.stderr) + return 1 + + created_at = args.created_at or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + launcher_rel = Path(args.launcher) + + launcher, files = walk_client(source, launcher_rel) + + manifest = build_manifest( + version=args.version, + created_at=created_at, + previous=args.previous, + notes=args.notes, + min_launcher_version=args.min_launcher_version, + launcher=launcher, + files=files, + ) + + args.out.write_bytes(canonical_json(manifest)) + + total_size = launcher.size + sum(f.size for f in files) + print( + f"manifest: {args.out} " + f"files: {len(files) + 1} " + f"total: {total_size / (1024 * 1024):.1f} MiB", + file=sys.stderr, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())