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
John Lauer
Release tool: keep the CA bundle out of repo pushes (secret scanner, #79 root cause); runner traces a real net
08c8817
2mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
#!/usr/bin/env python3
"""Build (and optionally publish) a kicad-bridge runtime release.
Until now the shipping file list lived only in a maintainer's scratchpad, so
nobody else could reproduce a release and a container reset would have lost it.
It is `tools/release_files.json` now, in the repo, next to this script.
python3 tools/build_release.py 0.9.219 # build + update manifest
python3 tools/build_release.py 0.9.219 --publish # ... and push + upload
The zip is built from the explicit list, never a directory walk: a stray file in
the working tree must never end up on a user's machine.
"""
import hashlib
import json
import os
import subprocess
import re
import sys
import zipfile
from datetime import datetime, timezone
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
REF = "adom/kicad-bridge"
MANIFEST = "adom-bridge-kicad-manifest.json"
def _ver(v):
"""Numeric compare, never string: '0.9.9' vs '0.9.10' sorts wrong as text."""
out = []
for part in str(v).split("."):
digits = "".join(c for c in part if c.isdigit())
out.append(int(digits) if digits else 0)
return tuple(out)
def _live_version():
"""BRIDGE_VERSION as the PAGE REPO currently has it, or None if unreadable.
Unreadable must not block a release: a network blip is not a reason to
refuse, it is a reason to say so and continue."""
try:
r = subprocess.run(["adom-wiki", "repo", "show", REF, "BRIDGE_VERSION"],
capture_output=True, text=True, timeout=90)
if r.returncode != 0:
print("NOTE: could not read the live version; stale-tree guard SKIPPED.")
return None
return r.stdout.strip().splitlines()[0].strip() if r.stdout.strip() else None
except Exception as e:
print(f"NOTE: live-version check failed ({e}); stale-tree guard SKIPPED.")
return None
def main() -> int:
if len(sys.argv) < 2:
print(__doc__)
return 2
version = sys.argv[1].lstrip("v")
# The version is positional. A flag in slot 1 ("--notes ...") once got
# stamped into BRIDGE_VERSION and bridge.json as the version string.
if not re.fullmatch(r"\d+\.\d+\.\d+", version):
print(f"REFUSING: first argument must be the version (got {version!r}); usage: build_release.py <x.y.z> [--publish] --notes \"...\"")
return 2
publish = "--publish" in sys.argv
# WHY NOTES ARE MANDATORY (John, 2026-09-02).
#
# Every release this script cut until now carried a changelog the SERVER
# synthesised from the repo-push commit messages, which this script also did
# not set — so twelve consecutive releases shipped with the changelog
# "- Update 40 file(s)" repeated, i.e. no record at all of what changed.
#
# That is survivable while everything ships straight to the public tier and
# the person who built it still remembers. It stops being survivable the
# moment builds queue up in the insiders tier: promoting to public then means
# deciding which accumulated versions are safe, and there is nothing written
# down to decide FROM. The note is the thing a future human reads when they
# have forgotten this week entirely.
#
# So: no note, no publish. Same shape as ab's `reason` gate — a required
# sentence written for the person who will read it later, not for the log.
notes = None
for i, a in enumerate(sys.argv):
if a in ("--notes", "-n") and i + 1 < len(sys.argv):
notes = sys.argv[i + 1].strip()
if publish and (not notes or len(notes) < 12):
print("REFUSING TO PUBLISH - this release has no changelog.\n"
' Pass --notes "<what changed and why, for someone who has forgotten>"\n'
" Weak: 'fixes' / 'update' / 'various'.\n"
" Good: 'kicad_errors reads dialog text via AD UIA at discovery; the\n"
" two-step read raced and lost the message (wiki #71).'\n"
" Without it a promote has nothing to judge: the server synthesises\n"
' "- Update N file(s)" and that is what a future you would read.')
return 1
if "--force-stale" in sys.argv:
publish_forced = True
else:
publish_forced = False
os.chdir(ROOT)
# ------------------------------------------------------------------
# STALE-TREE GUARD (2026-08-26). This working copy is NOT the only writer
# of this repo: Colby and Kyle publish runtime releases from their own
# trees. On 2026-08-26 this tree sat at 0.9.250 while live was 0.9.253,
# missing handlers/install_library_bundle.py entirely and three of Colby's
# releases. Building from here would have REGRESSED the runtime and DELETED
# a shipped verb, and the zip is built from an explicit file list so nothing
# downstream would have noticed the loss.
#
# A publish from a tree that is behind live is nearly always an accident, so
# refuse by default and make the operator say --force-stale out loud.
if publish and not publish_forced:
live = _live_version()
if live and _ver(version) <= _ver(live):
print("REFUSING TO PUBLISH - this tree is BEHIND the live release.")
print(f" live: {live}")
print(f" publishing:{version}")
print(" Someone else has published since this tree was last synced.")
print(" Merge live into this tree first (adom-wiki repo clone ... and diff),")
print(" or pass --force-stale if you truly mean to overwrite their work.")
return 1
files = json.load(open("tools/release_files.json"))["files"]
# Whole directories (vendored wheels for the routing verbs, tools/release_files.json
# "dirs"). Walked at build time because the file names carry wheel versions.
for d in json.load(open("tools/release_files.json")).get("dirs", []):
if not os.path.isdir(d):
print("REFUSING TO BUILD - listed directory is missing:", d)
return 1
for root, _dirs, names in os.walk(d):
if "__pycache__" in root:
continue
files.extend(os.path.join(root, n).replace(os.sep, "/") for n in sorted(names))
missing = [f for f in files if not os.path.isfile(f)]
if missing:
print("REFUSING TO BUILD - listed files are missing:")
for m in missing:
print(" ", m)
return 1
# Version lives in two places and they must agree; a mismatch has shipped
# before and is invisible until someone reads bridgeVersion off a box.
open("BRIDGE_VERSION", "w").write(version)
bj = json.load(open("bridge.json"))
bj["version"] = version
json.dump(bj, open("bridge.json", "w"), indent=2)
out = f"adom-bridge-kicad-v{version}.zip"
# --reuse-zip: publish the exact bytes that were staged and tested on a box
# (a rebuild changes zip timestamps, hence the sha256 the test evidence names).
if "--reuse-zip" in sys.argv and os.path.isfile(out):
print(f"reusing existing {out} (tested bytes; not rebuilt)")
else:
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z:
for f in files:
z.write(f, f)
raw = open(out, "rb").read()
sha, size = hashlib.sha256(raw).hexdigest(), len(raw)
# Guards for two failures that actually shipped: a raw NUL byte written into
# a source file (un-importable, bricks the bridge), and a version mismatch
# between the zip and BRIDGE_VERSION.
with zipfile.ZipFile(out) as z:
if z.read("BRIDGE_VERSION").decode().strip() != version:
print("REFUSING: BRIDGE_VERSION inside the zip does not match", version)
return 1
for n in z.namelist():
if n.endswith(".py") and b"\x00" in z.read(n):
print(f"REFUSING: NUL byte in {n} - Python cannot import this file")
return 1
m = json.load(open(MANIFEST))
m.update(version=version, sha256=sha, size=size,
url=f"https://wiki.adom.inc/download/{REF}/{version}/{out}",
released_at=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"))
json.dump(m, open(MANIFEST, "w"), indent=2)
print(f"built {out}\n files : {len(files)}\n size : {size}\n sha256: {sha}")
if not publish:
print("\nnot published (pass --publish). Manifest updated locally.")
return 0
# PUSH THE SOURCE, NOT JUST THE VERSION STAMP.
#
# This pushed only BRIDGE_VERSION + bridge.json + the manifest, and that
# single omission cost the project a full release. 0.9.250 shipped a zip
# containing real fixes (the quote-aware .kicad_sym parser that repaired a
# corrupted user library, demo captions, the #54 sub-node verify) whose
# SOURCE never reached the page repo. Another maintainer then cloned the
# page repo, published 0.9.251 from it, and silently REVERTED all of it.
# Nothing failed, nothing warned: the zip and the repo simply disagreed,
# and the repo is what the next person builds from.
#
# So push every file the zip actually ships. The release and the repo now
# tell the same story by construction rather than by the operator
# remembering to list "<changed sources>" by hand.
# The PUBLIC manifest is deliberately NOT in this push. The first insiders
# ship (0.9.273) pushed it here as part of "push all shipped source" and
# moved the public tier as a side effect of building - the split check
# (insiders-tier-verify) caught public == insiders == 0.9.273 immediately.
# The public manifest is written by tools/promote.py, or by --public.
tier_public = "--public" in sys.argv
# Vendored third-party trees (release_files.json "dirs") ship in the zip but
# are NOT source: tools/vendor_routing_deps.py rebuilds them from
# requirements-routing.txt, so they stay out of the page repo.
vendored = tuple(d.rstrip("/") + "/" for d in json.load(open("tools/release_files.json")).get("dirs", []))
# push_exclude: shipped but never pushed. certs/cacert.pem is the public
# Mozilla CA bundle; the wiki's secret scanner rejects every .pem, and one
# rejected file fails the whole chunk it rides in (that is how the #79
# handlers went missing: "certs" sorts right before "handlers").
excluded = set(json.load(open("tools/release_files.json")).get("push_exclude", []))
push_files = ["BRIDGE_VERSION", "bridge.json"] + ([MANIFEST] if tier_public else []) + [
f for f in files if os.path.isfile(f) and f != MANIFEST and not f.startswith(vendored)
and f not in excluded]
print(f"pushing {len(push_files)} source file(s) to the page repo")
# chunked: a single push of the whole file list can exceed the request cap
CHUNK = 40
# -m matters twice: the page reuses the commit message as its changelog
# verbatim, so an unset message is where "- Update N file(s)" came from.
msg = f"{version}: {notes}" if notes else f"{version}"
# Every push reply is checked (issue #79: five handlers silently never reached
# master across twenty releases because a failed chunk was ignored).
failed = []
for i in range(0, len(push_files), CHUNK):
chunk = push_files[i:i + CHUNK]
proc = subprocess.run(["adom-wiki", "repo", "push", REF, "-m", msg, "--files"] + chunk,
capture_output=True, text=True)
ok = '"ok": true' in proc.stdout
print(f" push {i // CHUNK + 1}: {len(chunk)} file(s) -> {'ok' if ok else 'FAILED'}")
if not ok:
print((proc.stdout + proc.stderr)[-600:])
failed.extend(chunk)
if failed:
print("REFUSING TO RELEASE: source push failed for", len(failed), "file(s); the repo would not match the zip:")
for f in failed[:20]:
print(" ", f)
return 1
subprocess.run(["adom-wiki", "release", "create", REF, version,
"--changelog", notes], check=False)
subprocess.run(["adom-wiki", "release", "upload", REF, version, out], check=False)
# TIER. Every publish is INSIDERS by default (insiders-tier-publish): the
# bytes live on this page as always, but the manifest that moves is the one
# on adom/insiders. The PUBLIC manifest is written only by tools/promote.py,
# which requires a human's attestation. --public here is the deliberate
# exception and says so out loud.
#
# This bridge shipped 0.9.261-0.9.272 straight to public because the tier was
# never adopted (adom/insiders#1 had "KiCad bridge" unticked while four other
# bridges had adopted it). Insiders like Ray were getting public builds -
# which is to say, everyone was.
ins_doc = dict(m)
ins_doc["tier"] = "insiders"
# repo push uses each file's RELATIVE PATH as its destination, so the doc has
# to sit at kicad-bridge/version.json in the directory we push from.
import tempfile as _tf
ins_dir = _tf.mkdtemp(prefix="kicad-insiders-")
os.makedirs(os.path.join(ins_dir, "kicad-bridge"), exist_ok=True)
ins_path = os.path.join(ins_dir, "kicad-bridge", "version.json")
json.dump(ins_doc, open(ins_path, "w"), indent=2)
if tier_public:
print("\n--public: wrote the PUBLIC manifest directly, skipping the insiders tier.")
else:
# The insiders manifest lives on adom/insiders under this bridge's slug;
# the ZIP stays a release on THIS page (never a release on adom/insiders,
# which would collide with that page's own version namespace, adom/wiki#170).
subprocess.run(["adom-wiki", "repo", "push", "adom/insiders", "-m",
f"kicad-bridge {version}: {notes}",
"--files", "kicad-bridge/version.json"],
cwd=ins_dir, check=False)
# Roll the PUBLIC manifest back to what it was, so a publish does not
# quietly move the public tier as a side effect of building.
print(f"\nSHIPPED TO INSIDERS ({version}). Public tier untouched.")
print(" Promote with: python3 tools/promote.py {v} --because \"<your words>\"".format(v=version))
# A local ledger as well as the server's changelog: the promote tool reads
# this to show what is queued between the public and insiders tiers without
# N API calls, and it survives a page being unreachable.
try:
import datetime as _dt
led = os.path.join(ROOT, "RELEASE_NOTES.md")
entry = (f"## {version} ({_dt.datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')})\n\n"
f"{notes}\n\n")
prior = open(led, encoding="utf-8").read() if os.path.isfile(led) else \
"# kicad-bridge release notes\n\nNewest first. Written at publish time by\n" \
"tools/build_release.py, which refuses to publish without one.\n\n"
head, _, rest = prior.partition("\n\n## ")
open(led, "w", encoding="utf-8").write(
(head + "\n\n" + entry + ("## " + rest if rest else "")) if rest else (prior + entry))
print(f" note recorded in RELEASE_NOTES.md")
except Exception as e: # never block a publish on the ledger
print(f" (could not update RELEASE_NOTES.md: {e})")
print("\nPUBLISHED. Now INSTALL it - refresh_bridges does not pull a release:")
print(f" adom-bridge --target <BOX> bridge_install '{{\"manifestUrl\":"
f"\"https://wiki.adom.inc/api/v1/pages/kicad-bridge/files/{MANIFEST}\","
f"\"force\":true,\"reason\":\"...\"}}'")
print(" then confirm with kicad_status -> bridgeVersion")
return 0
if __name__ == "__main__":
raise SystemExit(main())