master
John Lauer v0.16.22 (insiders): Docs release: README What's new and version table, INSTALL-FLOW (fresh box, updating, host restore… b92ffc8 1mo ago
#!/usr/bin/env bash
# ship.sh - THE publish pipeline for the nb (native-browser) bridge, living IN THE REPO.
#
#   tools/ship.sh <version> "<changelog sentence>"              # -> INSIDERS tier (the default)
#   tools/promote.sh <version> "<human's words>" --by "<name>"   # -> PUBLIC tier, attested
#
# RELEASE TIERS (adom/nb-bridge#39, ab 2.0.99 insidersManifestUrl; adopted 2026-09-02 on John's word).
# The fleet convention is ONE shared private page, adom/insiders, whose repo carries ONLY
# <product>/version.json manifests plus a per-product release log; the bytes live as releases on the
# product's own page. pup-bridge did it first (tools/ship.sh there); this is the same pipeline with
# nb's file layout (bridge/ instead of src/, and the dashboard's VERSION const in lockstep).
#
#   - Every ship: the zip becomes a release on adom/nb-bridge, the INSIDERS manifest is written to
#     adom/insiders as nb-bridge/version.json (bridge-manifest shape + tier), the release log entry
#     is appended to nb-bridge/README.md there, the source text files are pushed to the public page
#     repo (source is open), and the skills pkg is published at the SAME version so the page's top
#     version row always carries a tarball (release rows + pkg rows share one namespace; a
#     release-only row on top breaks `pkg install`, adom/wiki#170). The PUBLIC manifest
#     (native-browser-bridge-manifest.json, the updateManifestUrl) is NEVER touched here.
#   - Desktops on ab >= 2.0.99 whose login can read adom/insiders follow the insiders manifest;
#     everyone else follows the public manifest and never learns the tier exists.
#   - The public manifest moves ONLY via tools/promote.sh, which REFUSES without the human's exact
#     words and records them in the manifest. One plain semver per build in both tiers.
#
# ONE VERSION FOR EVERYTHING (from 0.16.0): bridge/bridge.json, bridge/package.json,
# bridge/BRIDGE_VERSION, package.json (skills pkg) and bin/nb-dashboard's VERSION const all carry
# the same number. Before this the runtime (0.5.x) and the pkg (0.15.x) were separate streams and
# the three-way lockstep drifted twice; the ship script now owns all five.
#
# Env: FORCE_PUSH=1 pushes even when a push reports failure. DRY_RUN=1 stops after the zip.
set -euo pipefail
VER="${1:?usage: tools/ship.sh <version> \"<changelog>\"}"
LOG="${2:?changelog sentence required}"
REPO="$(cd "$(dirname "$0")/.." && pwd)"
PAGE="adom/nb-bridge"
INSIDERS_PAGE="adom/insiders"
INSIDERS_DIR="dist/insiders-repo"          # working copy shaped like the adom/insiders repo
INSIDERS_REL="nb-bridge/version.json"       # the convention: <product>/version.json
ZIP="native-browser-bridge-v${VER}.zip"
ARCH="dist/${VER}"
PUBLIC_MANIFEST="native-browser-bridge-manifest.json"
cd "$REPO"
export PATH="$HOME/.local/bin:$PATH"

echo "== 0. static gates =="
node --check bridge/server.js
node --check bin/nb-dashboard
cp extension/src/native.js /tmp/nb-native-check.mjs && node --check /tmp/nb-native-check.mjs && rm -f /tmp/nb-native-check.mjs
cp extension/src/cdp.js /tmp/nb-cdp-check.mjs && node --check /tmp/nb-cdp-check.mjs && rm -f /tmp/nb-cdp-check.mjs
grep -q '"insidersManifestUrl": "https://wiki.adom.inc/api/v1/pages/insiders/files/nb-bridge/version.json"' bridge/bridge.json \
  || { echo "bridge.json must declare insidersManifestUrl -> adom/insiders nb-bridge/version.json (#39)"; exit 1; }
