main
Colby Knox Restore tools/symbol_modes.py from 4d4e030 (pin electrical types): the 0.2.52 release's pkg publish (cc812e5) wrote bac… 9c53d98 1d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591
#!/usr/bin/env python3
"""Build + publish altium-bridge on the TWO-TIER release model (adom/altium-bridge#9;
pattern: adom/insiders skills + adom/pup-bridge tools/ship.sh, which did it first).

  python3 scripts/release.py                       # build + lint only (dist/ artifact)
  python3 scripts/release.py --publish "<log>"     # -> INSIDERS tier (the default tier):
                                                   #    release zip on THIS page, manifest to
                                                   #    adom/insiders altium-bridge/version.json,
                                                   #    release-log entry, source stamps pushed,
                                                   #    skills pkg published in lockstep.
                                                   #    The PUBLIC manifest is NEVER touched here.
  python3 scripts/release.py --promote <ver> --attest "<human's exact words>" --by "<name>"
                                                   # -> PUBLIC tier. REFUSES without the words;
                                                   #    they are recorded in the manifest
                                                   #    (promotion_attestation/promoted_by/_at).
  python3 scripts/release.py --rollback <ver> --attest "<words>" --by "<name>"
                                                   # re-point PUBLIC at an older archived build.
  python3 scripts/release.py --stage "<log>"       # build, sign, upload the release asset ONLY: no tier,
                                                   #    no skills pkg (a build waiting for a live battery).

SIGNING: every PE file the zip ships (.exe AND .dll) is Authenticode-signed as "Adom Industries, Inc." before
packaging (Windows Smart App Control blocks unsigned exes, and unsigned DLLs a program loads). The signing identifiers live in a
PRIVATE config outside this public repo ($ADOM_SIGNING_DIR, default ~/.adom/signing/altium-bridge:
config.json + sign-verify.ps1); the exe is signed on the Windows host that config names, through
that host's signer login, verified there (Get-AuthenticodeSignature + signtool verify /pa), pulled
back with a hash check, and verified AGAIN here. build_zip refuses any unsigned .exe or .dll, so no
path through this script can package one. Signing is the last change to the file before packaging.

Rules (insiders-tier-publish, verbatim where it matters): every publish is insiders; public only
on a human's words; an AI never promotes on its own judgment; one plain semver per build in both
tiers; every insiders publish appends its release-log entry before it is called done.

Versions live in ONE place: src/bridge.json (BRIDGE_VERSION, main.rs, package.json, page.json are checked).
"""
import datetime
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import zipfile
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
SRC = ROOT / "src"
RS = ROOT / "bridge-rs"
TARGET = "x86_64-pc-windows-gnu"
EXE = RS / "target" / TARGET / "release" / "altium-bridge.exe"
DIST = ROOT / "dist"
ARCHIVE = ROOT / "scripts" / "releases"
PAGE = "adom/altium-bridge"
PAGE_SLUG = "altium-bridge"
INSIDERS_PAGE = "adom/insiders"
INSIDERS_REL = "altium-bridge/version.json"
INSIDERS_LOG = "altium-bridge/README.md"
INSIDERS_URL = f"https://wiki.adom.inc/api/v1/pages/insiders/files/{INSIDERS_REL}"
PUBLIC_MANIFEST = ROOT / "adom-bridge-altium-manifest.json"

# (zip path, source path). bridge.json at the ROOT; everything else beside the exe exactly where
# the exe looks for it (agent/, resources/, extension/ relative to current_exe).
INCLUDE = [
    ("bridge.json", SRC / "bridge.json"),
    ("BRIDGE_VERSION", SRC / "BRIDGE_VERSION"),
    ("altium-bridge.exe", DIST / "signed" / "altium-bridge.exe"),
    ("agent/AdomAgent.pas.tmpl", SRC / "agent" / "AdomAgent.pas.tmpl"),
    ("agent/AdomAgent.PrjScr", SRC / "agent" / "AdomAgent.PrjScr"),
    ("resources/adom-overlay-32.png", SRC / "resources" / "adom-overlay-32.png"),
    ("extension/Install-AdomBridge.ps1", SRC / "extension" / "Install-AdomBridge.ps1"),
    ("extension/REMOVE-AdomBridge.cmd", SRC / "extension" / "REMOVE-AdomBridge.cmd"),
    ("extension/AdomBridge/AdomBridge.dll", DIST / "signed" / "AdomBridge.dll"),
    ("extension/AdomBridge/AdomBridge.ins", SRC / "extension" / "AdomBridge" / "AdomBridge.ins"),
    ("extension/AdomBridge/AdomBridge.rcs", SRC / "extension" / "AdomBridge" / "AdomBridge.rcs"),
    ("extension/AdomBridge/Help/ServerDlls.txt", SRC / "extension" / "AdomBridge" / "Help" / "ServerDlls.txt"),
]


