scripts: add make-manifest.py manifest generator
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.
This commit is contained in:
220
scripts/make-manifest.py
Executable file
220
scripts/make-manifest.py
Executable file
@@ -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())
|
||||
Reference in New Issue
Block a user