master
Colby Knox Restore 25 files to their state before PR #61: the wiki's pr revert left them truncated to the diff's context 4f3a0f4 8d ago
#!/usr/bin/env python3
"""Build the native (Rust) kicad bridge zip for Windows, and optionally ship it to INSIDERS.

  python3 tools/build_native_release.py                                  # build only
  python3 tools/build_native_release.py --publish --notes "what changed"  # insiders tier

Publishing mirrors tools/build_release.py: push every shipped source file to the page repo
(the rust/ tree), create the release on adom/kicad-bridge and upload the zip, update the
local manifest (version, url, sha256, size), push the INSIDERS manifest to adom/insiders
(the public tier moves only through tools/promote.py with a human's words), and record the
note in RELEASE_NOTES.md. The version must be a plain x.y.z (no prerelease suffix) to
publish; alpha builds are for staging on a pinned box only.

Output: rust/dist/adom-bridge-kicad-native-v<ver>.zip containing kicad-bridge.exe,
bridge.json (spawn.kind exe, verbs generated from the binary's own kicad_describe) and
BRIDGE_VERSION. Version comes from rust/Cargo.toml [workspace.package].version.
The Python bridge keeps shipping through tools/build_release.py until the native binary
passes the verb runner; flipping the public manifest to this zip is a separate, human step.
"""
import hashlib, json, os, re, shutil, subprocess, sys, time, urllib.request, zipfile
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
RUST = ROOT / "rust"
TARGET = "x86_64-pc-windows-gnu"

# The native zip is a Windows x64 exe. Both manifests carry this so a macOS or Linux installer
# refuses with a reason instead of installing something that cannot run (wiki #108).
PLATFORMS = {
    "windows": {"supported": True, "notes": "Pre-built x64 kicad-bridge.exe ships in the zip."},
    "macos": {"supported": False, "notes": "Kyle's build: cargo build --release on a Mac, crates/kicad-platform/src/macos.rs."},
    "linux": {"supported": True, "notes": "x86_64 kicad-bridge (glibc 2.39+: Ubuntu 24.04, Debian 13 and newer) ships in the same zip. kicad-cli verbs, file edits and the KiCad IPC API work; window verbs (screenshots, keys, menus) answer not_supported_on_platform for now. KiCad from the distro, snap or Flatpak (kicad_upgrade installs the Flathub app with --user). Wiki #127."},
}

def authenticode_size(path):
    """Size of the PE certificate table (the Authenticode signature); 0 = unsigned."""
    import struct
    b = Path(path).read_bytes()
    pe = struct.unpack_from("<I", b, 0x3C)[0]
    magic = struct.unpack_from("<H", b, pe + 24)[0]
    dd = pe + 24 + (96 if magic == 0x10B else 112)
    return struct.unpack_from("<II", b, dd + 32)[1]


def sh(cmd, cwd=None):
    r = subprocess.run(cmd, cwd=cwd, text=True, capture_output=True)
    if r.returncode != 0:
        print(r.stdout[-2000:]); print(r.stderr[-4000:]); sys.exit(f"FAILED: {' '.join(cmd)}")
    return r.stdout

def version():
    m = re.search(r'^\[workspace\.package\]\s*\nversion\s*=\s*"([^"]+)"', (RUST / "Cargo.toml").read_text(), re.M)
    if not m: sys.exit("REFUSING: no workspace.package.version in rust/Cargo.toml")
    return m.group(1)

def describe_via_linux_binary():
    """Run the host build for a moment to read the verb list: bridge.json must list every verb or ab will not route it."""
    exe = RUST / "target/release/kicad-bridge"
    p = subprocess.Popen([str(exe), "--port", "47739"], stderr=subprocess.DEVNULL)
    try:
        for _ in range(40):
            try:
                req = urllib.request.Request("http://127.0.0.1:47739/command", data=b'{"command":"kicad_describe"}', headers={"Content-Type": "application/json"})
                with urllib.request.urlopen(req, timeout=2) as r:
                    return json.load(r)
            except Exception:
                time.sleep(0.1)
        sys.exit("REFUSING: the host binary never answered kicad_describe")
    finally:
        p.kill()

