#!/usr/bin/env python3 """Prove the build reproducible: build the working tree twice from clean copies in different folders, compare the bytes. Copies every tracked and untracked file (not what .gitignore hides) to build/reproducible/a and build/reproducible/the/other/b, so a leaked path shows up. Each build gets its own Gradle home with --fresh-gradle (slower: Loom sets up Minecraft twice); the pinned tools are shared, since they're checked by hash. On a mismatch it names the jar or pack entries that differ. Usage: tools/check_reproducible.py [--fresh-gradle] """ import argparse import os import shutil import subprocess import sys import zipfile from pathlib import Path import lock ROOT = lock.ROOT WORK = ROOT / "build/reproducible" def build(copy, fresh_gradle): shutil.rmtree(copy, ignore_errors=True) files = subprocess.run(["git", "ls-files", "-co", "--exclude-standard", "-z"], cwd=ROOT, check=True, capture_output=True, text=True).stdout.split("\0") for name in filter(None, files): if (ROOT / name).is_file(): target = copy / name target.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(ROOT / name, target) env = os.environ | {"COBBLESURVIVAL_CACHE": str(lock.CACHE)} if fresh_gradle: env["GRADLE_USER_HOME"] = str(copy.parent / f"{copy.name}-gradle") with open(copy.parent / f"{copy.name}.log", "w") as log: subprocess.run([sys.executable, str(copy / "tools/build.py")], cwd=copy, env=env, stdout=log, stderr=subprocess.STDOUT, check=True) return dict(reversed(line.split(" ", 1)) for line in (copy / "build/SHA256SUMS").read_text().splitlines()) def entries(path): with zipfile.ZipFile(path) as z: return {i.filename: (i.CRC, i.date_time, i.external_attr) for i in z.infolist()} def explain(a, b, name): if not name.endswith((".jar", ".mrpack", ".zip")): return ea, eb = entries(a / name), entries(b / name) for entry in sorted(ea.keys() | eb.keys()): if ea.get(entry) != eb.get(entry): print(f" {entry}: {ea.get(entry)} / {eb.get(entry)}") if list(ea) != list(eb): print(" entries are in a different order") def main(): parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--fresh-gradle", action="store_true", help="give each build its own empty Gradle home") args = parser.parse_args() a, b = WORK / "a", WORK / "the/other/b" first, second = build(a, args.fresh_gradle), build(b, args.fresh_gradle) different = [name for name in sorted(first.keys() | second.keys()) if first.get(name) != second.get(name)] for name in sorted(first): print(f"{'DIFFERENT' if name in different else 'same '} {first[name][:16]} {name}") for name in different: print(f" {name}:") explain(a, b, name) if different: sys.exit(f"{len(different)} of {len(first)} outputs differ") print(f"All {len(first)} outputs are byte-identical.") if __name__ == "__main__": main()