def run(*a, **kw):
    return subprocess.run(a, check=True, **kw)


def wiki_head():
    out = subprocess.run(["adom-wiki", "repo", "log", PAGE, "--limit", "1", "--json"],
                         check=True, capture_output=True, text=True).stdout
    d = json.loads(out)
    return ((d.get("data") or d).get("log") or [{}])[0].get("hash", "")


def assert_source_is_head(source, when):
    """The working copy is a pull, and a pull records no commit, so the operator names the commit
    they pulled. Another session can push a new source between that pull and this publish; building
    then ships stale code, and the stamp push overwrites their CHANGELOG with ours."""
    head = wiki_head()
    if not source or not head.startswith(source):
        raise SystemExit(f"REFUSED ({when}): the wiki head of {PAGE} is {head[:7]}, but --source says {source or '(none)'}. "
                         f"Pull again (adom-wiki repo pull {PAGE}), read what changed, and rerun with --source {head[:7]}.")
    return head


def now_iso():
    return datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")


def lint_verbs():
    """bridge.json's verbs[] is a PROMISE ab routes on; bridge-rs/src/verbs.rs KNOWN is the
    dispatch of record. Drift shipped live bugs on 2026-09-01, so the release refuses instead."""
    rs = (RS / "src" / "verbs.rs").read_text(encoding="utf-8", errors="replace")
    m = re.search(r"pub const KNOWN: &\[&str\] = &\[(.*?)\];", rs, re.S)
    if not m:
        raise SystemExit("cannot find KNOWN in bridge-rs/src/verbs.rs")
    known = set(re.findall(r'"([a-z0-9_]+)"', m.group(1)))
    declared = {v[len("altium_"):] for v in json.loads((SRC / "bridge.json").read_text())["verbs"]}
    phantom, unreachable = sorted(declared - known), sorted(known - declared)
    if phantom or unreachable:
        raise SystemExit(
            "bridge.json does not match bridge-rs/src/verbs.rs\n"
            f"  declared but NOT dispatchable: {phantom}\n"
            f"  dispatchable but NOT declared: {unreachable}")
    # ab cuts a verb at bridge.json's timeouts (60 s unless listed), whatever its describe says: on
    # 2026-10-15, 73 verbs documented 90-900 s were cut at 60 (a schematic build answered timed_out
    # while it was still drawing). Every documented timeout over the default must be listed.
    t = json.loads((SRC / "bridge.json").read_text())["timeouts"]
    short = []
    for m in re.finditer(r'verb_doc(?:_long)?\("([a-z_0-9]+)",', rs):
        depth, cur, args, j = 1, "", [], m.end()
        while j < len(rs) and depth > 0:
            ch = rs[j]
            depth += ch in "([{"
            depth -= ch in ")]}"
            if depth == 1 and ch == ",":
                args.append(cur.strip()); cur = ""
            elif depth >= 1:
                cur += ch
            j += 1
        nums = [a for a in args if re.fullmatch(r"\d+", a)]
        if nums and int(nums[0]) > t["verbs"].get(m.group(1), t["default"]):
            short.append(f"{m.group(1)} documents {nums[0]} s, ab allows {t['verbs'].get(m.group(1), t['default'])}")
    if short:
        raise SystemExit("bridge.json timeouts are shorter than the verbs' documented timeouts:\n  " + "\n  ".join(short))
    return len(declared)