def push_chunked(ref, files, msg, cwd=None):
    """Push files to a page repo in chunks; every reply checked (issue #79)."""
    failed = []
    for i in range(0, len(files), 40):
        chunk = files[i:i + 40]
        proc = subprocess.run(["adom-wiki", "repo", "push", ref, "-m", msg, "--files"] + chunk, capture_output=True, text=True, cwd=cwd)
        # already identical on the page (PUSH_UNCHANGED) is success: the release must not refuse a source
        # push that has nothing left to commit (it did after a merged PR, 1.0.59)
        ok = '"ok": true' in proc.stdout or ("PUSH_UNCHANGED" in proc.stdout + proc.stderr and proc.returncode in (0, 1) and '"unchanged": ' in proc.stdout)
        print(f"  push {i // 40 + 1}: {len(chunk)} file(s) -> {'ok' if ok else 'FAILED'}")
        if not ok:
            print((proc.stdout + proc.stderr)[-600:])
            failed.extend(chunk)
    return failed


def main():
    publish = "--publish" in sys.argv
    notes = ""
    if "--notes" in sys.argv:
        notes = sys.argv[sys.argv.index("--notes") + 1]
    if publish and not notes.strip():
        sys.exit("REFUSING: --publish needs --notes \"what changed\" (the page reuses it as the changelog)")
    ver = version()
    # --battery <dir or report.json>: the run that tested this build. Its KiCad version goes into the binary
    # (kicad_readiness), the manifest (testedWith) and the notes; a failed or partial run refuses the release.
    tested = None
    if "--battery" in sys.argv:
        bp = Path(sys.argv[sys.argv.index("--battery") + 1])
        rep = json.loads(((bp / "report.json") if bp.is_dir() else bp).read_text())
        t, tot = rep.get("tested") or {}, rep.get("totals") or {}
        if tot.get("fail") or rep.get("coverage_gap") or rep.get("foreground_grabs"):
            sys.exit(f"REFUSING: the battery in {bp} failed ({tot.get('fail')} fail, coverage gap {rep.get('coverage_gap')}, grabs {len(rep.get('foreground_grabs') or [])})")
        if not t.get("kicad"):
            sys.exit(f"REFUSING: the battery in {bp} does not record the KiCad it ran on (run_verb_tests.py from 1.0.97 does)")
        if t.get("bridge") and t["bridge"] != ver:
            sys.exit(f"REFUSING: the battery tested bridge {t['bridge']}, not {ver}")
        tested = {"kicad": t["kicad"], "battery": f"{tot.get('pass')}/{tot.get('fail')}/{tot.get('skip')}", "target": rep.get("target"), "at": rep.get("started")}
        os.environ["ADOM_TESTED_KICAD"] = t["kicad"]
        notes = (notes.rstrip() + f"\n\nTested on KiCad {t['kicad']} (battery {tested['battery']} pass/fail/skip on {tested['target']}).") if notes else notes
        print(f"tested on KiCad {t['kicad']}, battery {tested['battery']}")
    elif publish:
        print("WARNING: no --battery given: the release will not record which KiCad it was tested on")
    if publish and not re.fullmatch(r"\d+\.\d+\.\d+", ver):
        sys.exit(f"REFUSING: {ver} is not a plain x.y.z; set [workspace.package].version in rust/Cargo.toml before publishing (ab and the tiers want one plain semver per build)")
    print(f"native kicad bridge {ver}")
    dist = RUST / "dist"
    zpath = dist / f"adom-bridge-kicad-native-v{ver}.zip"
    if "--reuse-zip" in sys.argv:
        # Ship the exact zip that was staged and verified on a desktop; never a rebuild of it.
        if not zpath.is_file():
            sys.exit(f"REFUSING: --reuse-zip but {zpath} does not exist")
        with zipfile.ZipFile(zpath) as z:
            verbs = json.loads(z.read("bridge.json"))["verbs"]
            inner = dist / "stage-check.exe"; inner.write_bytes(z.read("kicad-bridge.exe"))
            if "kicad-bridge" not in z.namelist():
                sys.exit("REFUSING: the zip has no Linux binary")
        ok = authenticode_size(inner) > 0; inner.unlink()
        if publish and not ok:
            sys.exit("REFUSING: the exe inside the reused zip is unsigned")
        sha = hashlib.sha256(zpath.read_bytes()).hexdigest()
        print(f"reusing {zpath.name} ({len(verbs)} verbs, sha256 {sha})")
        return publish_zip(ver, zpath, sha, notes, tested) if publish else None
    sh(["cargo", "build", "--release"], cwd=RUST)
    sh(["cargo", "build", "--release", "--target", TARGET], cwd=RUST)
    exe = RUST / "target" / TARGET / "release" / "kicad-bridge.exe"
    desc = describe_via_linux_binary()
    verbs = [v["name"] for v in desc["verbs"]]
    # the README lists every verb, written from this catalog (tools/readme_verbs.py)
    import tempfile
    dpath = Path(tempfile.mkdtemp(prefix="kicad-describe-")) / "describe.json"
    dpath.write_text(json.dumps(desc))
    sh([sys.executable, str(ROOT / "tools" / "readme_verbs.py"), str(dpath)])
    if "kicad_describe" not in verbs or "kicad_uninstall" not in verbs:
        sys.exit("REFUSING: describe or uninstall missing from the catalog")
    # The repo's bridge.json is what ab and catalog.rs's manifest test read: keep its verb list the catalog's
    # (1.0.61 shipped with 211 verbs while bridge.json still listed 133).
    root_manifest = json.loads((ROOT / "bridge.json").read_text())
    if sorted(root_manifest.get("verbs", [])) != sorted(verbs):
        root_manifest["verbs"] = verbs
        (ROOT / "bridge.json").write_text(json.dumps(root_manifest, indent=2) + "\n")
        print(f"bridge.json: verb list synced to the catalog ({len(verbs)} verbs)")
    py = json.loads((ROOT / "bridge.json").read_text())
    manifest = dict(py)
    manifest["version"] = ver
    # One zip, both binaries (wiki #127): ab 2.1.175+ picks this OS's spawn.entrypoints value.
    manifest["spawn"] = {"kind": "exe", "entrypoint": "kicad-bridge.exe", "entrypoints": {"windows": "kicad-bridge.exe", "linux": "kicad-bridge"}, "port": 0, "healthEndpoint": "/status", "stopMethod": "kill", "killImageName": "kicad-bridge.exe"}
    manifest.pop("dependencies", None)
    manifest["verbs"] = verbs
    manifest["runtime"] = "native"
    manifest["platforms"] = PLATFORMS
    dist = RUST / "dist"; dist.mkdir(exist_ok=True)
    stage = dist / "stage"; shutil.rmtree(stage, ignore_errors=True); stage.mkdir()
    shutil.copy2(exe, stage / "kicad-bridge.exe")
    linux_bin = RUST / "target" / "release" / "kicad-bridge"
    shutil.copy2(linux_bin, stage / "kicad-bridge")
    os.chmod(stage / "kicad-bridge", 0o755)
    # SIGNING, the last change to the exe (anything after it would break the signature). Windows 11
    # Smart App Control blocks unsigned exes, so a published build is ALWAYS signed as Adom
    # Industries, Inc.; the signer is a private tool outside this public repo (ADOM_SIGN_EXE,
    # default ~/bin/adom-sign-exe). Every native build up to 1.0.52 shipped unsigned.
    # Two signers, the same certificate: a local helper (ADOM_SIGN_EXE, ~/bin/adom-sign-exe) where one is
    # installed, else Azure Trusted Signing on the signing host through Adom Bridge (tools/sign_azure.py, the
    # path every Adom release uses; its private config is outside this repo).
    signed = False
    if publish or "--sign" in sys.argv:
        signer = os.environ.get("ADOM_SIGN_EXE") or os.path.expanduser("~/bin/adom-sign-exe")
        if os.path.isfile(signer):
            r = subprocess.run([signer, str(stage / "kicad-bridge.exe"), "--bridge", "kicad-bridge"], text=True)
            if r.returncode != 0:
                sys.exit("REFUSING: signing failed (see above); nothing was published")
        else:
            sys.path.insert(0, str(ROOT / "tools"))
            import sign_azure
            report = sign_azure.sign(stage / "kicad-bridge.exe", ver)
            (dist / "signing-report.json").write_text(json.dumps(report, indent=2) + "\n")
        signed = authenticode_size(stage / "kicad-bridge.exe") > 0
        if not signed:
            sys.exit("REFUSING: the signer reported success but the exe carries no signature")
    (stage / "bridge.json").write_text(json.dumps(manifest, indent=2) + "\n")
    (stage / "BRIDGE_VERSION").write_text(ver + "\n")
    # Non-code assets the Python zip shipped and the native verbs still read at run time:
    # the dock card manifest, the taskbar badge, the demo narration and the board template.
    # (No certs: the native build carries its TLS roots in the binary; no scripts.)
    assets = ["dockbar.json", "LICENSE", "SKILL.md", "package.json", "page.json",
              "resources/adom-overlay-32.png", "resources/adom-overlay.ico",
              "templates/blank-board.kicad_pcb"]
    assets += [str(p.relative_to(ROOT)) for p in sorted((ROOT / "resources" / "demo-audio").glob("*")) if p.is_file()]
    missing = [a for a in assets if not (ROOT / a).is_file()]
    if missing:
        sys.exit(f"REFUSING: assets missing from the checkout: {missing}")
    for a in assets:
        dst = stage / a; dst.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(ROOT / a, dst)
    zpath = dist / f"adom-bridge-kicad-native-v{ver}.zip"
    with zipfile.ZipFile(zpath, "w", zipfile.ZIP_DEFLATED) as z:
        for f in sorted(stage.rglob("*")):
            if f.is_file():
                z.write(f, f.relative_to(stage).as_posix())
    sha = hashlib.sha256(zpath.read_bytes()).hexdigest()
    if publish:
        # check what actually ships: the exe INSIDE the zip must be the signed one
        with zipfile.ZipFile(zpath) as z:
            inner = dist / "stage-check.exe"; inner.write_bytes(z.read("kicad-bridge.exe"))
        ok = authenticode_size(inner) > 0; inner.unlink()
        if not ok:
            sys.exit("REFUSING: the exe inside the zip is unsigned; nothing was published")
    print(f"exe signed: {'yes (Adom Industries, Inc.)' if signed else 'NO (local build only; --publish signs)'}")
    print(f"built {zpath.name}\n  verbs : {len(verbs)}\n  linux : {linux_bin.stat().st_size} bytes\n  exe   : {exe.stat().st_size} bytes\n  zip   : {zpath.stat().st_size} bytes\n  sha256: {sha}")
    if not publish:
        print("not published (pass --publish --notes \"...\" to ship to insiders)")
        return
    publish_zip(ver, zpath, sha, notes, tested)


