#!/usr/bin/env python3 """Build the companion mod and export the pack. Outputs, all under build/: stage/full/ every mod plus the companion jar; the Prism instance and the server smoke test install this -.mrpack Modrinth pack --curseforge.zip CurseForge pack SHA256SUMS what the build produced Reproducible: the same commit gives the same bytes, whatever the machine has installed and wherever the repo is. Every tool is pinned (tools/lock.py), and tools/check_reproducible.py proves it with two builds from clean copies. Each platform export only lists mods that platform hosts. A mod hosted on just one platform is left out of the other export and reported; it is never copied into overrides/, because the pack must not bundle third-party jars. The companion mod is our own work, so it is the one jar allowed in overrides/. Usage: tools/build.py [--skip-mod] """ import argparse import json import os import shutil import subprocess import sys import tomllib import zipfile from pathlib import Path import lock ROOT = Path(__file__).resolve().parent.parent PACK = ROOT / "pack" BUILD = ROOT / "build" MODRINTH_HOSTS = ("https://cdn.modrinth.com/", "https://github.com/", "https://raw.githubusercontent.com/", "https://gitlab.com/") def run(cmd, cwd): print(f"$ {' '.join(map(str, cmd))} (in {cwd.relative_to(ROOT) if cwd.is_relative_to(ROOT) else cwd})") subprocess.run(cmd, cwd=cwd, check=True) def packwiz(*args, cwd): run([lock.packwiz(), *args, "-y"], cwd) def gradle_properties(): return dict(line.split("=", 1) for line in (ROOT / "mod/gradle.properties").read_text().splitlines() if "=" in line and not line.startswith("#")) def check_versions(): """mod/gradle.properties compiles against the versions the pack installs.""" props = gradle_properties() if props["neoforge_version"] != tomllib.loads((PACK / "pack.toml").read_text())["versions"]["neoforge"]: sys.exit("mod/gradle.properties and pack/pack.toml name different NeoForge versions") for slug, prop in (("cobblemon", "cobblemon_version"), ("kotlin-for-forge", "kotlinforforge_version"), ("jei", "jei_version")): filename = tomllib.loads((PACK / f"mods/{slug}.pw.toml").read_text())["filename"] if props[prop] not in filename: sys.exit(f"mod/gradle.properties says {prop}={props[prop]}, the pack installs {filename}") def build_mod(): """From clean, with only the pinned JDK to compile against (an incremental build can differ from a full one).""" jdk = lock.jdk() subprocess.run(["./gradlew", "clean", "build", "--console=plain", "--no-build-cache", f"-Porg.gradle.java.installations.paths={jdk}", "-Porg.gradle.java.installations.auto-detect=false", "-Porg.gradle.java.installations.auto-download=false"], cwd=ROOT / "mod", env=os.environ | {"JAVA_HOME": str(jdk)}, check=True) def companion_jar(): props = gradle_properties() jar = ROOT / "mod/build/libs" / f"{props['mod_id']}-{props['mod_version']}.jar" if not jar.exists(): sys.exit(f"companion jar not found: {jar} (run without --skip-mod)") return jar def platforms_of(meta): hosts = set() if "curseforge" in meta.get("update", {}): hosts.add("curseforge") if meta.get("download", {}).get("url", "").startswith(MODRINTH_HOSTS): hosts.add("modrinth") return hosts def stage(name, jar, platform=None): dest = BUILD / "stage" / name shutil.rmtree(dest, ignore_errors=True) shutil.copytree(PACK, dest) left_out = [] if platform: for path in sorted((dest / "mods").glob("*.pw.toml")): meta = tomllib.loads(path.read_text()) if platform not in platforms_of(meta): left_out.append(meta["name"]) path.unlink() shutil.copy2(jar, dest / "mods" / jar.name) packwiz("refresh", cwd=dest) return dest, left_out def check_overrides(archive, jar): bundled = [n for n in zipfile.ZipFile(archive).namelist() if n.endswith(".jar") and Path(n).name != jar.name] if bundled: sys.exit(f"{archive.name} would bundle third-party jars: {bundled}") def check_default_shader(): """The shader pack named in the default Iris settings is the file the pack installs.""" settings = (PACK / "config/defaultoptions/extra/config/iris.properties").read_text() named = next(line.split("=", 1)[1] for line in settings.splitlines() if line.startswith("shaderPack=")) files = [tomllib.loads(p.read_text())["filename"] for p in (PACK / "shaderpacks").glob("*.pw.toml")] if named not in files: sys.exit(f"config/defaultoptions/extra/config/iris.properties names {named}, the pack has {files}") def normalise_export(archive): """packwiz writes an export's entries, the manifest's file list and the mod list in a random order: sort them.""" with zipfile.ZipFile(archive) as z: files = {i.filename: z.read(i.filename) for i in z.infolist() if not i.is_dir()} if "manifest.json" in files: manifest = json.loads(files["manifest.json"]) manifest["files"].sort(key=lambda f: (f["projectID"], f["fileID"])) files["manifest.json"] = (json.dumps(manifest, indent=2, ensure_ascii=False) + "\n").encode() if "modlist.html" in files: lines = files["modlist.html"].decode().splitlines() items = sorted(line for line in lines if line.lstrip().startswith("
  • ")) files["modlist.html"] = "\n".join(["
      ", *items, "
    ", ""]).encode() with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as z: for name in sorted(files): info = zipfile.ZipInfo(name, (1980, 1, 1, 0, 0, 0)) info.compress_type = zipfile.ZIP_DEFLATED info.external_attr = 0o644 << 16 z.writestr(info, files[name]) def write_checksums(paths): (BUILD / "SHA256SUMS").write_text("".join(f"{lock.digest(p)} {p.relative_to(ROOT).as_posix()}\n" for p in sorted(paths))) def main(): lock.enter_venv() parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--skip-mod", action="store_true", help="reuse the last companion mod build") args = parser.parse_args() check_versions() check_default_shader() # Written from the pack's data, so these go first: the drop pages ship in the data pack the mod embeds. run([sys.executable, str(ROOT / "tools/jei_sources.py")], ROOT) run([sys.executable, str(ROOT / "tools/quests.py")], ROOT) # the quest book (Phase 8) from docs/quests.yaml if not args.skip_mod: build_mod() jar = companion_jar() packwiz("refresh", cwd=PACK) pack = tomllib.loads((PACK / "pack.toml").read_text()) base = f"{pack['name'].lower().replace(' ', '-')}-{pack['version']}" stage("full", jar) report = {} for platform, suffix, command in (("modrinth", ".mrpack", "modrinth"), ("curseforge", "-curseforge.zip", "curseforge")): dest, left_out = stage(platform, jar, platform) out = BUILD / f"{base}{suffix}" out.unlink(missing_ok=True) packwiz(command, "export", "-o", str(out), cwd=dest) normalise_export(out) check_overrides(out, jar) report[out.name] = left_out write_checksums([jar, BUILD / "stage/full/pack.toml", BUILD / "stage/full/index.toml", *(BUILD / name for name in report)]) print("\nExports (no third-party jars in overrides):") for name, left_out in report.items(): print(f" build/{name}") for mod in left_out: print(f" left out, not hosted on this platform: {mod}") print(" build/stage/full/pack.toml (all mods; used by Prism sync and the smoke test)") if __name__ == "__main__": main()