def lint_versions(ver):
    """One version, four stamps beside bridge.json — refuse a half-bumped release. page.json is the
    wiki page's own version (0.2.47 shipped with it at 0.2.46); main.rs is what the exe reports
    (0.2.46 shipped reporting 0.2.45, adom/altium-bridge#22)."""
    stamps = {
        "src/BRIDGE_VERSION": (SRC / "BRIDGE_VERSION").read_text().strip(),
        "package.json": json.loads((ROOT / "package.json").read_text())["version"],
        "page.json": json.loads((ROOT / "page.json").read_text())["version"],
    }
    m = re.search(r'BRIDGE_VERSION: &str = "([^"]+)"', (RS / "src" / "main.rs").read_text())
    stamps["bridge-rs/src/main.rs"] = m.group(1) if m else "?"
    bad = {k: v for k, v in stamps.items() if v != ver}
    if bad:
        raise SystemExit(f"version drift against bridge.json {ver}: {bad}")
    bj = json.loads((SRC / "bridge.json").read_text())
    if bj.get("insidersManifestUrl") != INSIDERS_URL:
        raise SystemExit(f"bridge.json insidersManifestUrl must be {INSIDERS_URL} (#9)")


# Identifiers MEASURED to halt Altium's DelphiScript engine (an unknown identifier is a RUNTIME
# error: an 'Error' box, then the script parks on a breakpoint and Altium will not even close).
# dev-skills/altium-bridge-dev lists how each was found. Add to this list whenever one is found;
# a build that contains one is refused, because the only other way to find it is a user's Altium.
ENGINE_HALTING = [
    r"\.ImplementationCount\b", r"\.Implementation\[", r"\bBottomXSize\b", r"\bMidXSize\b",
    r"\beProcessFreePrimitives\b",   # (IPCB_Board.GroupIterator_Create also halts, but the same call is
    # correct on a component/footprint and a regex cannot see the receiver: review those by hand)
    r"\bRunProcessWithParameters\b", r"\.SuperClass\s*:=", r"\bCreateOleObject\b",
    r"\.MinimumRing\b",            # IPCB_MinimumAnnularRing has no MinimumRing on AD 26.11.1 (measured 2026-10-15)
    r"\.Priority\s*:=",            # writing a rule's Priority halts with an empty Error box (AD 26.11.1, 2026-10-15);
                                   # a newly added rule already lands at priority 1
]


def lint_agent_script():
    """Refuse an agent template that uses an identifier known to halt the engine."""
    src = (SRC / "agent" / "AdomAgent.pas.tmpl").read_text(encoding="utf-8", errors="replace")
    code = re.sub(r"\{[^}]*\}", "", src)            # brace comments may NAME the identifier
    code = re.sub(r"//[^\n]*", "", code)
    code = re.sub(r"'[^'\n]*'", "''", code)          # and so may string literals
    hits = []
    for pat in ENGINE_HALTING:
        for m in re.finditer(pat, code):
            hits.append(f"{pat}  ->  ...{code[max(0, m.start() - 40):m.end() + 20].strip()}...")
    # A raw StrToFloat/StrToInt on a caller value RAISES on malformed input, and a raise is an Error
    # box on the user's screen. Only the AdomFloat/AdomInt helpers may call them.
    body = re.sub(r"Function AdomFloat\(.*?^End;|Function AdomInt\(.*?^End;", "", code, flags=re.S | re.M)
    for m in re.finditer(r"(?<![A-Za-z_])(StrToFloat|StrToInt)\(", body):
        hits.append(f"raw {m.group(1)}( outside AdomFloat/AdomInt  ->  ...{body[max(0, m.start() - 40):m.end() + 25].strip()}...")
    if hits:
        raise SystemExit("agent template uses engine-halting identifiers (see ENGINE_HALTING):\n  " + "\n  ".join(hits))


def build_exe():
    subprocess.run(["cargo", "test", "--quiet"], cwd=RS, check=True)
    subprocess.run(["cargo", "build", "--release", "--target", TARGET], cwd=RS, check=True)
    if not EXE.exists():
        raise SystemExit(f"exe missing after build: {EXE}")


SIGNER_ORG = "Adom Industries, Inc."


def signing_dir():
    return Path(os.environ.get("ADOM_SIGNING_DIR", Path.home() / ".adom" / "signing" / "altium-bridge"))


