#!/usr/bin/env python3 """Build a byte-for-byte reproducible release zip. Two runs over the same input files, on any OS, produce an identical archive: - members are written in the order given (sort them yourself, or pass --sort) and never pick up directory entries; - every ZipInfo carries the fixed timestamp 1980-01-01 00:00:00 (the zip epoch, the same value deploy/miner/build-windows-bundle.py has always used), so neither the build time nor the filesystem mtime leaks in; - external_attr is 0755 for executables (*.exe, *.sh, extensionless) and 0644 for everything else, independent of the umask on the build host; - compression is DEFLATE level 9 via the stdlib, no extra fields, no comments. Usage: python -I scripts/release/make-bundle.py --out dist/NAME.zip --root moonbite-miner \ [--sums dist/SHA256SUMS.txt] [--sort] SRC[=ARCNAME] ... Each positional is a source path, optionally followed by =ARCNAME to rename it inside the archive. ARCNAME is placed under --root. --sums appends a " *" line (and one per member) to the manifest. Run with `python -I` when the inputs came from a download: it stops Python from importing a planted module from the inputs' directory. """ from __future__ import annotations import argparse import hashlib import os import sys import zipfile ZIP_EPOCH = (1980, 1, 1, 0, 0, 0) EXEC_SUFFIXES = (".exe", ".sh", ".bat", ".ps1") def sha256_file(path: str) -> str: h = hashlib.sha256() with open(path, "rb") as f: for chunk in iter(lambda: f.read(1 << 20), b""): h.update(chunk) return h.hexdigest() def is_executable(arcname: str) -> bool: base = os.path.basename(arcname) return base.endswith(EXEC_SUFFIXES) or "." not in base def parse_member(spec: str) -> tuple[str, str]: """'path/to/file' or 'path/to/file=arcname' -> (src, arcname).""" if "=" in spec: src, arc = spec.split("=", 1) else: src, arc = spec, os.path.basename(spec) arc = arc.replace("\\", "/").lstrip("/") if not arc or ".." in arc.split("/"): sys.exit(f"refusing arcname {arc!r} from {spec!r}") return src, arc def build(out: str, root: str, members: list[tuple[str, str]], sort: bool) -> list[tuple[str, str]]: missing = [s for s, _ in members if not os.path.isfile(s)] if missing: sys.exit("missing input files: " + ", ".join(missing)) if sort: members = sorted(members, key=lambda m: m[1]) arcnames = [a for _, a in members] if len(set(arcnames)) != len(arcnames): sys.exit("duplicate arcnames: " + ", ".join(sorted(set(a for a in arcnames if arcnames.count(a) > 1)))) os.makedirs(os.path.dirname(os.path.abspath(out)), exist_ok=True) root = root.strip("/") with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as z: for src, arc in members: full = f"{root}/{arc}" if root else arc info = zipfile.ZipInfo(full, date_time=ZIP_EPOCH) info.compress_type = zipfile.ZIP_DEFLATED info.create_system = 3 # "Unix": makes external_attr the mode bits info.external_attr = (0o755 if is_executable(arc) else 0o644) << 16 with open(src, "rb") as f: z.writestr(info, f.read()) return members def main() -> None: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--out", required=True, help="output .zip path") ap.add_argument("--root", default="", help="top-level folder inside the zip (may be empty)") ap.add_argument("--sums", help="SHA256SUMS.txt to append to (created if missing)") ap.add_argument("--sort", action="store_true", help="sort members by arcname instead of argument order") ap.add_argument("members", nargs="+", metavar="SRC[=ARCNAME]") args = ap.parse_args() members = build(args.out, args.root, [parse_member(m) for m in args.members], args.sort) zip_name = os.path.basename(args.out) zip_hash = sha256_file(args.out) print(f"{zip_hash} {zip_name} ({os.path.getsize(args.out)} bytes)") for src, arc in members: print(f" {sha256_file(src)} {arc}") if args.sums: with open(args.sums, "a", encoding="utf-8", newline="\n") as sf: sf.write(f"{zip_hash} *{zip_name}\n") print("appended ->", args.sums) if __name__ == "__main__": main()