if grep -q '&mdash;\|&ndash;' bin/nb-dashboard; then echo "em-dash entity in the dashboard UI (John: none, ever)"; exit 1; fi
echo "   server.js, nb-dashboard, native.js, cdp.js parse; insidersManifestUrl declared; no dash entities"

echo "== 1. version lockstep (five files) =="
printf '%s' "$VER" > bridge/BRIDGE_VERSION
python3 - "$VER" <<'PY'
import json, sys, re
v = sys.argv[1]
for p in ('bridge/bridge.json', 'bridge/package.json', 'package.json'):
    d = json.load(open(p)); d['version'] = v
    json.dump(d, open(p, 'w'), indent=2); open(p, 'a').write('\n')
    print(f"   {p} -> {v}")
p = 'bin/nb-dashboard'; s = open(p).read()
s2 = re.sub(r"^const VERSION = '[^']+';", f"const VERSION = '{v}';", s, count=1, flags=re.M)
assert s2 != s or f"const VERSION = '{v}';" in s, 'nb-dashboard VERSION const not found'
open(p, 'w').write(s2); print(f"   bin/nb-dashboard VERSION -> {v}")
PY

echo "== 1b. regenerate bridge.json verbs[] from VERB_META (never hand-edited) =="
node -e 'const fs=require("fs");const s=fs.readFileSync("bridge/server.js","utf8");
const i=s.indexOf("const VERB_META = {");let d=0,e=-1;
for(let j=s.indexOf("{",i);j<s.length;j++){const c=s[j];if(c==="{")d++;else if(c==="}"){d--;if(!d){e=j;break}}}
const k=[...s.slice(i,e+1).matchAll(/^ {2}(nbrowser_[a-z0-9_]+):\s*\{/gm)].map(m=>m[1]);
const b=JSON.parse(fs.readFileSync("bridge/bridge.json","utf8"));b.verbs=k;
fs.writeFileSync("bridge/bridge.json",JSON.stringify(b,null,2)+"\n");console.log("   verbs:",k.length)'
node tools/check-guard-coverage.mjs >/dev/null || { echo "   guard-coverage gate FAILED (see: node tools/check-guard-coverage.mjs)"; exit 1; }
echo "   guard coverage / verb drift gate: ok"

echo "== 2. build the zip (bridge files at the zip ROOT, no node_modules) =="
mkdir -p "$ARCH"
python3 - "$ARCH/$ZIP" <<'PY'
import zipfile, os, hashlib, sys
out = sys.argv[1]
members = ['bridge.json', 'package.json', 'server.js', 'BRIDGE_VERSION', 'adom-favicon.png']
with zipfile.ZipFile(out, 'w', zipfile.ZIP_DEFLATED) as z:
    for m in members:
        p = os.path.join('bridge', m)
        if not os.path.exists(p):
            if m == 'adom-favicon.png': continue
            raise SystemExit(f'missing {p}')
        z.write(p, m)
print(f"   {out}  sha256={hashlib.sha256(open(out,'rb').read()).hexdigest()}  size={os.path.getsize(out)}")
PY
[ "${DRY_RUN:-0}" = "1" ] && { echo "DRY_RUN: stopping after the zip"; exit 0; }

echo "== 3. release on the product page ($PAGE) =="
adom-wiki release create "$PAGE" "$VER" --title "native-browser bridge runtime v${VER}" >/dev/null 2>&1 || true
UP=$(adom-wiki release upload "$PAGE" "$VER" "$ARCH/$ZIP" --platform any --json 2>&1 | tail -c 4000)
mkdir -p "$INSIDERS_DIR/nb-bridge"
python3 - "$UP" "$VER" "$ZIP" "$ARCH" "$INSIDERS_DIR/$INSIDERS_REL" "$PUBLIC_MANIFEST" <<'PY'
import json, sys, datetime, hashlib, os
raw, ver, zipname, arch, ins_path, pub = sys.argv[1:7]
i = raw.find('{')
d = json.loads(raw[i:]) if i >= 0 else {}
a = d.get('data') or d.get('asset') or d
sha, size = a.get('sha256'), a.get('size')
url = a.get('download_url') or f"https://wiki.adom.inc/download/adom/nb-bridge/{ver}/{zipname}"
if not url.startswith('http'): url = 'https://wiki.adom.inc' + url
zp = os.path.join(arch, zipname)
local_sha = hashlib.sha256(open(zp, 'rb').read()).hexdigest(); local_size = os.path.getsize(zp)
if not (sha and size): sha, size = local_sha, local_size
assert sha == local_sha, f'server sha {sha} != local {local_sha}'
base = json.load(open(pub))
for k in ('promotion_attestation', 'promoted_by', 'promoted_at', 'rollback_attestation', 'rolled_back_by', 'rolled_back_at', 'insiders_released_at'):
    base.pop(k, None)
base.update({'name': 'native-browser', 'version': ver, 'url': url, 'sha256': sha, 'size': size, 'tier': 'insiders',
             'released_at': datetime.datetime.now(datetime.timezone.utc).isoformat().replace('+00:00', 'Z')})
json.dump(base, open(ins_path, 'w'), indent=2); open(ins_path, 'a').write('\n')
json.dump(base, open(os.path.join(arch, 'insiders-manifest.json'), 'w'), indent=2)
print(f"   release asset -> {url}\n   sha256={sha} size={size}\n   insiders manifest staged at {ins_path}; archived {arch}/")
PY
curl -sI "$(python3 -c "import json,sys;print(json.load(open(sys.argv[1]))['url'])" "$ARCH/insiders-manifest.json")" | head -1 | grep -q " 200" \
  || { echo "   release asset does not answer 200"; exit 1; }
echo "   download URL answers 200"

echo "== 4. insiders manifest + release log -> $INSIDERS_PAGE:nb-bridge/ =="
python3 - "$INSIDERS_DIR/nb-bridge/README.md" "$VER" "$LOG" <<'PY'
import sys, datetime, re, os, subprocess
path, ver, log = sys.argv[1:4]
today = datetime.date.today().isoformat()
entry = f"- **{ver}** insiders, {today}. {log} Not promoted.\n"
tok = open('/var/run/adom/api-key').read().strip()
cur = subprocess.run(['curl', '-s', '-H', f'Authorization: Bearer {tok}',
    'https://wiki.adom.inc/api/v1/pages/adom/insiders/files/nb-bridge/README.md'], capture_output=True, text=True).stdout
if cur.startswith('{') and 'FILE_NOT_FOUND' in cur: cur = ''
s = cur or ("# nb (Native Browser bridge) on the insiders tier\n\n"
  "**Publishing thread:** the nb-bridge thread owns this file. Every insiders publish (`tools/ship.sh` in the nb-bridge repo) appends an entry to the release log below before the publish is called done; a promote (`tools/promote.sh`, 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"
  "| Manifest | [nb-bridge/version.json](https://wiki.adom.inc/adom/insiders/files/nb-bridge/version.json) | [adom/nb-bridge native-browser-bridge-manifest.json](https://wiki.adom.inc/adom/nb-bridge/files/native-browser-bridge-manifest.json) |\n"
  "| Bridge zip | [adom/nb-bridge releases](https://wiki.adom.inc/adom/nb-bridge/releases) (unlisted until promoted) | same files, same URLs |\n"
  "| Skills pkg | `pkg install adom/nb-bridge` follows the newest row until the registry insiders dist-tag lands (adom/wiki#168) | same |\n\n"
  "The bridge declares `insidersManifestUrl` in `bridge/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}**" in s: print(f"   release log already has {ver}")
else:
    s = re.sub(r"(## Release log\n\n)", lambda m: m.group(1) + entry, s, count=1)
    print(f"   release log += {ver}")
os.makedirs(os.path.dirname(path), exist_ok=True); open(path, 'w').write(s)
PY
( cd "$INSIDERS_DIR" && adom-wiki repo push "$INSIDERS_PAGE" --files "$INSIDERS_REL" nb-bridge/README.md -m "nb-bridge ${VER} (insiders): ${LOG}" >/dev/null 2>&1 ) || {
  [ "${FORCE_PUSH:-0}" = "1" ] || { echo "   INSIDERS PUSH FAILED (set FORCE_PUSH=1 to retry)"; exit 1; }
}
TOK=$(cat /var/run/adom/api-key)
ANON=$(curl -s -o /dev/null -w '%{http_code}' https://wiki.adom.inc/api/v1/pages/adom/insiders/files/nb-bridge/version.json)
BEAR=$(curl -s -H "Authorization: Bearer $TOK" https://wiki.adom.inc/api/v1/pages/adom/insiders/files/nb-bridge/version.json | python3 -c 'import sys,json;print(json.load(sys.stdin).get("version"))' 2>/dev/null || echo '?')
echo "   two-curl: anonymous $ANON (want 404), bearer version $BEAR (want $VER)"
[ "$ANON" = "404" ] && [ "$BEAR" = "$VER" ] || { echo "   TIER SPLIT NOT PROVEN"; exit 1; }

echo "== 5. push the source text files to the product page (source is open; public manifest untouched) =="
adom-wiki repo push "$PAGE" \
  --files bridge/bridge.json bridge/BRIDGE_VERSION bridge/package.json bridge/server.js \
          extension/src/native.js extension/src/cdp.js extension/src/sw.js extension/manifest.json \
          bin/nb-dashboard package.json dockbar.json tools/ship.sh tools/promote.sh tools/verb-battery.mjs tools/verbs-policy.json tools/check-guard-coverage.mjs \
          skills/*/SKILL.md README.md docs/*.md \
  -m "v${VER} (insiders): ${LOG}" >/dev/null 2>&1 || {
    [ "${FORCE_PUSH:-0}" = "1" ] || { echo "   PUSH FAILED (set FORCE_PUSH=1 to retry)"; exit 1; }
  }

echo "== 6. skills pkg at the SAME version (keeps the top version row carrying a tarball, adom/wiki#170) =="
PUBOUT=$(adom-wiki pkg publish --org adom 2>&1); PUBRC=$?
echo "$PUBOUT" | grep -E '"published"|error' | head -2
[ $PUBRC -eq 0 ] || { echo "PKG PUBLISH FAILED (exit $PUBRC)"; echo "$PUBOUT" | tail -15; exit 1; }
curl -s https://wiki.adom.inc/api/v1/packages/nb-bridge/manifest | python3 -c "
import sys,json; d=json.load(sys.stdin); v=d.get('version'); assert v=='$VER', f'registry manifest is {v}, not $VER'; print('   registry manifest', v)"

echo "== 7. DIFF-VERIFY =="
ON_DISK=$(cat bridge/BRIDGE_VERSION)
MAN=$(python3 -c "import json,sys;print(json.load(open(sys.argv[1]))['version'])" "$INSIDERS_DIR/$INSIDERS_REL")
BJ=$(python3 -c "import json;print(json.load(open('bridge/bridge.json'))['version'])")
PUB=$(curl -s "https://wiki.adom.inc/api/pages/adom/nb-bridge/files/$PUBLIC_MANIFEST" | python3 -c "import json,sys;print(json.load(sys.stdin).get('version'))")
[ "$ON_DISK" = "$VER" ] && [ "$MAN" = "$VER" ] && [ "$BJ" = "$VER" ] \
  && echo "VERIFIED $VER on INSIDERS (BRIDGE_VERSION + bridge.json + adom/insiders nb-bridge/version.json in lockstep); PUBLIC manifest still serves $PUB" \
  || { echo "LOCKSTEP BROKEN: disk=$ON_DISK bridge.json=$BJ insiders=$MAN"; exit 1; }
echo "   Land it on an insider desktop: adom-bridge --target <box> refresh_bridges '{\"name\":\"native-browser\"}' (a desktop whose installed bridge predates the field needs the one-time bootstrap in insiders-tier-verify 3b)."
echo "   To roll $VER out to everyone, on the human's words: tools/promote.sh $VER \"<their exact words>\" --by \"<name>\""