def authenticode_subjects(path):
    """Certificate subjects in a PE's Authenticode signature, read here without Windows: the
    optional header's security data directory points at a WIN_CERTIFICATE holding PKCS#7 DER.
    [] means unsigned."""
    import struct
    import tempfile
    b = Path(path).read_bytes()
    pe = struct.unpack_from("<I", b, 0x3C)[0]
    if b[pe:pe + 4] != b"PE\0\0":
        raise SystemExit(f"{path}: not a PE file")
    opt = pe + 24
    magic = struct.unpack_from("<H", b, opt)[0]
    dirs = opt + (112 if magic == 0x20B else 96)
    off, size = struct.unpack_from("<II", b, dirs + 4 * 8)
    if not off or not size:
        return []
    length, _rev, ctype = struct.unpack_from("<IHH", b, off)
    if ctype != 2:
        return []
    with tempfile.NamedTemporaryFile(suffix=".p7b", delete=False) as f:
        f.write(b[off + 8:off + length])
    r = subprocess.run(["openssl", "pkcs7", "-inform", "DER", "-in", f.name, "-print_certs", "-noout"],
                       capture_output=True, text=True)
    os.unlink(f.name)
    return [l[len("subject="):].strip() for l in r.stdout.splitlines() if l.startswith("subject=")]


def assert_signed(path):
    subs = authenticode_subjects(path)
    if not any(f'CN = "{SIGNER_ORG}"' in s for s in subs):
        raise SystemExit(f"REFUSED: {path} is not Authenticode-signed by {SIGNER_ORG} "
                         f"(found {subs or 'no signature'}). Windows Smart App Control blocks unsigned exes and the "
                         f"unsigned DLLs a program loads; this script never packages one.")
    return subs


def ab_json(*args, timeout=600):
    out = subprocess.run(["adom-bridge", *args], capture_output=True, text=True, timeout=timeout).stdout
    d = json.loads(out[out.find("{"):]) if "{" in out else {}
    o = d.get("output")
    if isinstance(o, str) and o.strip().startswith("{"):
        try:
            d = json.loads(o)
        except ValueError:
            pass
    return d


# (published name, unsigned source). Each is signed on the host, then shipped from dist/signed/.
PE_SOURCES = [
    ("altium-bridge.exe", EXE),
    ("AdomBridge.dll", SRC / "extension" / "AdomBridge" / "AdomBridge.dll"),
]