def publish_zip(ver, zpath, sha, notes, tested=None):
    from datetime import datetime, timezone
    REF = "adom/kicad-bridge"
    # 1. The repo must tell the same story as the zip: push every rust source file.
    # never an editor's or a patch's leftovers (.orig, .rej, .bak): two .orig copies once rode along this way
    src = [str(p.relative_to(ROOT)) for p in sorted((RUST).rglob("*")) if p.is_file() and "target" not in p.parts and "dist" not in p.parts
           and p.suffix not in (".orig", ".rej", ".bak")]
    src += ["tools/build_native_release.py", "tools/sign_azure.py", "tools/sync_standard_names.py", "tools/readme_verbs.py", "bridge.json", "README.md"]
    msg = f"{ver}: {notes}"
    failed = push_chunked(REF, src, msg, cwd=str(ROOT))
    if failed:
        sys.exit(f"REFUSING TO RELEASE: source push failed for {len(failed)} file(s)")
    # 2. Release row + zip on the product page (never on adom/insiders: adom/wiki#170).
    subprocess.run(["adom-wiki", "release", "create", REF, ver, "--changelog", notes], check=False)
    subprocess.run(["adom-wiki", "release", "upload", REF, ver, str(zpath)], check=False)
    # 3. Manifest: same document shape as the Python bridge's, pointing at this zip.
    mpath = ROOT / "adom-bridge-kicad-manifest.json"
    m = json.loads(mpath.read_text())
    m.update(version=ver, sha256=sha, size=zpath.stat().st_size,
             url=f"https://wiki.adom.inc/download/{REF}/{ver}/{zpath.name}",
             released_at=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
             runtime="native")
    if tested:
        m["testedWith"] = tested
    else:
        m.pop("testedWith", None)
    # ab reads every platforms entry as a download (url, sha256, size): one zip serves both OSes.
    dl = {"url": m["url"], "sha256": sha, "size": zpath.stat().st_size}
    m["platforms"] = {k: dict(v, **dl) for k, v in PLATFORMS.items() if v.get("supported")}
    # NOT written back to mpath: the page's adom-bridge-kicad-manifest.json IS the public tier
    # (bridge.json updateManifestUrl), so a local copy at the insiders version is one careless push
    # away from promoting it. Only tools/promote.py moves it.
    # 4. INSIDERS tier only. The public manifest moves through tools/promote.py.
    ins = dict(m); ins["tier"] = "insiders"
    import tempfile
    d = Path(tempfile.mkdtemp(prefix="kicad-native-insiders-"))
    (d / "kicad-bridge").mkdir()
    (d / "kicad-bridge" / "version.json").write_text(json.dumps(ins, indent=2) + "\n")
    r = subprocess.run(["adom-wiki", "repo", "push", "adom/insiders", "-m", f"kicad-bridge {ver} (native): {notes}", "--files", "kicad-bridge/version.json"], cwd=str(d), capture_output=True, text=True)
    if '"ok": true' not in r.stdout:
        sys.exit("REFUSING: the insiders manifest push failed:\n" + (r.stdout + r.stderr)[-600:])
    # 5. Ledger.
    led = ROOT / "RELEASE_NOTES.md"
    entry = f"## {ver}  ({datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')})\n\nNATIVE (Rust) build. {notes}\n\n"
    prior = led.read_text(encoding="utf-8")
    head, _, rest = prior.partition("\n\n## ")
    led.write_text((head + "\n\n" + entry + ("## " + rest if rest else "")) if rest else (prior + entry), encoding="utf-8")
    # 5b. The EDA Bridge Standard measures this bridge from its verb list: refresh it with every release.
    try:
        refresh_standard(ver, zpath)
    except Exception as e:  # the release has shipped; the standard can be refreshed by hand
        print(f"WARNING: refreshing the EDA Bridge Standard failed ({e!r}); push reference/kicad-{ver}.json by hand")
    # 6. The page repo keeps the ledger (it was left local before). Never the public manifest here.
    failed = push_chunked(REF, ["RELEASE_NOTES.md"], f"{ver}: release record", cwd=str(ROOT))
    if failed:
        print("WARNING: the RELEASE_NOTES.md push failed; push it by hand")
    print(f"\nSHIPPED TO INSIDERS ({ver}, native). Public tier untouched.")
    print(f"  Promote with: python3 tools/promote.py {ver} --because \"<your words>\"")
    print("  Boxes pick it up on the 4 h poll, or now with bridge_install {manifestUrl: the insiders version.json, force: true}")

