← Commit history

release.py: every shipped exe is Authenticode-signed before packaging; build_zip refuses an unsigned exe; --stage mode (signed release asset, no tier). Signing identifiers live in a private config outside the repo.

Colby Knox ·bbac1b431f ·22d ago ·parent 1981b4c
Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 file changed +164−2
scripts/release.py+164−2
@@ -15,6 +15,16 @@ pattern: adom/insiders skills + adom/pup-bridge tools/ship.sh, which did it firs                                                    #    (promotion_attestation/promoted_by/_at).   python3 scripts/release.py --rollback <ver> --attest "<words>" --by "<name>"                                                    # re-point PUBLIC at an older archived build.+  python3 scripts/release.py --stage "<log>"       # build, sign, upload the release asset ONLY: no tier,+                                                   #    no skills pkg (a build waiting for a live battery).++SIGNING: every .exe the zip ships is Authenticode-signed as "Adom Industries, Inc." before+packaging (Windows Smart App Control blocks unsigned exes). The signing identifiers live in a+PRIVATE config outside this public repo ($ADOM_SIGNING_DIR, default ~/.adom/signing/altium-bridge:+config.json + sign-verify.ps1); the exe is signed on the Windows host that config names, through+that host's signer login, verified there (Get-AuthenticodeSignature + signtool verify /pa), pulled+back with a hash check, and verified AGAIN here. build_zip refuses any unsigned .exe, so no path+through this script can package one. Signing is the last change to the file before packaging.  Rules (insiders-tier-publish, verbatim where it matters): every publish is insiders; public only on a human's words; an AI never promotes on its own judgment; one plain semver per build in both@@ -53,7 +63,7 @@ PUBLIC_MANIFEST = ROOT / "adom-bridge-altium-manifest.json" INCLUDE = [     ("bridge.json", SRC / "bridge.json"),     ("BRIDGE_VERSION", SRC / "BRIDGE_VERSION"),-    ("altium-bridge.exe", EXE),+    ("altium-bridge.exe", DIST / "signed" / "altium-bridge.exe"),     ("agent/AdomAgent.pas.tmpl", SRC / "agent" / "AdomAgent.pas.tmpl"),     ("agent/AdomAgent.PrjScr", SRC / "agent" / "AdomAgent.PrjScr"),     ("resources/adom-overlay-32.png", SRC / "resources" / "adom-overlay-32.png"),@@ -146,10 +156,135 @@ def build_exe():         raise SystemExit(f"exe missing after build: {EXE}")  +SIGNER_ORG = "Adom Industries, Inc."+++def signing_dir():+    return Path(os.environ.get("ADOM_SIGNING_DIR", Path.home() / ".adom" / "signing" / "altium-bridge"))+++def authenticode_subjects(path):+    """Certificate subjects in a PE's Authenticode signature, read here without Windows: the+    optional header's security data directory points at a WIN_CERTIFICATE holding PKCS#7 DER.+    [] means unsigned."""+    import struct+    import tempfile+    b = Path(path).read_bytes()+    pe = struct.unpack_from("<I", b, 0x3C)[0]+    if b[pe:pe + 4] != b"PE\0\0":+        raise SystemExit(f"{path}: not a PE file")+    opt = pe + 24+    magic = struct.unpack_from("<H", b, opt)[0]+    dirs = opt + (112 if magic == 0x20B else 96)+    off, size = struct.unpack_from("<II", b, dirs + 4 * 8)+    if not off or not size:+        return []+    length, _rev, ctype = struct.unpack_from("<IHH", b, off)+    if ctype != 2:+        return []+    with tempfile.NamedTemporaryFile(suffix=".p7b", delete=False) as f:+        f.write(b[off + 8:off + length])+    r = subprocess.run(["openssl", "pkcs7", "-inform", "DER", "-in", f.name, "-print_certs", "-noout"],+                       capture_output=True, text=True)+    os.unlink(f.name)+    return [l[len("subject="):].strip() for l in r.stdout.splitlines() if l.startswith("subject=")]+++def assert_signed(path):+    subs = authenticode_subjects(path)+    if not any(f'CN = "{SIGNER_ORG}"' in s for s in subs):+        raise SystemExit(f"REFUSED: {path} is not Authenticode-signed by {SIGNER_ORG} "+                         f"(found {subs or 'no signature'}). Unsigned exes are blocked by Windows Smart App Control; "+                         f"this script never packages one.")+    return subs+++def ab_json(*args, timeout=600):+    out = subprocess.run(["adom-bridge", *args], capture_output=True, text=True, timeout=timeout).stdout+    d = json.loads(out[out.find("{"):]) if "{" in out else {}+    o = d.get("output")+    if isinstance(o, str) and o.strip().startswith("{"):+        try:+            d = json.loads(o)+        except ValueError:+            pass+    return d+++def sign_exe(ver):+    """Sign EXE on the private config's Windows host, verify there, pull it back, verify here."""+    import base64+    sd = signing_dir()+    cfg_p, ps1 = sd / "config.json", sd / "sign-verify.ps1"+    if not cfg_p.exists() or not ps1.exists():+        raise SystemExit(f"REFUSED: private signing config missing at {sd} (config.json + sign-verify.ps1). "+                         f"It is deliberately not in this public repo; ask the release owner for it.")+    cfg = json.loads(cfg_p.read_text())+    host, thread = cfg["host"], "altium-bridge release signing"+    stage = cfg["stageDir"].replace("%LOCALAPPDATA%", cfg.get("localAppData", r"C:\Users\cknox\AppData\Local")) + "\\" + ver+    work = DIST / "signing"+    shutil.rmtree(work, ignore_errors=True)+    work.mkdir(parents=True)+    unsigned = work / "altium-bridge.exe"+    shutil.copy2(EXE, unsigned)+    shutil.copy2(ps1, work / "sign-verify.ps1")+    print(f"== sign {unsigned.name} on the signing host ==")+    sent = ab_json("--target", host, "--ai-thread", thread, "send_files", json.dumps({+        "filePaths": [str(unsigned), str(work / "sign-verify.ps1")], "dest": stage,+        "reason": f"stage altium-bridge {ver} for Authenticode signing"}))+    if int(sent.get("filesReceived") or 0) != 2:+        raise SystemExit(f"signing host did not receive the files: {sent.get('error') or sent}")+    win_exe = stage + "\\altium-bridge.exe"+    cmd = (f"& '{stage}\\sign-verify.ps1' -Exe '{win_exe}' -Endpoint '{cfg['endpoint']}' "+           f"-Account '{cfg['account']}' -Profile '{cfg['profile']}' -Tenant '{cfg['tenant']}' "+           f"-Timestamp '{cfg['timestampUrl']}'")+    r = ab_json("--target", host, "--ai-thread", thread, "run_script", json.dumps({+        "interpreter": "powershell", "scriptB64": base64.b64encode(cmd.encode()).decode(), "timeout": 300,+        "reason": f"Authenticode-sign altium-bridge {ver} as {SIGNER_ORG}, then Get-AuthenticodeSignature + signtool verify"}))+    so = r.get("stdout") or ""+    try:+        v = json.loads(so[so.find("{"):])+    except ValueError:+        raise SystemExit(f"signing script gave no verdict: {so[:400]} {(r.get('stderr') or '')[:400]}")+    if v.get("error"):+        raise SystemExit(f"REFUSED: signing host cannot sign ({v['error']}): {v.get('detail', '')}\n  fix: {v.get('fix', '')}")+    checks = {+        "Status Valid": v.get("status") == "Valid",+        "signer subject": cfg["expectSubject"] in (v.get("subject") or ""),+        "timestamped": "Microsoft" in (v.get("timestamper") or ""),+        "signtool verify /pa": "Successfully verified" in (v.get("signtoolVerify") or ""),+    }+    if not all(checks.values()):+        raise SystemExit(f"REFUSED: signature checks failed on the signing host: {checks}\n{json.dumps(v, indent=1)}")+    pulled = ab_json("--target", host, "--ai-thread", thread, "pull_file", json.dumps({+        "filePaths": [win_exe], "reason": f"pull the signed altium-bridge {ver} exe back for packaging"}))+    files = pulled.get("files") or []+    if not files:+        raise SystemExit(f"could not pull the signed exe back: {pulled.get('error') or pulled}")+    signed = DIST / "signed" / "altium-bridge.exe"+    signed.parent.mkdir(parents=True, exist_ok=True)+    shutil.move(files[0]["path"], signed)+    local_sha = hashlib.sha256(signed.read_bytes()).hexdigest()+    if local_sha != v.get("sha256"):+        raise SystemExit(f"REFUSED: signed exe changed in transit (host {v.get('sha256')}, here {local_sha})")+    subs = assert_signed(signed)+    report = {"file": "altium-bridge.exe", "status": v["status"], "subject": v["subject"],+              "timestamper": v["timestamper"], "signtoolVerify": v["signtoolVerify"],+              "signer": v.get("signer"), "sha256": local_sha, "localSubjects": subs}+    (DIST / "signed" / "signing-report.json").write_text(json.dumps(report, indent=2) + "\n")+    for k, ok in checks.items():+        print(f"   {k}: {'ok' if ok else 'FAIL'}")+    print(f"   verified again here: {SIGNER_ORG}, sha256 {local_sha[:12]}")+    return report++ def build_zip(ver):     DIST.mkdir(exist_ok=True)     zname = f"adom-bridge-altium-v{ver}.zip"     zpath = DIST / zname+    for rel, p in INCLUDE:+        if rel.lower().endswith(".exe"):+            assert_signed(p)     with zipfile.ZipFile(zpath, "w", zipfile.ZIP_DEFLATED) as z:         for rel, p in INCLUDE:             if not p.exists():@@ -195,6 +330,25 @@ def two_curl_split_check():     return served  +def stage_only(log_line):+    """A build waiting for a live battery: signed and uploaded as a release asset, but NO tier moves+    and no skills pkg row. Its manifest is archived so a later publish or pin can point at it."""+    ver = json.loads((SRC / "bridge.json").read_text())["version"]+    lint_versions(ver)+    lint_agent_script()+    n = lint_verbs()+    print(f"verb lint ok: {n} verbs")+    build_exe()+    sign_exe(ver)+    zname, zpath, sha = build_zip(ver)+    subprocess.run(["adom-wiki", "release", "create", PAGE, ver, "--title", f"altium-bridge {ver}",+                    "--changelog", f"STAGED, not on any tier: {log_line}"], check=False, capture_output=True)+    run("adom-wiki", "release", "upload", PAGE, ver, str(zpath), "--platform", "windows", "--arch", "x64")+    ARCHIVE.mkdir(exist_ok=True)+    (ARCHIVE / f"{ver}.json").write_text(json.dumps(manifest_for(ver, zname, zpath, sha, "insiders"), indent=2) + "\n")+    print(f"STAGED {ver}: signed zip sha256 {sha[:12]} uploaded; no tier moved. Archived scripts/releases/{ver}.json.")++ def ship_insiders(log_line):     ver = json.loads((SRC / "bridge.json").read_text())["version"]     lint_versions(ver)@@ -202,6 +356,7 @@ def ship_insiders(log_line):     n = lint_verbs()     print(f"verb lint ok: {n} verbs declared and dispatchable")     build_exe()+    sign_exe(ver)     zname, zpath, sha = build_zip(ver)      print(f"== release {ver} on {PAGE} (bytes live on the product page, both tiers) ==")@@ -342,14 +497,21 @@ def main():         if not log_line:             raise SystemExit('usage: release.py --publish "<one changelog sentence for the release log>"')         ship_insiders(log_line)+    elif "--stage" in args:+        i = args.index("--stage")+        log_line = args[i + 1] if len(args) > i + 1 and not args[i + 1].startswith("--") else ""+        if not log_line:+            raise SystemExit('usage: release.py --stage "<one line: what this staged build is for>"')+        stage_only(log_line)     else:+        # lint + build only: packaging needs a signed exe, so the zip is made by --stage/--publish         ver = json.loads((SRC / "bridge.json").read_text())["version"]         lint_versions(ver)         lint_agent_script()         n = lint_verbs()         print(f"verb lint ok: {n} verbs")         build_exe()-        build_zip(ver)+        print("built the exe (unsigned). --stage or --publish signs, packages and uploads.")   if __name__ == "__main__":