app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
#!/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()