Some checks failed
Build & Release Pack / build (push) Failing after 1m56s
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.
146 lines
No EOL
5.1 KiB
Python
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() |