def refresh_standard(ver, zpath):
    """Push reference/kicad-<ver>.json (this release's verbs, aliases included) to adom/eda-bridge-standard, and nothing
    else: standard-lane owns the page and regenerates its coverage (pinned issue #3 there). The coverage KiCad would
    get is computed in a scratch clone and printed, never pushed. A failure here is reported, never fatal: the release
    has already shipped."""
    import tempfile
    with zipfile.ZipFile(zpath) as z:
        verbs = sorted(set(json.loads(z.read("bridge.json"))["verbs"]))
    base = Path(tempfile.mkdtemp(prefix="eda-std-"))
    fresh, check = base / "std", base / "check"
    r = subprocess.run(["adom-wiki", "repo", "clone", "adom/eda-bridge-standard", "--dir", str(fresh)], capture_output=True, text=True)
    if r.returncode != 0:
        print(f"WARNING: could not clone adom/eda-bridge-standard; push reference/kicad-{ver}.json by hand ({r.stderr[-200:]})")
        return
    ref = f"reference/kicad-{ver}.json"
    (fresh / ref).write_text(json.dumps({"bridge": "kicad", "version": ver, "verbs": verbs}, indent=1) + "\n")
    files = [ref]
    # An exact equivalent the release now answers by the standard's own name (an alias in catalog::ALIASES pointing
    # at the same verb) is dropped in the same push, so no regeneration ever sees neither credit it. A partial one
    # stays: the equivalents entry wins over the name match and keeps the credit honest.
    eqf = fresh / "tools/equivalents/kicad.json"
    if eqf.exists():
        aliases = dict(re.findall(r'\("kicad_(\w+)", "kicad_(\w+)"\)', (RUST / "crates/kicad-bridge/src/catalog.rs").read_text()))
        eq = json.loads(eqf.read_text())
        # an entry is [verbs, match] or, with a note, [verbs, match, note]
        named = [k for k, e in eq.items() if len(e) >= 2 and e[1] == "exact" and aliases.get(k) == str(e[0]).strip() and f"kicad_{k}" in verbs]
        if named:
            for k in named:
                eq.pop(k)
            eqf.write_text(json.dumps(eq, indent=1) + "\n")
            files.append("tools/equivalents/kicad.json")
            print(f"EDA Bridge Standard: dropping equivalents now answered by name: {', '.join(named)}")
    r = subprocess.run(["adom-wiki", "repo", "push", "adom/eda-bridge-standard", "-m", f"kicad-lane: KiCad bridge {ver} reference", "--files"] + files,
                       cwd=fresh, capture_output=True, text=True)
    ok = '"ok": true' in r.stdout or "PUSH_UNCHANGED" in r.stdout + r.stderr
    if not ok:
        print(f"EDA Bridge Standard: push of {ref} FAILED: {(r.stdout + r.stderr)[-300:]}")
        return
    # read it back from a second clone, and work out the coverage standard-lane's regeneration will show
    subprocess.run(["adom-wiki", "repo", "clone", "adom/eda-bridge-standard", "--dir", str(check)], capture_output=True, text=True)
    same = (check / ref).exists() and (check / ref).read_text() == (fresh / ref).read_text()
    g = subprocess.run([sys.executable, "tools/generate.py"], cwd=check, capture_output=True, text=True)
    print(f"EDA Bridge Standard: pushed {', '.join(files)} ({'read back' if same else 'NOT found on a fresh clone'}); "
          f"expected after regeneration: {g.stdout.strip() or g.stderr[-200:]}. Now ping standard-lane to regenerate: "
          f"a SendMessage to the session named in adom/eda-bridge-standard issue #3, or a 'kicad-lane:' issue there.")


if __name__ == "__main__":
    main()