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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
#!/usr/bin/env python3
"""What is queued for promotion, and the promote itself.
python3 tools/promote.py # show the gap: insiders vs public
python3 tools/promote.py 0.9.272 --because "<the human's words>"
WHY THIS EXISTS (John, 2026-09-02)
----------------------------------
With a two-tier ship, builds accumulate in the insiders tier and someone later
has to decide which of them is safe to make public. That decision needs a record
of WHAT IS IN EACH BUILD, written when it was fresh. Without it the question
"which version do we feel safe shipping" has no evidence behind it, and the
honest answer becomes "the newest one, probably", which is not a decision.
So this tool never invents a summary. It shows the notes each release was
published with (tools/build_release.py refuses to publish without one) and makes
the human type the sentence that moves the public manifest. Promotion is a
MANIFEST EDIT, not a rebuild: the same signed zip serves both tiers, so what is
promoted is exactly what insiders have been running.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import urllib.request
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
REF = "adom/kicad-bridge"
PUBLIC_MANIFEST = "adom-bridge-kicad-manifest.json"
INSIDERS_PATH = "kicad-bridge/version.json"
INSIDERS_REF = "adom/insiders"
def _ver(v: str):
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 _get_json(url: str):
# A bare urlopen sends Python's default UA and the wiki answers 403, which
# this tool then reported as "(unreadable)" - a tool that cannot read the
# tier it is about to change is worse than no tool.
try:
req = urllib.request.Request(url, headers={"User-Agent": "adom-kicad-bridge promote"})
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r)
except Exception as e:
print(f" (could not read {url}: {type(e).__name__})")
return None
def _tier_versions():
pub = _get_json(f"https://wiki.adom.inc/api/v1/pages/kicad-bridge/files/{PUBLIC_MANIFEST}") or {}
ins = None
try:
r = subprocess.run(["adom-wiki", "repo", "show", INSIDERS_REF, INSIDERS_PATH],
capture_output=True, text=True, timeout=60)
if r.returncode == 0 and r.stdout.strip():
ins = json.loads(r.stdout)
except Exception:
pass
return (pub.get("version"), (ins or {}).get("version"))
def _notes() -> dict:
"""version -> note, from the local ledger the publisher writes."""
p = os.path.join(ROOT, "RELEASE_NOTES.md")
if not os.path.isfile(p):
return {}
out, cur = {}, None
for line in open(p, encoding="utf-8"):
if line.startswith("## "):
cur = line[3:].split()[0].strip()
out[cur] = ""
elif cur and line.strip():
out[cur] = (out[cur] + " " + line.strip()).strip()
return out
def show():
pub, ins = _tier_versions()
notes = _notes()
print(f" public tier : {pub or '(unreadable)'}")
print(f" insiders tier : {ins or '(none — kicad-bridge has not adopted the tier)'}")
if not ins:
print("\n Nothing is queued: every build has gone straight to public.")
print(" Adopt the tier by adding insidersManifestUrl to bridge.json.")
return 0
queued = sorted([v for v in notes if pub and _ver(v) > _ver(pub) and _ver(v) <= _ver(ins)],
key=_ver)
if not queued:
print("\n Public is level with insiders. Nothing to promote.")
return 0
print(f"\n QUEUED FOR PROMOTION ({len(queued)} build(s), oldest first):\n")
for v in queued:
print(f" {v}")
for line in (notes.get(v) or "(no note recorded)").split(". "):
if line.strip():
print(f" {line.strip().rstrip('.')}.")
print()
print(" Promote with:")
print(f" python3 tools/promote.py {queued[-1]} --because \"<why this one is safe>\"")
print(" You may promote an EARLIER version than the newest: that is the point of")
print(" the list. Read the notes and pick the line you are willing to defend.")
return 0
def promote(version: str, because: str):
if not because or len(because) < 12:
print("REFUSING TO PROMOTE - promotion needs the human's words.\n"
' --because "<why this build is safe to make public>"\n'
" It is recorded as the public release's changelog and is the only\n"
" attestation that a person, not a script, moved the public manifest.")
return 1
pub, ins = _tier_versions()
if not ins:
print("No insiders manifest for this bridge; nothing to promote from.")
return 1
if _ver(version) > _ver(ins):
print(f"REFUSING: {version} is newer than the insiders tier ({ins}).\n"
" You can only promote something insiders have actually been running.")
return 1
if pub and _ver(version) <= _ver(pub):
print(f"REFUSING: public is already at {pub}; {version} would be a downgrade.\n"
" To roll back, edit the public manifest deliberately - not through promote.")
return 1
# The promote is a manifest edit: the document of the REQUESTED version becomes the public
# manifest. Same zip, same sha, same URL - only the pointer moves.
#
# Until 2026-09-21 this copied the CURRENT insiders document whatever version was asked for.
# Every promotion before then was of the newest build, so nobody noticed; "promote 1.0.37" with
# insiders at 1.0.39 put 1.0.39, a build the human had NOT approved, on the public tier and
# printed "PROMOTED 1.0.37". So: take the insiders document only when it IS the requested
# version, otherwise the manifest the build tool committed for that version; check the zip's
# hash; and read the public manifest back after the push.
src = subprocess.run(["adom-wiki", "repo", "show", INSIDERS_REF, INSIDERS_PATH],
capture_output=True, text=True, timeout=60)
doc = json.loads(src.stdout)
if str(doc.get("version")) != version:
doc = None
log = subprocess.run(["git", "-C", ROOT, "log", "--format=%H", "--", PUBLIC_MANIFEST],
capture_output=True, text=True).stdout.split()
for sha in log:
shown = subprocess.run(["git", "-C", ROOT, "show", f"{sha}:{PUBLIC_MANIFEST}"],
capture_output=True, text=True).stdout
try:
cand = json.loads(shown)
except Exception:
continue
if str(cand.get("version")) == version:
doc = cand
break
if doc is None:
print(f"REFUSING: no manifest for {version} in this repo's history, and insiders is at {ins}.")
return 1
import hashlib
try:
req = urllib.request.Request(doc["url"], headers={"User-Agent": "adom-kicad-bridge promote"})
with urllib.request.urlopen(req, timeout=180) as r:
blob = r.read()
except Exception as e:
print(f"REFUSING: could not download {doc.get('url')}: {type(e).__name__}")
return 1
if hashlib.sha256(blob).hexdigest() != doc.get("sha256") or len(blob) != doc.get("size"):
print(f"REFUSING: the zip at {doc['url']} does not match the manifest's sha256/size.")
return 1
doc.pop("tier", None)
open(os.path.join(ROOT, PUBLIC_MANIFEST), "w", encoding="utf-8").write(
json.dumps(doc, indent=2) + "\n")
subprocess.run(["adom-wiki", "repo", "push", REF, "-m",
f"promote {version} to public: {because}",
"--files", PUBLIC_MANIFEST], check=False)
now_public, _ = _tier_versions()
if str(now_public) != version:
print(f"PROMOTION DID NOT LAND: asked for {version}, the public manifest says {now_public}. Fix this now.")
return 1
print(f"PROMOTED {version} to the public tier.")
print(f" attestation: {because}")
print(" The zip did not change; the public manifest now points at what insiders ran.")
return 0
if __name__ == "__main__":
args = [a for a in sys.argv[1:]]
if not args or args[0].startswith("-"):
sys.exit(show())
ver = args[0]
why = ""
for i, a in enumerate(sys.argv):
if a == "--because" and i + 1 < len(sys.argv):
why = sys.argv[i + 1].strip()
sys.exit(promote(ver, why))