master
John Lauer promote.py promotes the requested version and verifies it landed eb4bfbc 1mo ago
#!/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))