def sign_pe_files(ver):
    """Sign every PE file the zip ships on the private config's Windows host, verify each there,
    pull them back, verify each here."""
    import base64
    sd = signing_dir()
    cfg_p, ps1 = sd / "config.json", sd / "sign-verify.ps1"
    if not cfg_p.exists() or not ps1.exists():
        raise SystemExit(f"REFUSED: private signing config missing at {sd} (config.json + sign-verify.ps1). "
                         f"It is deliberately not in this public repo; ask the release owner for it.")
    cfg = json.loads(cfg_p.read_text())
    host, thread = cfg["host"], "altium-bridge release signing"
    stage = cfg["stageDir"].replace("%LOCALAPPDATA%", cfg.get("localAppData", r"C:\Users\cknox\AppData\Local")) \
        + "\\build-" + ver.replace(".", "_")  # a dotted name reads as a file path to send_files
    work = DIST / "signing"
    shutil.rmtree(work, ignore_errors=True)
    work.mkdir(parents=True)
    for name, src in PE_SOURCES:
        shutil.copy2(src, work / name)
    shutil.copy2(ps1, work / "sign-verify.ps1")
    names = [n for n, _ in PE_SOURCES]
    print(f"== sign {', '.join(names)} on the signing host ==")
    sent = ab_json("--target", host, "--ai-thread", thread, "send_files", json.dumps({
        "filePaths": [str(work / n) for n in names] + [str(work / "sign-verify.ps1")], "dest": stage,
        "reason": f"stage altium-bridge {ver} for Authenticode signing"}))
    if int(sent.get("filesReceived") or 0) != len(names) + 1:
        raise SystemExit(f"signing host did not receive the files: {sent.get('error') or sent}")
    calls = "; ".join(
        f"(& '{stage}\\sign-verify.ps1' -Exe '{stage}\\{n}' -Endpoint '{cfg['endpoint']}' -Account '{cfg['account']}' "
        f"-Profile '{cfg['profile']}' -Tenant '{cfg['tenant']}' -Timestamp '{cfg['timestampUrl']}' | Out-String)"
        for n in names)
    cmd = f"$r = @({calls}); '[' + ($r -join ',') + ']'"
    r = ab_json("--target", host, "--ai-thread", thread, "run_script", json.dumps({
        "interpreter": "powershell", "scriptB64": base64.b64encode(cmd.encode()).decode(), "timeout": 540,
        "reason": f"Authenticode-sign altium-bridge {ver} ({', '.join(names)}) as {SIGNER_ORG}, then Get-AuthenticodeSignature + signtool verify"}), timeout=900)
    so = r.get("stdout") or ""
    try:
        verdicts = json.loads(so[so.find("["):])
    except ValueError:
        raise SystemExit(f"signing script gave no verdict: {so[:400]} {(r.get('stderr') or '')[:400]}")
    reports = []
    for name, v in zip(names, verdicts):
        if v.get("error"):
            raise SystemExit(f"REFUSED: signing host cannot sign ({v['error']}): {v.get('detail', '')}\n  fix: {v.get('fix', '')}")
        checks = {
            "Status Valid": v.get("status") == "Valid",
            "signer subject": cfg["expectSubject"] in (v.get("subject") or ""),
            "timestamped": "Microsoft" in (v.get("timestamper") or ""),
            "signtool verify /pa": "Successfully verified" in (v.get("signtoolVerify") or ""),
        }
        if not all(checks.values()):
            raise SystemExit(f"REFUSED: signature checks failed for {name}: {checks}\n{json.dumps(v, indent=1)}")
        pulled = ab_json("--target", host, "--ai-thread", thread, "pull_file", json.dumps({
            "filePaths": [f"{stage}\\{name}"], "reason": f"pull the signed {name} ({ver}) back for packaging"}))
        files = pulled.get("files") or []
        if not files:
            raise SystemExit(f"could not pull the signed {name} back: {pulled.get('error') or pulled}")
        signed = DIST / "signed" / name
        signed.parent.mkdir(parents=True, exist_ok=True)
        shutil.move(files[0]["path"], signed)
        local_sha = hashlib.sha256(signed.read_bytes()).hexdigest()
        if local_sha != v.get("sha256"):
            raise SystemExit(f"REFUSED: {name} changed in transit (host {v.get('sha256')}, here {local_sha})")
        subs = assert_signed(signed)
        reports.append({"file": name, "status": v["status"], "subject": v["subject"],
                        "timestamper": v["timestamper"], "signtoolVerify": v["signtoolVerify"].split(":")[0],
                        "signer": v.get("signer"), "sha256": local_sha, "localSubjects": subs})
        print(f"   {name}: " + ", ".join(f"{k} {'ok' if ok else 'FAIL'}" for k, ok in checks.items())
              + f"; verified again here, sha256 {local_sha[:12]}")
    if len(reports) != len(names):
        raise SystemExit(f"REFUSED: expected {len(names)} verdicts, got {len(reports)}")
    (DIST / "signed" / "signing-report.json").write_text(json.dumps(reports, indent=2) + "\n")
    return reports


def build_zip(ver):
    DIST.mkdir(exist_ok=True)
    zname = f"adom-bridge-altium-v{ver}.zip"
    zpath = DIST / zname
    for rel, p in INCLUDE:
        if rel.lower().endswith((".exe", ".dll")):
            assert_signed(p)
    with zipfile.ZipFile(zpath, "w", zipfile.ZIP_DEFLATED) as z:
        for rel, p in INCLUDE:
            if not p.exists():
                sys.exit(f"missing {p}")
            z.write(p, rel)
    sha = hashlib.sha256(zpath.read_bytes()).hexdigest()
    print(f"built {zpath} ({zpath.stat().st_size} bytes) sha256={sha[:12]}")
    return zname, zpath, sha


def manifest_for(ver, zname, zpath, sha, tier):
    m = {
        "name": "altium", "version": ver,
        "url": f"https://wiki.adom.inc/download/{PAGE}/{ver}/{zname}",
        "sha256": sha, "size": zpath.stat().st_size,
        "verbPrefixes": ["altium_"], "healthEndpoint": "/health",
        "updateManifestUrl": f"https://wiki.adom.inc/api/v1/pages/{PAGE_SLUG}/files/adom-bridge-altium-manifest.json",
        "insidersManifestUrl": INSIDERS_URL,
        "released_at": now_iso(),
    }
    if tier == "insiders":
        m["tier"] = "insiders"
    return m


