fix(release): ship server/commands, and check that the bundle is complete #18
Reference in New Issue
Block a user
No description provided.
Delete Branch "fix/bundle-ships-commands"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What & why
v1.0.0 is broken on install. It unpacks fine and then dies at the register stage on every boot:
server/commands/arrived with the Teams cutover (2d1d91e,/guild). The release assembles the tarball from an include list, that list was hardcoded inrelease.yml, and it was never told about the new directory. The bundle shipped without it, so the module is dead on the operator's box.Why nothing caught it
This is the more interesting half. Every PR check runs against the whole repo —
frozen-manifesteven installs the module into core by tarring the entire tree — but a release is a subset of the repo, and that subset exists nowhere except the release. CI was green on a tree that had the file while the artifact did not.The pre-publish check in
release.ymldidn't catch it either: it stats the pathsmodule.jsondeclares (server,schema,purge,client.entry, the fragment), and a file reached only by a require insideregister()is named in none of them. It passed on a bundle that could not load.The fix
The include list stays an include list —
release.yml's header makes that case (an exclude list ships whatever it forgot) and it still holds. What changes is that it is declared once, inci/bundle.json, with two readers instead of one:release.ymlassembles from it (viajq) rather than from its own private copy.server/scripts/checkBundle.js(new, wired into PR checks ascheck:bundle) asks whether that list still covers everythingserver/index.jsreaches — following requires transitively and through function bodies.And the release gains a real loadability check:
checkBundle.js --bundle <dir>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 since moved.Why not just
require()the entry point:index.jsputs its requires insideregister()on purpose — require order is load-bearing,core.init(ctx)has to run before anything underrouter/is required. So requiring the entry evaluates exactly one line (require('./core')) and reports success on a bundle missing every router it has. Callingregister()for real would need a fakectxcomplete enough to satisfy the module, which is whattest/is for — andtest/doesn't ship. Walking the requires statically asks the same question without needing either.How it was tested
Both modes were run against the real defect, not only against fixtures —
commandswas removed from the list and from an assembled bundle, and each mode was confirmed to fail and then to pass again:commandspresentcommandsremovedcheck:bundle(source tree)OK — ships every file server/index.js reaches (57 files)server/commands--bundle(assembled bundle)OK — every relative require resolves (56 files scanned)requires "./commands/guild.command" — not in the bundleThe
--bundlerun was against a bundle assembled locally by replaying the workflow's newjq-driven copy steps, so the assembly change is exercised too.Also run locally, all green:
After merge
The release engine derives the version from commit subjects, so this
fix:cuts v1.0.1 automatically. Reinstall from the newmodule-uo-1.0.1.jsonon Admin → Modules.Two things I deliberately did not touch
frozen-manifestinstalls the module by tarring the whole tree. Making it assemble perci/bundle.jsonwould prove that what ships registers against a real core — strictly stronger than the static check added here. It's a change to a delicate job and beyond this fix's scope, so it is flagged rather than done.check:swaggerreports STALE on a Windows working tree. A local artifact ofcore.autocrlf=true(the generator writes LF, the working tree holds CRLF), not real staleness — regenerating produces a zero-byte diff and CI on Linux passes. Pre-existing onmain; left alone.Checklist
AI-assisted contributions (required)
Claude Code (Opus 5). I have reviewed and understandevery change, and take responsibility for it. AI-authored commits are
marked with a
Co-Authored-By/Assisted-Bytrailer.License
(GNU GPL v3.0 or later), and I have the right to contribute it.
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