← 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

John Lauer ·1ec1af8d7c ·1mo ago ·parent 8579e8c
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))