def two_curl_split_check():
    """The tier's whole security story: the insiders manifest must be bearer-readable and
    anonymously invisible. Refuse to call the publish done otherwise."""
    import urllib.request
    anon_ok = False
    try:
        urllib.request.urlopen(INSIDERS_URL, timeout=15)
    except urllib.error.HTTPError as e:
        anon_ok = e.code in (401, 403, 404)
    if not anon_ok:
        raise SystemExit(f"TIER LEAK: {INSIDERS_URL} is anonymously readable (or unreachable in an unexpected way)")
    # the authenticated read goes through the CLI (its session auth is what insider desktops
    # use); a raw container bearer gets 403 on private-page file endpoints (measured 2026-09-02)
    out = subprocess.run(["adom-wiki", "api", "/api/v1/pages/insiders/files/altium-bridge/version.json"],
                         capture_output=True, text=True, check=True).stdout
    served = json.loads(out[out.find("{"):])
    print(f"   two-curl split ok: anonymous blocked, bearer serves {served.get('version')}")
    return served


def stage_only(log_line, source):
    """A build waiting for a live battery: signed and uploaded as a release asset, but NO tier moves
    and no skills pkg row. Its manifest is archived so a later publish or pin can point at it."""
    assert_source_is_head(source, "before build")
    ver = json.loads((SRC / "bridge.json").read_text())["version"]
    lint_versions(ver)
    lint_agent_script()
    n = lint_verbs()
    print(f"verb lint ok: {n} verbs")
    build_exe()
    assert_source_is_head(source, "before signing")
    sign_pe_files(ver)
    zname, zpath, sha = build_zip(ver)
    subprocess.run(["adom-wiki", "release", "create", PAGE, ver, "--title", f"altium-bridge {ver}",
                    "--changelog", f"STAGED, not on any tier: {log_line}"], check=False, capture_output=True)
    run("adom-wiki", "release", "upload", PAGE, ver, str(zpath), "--platform", "windows", "--arch", "x64")
    ARCHIVE.mkdir(exist_ok=True)
    (ARCHIVE / f"{ver}.json").write_text(json.dumps(manifest_for(ver, zname, zpath, sha, "insiders"), indent=2) + "\n")
    print(f"STAGED {ver}: signed zip sha256 {sha[:12]} uploaded; no tier moved. Archived scripts/releases/{ver}.json.")


