#!/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 a miner: a user who wants only to hold coins must never end up with a CPU miner on disk, and a miner download must never carry a program that handles keys. The two inputs live in different directories (release/miner, release/wallet) so the member lists below cannot accidentally cross. Reproducible: run it anywhere the release binaries exist (a build box, the droplet) and it produces byte-for-identical contents with a printed manifest, so what users download is always accountable to a known set of files. Nothing is downloaded, nothing is packed with UPX or any other runtime packer, and the archives 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 annotations import argparse import hashlib import os import sys import zipfile REPO = 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_sums def 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 built def 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/ 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 False def 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()