bot / deploy / check-dist.sh
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
#!/usr/bin/env bash
# The committed dists must be exactly what a fresh build of src/ produces —
# a stale dist would ship behavior the sources don't show. Also the two-stack
# tripwire: each process entry may name only its own kit stack, and — new with
# the two npm roots — must be able to RESOLVE every bare import it emits from
# its own root, at its own libp2p generation.
set -euo pipefail
cd "$(dirname "$0")/.."

STACK_A="stack-a/dist/main.js stack-a/dist/rl/train.js stack-a/dist/fleet/link.js"
STACK_B="stack-b/dist/worker.js"
BUNDLES="$STACK_A $STACK_B"

# a stale in-tree shadow cache can mask clean-clone divergence
rm -rf .shadow-cljs

npm run --silent build >/dev/null
if [ -n "$(git status --porcelain stack-a/dist stack-b/dist)" ]; then
  echo "ABORT: a committed dist is stale — commit the rebuilt output first:" >&2
  git status --porcelain stack-a/dist stack-b/dist >&2
  exit 1
fi

# macOS home-dir prefix, built from pieces: the published bytes of this
# script must never contain the pattern the idpat leak scan hunts for
LEAKPAT='/Use''rs/'
if grep -rF "$LEAKPAT" stack-a/dist stack-b/dist; then
  echo "ABORT: local filesystem path embedded in a dist/" >&2
  exit 1
fi

# ---- the two-stack law (house rule 11) --------------------------------------
# Both network kits are compiled FROM SOURCE now, so a stack no longer arrives
# by its kit's package name — it arrives as compiled namespaces plus a set of
# bare transport imports, and both stacks emit the SAME name `libp2p`. What
# tells them apart is (1) which namespaces are in the bundle and (2) the
# packages that exist in only one generation. Both are gated.

# 1. NAMESPACES. A rooms namespace compiled into a stack-A bundle would emit
#    `import "libp2p"` from stack-a/dist and get v3 — the wrong generation,
#    silently, with a second libdatachannel headed for the same process.
#    ardegazu.rooms.js and ardegazu.rooms.lib.crypto are the two ALLOWED
#    leaves (webcrypto only; the vendored banca/bursa mailbox libs require
#    them), so they are subtracted before the count.
for f in $STACK_A; do
  # set +o pipefail inside the subshell: an empty grep is the PASSING case
  # here, and pipefail would turn "found nothing" into a script abort
  n=$(set +o pipefail
      grep -o 'ardegazu\.rooms\.[a-z_.-]*' "$f" \
        | grep -vE '^ardegazu\.rooms\.(js|lib\.crypto)' | sort -u | tr '\n' ' ')
  if [ -n "$n" ]; then
    echo "ABORT: $f carries rooms-kit namespaces beyond the two allowed leaves:" >&2
    echo "       $n" >&2
    echo "       a stack-A bundle resolves libp2p to v3 — this is the two-stack law" >&2
    exit 1
  fi
done
for f in $STACK_B; do
  if grep -q 'ardegazu\.peer\.' "$f"; then
    echo "ABORT: $f carries peer-kit namespaces — stack B resolves libp2p to v2" >&2
    exit 1
  fi
done

# 2. PACKAGES THAT EXIST IN ONLY ONE GENERATION. These names cannot be
#    explained away by a shared dependency: orbit/helia/the old gossipsub name
#    and the @ipshipyard datachannel belong to rooms-kit's v2 world, while
#    @libp2p/gossipsub and the bare node-datachannel belong to peer-kit's v3.
V2_ONLY='"(helia|@helia/|@orbitdb/|blockstore-fs|datastore-fs|@chainsafe/libp2p-gossipsub|@ipshipyard/)'
V3_ONLY='"(@libp2p/gossipsub|node-datachannel)"'
for f in $STACK_A; do
  if grep -qE "$V2_ONLY" "$f"; then
    echo "ABORT: $f names a libp2p-v2-only package — that is stack B's generation" >&2
    grep -ohE "$V2_ONLY[^\"]*\"" "$f" | sort -u | sed 's/^/       /' >&2
    exit 1
  fi
done
for f in $STACK_B; do
  if grep -qE "$V3_ONLY" "$f"; then
    echo "ABORT: $f names a libp2p-v3-only package — that is stack A's generation" >&2
    exit 1
  fi
done

# 3. NO KIT IS EVER A DIST AGAIN. Every one of our kits is on deps.edn's
#    :paths and compiled from source; importing one as an npm dist as well is
#    the hybrid this migration existed to kill, and it would put two
#    differently-compiled copies of the same kit in one bundle. This is the
#    single assertion that replaces the old per-kit list — it admits nothing.
for f in $BUNDLES; do
  if grep -qE 'from *"ardegazu-' "$f"; then
    echo "ABORT: $f imports one of our kits as an npm dist — every kit is" >&2
    echo "       compiled from source off the classpath (deps.edn's classpath law)" >&2
    grep -ohE 'from *"ardegazu-[^"]*"' "$f" | sort -u | sed 's/^/       /' >&2
    exit 1
  fi
done

