master
John Lauer rust/verbs/assets/import-scan.ps1 (embedded by import.rs, missed by the rust/ push's extension filter; pup-bridge#118) 9feb6ee 1mo ago
#!/usr/bin/env bash
# ship.sh — THE publish pipeline for this 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
#   tools/rollback.sh <version> "<human's words>" --by "<name>" # re-point PUBLIC at an older build
#
# RELEASE TIERS (pup-bridge#86, ab 2.0.99 insidersManifestUrl; adopted 2026-09-02 on John's word,
# and corrected the same hour when he caught the first cut creating a NEW private page: that is
# wiki-bloat. The fleet convention is ONE shared private page, adom/insiders, whose repo carries
# ONLY <product>/version.json manifests; the bytes live as releases on the product's own page.)
#
#   - Every ship: the zip becomes a release on adom/pup-bridge (like ab's installer on
#     adom/adom-bridge), the INSIDERS manifest is written to adom/insiders as
#     pup-bridge/version.json (bridge-manifest shape), the source text files are pushed to the
#     public page repo (source is open), and the skills pkg is published so the page's top
#     version row always carries a tarball (release rows + pkg rows share one namespace; a
#     release-only row breaks `pkg install`, #66). The PUBLIC manifest
#     (adom-bridge-puppeteer-manifest.json, 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.
#
# Env: FORCE_PUSH=1 pushes even when the working tree looks unchanged.
set -euo pipefail
VER="${1:?usage: tools/ship.sh <version> \"<changelog>\" [--shell rust|node] [--candidate | --insiders-from-candidate]}"
LOG="${2:?changelog sentence required}"
# --shell rust (DEFAULT since Phase 4 of the Rust port, 2026-09-05): the package is the Rust exe.
#   --shell node builds the Node zip (the public tier's shell until a Rust build is promoted).
# --candidate: everything EXCEPT the insiders manifest push; the manifest is pushed to the product
#   page as adom-bridge-puppeteer-manifest-candidate.json so ONE desktop can bridge_install it and be
#   tested thoroughly (John: "test thoroughly before sending to insiders").
# --insiders-from-candidate: only the insiders push, from the archived dist/<ver>/insiders-manifest.json.
SHELL_KIND="rust"; MODE="full"
shift 2 || true
while [ $# -gt 0 ]; do case "$1" in --shell) SHELL_KIND="$2"; shift 2;; --candidate) MODE="candidate"; shift;; --insiders-from-candidate) MODE="finalize"; shift;; *) echo "unknown arg $1"; exit 1;; esac; done
REPO="$(cd "$(dirname "$0")/.." && pwd)"
PAGE="adom/pup-bridge"
INSIDERS_PAGE="adom/insiders"
INSIDERS_DIR="dist/insiders-repo"          # working copy shaped like the adom/insiders repo
INSIDERS_REL="pup-bridge/version.json"      # the convention: <product>/version.json
ZIP="adom-bridge-puppeteer-v${VER}.zip"
ARCH="dist/${VER}"
cd "$REPO"

if [ "$MODE" = "finalize" ]; then
  [ -f "dist/${VER}/insiders-manifest.json" ] || { echo "no archived candidate for $VER (run --candidate first)"; exit 1; }
  echo "== finalize: pushing the archived candidate manifest for $VER to insiders =="
  mkdir -p "$INSIDERS_DIR/pup-bridge"; cp "dist/${VER}/insiders-manifest.json" "$INSIDERS_DIR/$INSIDERS_REL"
fi
if [ "$MODE" != "finalize" ]; then
echo "== 0. static gates ($SHELL_KIND) =="
if [ "$SHELL_KIND" = "node" ]; then
node --check src/server.js
node --check src/chrome.js
node --check src/card_vault.js
node tests/check_dashboard_script.js >/dev/null
node tests/check_card_vault.js >/dev/null
node tests/check_consent.js >/dev/null
node tests/check_unpack.js >/dev/null
node tests/check_icon.js >/dev/null
echo "   server/chrome/card_vault parse; dashboard script parses; card vault checks; dock favicon matches"
fi

echo "== 1. version lockstep =="
printf '%s' "$VER" > src/BRIDGE_VERSION
python3 - "$VER" "$LOG" <<'PY'
import json, sys
v = sys.argv[1]
p = 'src/bridge.json'
d = json.load(open(p))
d['version'] = v
assert d.get('insidersManifestUrl', '').endswith('/pages/insiders/files/pup-bridge/version.json'), 'bridge.json insidersManifestUrl must point at adom/insiders pup-bridge/version.json (pup-bridge#86)'
json.dump(d, open(p, 'w'), indent=2)
open(p, 'a').write('\n')
print(f"   src/BRIDGE_VERSION + src/bridge.json -> {v}")
p2 = 'package.json'
d2 = json.load(open(p2))
d2['version'] = v
d2['changelog'] = sys.argv[2] if len(sys.argv) > 2 else d2.get('changelog', '')   # forces a pkg version row even when the skills tarball is byte-identical (PUBLISH_UNCHANGED), so the release-born row (#66, wiki#188) never sits on top without a tarball
json.dump(d2, open(p2, 'w'), indent=2)
open(p2, 'a').write('\n')
print(f"   package.json (skillpack) -> {v}")
PY

if [ "$SHELL_KIND" = "rust" ]; then
echo "== 2. build the Rust exe zip (tools/rust-build.sh) =="
tools/rust-build.sh "$VER"
else
echo "== 2. build the zip (src/ at zip root, no node_modules) =="
mkdir -p "$ARCH"
python3 - "$ARCH/$ZIP" <<'PY'
import zipfile, os, hashlib, sys
out = sys.argv[1]
with zipfile.ZipFile(out, 'w', zipfile.ZIP_DEFLATED) as z:
    for dirpath, dirs, files in os.walk('src'):
        if 'node_modules' in dirs: dirs.remove('node_modules')
        for f in files:
            full = os.path.join(dirpath, f)
            z.write(full, os.path.relpath(full, 'src'))
print(f"   {out}  sha256={hashlib.sha256(open(out,'rb').read()).hexdigest()}  size={os.path.getsize(out)}")
PY
fi

echo "== 2b. STRICT YAML gate on every bundled SKILL.md (#103: a double-quoted description with inner quotes was rejected by Codex; the pkg lint only warned) =="
python3 - <<'PYEOF' || { echo "SHIP ABORTED: a SKILL.md frontmatter does not parse strictly; fix it (description: >- folded scalar) and re-run"; exit 1; }
import glob, re, sys, yaml
bad = []
for p in sorted(glob.glob('skills/*/SKILL.md') + glob.glob('dev-skills/*/SKILL.md') + glob.glob('publish-skills/*/SKILL.md') + ['SKILL.md']):
    m = re.match(r'^---\n(.*?)\n---\n', open(p).read(), re.S)
    if not m: bad.append((p, 'no frontmatter')); continue
    try:
        d = yaml.safe_load(m.group(1))
        if not isinstance(d, dict) or not isinstance(d.get('name'), str) or not isinstance(d.get('description'), str): bad.append((p, 'name/description missing'))
    except Exception as e: bad.append((p, str(e).splitlines()[0][:80]))
for p, why in bad: print('   INVALID', p, '|', why)
print('   strict YAML: %d skill files parsed, %d invalid' % (len(glob.glob('skills/*/SKILL.md')) + len(glob.glob('dev-skills/*/SKILL.md')) + len(glob.glob('publish-skills/*/SKILL.md')) + 1, len(bad)))
sys.exit(1 if bad else 0)
PYEOF
echo "== 3a. skills pkg FIRST (a release-born version row never gets the pkg tarball attached: 2.0.398; #66) =="
PUBOUT=$(adom-wiki pkg publish --org adom 2>&1); PUBRC=$?
echo "$PUBOUT" | tail -2
[ $PUBRC -eq 0 ] || { echo "PKG PUBLISH FAILED (exit $PUBRC)"; echo "$PUBOUT" | tail -15; exit 1; }
adom-wiki pkg info adom/pup-bridge 2>/dev/null | python3 -c "
import sys,json
raw=sys.stdin.read()
d,_=json.JSONDecoder().raw_decode(raw[raw.find('{'):])
v=d['versions'][0]
assert v.get('integrity') and v.get('size_bytes'), f\"top row {v['version']} has NO tarball\"
print(f\"   pkg tarball verified on top row {v['version']} ({v['size_bytes']} bytes)\")" || { echo "PKG TARBALL VERIFY FAILED"; exit 1; }

echo "== 3. release on the product page ($PAGE) =="
adom-wiki release create "$PAGE" "$VER" >/dev/null 2>&1 || true
UP=$(adom-wiki release upload "$PAGE" "$VER" "$ARCH/$ZIP" --json 2>&1 | tail -c 4000)
mkdir -p "$INSIDERS_DIR/pup-bridge"
PUP_SHELL_KIND="$SHELL_KIND" python3 - "$UP" "$VER" "$ZIP" "$ARCH" "$INSIDERS_DIR/$INSIDERS_REL" <<'PY'
import json, sys, datetime, hashlib, os
raw, ver, zipname, arch, ins_path = sys.argv[1:6]
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/pup-bridge/{ver}/{zipname}"
if not url.startswith('http'): url = 'https://wiki.adom.inc' + url
zp = os.path.join(arch, zipname)
if not (sha and size):
    sha = hashlib.sha256(open(zp, 'rb').read()).hexdigest(); size = os.path.getsize(zp)
# Insiders manifest = the public manifest's SHAPE (name/entrypoint/etc. identical between tiers).
base = json.load(open('adom-bridge-puppeteer-manifest.json'))
for k in ('promotion_attestation', 'promoted_by', 'promoted_at', 'rollback_attestation', 'rolled_back_by', 'rolled_back_at', 'insiders_released_at'):
    base.pop(k, None)
bj = json.load(open('src/bridge.json'))
base.update({'verbPrefixes': bj.get('verbPrefixes', base.get('verbPrefixes')), 'statusVerb': bj.get('statusVerb', base.get('statusVerb')), 'healthEndpoint': bj.get('spawn', {}).get('healthEndpoint', '/status')})
base['shell'] = os.environ.get('PUP_SHELL_KIND', 'node')
base.update({'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

if [ "$MODE" = "candidate" ]; then
  echo "== 4c. CANDIDATE manifest -> $PAGE:adom-bridge-puppeteer-manifest-candidate.json (insiders untouched) =="
  cp "$ARCH/insiders-manifest.json" adom-bridge-puppeteer-manifest-candidate.json
  adom-wiki repo push "$PAGE" --files adom-bridge-puppeteer-manifest-candidate.json src/bridge.json src/BRIDGE_VERSION package.json -m "v${VER} candidate ($SHELL_KIND): ${LOG}" >/dev/null 2>&1 || { echo "   CANDIDATE PUSH FAILED"; exit 1; }
  echo "   install on ONE desktop: adom-bridge --target <box> bridge_install '{\"manifestUrl\":\"https://wiki.adom.inc/api/v1/pages/pup-bridge/files/adom-bridge-puppeteer-manifest-candidate.json\",\"force\":true}'"
  echo "   then, when it is proven: tools/ship.sh $VER \"<log>\" --insiders-from-candidate"
  exit 0
fi
fi   # MODE != finalize

echo "== 4. insiders manifest + release log -> $INSIDERS_PAGE:pup-bridge/ =="
# Convention (adom-bridge/README.md, hydrogen/README.md on adom/insiders): every insiders publish
# APPENDS a release-log entry to <product>/README.md before the publish is called done; promote
# edits that entry with the date and the human's words. Newest first.
python3 - "$INSIDERS_DIR/pup-bridge/README.md" "$VER" "$LOG" <<'PY'
import sys, datetime, re, os
path, ver, log = sys.argv[1:4]
today = datetime.date.today().isoformat()
entry = f"- **{ver}** insiders, {today}. {log} Not promoted.\n"
s = open(path).read() if os.path.exists(path) else "# pup (Puppeteer bridge) on the insiders tier\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)", r"\1" + entry.replace('\\', '\\\\'), s, count=1)
    open(path, 'w').write(s); print(f"   release log += {ver}")
PY
( cd "$INSIDERS_DIR" && adom-wiki repo push "$INSIDERS_PAGE" --files "$INSIDERS_REL" pup-bridge/README.md -m "pup-bridge ${VER} (insiders): ${LOG}" >/dev/null 2>&1 ) || {
  [ "${FORCE_PUSH:-0}" = "1" ] || { echo "   INSIDERS PUSH FAILED (set FORCE_PUSH=1 to retry)"; exit 1; }
}

echo "== 5. push the source text files to the product page (source is open; public manifest untouched) =="
# 2.0.595 (pup-bridge#117, Kyle): the Rust tree the exe is built from ships with it, every release, so a
# port (host-mac) is written against code that compiles. Everything under rust/ except target/, plus the
# two build tools. 62 files, ~1.5 MB. Cargo.lock included so a checkout builds the same dependency set.
# 2.0.598 (pup-bridge#118, Kyle): plus every file the Rust build EMBEDS from outside rust/ (the extensions,
# icons, dashboard pages). Derived from the include_bytes!/include_str! sites, so a new embed ships by itself.
# ALL of them, rust/ included: the rust/ find below keeps only source-like extensions, and a clean checkout
# still failed on rust/verbs/assets/import-scan.ps1 (measured from a fresh pull, 2026-09-16).
EMBED_FILES=$(python3 tools/list-embedded.py | tr '\n' ' ') || { echo "   an embedded file is missing on disk (tools/list-embedded.py)"; exit 1; }
RUST_FILES=$(find rust -path rust/target -prune -o -type f \( -name '*.rs' -o -name 'Cargo.toml' -o -name 'Cargo.lock' -o -name '*.ttf' -o -name '*.png' -o -name '*.svg' -o -name '*.json' -o -name '*.js' -o -name '*.html' -o -name '*.md' \) -print | tr '\n' ' ')
adom-wiki repo push "$PAGE" \
  --files src/bridge.json src/BRIDGE_VERSION src/*.js src/extension/*.js src/extension/*.json SKILL.md package.json \
          skills/*/SKILL.md dev-skills/*/SKILL.md publish-skills/*/SKILL.md \
          $RUST_FILES $EMBED_FILES tools/rust-build.sh tools/check-borders.sh tools/gen-assets.py tools/list-embedded.py tools/sync-nbe.sh \
  -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 "== 7. DIFF-VERIFY =="
ON_DISK=$(cat src/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('src/bridge.json'))['version'])")
PUB=$(python3 -c "import json;print(json.load(open('adom-bridge-puppeteer-manifest.json'))['version'])")
[ "$ON_DISK" = "$VER" ] && [ "$MAN" = "$VER" ] && [ "$BJ" = "$VER" ] \
  && echo "VERIFIED $VER on INSIDERS (BRIDGE_VERSION + bridge.json + adom/insiders pup-bridge/version.json in lockstep); PUBLIC manifest still at $PUB" \
  || { echo "LOCKSTEP BROKEN: disk=$ON_DISK bridge.json=$BJ insiders=$MAN"; exit 1; }
echo "   To roll $VER out to everyone, on the human's words: tools/promote.sh $VER \"<their exact words>\" --by \"<name>\""