← Commit history

Release tooling: Azure Trusted Signing on the signing host (tools/sign_azure.py), insiders releases never touch the public manifest, release notes pushed; verb battery covers the 12 standard verbs and the remove_route dry run

Colby Knox ·391aeb92e5 ·15d ago ·parent b0bde5c
4 files changed +242−12
skills/kicad-bridge-publish/SKILL.md+20−3
@@ -248,6 +248,23 @@ with that page's skills package (adom/wiki#170). The zip stays here.  `tools/build_native_release.py --publish` signs `kicad-bridge.exe` as Adom Industries, Inc. as the LAST change to it, then checks that the exe inside the zip carries the signature, and refuses to publish otherwise. Windows 11-Smart App Control blocks unsigned exes; every native build up to 1.0.52 shipped unsigned. The signer is a private-tool outside this repo (`ADOM_SIGN_EXE`, default `~/bin/adom-sign-exe`); its account details never go in this repo,-a skill, a README or an issue. `--sign` signs a local build without publishing.+Smart App Control blocks unsigned exes; every native build up to 1.0.52 shipped unsigned. `--sign` signs a local+build without publishing. Two signers, one certificate:++- **A local helper** (`ADOM_SIGN_EXE`, default `~/bin/adom-sign-exe`) when one is installed.+- **Otherwise Azure Trusted Signing on the signing host** (from 1.0.59, `tools/sign_azure.py`), the path every+  Adom release uses (altium-bridge's `release.py`). Its private config (`config.json` + `sign-verify.ps1`) lives+  in `$ADOM_SIGNING_DIR`, else `~/.adom/signing/kicad-bridge`, else `~/.adom/signing/altium-bridge`. The script:+  1. stages the exe on the host (`send_files`, a folder name without dots);+  2. signs and verifies it there with one `run_script` (Status Valid, the expected subject, a Microsoft+     timestamp, `signtool verify /pa`);+  3. pulls it back, checks the sha256 and the signer `CN = "Adom Industries, Inc."` here, and writes+     `rust/dist/signing-report.json`.++  `az_token_unavailable` means the host's Azure login lapsed: renew it with a device-code `az login` on the host+  and have the account owner approve it.++The account details never go in this repo, a skill, a README, an issue or a reply.++The public manifest is `adom-bridge-kicad-manifest.json` on the page (bridge.json `updateManifestUrl`). An+insiders release never writes or pushes it: only `tools/promote.py` does, with a person's words.
skills/kicad-bridge-test/run_verb_tests.py+41−2
@@ -91,6 +91,21 @@ CHECKS = {     # placement (1.0.3): live when the PCB editor has the board on the IPC API, the file otherwise     "placement_state": lambda r: ok(r) and "revision" in r and isinstance(r.get("footprints"), list)                                  and len(r["footprints"]) > 0 and "totalMm" in (r.get("ratsnest") or {}),+    # the EDA Bridge Standard board model and its companions (1.0.59), read from the fixture's file+    "standard_model": lambda r: ok(r) and r.get("units") == "mm" and len(r.get("pads") or []) > 0+                                and len(r.get("revision") or "") == 16 and isinstance(r.get("unconnected"), int)+                                and len(r.get("outlineEdges") or []) >= 3,+    "layer_stack": lambda r: ok(r) and (r.get("copperLayers") or 0) >= 2 and "dielectricUm" in (r.get("layers") or [{}])[0],+    "pour_state": lambda r: ok(r) and isinstance(r.get("zones"), list) and "filledAreaByLayerMm2" in r,+    "bodies": lambda r: ok(r) and (r.get("parts") or 0) > 0 and isinstance(r.get("bodies"), list)+                        and len(r["bodies"]) + len(r.get("noBody") or []) >= r["parts"],+    "std_netlist": lambda r: ok(r) and (r.get("netCount") or 0) > 0 and isinstance(r.get("nets"), dict)+                             and any(p.get("pins") for p in (r.get("parts") or [])),+    "board_summary": lambda r: ok(r) and (r.get("summary") or {}).get("components", 0) > 0,+    "count_list": lambda r: ok(r) and (r.get("count") or 0) > 0,+    "layers_in_use": lambda r: ok(r) and len(r.get("copperLayersInUse") or []) > 0,+    "component_data": lambda r: ok(r) and (r.get("component") or {}).get("pads", 0) > 0,+    "component_pins": lambda r: ok(r) and (r.get("count") or 0) > 0,     "move_dry": lambda r: ok(r) and r.get("dryRun") is True and r.get("mutated") is False                           and "checks" in r and r.get("undoSteps") == 0,     "taskbar_report": lambda r: ok(r) and r.get("errors") == 0 and ("already" in r or "written" in r),@@ -153,6 +168,19 @@ def matrix(w: dict) -> dict:         "run_erc":           dict(phase=2, args={"filePath": S}, timeout=200),         "lint_board":        dict(phase=2, args={"filePath": B}, timeout=200),         "lint_schematic":    dict(phase=2, args={"filePath": S}, timeout=200),+        # ---- phase 2a: the EDA Bridge Standard board model and companions (1.0.59), from the file+        "layer_stack":       dict(phase=2, args={"filePath": B, "source": "file"}, check="layer_stack"),+        "pour_state":        dict(phase=2, args={"filePath": B, "source": "file", "geometry": False}, check="pour_state"),+        "bodies":            dict(phase=2, args={"filePath": B, "source": "file"}, check="bodies", timeout=120,+                                  note="3D model bounds from the files the footprints name; every part is a body or in noBody"),+        "netlist":           dict(phase=2, args={"filePath": S, "parts": True}, check="std_netlist", timeout=120),+        "board_summary":     dict(phase=2, args={"filePath": B, "source": "file"}, check="board_summary"),+        "list_nets":         dict(phase=2, args={"filePath": B, "source": "file"}, check="count_list"),+        "list_designators":  dict(phase=2, args={"filePath": B, "source": "file"}, check="count_list"),+        "copper_layers":     dict(phase=2, args={"filePath": B, "source": "file"}, check="count_list"),+        "layers_in_use":     dict(phase=2, args={"filePath": B, "source": "file"}, check="layers_in_use"),+        "component_data":    dict(phase=2, args={"filePath": B, "source": "file", "designator": "U1"}, check="component_data"),+        "component_pins":    dict(phase=2, args={"filePath": B, "source": "file", "designator": "U1"}, check="component_pins"),         # ---- phase 2b: native-build verbs (Rust port). All read-only or on the fixture copy.         "freerouting":       dict(phase=1, args={"action": "status"}, timeout=60,                                   note="status only: install is on demand and asks the user first"),@@ -783,7 +811,12 @@ def main():                 if ok(st) and st.get("revision"):                     break                 time.sleep(5)-            record("routing_state", st, ok(st) and bool(st.get("revision")) and isinstance(st.get("pads"), (list, str)))+            # the standard board model, live: the same board, the revision the edit verbs accept+            sm = ab(a.target, "kicad_routing_state", {"filePath": L, "model": "standard"}, timeout=150)+            std_ok = CHECKS["standard_model"](sm) and sm.get("source") == "live-editor"+            record("routing_state", {"legacy": st, "standard": {k: sm.get(k) for k in ("success", "source", "revision", "unconnected", "counts", "errorCode", "error")}},+                   ok(st) and bool(st.get("revision")) and isinstance(st.get("pads"), (list, str)) and std_ok,+                   None if std_ok else "the standard model did not read live")             rev = [st.get("revision")]             pads = st.get("pads") or []             if isinstance(pads, str):@@ -824,8 +857,14 @@ def main():             if routed is not None:                 record("route_net", routed, ok(routed) and bool(routed.get("itemIds")))                 ids = routed.get("itemIds") or []+                # wiki #134: a dry run must leave the copper where it is+                d = step("remove_route", {"filePath": L, "expectedRevision": rev[0], "itemIds": ids, "dryRun": True, "reason": WHY}, chain=False)+                after = ab(a.target, "kicad_routing_state", {"filePath": L}, timeout=150)+                still = {x.get("uuid") for x in (after.get("segments") or [])} | {x.get("uuid") for x in (after.get("vias") or [])}+                dry_ok = ok(d) and d.get("mutated") is False and all(i in still for i in ids)                 r = step("remove_route", {"filePath": L, "expectedRevision": rev[0], "itemIds": ids, "reason": WHY})-                record("remove_route", r, ok(r))+                record("remove_route", {"dryRun": d, "remove": r}, dry_ok and ok(r),+                       None if dry_ok else "the dry run moved copper or did not answer mutated:false")             else:                 well_formed = bool(last.get("errorCode")) and last.get("mutated") is not True                 record("route_net", last, well_formed, "no straight pad-to-pad hop passed DRC; the refusal path was exercised")
tools/build_native_release.py+20−7
@@ -137,14 +137,21 @@ def main():     # 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 not os.path.isfile(signer):-            sys.exit(f"REFUSING: no exe signer at {signer}; an unsigned exe is never published")-        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")+        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")@@ -189,7 +196,7 @@ def publish_zip(ver, zpath, sha, notes):     REF = "adom/kicad-bridge"     # 1. The repo must tell the same story as the zip: push every rust source file.     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]-    src += ["tools/build_native_release.py"]+    src += ["tools/build_native_release.py", "tools/sign_azure.py"]     msg = f"{ver}: {notes}"     failed = push_chunked(REF, src, msg, cwd=str(ROOT))     if failed:@@ -207,7 +214,9 @@ def publish_zip(ver, zpath, sha, notes):     # 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")}-    mpath.write_text(json.dumps(m, indent=2) + "\n")+    # 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@@ -223,6 +232,10 @@ def publish_zip(ver, zpath, sha, notes):     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")+    # 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}")
tools/sign_azure.pyadded+161
@@ -0,0 +1,161 @@+#!/usr/bin/env python3+"""Authenticode-sign kicad-bridge.exe as Adom Industries, Inc. through Azure Trusted Signing, the path every+Adom release uses (adom/altium-bridge scripts/release.py, sign_pe_files).++The signing runs on a Windows host reached through Adom Bridge, under that host's own Azure login. Every+identifier (host, staging folder, endpoint, account, profile, tenant, timestamp URL, expected subject) and the+sign-verify.ps1 script live in a PRIVATE folder outside this public repo: $ADOM_SIGNING_DIR, else+~/.adom/signing/kicad-bridge, else ~/.adom/signing/altium-bridge (the same account signs every Adom bridge).+Nothing from it is ever written to this repo, a reply, a log line or an issue.++  python3 tools/sign_azure.py <exe> <version>     # sign one exe in place; exits non-zero unless verified++The flow, each step a gate:+  1. stage the exe and sign-verify.ps1 on the host (send_files; the folder name has no dots);+  2. one run_script signs and verifies there: Get-AuthenticodeSignature Valid, the expected subject,+     a Microsoft timestamp, signtool verify /pa;+  3. pull the signed exe back, check its sha256 matches the host's and differs from the unsigned build;+  4. read its Authenticode signer here (openssl): CN = "Adom Industries, Inc.".+"""+import base64+import hashlib+import json+import os+import shutil+import struct+import subprocess+import sys+import tempfile+from pathlib import Path++SIGNER_ORG = "Adom Industries, Inc."+THREAD = "kicad-bridge release signing"+++def signing_dir():+    env = os.environ.get("ADOM_SIGNING_DIR")+    if env:+        return Path(env)+    for name in ("kicad-bridge", "altium-bridge"):+        d = Path.home() / ".adom" / "signing" / name+        if (d / "config.json").is_file():+            return d+    return Path.home() / ".adom" / "signing" / "kicad-bridge"+++def authenticode_subjects(path):+    """Certificate subjects in a PE's Authenticode signature, read without Windows: the security data+    directory points at a WIN_CERTIFICATE holding PKCS#7 DER. [] means unsigned."""+    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} (found {subs or 'no signature'})")+    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` in place on the signing host; returns the signing report (no private values in it)."""+    exe = Path(exe)+    sd = signing_dir()+    cfg_p, ps1 = sd / "config.json", sd / "sign-verify.ps1"+    if not cfg_p.is_file() or not ps1.is_file():+        raise SystemExit(f"REFUSED: private signing config missing at {sd} (config.json + sign-verify.ps1). It is "+                         f"deliberately not in this public repo; ask Colby to copy it over privately.")+    cfg = json.loads(cfg_p.read_text())+    host = cfg["host"]+    stage = cfg["stageDir"].replace("%LOCALAPPDATA%", cfg.get("localAppData", r"C:\Users\cknox\AppData\Local")) \+        + "\\kicad-build-" + ver.replace(".", "_")  # a dotted folder name reads as a file path to send_files+    unsigned_sha = hashlib.sha256(exe.read_bytes()).hexdigest()+    work = Path(tempfile.mkdtemp(prefix="kicad-sign-"))+    name = "kicad-bridge.exe"+    shutil.copy2(exe, work / name)+    shutil.copy2(ps1, work / "sign-verify.ps1")+    print(f"== sign {name} {ver} on the signing host ==")+    sent = ab_json("--target", host, "--ai-thread", THREAD, "send_files", json.dumps({+        "filePaths": [str(work / name), str(work / "sign-verify.ps1")], "dest": stage, "destIsFolder": True,+        "reason": f"stage kicad-bridge {ver} for Authenticode signing"}))+    if int(sent.get("filesReceived") or 0) != 2:+        shutil.rmtree(work, ignore_errors=True)+        raise SystemExit(f"REFUSED: the signing host did not receive both files: {sent.get('error') or sent.get('_hint') or 'no reply'}")+    call = (f"(& '{stage}\\sign-verify.ps1' -Exe '{stage}\\{name}' -Endpoint '{cfg['endpoint']}' -Account '{cfg['account']}' "+            f"-Profile '{cfg['profile']}' -Tenant '{cfg['tenant']}' -Timestamp '{cfg['timestampUrl']}' | Out-String)")+    cmd = f"$r = @({call}); '[' + ($r -join ',') + ']'"+    r = ab_json("--target", host, "--ai-thread", THREAD, "run_script", json.dumps({+        "interpreter": "powershell", "scriptB64": base64.b64encode(cmd.encode()).decode(), "timeout": 540,+        "reason": f"Authenticode-sign kicad-bridge {ver} as {SIGNER_ORG}, then Get-AuthenticodeSignature + signtool verify"}), timeout=900)+    so = r.get("stdout") or ""+    try:+        v = json.loads(so[so.find("["):])[0]+    except (ValueError, IndexError):+        shutil.rmtree(work, ignore_errors=True)+        raise SystemExit(f"REFUSED: the signing script gave no verdict (stdout {len(so)} chars, stderr {len(r.get('stderr') or '')} chars, error {r.get('errorCode') or r.get('error') or '-'})")+    if v.get("error"):+        shutil.rmtree(work, ignore_errors=True)+        raise SystemExit(f"REFUSED: the 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()):+        shutil.rmtree(work, ignore_errors=True)+        raise SystemExit(f"REFUSED: signature checks failed on the host: {checks}")+    pulled = ab_json("--target", host, "--ai-thread", THREAD, "pull_file", json.dumps({+        "filePaths": [f"{stage}\\{name}"], "saveTo": str(work / "back"), "reason": f"pull the signed kicad-bridge {ver} back for packaging"}))+    files = pulled.get("files") or []+    if not files:+        shutil.rmtree(work, ignore_errors=True)+        raise SystemExit(f"REFUSED: could not pull the signed exe back: {pulled.get('error') or 'no files'}")+    back = Path(files[0]["path"])+    signed_sha = hashlib.sha256(back.read_bytes()).hexdigest()+    if v.get("sha256") and signed_sha != v["sha256"].lower():+        raise SystemExit("REFUSED: the signed exe changed in transit (host and local sha256 differ)")+    if signed_sha == unsigned_sha:+        raise SystemExit("REFUSED: the pulled-back exe is byte-identical to the unsigned build")+    subs = assert_signed(back)+    shutil.copy2(back, exe)+    shutil.rmtree(work, ignore_errors=True)+    report = {"file": name, "version": ver, "status": v["status"], "subject": SIGNER_ORG, "timestamper": v["timestamper"],+              "signtoolVerify": "Successfully verified", "sha256Unsigned": unsigned_sha, "sha256": signed_sha,+              "localSubjects": [s for s in subs if SIGNER_ORG in s]}+    print("   " + ", ".join(f"{k} {'ok' if ok else 'FAIL'}" for k, ok in checks.items()) + f"; verified again here, sha256 {signed_sha[:12]}")+    return report+++if __name__ == "__main__":+    if len(sys.argv) != 3:+        sys.exit(__doc__)+    print(json.dumps(sign(sys.argv[1], sys.argv[2]), indent=2))