def ship_insiders(log_line, source):
    assert_source_is_head(source, "before build")
    ver = json.loads((SRC / "bridge.json").read_text())["version"]
    lint_versions(ver)
    lint_agent_script()
    n = lint_verbs()
    print(f"verb lint ok: {n} verbs declared and dispatchable")
    build_exe()
    assert_source_is_head(source, "before signing")
    sign_pe_files(ver)
    zname, zpath, sha = build_zip(ver)
    assert_source_is_head(source, "before upload")

    print(f"== release {ver} on {PAGE} (bytes live on the product page, both tiers) ==")
    subprocess.run(["adom-wiki", "release", "create", PAGE, ver, "--title", f"altium-bridge {ver}",
                    "--changelog", "see CHANGELOG.md"], check=False, capture_output=True)
    run("adom-wiki", "release", "upload", PAGE, ver, str(zpath), "--platform", "windows", "--arch", "x64")

    print(f"== insiders manifest + release log -> {INSIDERS_PAGE} ==")
    ins = manifest_for(ver, zname, zpath, sha, "insiders")
    work = DIST / "insiders-repo"
    (work / "altium-bridge").mkdir(parents=True, exist_ok=True)
    # NEVER edit a stale working copy: adom/insiders is a SHARED repo that parallel sessions
    # (John's threads) write between our publishes. A Sept-8 working copy pushed on 2026-09-21
    # clobbered 26 release-log entries for 12 minutes. Fetch the LIVE log first, always.
    live = subprocess.run(["adom-wiki", "api", f"/api/v1/pages/insiders/files/{INSIDERS_LOG}"],
                          capture_output=True, text=True)
    if live.returncode == 0 and '"raw"' in live.stdout:
        raw = json.loads(live.stdout[live.stdout.find("{"):])["raw"]
        (work / INSIDERS_LOG).write_text(raw)
    (work / INSIDERS_REL).write_text(json.dumps(ins, indent=2) + "\n")
    ARCHIVE.mkdir(exist_ok=True)
    (ARCHIVE / f"{ver}.json").write_text(json.dumps(ins, indent=2) + "\n")

    log_path = work / INSIDERS_LOG
    today = datetime.date.today().isoformat()
    entry = f"- **{ver}** insiders, {today}. {log_line} Not promoted.\n"
    if log_path.exists():
        s = log_path.read_text()
    else:
        s = (
            "# altium-bridge on the insiders tier\n\n"
            "**Publishing thread:** the altium-bridge thread owns this file. Every insiders publish "
            "(`scripts/release.py --publish` in the altium-bridge repo) appends an entry to the release log "
            "below before the publish is called done; a promote (`--promote`, which refuses without a human's "
            "exact words) edits that version's entry with the date and the words. Newest first.\n\n"
            "| | Insiders | Public |\n|---|---|---|\n"
            f"| Manifest | [altium-bridge/version.json](https://wiki.adom.inc/adom/insiders/files/altium-bridge/version.json) | [adom-bridge-altium-manifest.json](https://wiki.adom.inc/adom/altium-bridge/files/adom-bridge-altium-manifest.json) |\n"
            "| Bridge zip | [adom/altium-bridge releases](https://wiki.adom.inc/adom/altium-bridge/releases) (unlisted until promoted) | same files, same URLs |\n"
            "| Skills pkg | `pkg install adom/altium-bridge` follows the newest row until the registry insiders dist-tag lands (adom/wiki#168) | same |\n\n"
            "The bridge declares `insidersManifestUrl` in `src/bridge.json`; a desktop on ab 2.0.99+ whose "
            "login can read this page follows the insiders manifest, everyone else follows the public one "
            "and never sees this file.\n\n## Release log\n\n"
        )
    if f"- **{ver}**" not in s:
        s = re.sub(r"(## Release log\n\n)", r"\1" + entry.replace("\\", "\\\\"), s, count=1)
    log_path.write_text(s)
    run("adom-wiki", "repo", "push", INSIDERS_PAGE, "--files", INSIDERS_REL, INSIDERS_LOG,
        "-m", f"altium-bridge {ver} (insiders): {log_line}", cwd=work)

    print("== source stamps to the product page (public manifest NOT touched) ==")
    assert_source_is_head(source, "before the stamp push")
    run("adom-wiki", "repo", "push", PAGE, "-m", f"v{ver} (insiders): {log_line}",
        "--files", "CHANGELOG.md", "src/bridge.json", "src/BRIDGE_VERSION", "package.json", "page.json",
        "bridge-rs/src/main.rs", cwd=ROOT)
    stamped = wiki_head()

    print("== skills pkg in lockstep (release rows share the version namespace, adom/wiki#170) ==")
    # pkg publish writes the package payload (tools/, skills) back into the page repo from this
    # working copy, so a source pushed since the stamp commit would be reverted file by file.
    assert_source_is_head(stamped[:7], "before pkg publish")
    run("adom-wiki", "pkg", "publish", "--org", "adom", cwd=ROOT)

    print("== verify ==")
    served = two_curl_split_check()
    if served.get("version") != ver:
        raise SystemExit(f"insiders manifest serves {served.get('version')}, expected {ver}")
    import urllib.request
    try:
        with urllib.request.urlopen(
                f"https://wiki.adom.inc/api/v1/pages/{PAGE_SLUG}/files/adom-bridge-altium-manifest.json",
                timeout=15) as r:
            pub = json.loads(r.read())
    except Exception:
        pub = json.loads(PUBLIC_MANIFEST.read_text())
    print(f"SHIPPED {ver} to INSIDERS; public tier serves {pub['version']} (read LIVE - the local manifest can be stale when another session promoted).")
    print(f'To promote: python3 scripts/release.py --promote {ver} --attest "<the human\'s exact words>" --by "<name>"')


def attestation_gate(words, by):
    a = re.sub(r"\s+", " ", words or "").strip()
    if len(a) < 8 or re.fullmatch(r"(?i)(ok|yes|promote|ship it|go|approved|test|placeholder|x+)", a):
        raise SystemExit('REFUSED: the PUBLIC tier moves only on the human\'s exact words (>= 8 chars, not a placeholder). An AI never promotes on its own judgment.')
    if not by:
        raise SystemExit('REFUSED: say who promoted (--by "<name>").')
    return a


