Merge pull request 'docs: design the update manager + manifest generator' (#3) from jann/m2dev-client:claude/update-manager into main

Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
2026-04-14 11:54:39 +02:00
7 changed files with 1030 additions and 0 deletions

220
scripts/make-manifest.py Executable file
View 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())

102
scripts/sign-manifest.py Executable file
View File

@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""Sign a manifest.json with the launcher's Ed25519 private key.
Produces a detached signature at the same path with a ``.sig`` suffix. The
signature is over the **literal bytes** of the manifest file — do not
re-serialize the JSON before signing, or the launcher will refuse the result.
Usage:
sign-manifest.py --manifest /path/to/manifest.json \\
--key ~/.config/metin/launcher-signing-key \\
[--out manifest.json.sig]
The private key file is 32 raw bytes (no PEM header, no encryption). Create it
with:
python3 -c 'from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey; from cryptography.hazmat.primitives import serialization; p=Ed25519PrivateKey.generate(); open("key","wb").write(p.private_bytes(serialization.Encoding.Raw,serialization.PrivateFormat.Raw,serialization.NoEncryption())); print(p.public_key().public_bytes(serialization.Encoding.Raw,serialization.PublicFormat.Raw).hex())'
Keep the file ``chmod 600`` on the release machine. Never commit it to any repo
and never store it in CI secrets unless the release flow is moved off the CI
runner entirely.
"""
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
try:
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives import serialization
except ImportError:
print("error: this script requires the 'cryptography' package "
"(pip install cryptography, or dnf install python3-cryptography)",
file=sys.stderr)
raise SystemExit(1)
def load_private_key(key_path: Path) -> Ed25519PrivateKey:
raw = key_path.read_bytes()
if len(raw) != 32:
raise SystemExit(
f"error: private key at {key_path} is {len(raw)} bytes, expected 32 "
f"(a raw Ed25519 seed, not a PEM file)"
)
return Ed25519PrivateKey.from_private_bytes(raw)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--manifest", required=True, type=Path,
help="Path to manifest.json to sign")
parser.add_argument("--key", required=True, type=Path,
help="Path to the raw 32-byte Ed25519 private key")
parser.add_argument("--out", type=Path,
help="Signature output path (default: <manifest>.sig)")
args = parser.parse_args()
manifest_path: Path = args.manifest
if not manifest_path.is_file():
print(f"error: manifest not found: {manifest_path}", file=sys.stderr)
return 1
key_path: Path = args.key
if not key_path.is_file():
print(f"error: private key not found: {key_path}", file=sys.stderr)
return 1
key_mode = key_path.stat().st_mode & 0o777
if key_mode & 0o077:
print(
f"warning: private key {key_path} is readable by group or world "
f"(mode {oct(key_mode)}). chmod 600 recommended.",
file=sys.stderr,
)
private_key = load_private_key(key_path)
manifest_bytes = manifest_path.read_bytes()
signature = private_key.sign(manifest_bytes)
assert len(signature) == 64, "Ed25519 signatures are always 64 bytes"
out_path: Path = args.out or manifest_path.with_suffix(manifest_path.suffix + ".sig")
out_path.write_bytes(signature)
os.chmod(out_path, 0o644)
public_hex = private_key.public_key().public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
).hex()
print(
f"signed {manifest_path} -> {out_path} "
f"(64 bytes, verify with public key {public_hex})",
file=sys.stderr,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())