SciCraft/tools/validate_pack.py
MiTHRAL c9c33c48c5
Some checks failed
Build & Release Pack / build (push) Failing after 1m56s
Make CI validation stdlib-only for Python 3.9 runner container
The node:20-bullseye runner image ships Python 3.9, which lacks
tomllib (3.11+). Extract inline workflow validation into
tools/validate_pack.py using regex-based parsing of the machine-
generated packwiz TOML subset, and call it from the workflow.
2026-08-19 15:38:29 -04:00

146 lines
No EOL
5.1 KiB
Python

#!/usr/bin/env python3
"""Validate a packwiz pack: index integrity, download resolvability, CF export completeness.
Stdlib-only (no tomllib) so it runs on Python 3.9 in CI containers.
Parses the limited, machine-generated TOML subset used by packwiz metadata.
"""
import glob
import json
import os
import re
import sys
import zipfile
PW_TOML = re.compile(r"^(\w[\w.-]*)\s*=\s*(?:\"([^\"]*)\"|([0-9]+))", re.M)
SECTION = re.compile(r"^\[\[?([\w.\-]+)\]\]?$", re.M)
def parse_toml(path):
"""Parse the minimal TOML subset packwiz emits: scalars + sections + array-of-tables."""
data = {}
current = data
array_section = None
with open(path, encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line or line.startswith("#"):
continue
m = SECTION.match(line)
if m:
name = m.group(1)
if line.startswith("[["):
key = name
array_section = key
data.setdefault(key, [])
current = {}
data[key].append(current)
else:
key = name.replace("-", "_").replace(".", "_")
if key not in data or not isinstance(data[key], dict):
data[key] = {}
current = data[key]
array_section = None
continue
m = PW_TOML.match(line)
if m:
key = m.group(1).replace("-", "_")
value = m.group(2) if m.group(2) is not None else m.group(3)
current[key] = value
return data
def check_integrity(pack_dir):
index = parse_toml(os.path.join(pack_dir, "index.toml"))
indexed = {e.get("file") for e in index.get("files", []) if e.get("file")}
mods = [os.path.basename(f) for f in glob.glob(os.path.join(pack_dir, "mods", "*.pw.toml"))]
missing = [f for f in mods if "mods/%s" % f not in indexed]
if missing:
print("ERROR: pw.toml files missing from index.toml:", missing)
sys.exit(1)
extra = [f for f in indexed if f.startswith("mods/") and f not in {"mods/%s" % m for m in mods}]
if extra:
print("ERROR: index.toml entries missing from mods/:", extra)
sys.exit(1)
errors = 0
for f in mods:
data = parse_toml(os.path.join(pack_dir, "mods", f))
d = data.get("download", {})
if not d or not d.get("hash"):
print("ERROR: %s missing [download] hash" % f)
errors += 1
continue
if not d.get("url") and not d.get("mode", "").startswith("metadata:"):
print("ERROR: %s missing [download] url or metadata mode" % f)
errors += 1
if d.get("url", "").startswith("http://"):
print("ERROR: %s uses insecure http url" % f)
errors += 1
hf = d.get("hash-format", "sha512")
if not re.fullmatch(r"[0-9a-f]+", d["hash"]):
print("ERROR: %s hash not hex (%s)" % (f, hf))
errors += 1
if errors:
sys.exit(1)
print("OK: %d mods indexed, all have [download] url or metadata source" % len(mods))
def check_sanity(pack_dir):
bad = []
url_count = 0
meta_count = 0
for f in sorted(glob.glob(os.path.join(pack_dir, "mods", "*.pw.toml"))):
d = parse_toml(f).get("download", {})
if d.get("url"):
url_count += 1
elif d.get("mode", "").startswith("metadata:"):
meta_count += 1
else:
bad.append(f)
if bad:
print("ERROR: mods without download.url or metadata mode:")
for f in bad:
print(" " + f)
sys.exit(1)
print("OK: %d direct urls + %d metadata sources = all mods resolvable" % (url_count, meta_count))
def check_cf(pack_dir, zip_path):
z = zipfile.ZipFile(zip_path)
manifest = json.loads(z.read("manifest.json"))
pids = {f["projectID"] for f in manifest["files"]}
ov = {n.split("overrides/mods/")[1] for n in z.namelist() if n.startswith("overrides/mods/")}
missing = []
for f in glob.glob(os.path.join(pack_dir, "mods", "*.pw.toml")):
data = parse_toml(f)
if data.get("side") == "server":
continue
fn = data.get("filename")
cf = data.get("update_curseforge", {}).get("project_id")
if cf:
if int(cf) not in pids:
missing.append(fn)
elif fn not in ov:
missing.append(fn)
if missing:
print("ERROR: mods missing from CurseForge export:", missing)
sys.exit(1)
print("OK: CurseForge export complete (%d manifest files + %d overrides)" % (len(pids), len(ov)))
def main():
if len(sys.argv) < 2:
print("usage: validate_pack.py <pack_dir> [cf <zip>]", file=sys.stderr)
sys.exit(2)
pack_dir = sys.argv[1]
cmd = sys.argv[2] if len(sys.argv) > 2 else "integrity"
if cmd == "cf":
check_cf(pack_dir, sys.argv[3])
elif cmd == "sanity":
check_sanity(pack_dir)
else:
check_integrity(pack_dir)
if __name__ == "__main__":
main()