app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Reference implementation of the KiCad bridge: multi-instance Python server, forward path via kicad-cli, reverse path via in-process plugin. Most complex of the three bundled bridges.
← Commit history
tier tooling: promote.py, mandatory release notes, RELEASE_NOTES.md ledger (backfilled 0.9.261-0.9.272), publish/dev skills document the ritual
5 files changed
+496−3
RELEASE_NOTES.mdadded+97@@ -0,0 +1,97 @@+# kicad-bridge release notes++Newest first. Written at publish time by tools/build_release.py, which refuses to+publish without one.++**Backfilled below 0.9.273 (2026-09-02).** Releases 0.9.261-0.9.272 shipped with+no usable changelog: build_release.py never passed `--changelog`, so the server+synthesised "- Update N file(s)" from repo-push commits that were themselves+unnamed. These entries were reconstructed after the fact from the work they+contain, which is exactly the situation the mandatory-notes gate now prevents.+Treat them as accurate but secondhand.++## 0.9.273 (2026-09-03 01:15 UTC)++Adopt the insiders tier: bridge.json declares insidersManifestUrl, build_release ships to insiders by default and refuses to publish without a changelog, tools/promote.py moves the public manifest only on a human's attestation. Wiki #72 groundwork.++## 0.9.272 (2026-09-02, backfilled)++Wiki #71 (Ray): stop asserting causes the bridge never checked. show_library's+library_cache_stale message claimed the editor indexed before the symbol was+added, which Ray's ordering disproved; it now describes KiCad's PROCESS-WIDE+library cache. The starting contract no longer reports "still coming up" when no+Symbol Editor window exists at all (it reported progress for 12 minutes on a+window that was never created). kicad_rescan_libraries' tree-refresh path no+longer claims parts "are now visible" - an effect it never verified - and returns+verified:false.++## 0.9.271 (2026-09-02, backfilled)++handlers/kicad_windows.py: ONE KiCad window finder replacing six per-module+EnumWindows loops, AD's desktop_list_windows first (carries z-order) with local+enumeration as fallback, 0.25s TTL because a bridge calling AD is re-entrant.+kicad_errors migrated onto it, so dialogs are matched by OWNING EXE instead of by+title. find_project_manager de-duplicated from the two editor modules.++## 0.9.270 (2026-09-02, backfilled)++kicad_screenshot_all captures via AD desktop_screenshot_window (owned popups,+coordMap, auto-pull) with the local PrintWindow kept as fallback; _canvas_probe+stays local because blank-GL-canvas detection needs raw BGRA. kicad_errors reads+TaskDialog text through AD UIA at DISCOVERY time - the previous two-step hand-off+raced and lost the message.++## 0.9.269 (2026-09-02, backfilled)++Documented the three error channels (dialogs, captured stderr, screenshots) and+kept the screenshot loop as the deliberate floor under the other two.++## 0.9.268 (2026-09-02, backfilled)++kicad_errors handles Windows TaskDialogs, whose DirectUI text win32 cannot read.++## 0.9.267 (2026-09-02, backfilled)++kicad_errors: enumerate KiCad error/warning dialogs read-only. KiCad writes no+log file and in GUI mode wxLogError becomes a message box, so the dialogs are+where a failure reason actually lives.++## 0.9.266 (2026-09-02, backfilled)++Capture KiCad's stderr instead of discarding it (launch used stdout/stderr=DEVNULL)+plus KICAD_ENABLE_WXTRACE, and add kicad_log_tail with a byte offset.++## 0.9.265 (2026-09-02, backfilled)++handlers/foreground_guard.py: caption before any action known to foreground, and+a z-order guard with ONE look inside 200ms, then hands off permanently so a user+alt-tabbing to KiCad is never fought.++## 0.9.264 (2026-09-01, backfilled)++PCM verbs resolve KICAD<n>_3RD_PARTY the way KiCad does, via the Windows shell+Documents folder - with OneDrive Known Folder Redirection that is+%USERPROFILE%/OneDrive/Documents, and installing to the wrong one made PCM report+a package installed while no library ever appeared.++## 0.9.263 (2026-09-01, backfilled)++kicad_pcm_install / pcm_list / pcm_uninstall: install a package from a KiCad PCM+repository on disk exactly as the Plugin and Content Manager does, so KiCad's own+dialog lists the result as Installed and registers the library tables itself.++## 0.9.262 (2026-09-01, backfilled)++Netlist and connectivity family: kicad_extract_netlist, trace_net,+find_connections, analyze_connections. Read-only via headless kicad-cli, so they+work with KiCad closed. Closes the largest gap against third-party KiCad MCP+servers, seven of eight of which had netlist tooling this bridge lacked.++## 0.9.261 (2026-09-01, backfilled)++Three-way merge recovering fixes that 0.9.251-0.9.260 had silently reverted (the+quote-aware .kicad_sym parser, demo captions, the #54 sub-node verify) while+keeping Colby's 0.9.251-0.9.260 work intact. Root cause fixed too:+build_release.py pushed only BRIDGE_VERSION/bridge.json/manifest, never the+source it shipped, so the release zip and the page repo disagreed and the next+person to publish from the repo reverted everything.
skills/kicad-bridge-dev/SKILL.md+37@@ -425,6 +425,43 @@ the package. Two layers, two commands, every time. Every one of these came from shipping something that looked verified and was not. +**A RELEASE WITHOUT A NOTE IS A RELEASE NOBODY CAN PROMOTE (2026-09-03, John).**+Twelve builds went out with "- Update N file(s)" as their entire changelog,+because the publisher never passed one and the server synthesised it from+unnamed pushes. `build_release.py --publish` now refuses without `--notes`, the+insiders tier is the default, and `tools/promote.py` shows the queued builds WITH+their notes before a human types `--because`. Also: the split check+(insiders-tier-verify) is not optional - it caught the first insiders ship+moving the public manifest through the source push within a minute.++**THE BRIDGE OWNS KICAD; AD OWNS WINDOWS (2026-09-01 audit, John).** Anything+about Windows ITSELF — screenshots, window lists, input, taskbar, notifications,+UIA — should call an AD verb, not a hand-rolled ctypes copy. `win_taskbar.py`+learned this the expensive way (an in-process ITaskbarList3::SetOverlayIcon+returns S_OK cross-process and the shell silently ignores it) and the rule was+never generalised. Now it is:+ * `kicad_screenshot_all` captures via `desktop_screenshot_window`, which brings+ OWNED POPUPS (an error dialog arrives with its editor), `coordMap` (click+ what you see) and the auto-pull. The local PrintWindow remains the FALLBACK —+ the screenshot loop is the floor under every other channel and must not+ depend on AD answering.+ * `handlers/kicad_windows.py` is now the ONE window finder (six modules each+ had their own EnumWindows). AD first for the list — it carries z-order,+ which is the only honest foreground check — local EnumWindows as fallback.+ * `kicad_errors` reads dialog text through AD's UIA at DISCOVERY time. The+ first cut returned an hwnd and told the caller to read it themselves; that+ raced and lost, because KiCad's TaskDialogs are transient.+What stays local, and why: KiCad detection, launching it, its file formats, the+plugin IPC, `_canvas_probe` (needs raw BGRA to spot a blank GL canvas), and+`close_windows.py`'s three scans — those are PID-gated / #32770-class /+orphan-catching queries that answer different questions, not duplicates.++**A loopback to AD is RE-ENTRANT — cache it.** AD calls this bridge, so a bridge+calling AD happens inside a call AD is already blocked on. `kicad_windows` holds+a 0.25s TTL so a verb that looks up windows five times makes ONE round trip. Do+not raise that TTL for speed: the staleness window is a correctness budget, and+`kicad_errors` reporting a dialog that closed a second ago is a lie.+ **KICAD HAS NO LOG FILE, AND ITS ERRORS DO NOT REACH stderr (2026-09-01).** wxLogError in a GUI app becomes a MESSAGE BOX, because wx installs wxLogGui as the active log target. Measured: a board that failed to parse raised a `KiCad PCB
skills/kicad-bridge-publish/SKILL.md+37@@ -163,3 +163,40 @@ they're known, and the fix belongs in the SDK, not just here: - Commit source to `main` in lockstep with each publish (drift = the bug that once dropped `updateManifestUrl`). - After ANY wiki change, refresh the wiki pup window showing the page. - The changelog must be real (≥2 words, no placeholders) — the server enforces it.++## ⛔ THE TIER (adopted 2026-09-03, first field-carrying build 0.9.273)++Every publish is INSIDERS by default. The public manifest moves only on a human's+words. Ray is an insider; before adoption he was getting public builds, which is+to say twelve builds in two days reached the whole fleet untested by anyone.++```bash+python3 tools/build_release.py 0.9.NNN --publish --notes "<what changed and why>"+# -> zip released on adom/kicad-bridge (bytes live on THIS page, always)+# -> adom/insiders/files/kicad-bridge/version.json updated (tier: insiders)+# -> RELEASE_NOTES.md gets the note; public manifest UNTOUCHED+python3 tools/promote.py # what is queued, with each build's notes+python3 tools/promote.py 0.9.NNN --because "<why this one is safe>" # public moves+python3 tools/build_release.py 0.9.NNN --publish --public --notes "..." # the explicit exception+```++**`--notes` is mandatory and the script refuses without it.** Twelve consecutive+releases shipped with the server-synthesised changelog "- Update N file(s)":+nothing written down about what changed, so a later promote would have had+nothing to judge. The note is for the person who has forgotten this week.++**Never put the public manifest in the source push.** The first insiders ship+pushed every shipped file to the page repo, the public manifest was in that+list, and the public tier moved to the insiders build as a side effect of+building. The split check caught it. `build_release.py` now excludes it unless+`--public`.++**Prove it every time (insiders-tier-verify):** public manifest version via anon+GET, insiders `version.json` via `adom-wiki repo show adom/insiders` (bearer),+anon GET of the insiders URL must be 404, and the insiders doc's sha256 must+equal the release asset's. Bootstrap a box whose installed bridge.json predates+the field with `bridge_install {manifestUrl: <insiders version.json>, force:true}`;+from then on it follows the tier by itself.++**Never create a release on adom/insiders** - it shares a version namespace+with that page's skills package (adom/wiki#170). The zip stays here.
tools/build_release.py+166−3@@ -24,14 +24,95 @@ 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") 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"] missing = [f for f in files if not os.path.isfile(f)] if missing:@@ -78,10 +159,92 @@ def main() -> int: print("\nnot published (pass --publish). Manifest updated locally.") return 0 - subprocess.run(["adom-wiki", "repo", "push", REF, "--files",- "BRIDGE_VERSION", "bridge.json", MANIFEST], check=False)- subprocess.run(["adom-wiki", "release", "create", REF, version], check=False)+ # 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+ push_files = ["BRIDGE_VERSION", "bridge.json"] + ([MANIFEST] if tier_public else []) + [+ f for f in files if os.path.isfile(f) and f != MANIFEST]+ 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}"+ for i in range(0, len(push_files), CHUNK):+ subprocess.run(["adom-wiki", "repo", "push", REF, "-m", msg, "--files"]+ + push_files[i:i + CHUNK], check=False)+ 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}\","
tools/promote.pyadded+159@@ -0,0 +1,159 @@+#!/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: copy the insiders document to the public+ # manifest. Same zip, same sha, same URL - only the pointer moves.+ src = subprocess.run(["adom-wiki", "repo", "show", INSIDERS_REF, INSIDERS_PATH],+ capture_output=True, text=True, timeout=60)+ doc = json.loads(src.stdout)+ 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)+ 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))