git / moonbite-network / deploy/miner/build-windows-bundle.py · raw

#!/usr/bin/env python3"""Assemble the Windows release archives the site serves under /download/.Two archives, always separate (docs/ADR-013-miner-distribution-policy.md):  moonbite-miner-windows-x86_64.zip   node + CLI + mine scripts + docs. NO wallet.  moonbite-wallet-windows-x86_64.zip  the desktop wallet app only. NO miner.The miner is never bundled with the wallet and the wallet never carries aminer: a user who wants only to hold coins must never end up with a CPU mineron disk, and a miner download must never carry a program that handles keys.The two inputs live in different directories (release/miner, release/wallet) sothe member lists below cannot accidentally cross.Reproducible: run it anywhere the release binaries exist (a build box, thedroplet) and it produces byte-for-identical contents with a printed manifest,so what users download is always accountable to a known set of files. Nothingis downloaded, nothing is packed with UPX or any other runtime packer, and thearchives contain no installer, no auto-start hook and no updater.    python deploy/miner/build-windows-bundle.py [--artifact miner|wallet|all]                                                [--out DIR] [--stage]--artifact  which archive(s) to build (default: all that have their inputs)--out       where to write the .zip files + SHA256SUMS.txt (default: release/dist)--stage     also copy the archives into the app's live download dir so the            running server serves them immediately (resolved the same way            web_app does)"""from __future__ import annotationsimport argparseimport hashlibimport osimport sysimport zipfileREPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))# Archive definitions. Each has its own source directory; a member list may# only name files in that directory, so the miner archive can never pick up# the wallet and vice versa. Order is fixed so the archives are reproducible.ARTIFACTS = {    "miner": {        "zip_name": "moonbite-miner-windows-x86_64.zip",        # The folder inside is plain "moonbite-miner" to match the bundle        # production already serves, so a rebuild is a drop-in.        "bundle_dir": "moonbite-miner",        "src": os.path.join(REPO, "release", "miner"),        "members": [            ("moonbited.exe", "moonbited.exe"),            ("moonbite-cli.exe", "moonbite-cli.exe"),            ("mine.ps1", "mine.ps1"),            ("Mine-MoonBite.bat", "Mine-MoonBite.bat"),            ("README.md", "README.md"),            ("ANTIVIRUS.md", "ANTIVIRUS.md"),        ],        # Files that must NOT be in this archive under any name.        "forbidden": ["moonbite-wallet.exe", "moonbite-wallet-desktop.py"],    },    "wallet": {        "zip_name": "moonbite-wallet-windows-x86_64.zip",        "bundle_dir": "moonbite-wallet",        "src": os.path.join(REPO, "release", "wallet"),        "members": [            ("moonbite-wallet.exe", "moonbite-wallet.exe"),            ("README.md", "README.md"),        ],        "forbidden": ["moonbited.exe", "moonbite-cli.exe", "mine.ps1",                      "Mine-MoonBite.bat", "mine.sh"],    },}# Kept for callers that imported the old module-level names.ZIP_NAME = ARTIFACTS["miner"]["zip_name"]BUNDLE_DIR = ARTIFACTS["miner"]["bundle_dir"]SRC = ARTIFACTS["miner"]["src"]MEMBERS = ARTIFACTS["miner"]["members"]def _sha256(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 _check_separation(spec: dict) -> None:    """Refuse to build an archive whose member list crosses the miner/wallet    line. This is a policy guard, not a convenience: see ADR-013."""    names = {s for s, _ in spec["members"]} | {a for _, a in spec["members"]}    crossed = sorted(names & set(spec["forbidden"]))    if crossed:        sys.exit(f"policy violation: {spec['zip_name']} would contain "                 f"{', '.join(crossed)} (miner and wallet ship separately; ADR-013)")def build_one(name: str, out_dir: str) -> tuple[str, list[tuple[str, str]]]:    """Build one archive. Returns (zip_path, [(arcname, sha256) for .exe members])."""    spec = ARTIFACTS[name]    _check_separation(spec)    src_dir = spec["src"]    missing = [s for s, _ in spec["members"] if not os.path.isfile(os.path.join(src_dir, s))]    if missing:        sys.exit(f"missing release files in {src_dir}: {', '.join(missing)}")    os.makedirs(out_dir, exist_ok=True)    zip_path = os.path.join(out_dir, spec["zip_name"])    # Deterministic: fixed member order, fixed timestamps, fixed compression.    with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED,                         compresslevel=9) as z:        for src_name, arc in spec["members"]:            src = os.path.join(src_dir, src_name)            info = zipfile.ZipInfo(f"{spec['bundle_dir']}/{arc}",                                   date_time=(1980, 1, 1, 0, 0, 0))            info.compress_type = zipfile.ZIP_DEFLATED            # 0644 for docs/scripts, 0755 for the executables.            info.external_attr = (0o755 if arc.endswith(".exe") else 0o644) << 16            with open(src, "rb") as f:                z.writestr(info, f.read())    exe_sums = [(arc, _sha256(os.path.join(src_dir, s)))                for s, arc in spec["members"] if s.endswith(".exe")]    print("built:", zip_path)    print(f"{'file':36} {'bytes':>12}  sha256")    for src_name, _ in spec["members"]:        p = os.path.join(src_dir, src_name)        print(f"{src_name:36} {os.path.getsize(p):>12}  {_sha256(p)}")    print("-" * 60)    print(f"{spec['zip_name']:36} {os.path.getsize(zip_path):>12}  {_sha256(zip_path)}")    print()    return zip_path, exe_sumsdef build(out_dir: str, which: list[str]) -> list[str]:    """Build the requested archives and ONE SHA256SUMS.txt covering all of them    (the zips and every .exe inside them), written from the artifacts just    built -- never hand-typed, never allowed to drift from what ships."""    built: list[str] = []    lines: list[str] = []    for name in which:        zip_path, exe_sums = build_one(name, out_dir)        built.append(zip_path)        lines.append(f"{_sha256(zip_path)} *{os.path.basename(zip_path)}")        for arc, digest in exe_sums:            lines.append(f"{digest} *{arc}")    sums_path = os.path.join(out_dir, "SHA256SUMS.txt")    with open(sums_path, "w", encoding="utf-8", newline="\n") as sf:        sf.write("\n".join(lines) + "\n")    print("sums :", sums_path)    print("\nNEXT: sign the manifest before publishing --")    print(f"    minisign -Sm {sums_path}   # produces {os.path.basename(sums_path)}.minisig")    print("  then --stage (or copy) the zips, SHA256SUMS.txt and .minisig together.")    print("  Submit each new .exe to the AV vendors' false-positive portals")    print("  (release/miner/ANTIVIRUS.md). See deploy/RELEASE-SIGNING.md.")    return builtdef stage(zip_paths: list[str]) -> None:    """Copy the built archives + their checksum manifest (+ signature, if    present) into the dir web_app serves downloads from.    Refuses to stage if a required signature is missing once signing has been    set up, so an unsigned bundle can never quietly reach users. The manifest    and signature are served next to the zips (the /downloads/<file> route), so    users can fetch and verify them.    """    sys.path.insert(0, REPO)    import storage  # noqa: E402  (resolve the same dir the route uses)    src_dir = os.path.dirname(zip_paths[0])    sums = os.path.join(src_dir, "SHA256SUMS.txt")    sig = sums + ".minisig"    pub = os.path.join(REPO, "deploy", "moonbite-release.pub")    if not os.path.isfile(sums):        sys.exit("refusing to stage: SHA256SUMS.txt missing (run build first).")    # If a real release pubkey is configured, a signature is mandatory.    if _pubkey_configured(pub) and not os.path.isfile(sig):        sys.exit(f"refusing to stage: {os.path.basename(sig)} missing but a "                 "release key is configured. Sign the manifest first:\n"                 f"    minisign -Sm {sums}\nSee deploy/RELEASE-SIGNING.md.")    dest_dir = storage.data_path("downloads", "MOONBITE_DOWNLOAD_DIR")    os.makedirs(dest_dir, exist_ok=True)    for src in (*zip_paths, sums, sig):        if not os.path.isfile(src):            continue        dest = os.path.join(dest_dir, os.path.basename(src))        with open(src, "rb") as fsrc, open(dest, "wb") as fdst:            fdst.write(fsrc.read())        print("staged ->", dest)def _pubkey_configured(pub_path: str) -> bool:    """True once the placeholder release pubkey has been replaced with a real    minisign public key line."""    if not os.path.isfile(pub_path):        return False    for line in open(pub_path, encoding="utf-8"):        line = line.strip()        if line and not line.startswith(("#", "untrusted comment")):            return not line.startswith("REPLACE_ME")    return Falsedef main() -> None:    ap = argparse.ArgumentParser()    ap.add_argument("--artifact", choices=["miner", "wallet", "all"], default="all",                    help="which archive to build (default: all whose inputs exist)")    ap.add_argument("--out", default=os.path.join(REPO, "release", "dist"))    ap.add_argument("--stage", action="store_true")    args = ap.parse_args()    if args.artifact == "all":        which = [n for n in ("miner", "wallet")                 if all(os.path.isfile(os.path.join(ARTIFACTS[n]["src"], s))                        for s, _ in ARTIFACTS[n]["members"])]        if not which:            sys.exit("no artifact has all its inputs present; see release/README.md")        skipped = [n for n in ("miner", "wallet") if n not in which]        if skipped:            print("note: skipping", ", ".join(skipped), "(inputs missing)")    else:        which = [args.artifact]    zips = build(args.out, which)    if args.stage:        stage(zips)if __name__ == "__main__":    main()