# ---- the two npm roots ------------------------------------------------------
# STRUCTURAL: each stack's tree must hold exactly one libp2p, at its own
# generation, and neither may hold the other's WebRTC native. Two builds of
# libdatachannel in one process abort it, and the ONLY thing keeping them apart
# is that these are two trees.
node --input-type=module - <<'NODE'
import { readFileSync, existsSync } from "node:fs";
const want = {
  "stack-a": { major: 3, native: "node-datachannel", forbid: "@ipshipyard/node-datachannel" },
  "stack-b": { major: 2, native: "@ipshipyard/node-datachannel", forbid: "node-datachannel" },
};
let bad = 0;
const die = (m) => { console.error("ABORT: " + m); bad = 1; };
for (const [stack, w] of Object.entries(want)) {
  const lockPath = `${stack}/package-lock.json`;
  if (!existsSync(lockPath)) { die(`${lockPath} is missing — the stack root is not installed/committed`); continue; }
  const lock = JSON.parse(readFileSync(lockPath, "utf8"));
  const libp2ps = Object.entries(lock.packages)
    .filter(([k]) => /(^|\/)node_modules\/libp2p$/.test(k));
  if (libp2ps.length !== 1) {
    die(`${stack}: expected exactly one libp2p in the tree, found ${libp2ps.length} — ` +
        libp2ps.map(([k, v]) => `${k}@${v.version}`).join(", "));
    continue;
  }
  const [path, meta] = libp2ps[0];
  if (path !== "node_modules/libp2p") die(`${stack}: libp2p is nested at ${path}, not hoisted`);
  const major = Number(String(meta.version).split(".")[0]);
  if (major !== w.major) die(`${stack}: libp2p is ${meta.version}, expected ${w.major}.x`);
  // the native, and the other generation's native
  const has = (name) => Object.keys(lock.packages).some((k) => k.endsWith(`node_modules/${name}`));
  if (!has(w.native)) die(`${stack}: ${w.native} is absent — this stack's WebRTC native`);
  if (has(w.forbid)) die(`${stack}: ${w.forbid} is present — that is the OTHER stack's ` +
                         `libdatachannel build, and two in one process abort it`);
}
if (bad) process.exit(1);
console.log("check-dist: two npm roots, one libp2p generation each (a=3.x, b=2.x)");
NODE

# RESOLUTION: every bare import a bundle emits must resolve INSIDE the stack
# root the bundle sits in. Node resolves a bare specifier by walking up from the
# importing file, so this is the gate that makes the split real: it is what
# would have caught the old accident (a bundle emitted from bot/dist reaching
# past its kit into the root's v3) and it catches a package a stack forgot to
# declare, which on a VPS is an ERR_MODULE_NOT_FOUND at 3 a.m.
node --input-type=module - <<'NODE'
import { readFileSync, existsSync } from "node:fs";
import { resolve, dirname, join } from "node:path";

const bundles = {
  "stack-a/dist/main.js": "stack-a",
  "stack-a/dist/rl/train.js": "stack-a",
  "stack-a/dist/fleet/link.js": "stack-a",
  "stack-b/dist/worker.js": "stack-b",
};
const pkgOf = (s) => (s.startsWith("@") ? s.split("/").slice(0, 2).join("/") : s.split("/")[0]);

/** node's own algorithm, minus the exports map: first node_modules/<pkg> up the tree. */
function lookup(fromDir, pkg) {
  for (let d = fromDir; ; d = dirname(d)) {
    const p = join(d, "node_modules", pkg, "package.json");
    if (existsSync(p)) return p;
    if (dirname(d) === d) return null;
  }
}

let bad = 0;
for (const [b, stack] of Object.entries(bundles)) {
  const src = readFileSync(b, "utf8");
  const specs = new Set();
  for (const m of src.matchAll(/(?:^|[;\s])(?:import|export)[^;]*?from *"([^"]+)"/g)) specs.add(m[1]);
  // shadow emits a dynamic import as `shadow.esm.dynamic_import("…")`, where
  // \bimport does NOT match (the preceding `_` is a word character) — miss
  // that and tfjs-node and @libp2p/webrtc go unchecked, which is exactly the
  // kind of import a stack forgets to declare.
  for (const m of src.matchAll(/(?:\bimport|dynamic_import)\( *"([^"]+)" *\)/g)) specs.add(m[1]);
  const root = resolve(stack);
  for (const s of specs) {
    if (s.startsWith("node:") || s.startsWith(".") || s.startsWith("/")) continue;
    const found = lookup(dirname(resolve(b)), pkgOf(s));
    if (!found) {
      console.error(`ABORT: ${b} imports "${s}" — not installed in ${stack}/node_modules`);
      bad = 1;
    } else if (!found.startsWith(root + "/")) {
      console.error(`ABORT: ${b} imports "${s}", which resolves OUTSIDE ${stack}/ (${found}) —`);
      console.error("       a bundle must never reach past its own npm root");
      bad = 1;
    }
  }
}
if (bad) process.exit(1);
console.log("check-dist: every bare import of every bundle resolves inside its own stack root");
NODE

# The worker lives in the OTHER root now, so bot.rooms derives its path instead
# of taking a sibling. Pin the derivation against both deployment shapes — the
# repo and /opt/ardegazu-bot — because getting it wrong is a bot that joins no
# chat room and says nothing about why.
node --input-type=module - <<'NODE'
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
const { workerPath } = await import(pathToFileURL(resolve("stack-a/dist/main.js")).href);
const cases = [
  ["/opt/ardegazu-bot/stack-a/dist/main.js", "/opt/ardegazu-bot/stack-b/dist/worker.js"],
  ["/srv/bots/one/stack-a/dist/main.js", "/srv/bots/one/stack-b/dist/worker.js"],
  [resolve("stack-a/dist/main.js"), resolve("stack-b/dist/worker.js")],
];
let bad = 0;
for (const [entry, want] of cases) {
  const got = workerPath(entry);
  if (got !== want) { console.error(`ABORT: workerPath(${entry}) = ${got}, want ${want}`); bad = 1; }
}
if (bad) process.exit(1);
console.log("check-dist: workerPath() crosses to stack-b in every deployment shape");
NODE

echo "check-dist: OK (committed dists match a fresh build, no local paths, two-stack clean)"

static mirror of HEAD · about · clone: git clone https://git.ardegazu.ro/bot.git