master
John Lauer Release tool: keep the CA bundle out of repo pushes (secret scanner, #79 root cause); runner traces a real net 08c8817 2mo ago
#!/usr/bin/env python3
"""Build (and optionally publish) a kicad-bridge runtime release.

Until now the shipping file list lived only in a maintainer's scratchpad, so
nobody else could reproduce a release and a container reset would have lost it.
It is `tools/release_files.json` now, in the repo, next to this script.

    python3 tools/build_release.py 0.9.219            # build + update manifest
    python3 tools/build_release.py 0.9.219 --publish  # ... and push + upload

The zip is built from the explicit list, never a directory walk: a stray file in
the working tree must never end up on a user's machine.
"""
import hashlib
import json
import os
import subprocess
import re
import sys
import zipfile
from datetime import datetime, timezone

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
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")
    # The version is positional. A flag in slot 1 ("--notes ...") once got
    # stamped into BRIDGE_VERSION and bridge.json as the version string.
    if not re.fullmatch(r"\d+\.\d+\.\d+", version):
        print(f"REFUSING: first argument must be the version (got {version!r}); usage: build_release.py <x.y.z> [--publish] --notes \"...\"")
        return 2
    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"]
    # Whole directories (vendored wheels for the routing verbs, tools/release_files.json
    # "dirs"). Walked at build time because the file names carry wheel versions.
    for d in json.load(open("tools/release_files.json")).get("dirs", []):
        if not os.path.isdir(d):
            print("REFUSING TO BUILD - listed directory is missing:", d)
            return 1
        for root, _dirs, names in os.walk(d):
            if "__pycache__" in root:
                continue
            files.extend(os.path.join(root, n).replace(os.sep, "/") for n in sorted(names))
    missing = [f for f in files if not os.path.isfile(f)]
    if missing:
        print("REFUSING TO BUILD - listed files are missing:")
        for m in missing:
            print("   ", m)
        return 1

    # Version lives in two places and they must agree; a mismatch has shipped
    # before and is invisible until someone reads bridgeVersion off a box.
    open("BRIDGE_VERSION", "w").write(version)
    bj = json.load(open("bridge.json"))
    bj["version"] = version
    json.dump(bj, open("bridge.json", "w"), indent=2)

    out = f"adom-bridge-kicad-v{version}.zip"
    # --reuse-zip: publish the exact bytes that were staged and tested on a box
    # (a rebuild changes zip timestamps, hence the sha256 the test evidence names).
    if "--reuse-zip" in sys.argv and os.path.isfile(out):
        print(f"reusing existing {out} (tested bytes; not rebuilt)")
    else:
        with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z:
            for f in files:
                z.write(f, f)

    raw = open(out, "rb").read()
    sha, size = hashlib.sha256(raw).hexdigest(), len(raw)

    # Guards for two failures that actually shipped: a raw NUL byte written into
    # a source file (un-importable, bricks the bridge), and a version mismatch
    # between the zip and BRIDGE_VERSION.
    with zipfile.ZipFile(out) as z:
        if z.read("BRIDGE_VERSION").decode().strip() != version:
            print("REFUSING: BRIDGE_VERSION inside the zip does not match", version)
            return 1
        for n in z.namelist():
            if n.endswith(".py") and b"\x00" in z.read(n):
                print(f"REFUSING: NUL byte in {n} - Python cannot import this file")
                return 1

    m = json.load(open(MANIFEST))
    m.update(version=version, sha256=sha, size=size,
             url=f"https://wiki.adom.inc/download/{REF}/{version}/{out}",
             released_at=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"))
    json.dump(m, open(MANIFEST, "w"), indent=2)

    print(f"built {out}\n  files : {len(files)}\n  size  : {size}\n  sha256: {sha}")
    if not publish:
        print("\nnot published (pass --publish). Manifest updated locally.")
        return 0

    # 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
    # Vendored third-party trees (release_files.json "dirs") ship in the zip but
    # are NOT source: tools/vendor_routing_deps.py rebuilds them from
    # requirements-routing.txt, so they stay out of the page repo.
    vendored = tuple(d.rstrip("/") + "/" for d in json.load(open("tools/release_files.json")).get("dirs", []))
    # push_exclude: shipped but never pushed. certs/cacert.pem is the public
    # Mozilla CA bundle; the wiki's secret scanner rejects every .pem, and one
    # rejected file fails the whole chunk it rides in (that is how the #79
    # handlers went missing: "certs" sorts right before "handlers").
    excluded = set(json.load(open("tools/release_files.json")).get("push_exclude", []))
    push_files = ["BRIDGE_VERSION", "bridge.json"] + ([MANIFEST] if tier_public else []) + [
        f for f in files if os.path.isfile(f) and f != MANIFEST and not f.startswith(vendored)
        and f not in excluded]
    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}"
    # Every push reply is checked (issue #79: five handlers silently never reached
    # master across twenty releases because a failed chunk was ignored).
    failed = []
    for i in range(0, len(push_files), CHUNK):
        chunk = push_files[i:i + CHUNK]
        proc = subprocess.run(["adom-wiki", "repo", "push", REF, "-m", msg, "--files"] + chunk,
                              capture_output=True, text=True)
        ok = '"ok": true' in proc.stdout
        print(f"  push {i // CHUNK + 1}: {len(chunk)} file(s) -> {'ok' if ok else 'FAILED'}")
        if not ok:
            print((proc.stdout + proc.stderr)[-600:])
            failed.extend(chunk)
    if failed:
        print("REFUSING TO RELEASE: source push failed for", len(failed), "file(s); the repo would not match the zip:")
        for f in failed[:20]:
            print("   ", f)
        return 1
    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}\","
          f"\"force\":true,\"reason\":\"...\"}}'")
    print("  then confirm with kicad_status -> bridgeVersion")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())