def promote(ver, words, by, rollback=False):
    a = attestation_gate(words, by)
    arch = ARCHIVE / f"{ver}.json"
    if not arch.exists():
        raise SystemExit(f"no archived insiders manifest for {ver} ({arch}) - ship it first")
    ins = json.loads(arch.read_text())
    import urllib.request
    with urllib.request.urlopen(ins["url"], timeout=120) as r:
        remote_sha = hashlib.sha256(r.read()).hexdigest()
    if remote_sha != ins["sha256"]:
        raise SystemExit(f"REMOTE ASSET MISMATCH: {ins['url']} serves {remote_sha}, record says {ins['sha256']}")
    print(f"   asset verified: {ins['url']} sha={remote_sha[:12]}")
    m = dict(ins)
    m.pop("tier", None)
    now = now_iso()
    if rollback:
        m.update({"rollback_attestation": a, "rolled_back_by": by, "rolled_back_at": now})
    else:
        m.update({"promotion_attestation": a, "promoted_by": by, "promoted_at": now,
                  "insiders_released_at": ins.get("released_at")})
    PUBLIC_MANIFEST.write_text(json.dumps(m, indent=2) + "\n")
    verb = "rolled back to" if rollback else "promoted to PUBLIC"
    run("adom-wiki", "repo", "push", PAGE, "--files", "adom-bridge-altium-manifest.json",
        "-m", f"v{ver}: {verb} - {by}: {a}", cwd=ROOT)
    # mark the release-log entry
    work = DIST / "insiders-repo"
    log_path = work / INSIDERS_LOG
    if log_path.exists() and not rollback:
        s = log_path.read_text()
        line = f"- **{ver}** insiders,"
        if line in s and "Promoted to public" not in s.split(line)[1].split("\n")[0]:
            s = s.replace(" Not promoted.\n", f' Promoted to public on {datetime.date.today().isoformat()} by {by}: "{a}".\n', 1)
            log_path.write_text(s)
            run("adom-wiki", "repo", "push", INSIDERS_PAGE, "--files", INSIDERS_LOG,
                "-m", f"altium-bridge {ver}: promoted to public - {by}", cwd=work)
    print(f"PUBLIC tier now serves {ver} ({'rollback' if rollback else 'promote'}), attested by {by}.")


def source_arg(args):
    return args[args.index("--source") + 1] if "--source" in args and len(args) > args.index("--source") + 1 else ""


def main():
    args = sys.argv[1:]
    if "--promote" in args:
        i = args.index("--promote")
        ver = args[i + 1]
        words = args[args.index("--attest") + 1] if "--attest" in args else ""
        by = args[args.index("--by") + 1] if "--by" in args else ""
        promote(ver, words, by)
    elif "--rollback" in args:
        i = args.index("--rollback")
        ver = args[i + 1]
        words = args[args.index("--attest") + 1] if "--attest" in args else ""
        by = args[args.index("--by") + 1] if "--by" in args else ""
        promote(ver, words, by, rollback=True)
    elif "--publish" in args:
        i = args.index("--publish")
        log_line = args[i + 1] if len(args) > i + 1 and not args[i + 1].startswith("--") else ""
        if not log_line:
            raise SystemExit('usage: release.py --publish "<one changelog sentence for the release log>" --source <commit you pulled>')
        ship_insiders(log_line, source_arg(args))
    elif "--stage" in args:
        i = args.index("--stage")
        log_line = args[i + 1] if len(args) > i + 1 and not args[i + 1].startswith("--") else ""
        if not log_line:
            raise SystemExit('usage: release.py --stage "<one line: what this staged build is for>" --source <commit you pulled>')
        stage_only(log_line, source_arg(args))
    else:
        # lint + build only: packaging needs a signed exe, so the zip is made by --stage/--publish
        ver = json.loads((SRC / "bridge.json").read_text())["version"]
        lint_versions(ver)
        lint_agent_script()
        n = lint_verbs()
        print(f"verb lint ok: {n} verbs")
        build_exe()
        print("built the exe (unsigned). --stage or --publish signs, packages and uploads.")


if __name__ == "__main__":
    main()