forked from metin-server/m2dev-client
Walks a client directory, sha256-hashes every file, emits a canonical JSON manifest matching docs/update-manifest.md. Excludes runtime artifacts (.log, .dxvk-cache, .pdb, .old) and the launcher is broken out as a top-level field rather than an entry in files[]. Does not sign; pair with a separate signer step.
221 lines
6.6 KiB
Python
Executable File
221 lines
6.6 KiB
Python
Executable File
#!/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())
|