fix(release): ship server/commands, and check that the bundle is complete
All checks were successful
PR Checks / client-build (pull_request) Successful in 16s
PR Checks / frozen-manifest (pull_request) Successful in 40s
PR Checks / server-tests (pull_request) Successful in 8m38s

v1.0.0 installed and then died on every boot:

  module "uo" failed to load — {"stage":"register","reason":"Cannot find
  module './commands/guild.command'"}

`server/commands/` arrived with the Teams cutover (2d1d91e, `/guild`). The
release assembles the tarball from an include list, that list was hardcoded in
release.yml, and it was never told about the new directory — so the bundle
shipped without it and the module was dead on the operator's box.

Nothing caught it, and that is the more interesting half. Every PR check runs
against the whole repo — `frozen-manifest` even installs the module into core by
tarring the entire tree — but a release is a SUBSET of the repo, and the subset
exists nowhere except the release. The pre-publish check in release.yml only
stats the paths `module.json` declares, and a file reached by a require inside
`register()` is named in none of them, so it passed on a bundle that could not
load.

The include list stays an include list — release.yml's header makes that case
and it still holds. What changes is that it is declared ONCE, in ci/bundle.json,
with two readers instead of one:

  • release.yml assembles from it (via jq) rather than from its own copy.
  • server/scripts/checkBundle.js asks, in PR checks, whether it still covers
    everything `server/index.js` reaches — following requires transitively and
    through function bodies, which is where index.js deliberately puts them.

And the release gains a real loadability check: `checkBundle.js --bundle` walks
the ASSEMBLED tree and asserts every relative require resolves inside it. Asked
of the artifact rather than the source, so it also catches a half-failed copy or
a list naming a path that has moved.

Requiring the entry point would not have worked as a check: index.js requires
inside `register()` because require order is load-bearing (`core.init(ctx)` must
run before anything under `router/`), so requiring it evaluates one line and
reports success on a bundle missing every router it has.

Both modes were verified against the real defect — each fails with `commands`
removed and passes with it present.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnDSWzpUjw8t8C2hghysNz
This commit is contained in:
2026-08-19 13:25:57 -05:00
parent 16cfbe194d
commit 3c179e3338
6 changed files with 540 additions and 3 deletions

View File

@@ -301,6 +301,14 @@ jobs:
# Stated as an INCLUDE list, not an exclude list. An exclude list ships
# whatever it forgot: the day someone adds `server/tools/` with a scratch
# credential in it, an exclude list packs it and nobody finds out.
#
# The list itself lives in `ci/bundle.json`, not here, because it has a
# second reader: `server/scripts/checkBundle.js` runs in PR checks and asks
# whether the list still covers everything `server/index.js` reaches. It
# was hardcoded in this file until v1.0.0 shipped without `server/commands/`
# — added by the Teams cutover, never added here — and the module died at
# the register stage on the operator's box. One declaration, two readers,
# so the next directory cannot go missing quietly.
- name: Assemble the bundle
if: ${{ steps.plan.outputs.release == 'true' }}
run: |
@@ -319,14 +327,15 @@ jobs:
# The two fragments, and the licence the code is under — a bundle that
# ships GPL code without its licence is not distributable.
cp swagger-fragment.json LICENSE.md README.md "$OUT/"
for f in $(jq -r '.root[]' ci/bundle.json); do
cp "$f" "$OUT/"
done
# The server half, minus what never runs inside core's process.
mkdir -p "$OUT/server"
for d in boot.js core.js index.js config data db model router utils; do
for d in $(jq -r '.server[]' ci/bundle.json); do
cp -r "server/$d" "$OUT/server/"
done
cp server/package.json "$OUT/server/"
cp -r server/node_modules "$OUT/server/"
# The client half is the BUILT chunk only. `client/src` is 5,000 lines
@@ -354,6 +363,20 @@ jobs:
console.log("bundle contents check: ok");
' "$OUT" "$VERSION"
# ── And that it can actually LOAD ─────────────────────────────────
#
# The check above stats the paths `module.json` declares, which is a
# real question but a shallow one: v1.0.0 passed it and was still
# missing `server/commands/`, because a file reached only by a require
# inside `register()` is named nowhere in `module.json`. This resolves
# every relative require in the assembled tree and asserts the target is
# in it — asked of the artifact, so it also catches a copy that half
# failed or a list naming a path that has since moved.
#
# Run from the SOURCE tree (`server/scripts/` never ships) against the
# assembled bundle.
node server/scripts/checkBundle.js --bundle "$OUT"
tar -C dist -czf "dist/module-uo-${VERSION}.tar.gz" "module-uo-${VERSION}"
rm -rf "$OUT"