feat(release): ship an OpenAPI fragment, a frozen manifest and a bundle (phase 3, slice 5) #6

Merged
whitlocktech merged 3 commits from feature/close-phase3 into main 2026-08-12 04:11:31 +00:00
Member

The three artifacts that make this module installable and checkable, closing phase 3's extraction. Nothing about what the module serves changes — the same 72 URLs, the same behaviour.

Pairs with website#141 (core's half: the request-time merge into /api/docs.json, and the last of core's UO copy) and docs#140. Merge this one first.

The OpenAPI fragment was never built, on either side

MODULE_API.md §2.8 and §6.1a say every module ships swagger-fragment.json and core merges started modules' fragments into /api/docs.json. Neither half existed. The 417 #swagger annotations came across in slice 1 and went nowhere, and src/app.js served the committed spec flat — so every route this module serves was in no OpenAPI spec at all. That is core's standing rule ("never ship a route that isn't in the spec") being broken by the extraction rather than by a route, and it is why it is fixed here rather than left to phase 4.

server/scripts/swaggerFragment.js generates it, and the prefixes are derived: the script runs the module's own register() against a recording api and asks require.cache which file each router object came from. A mount prefix therefore exists in one place — server/index.js — and not in a table beside it.

The 31 schemas moved here from core's swagger/swagger.js, namespaced Uo… because core wins every key collision in the merge and two games' modules must be able to describe the same idea. Error and ValidationError stay referenced by core's names and are not redefined — they resolve in the merged document, which is the point of a fragment.

The frozen manifest is derived by subtraction

MODULE_API.md §5.3. The frozen-manifest job clones core at the ref pinned in ci/core-ref.json, generates its route manifest without this module and then with it, and takes the difference.

Taking the difference rather than filtering by prefix buys the half of §5.3 that matters most for free: a module that shadowed or displaced one of core's routes shows up as a removal, not merely as an addition somewhere else. That is the frozen-URL promise §1.2 makes to the shipped Android app and the Discord bot.

The same job is where the fragment meets ground truth — a real core reporting the URLs it actually serves. Every route must have an operation, and every operation must be a route. It is the only check in this repo that can tell whether "the prefix I register" and "the path I document" name the same URL; everything else here compares two strings in the same repo.

Releases

A merge to main that leaves module.json at a version with no release yet publishes module-uo-<version>.tar.gz plus module-uo-<version>.json carrying its sha256.

  • The version is declared, not computed from commit subjects, unlike link and installer. module.json's version is what core records in installed_modules; two sources for one number is how they drift.
  • The workflow never writes to a branch — it tags and publishes — so main needs no push exception. That is the installer's model, adopted for the reason it was adopted there.
  • The bundle is an include list, not an exclude list. An exclude list ships whatever it forgot.

Five annotation defects, inherited and invisible until now

Nothing had ever generated a spec from these files, so nothing had ever read their annotations:

  • two requestBody literals a brace short — the route documented with an empty body;
  • two descriptions whose inner quoting swagger-autogen cannot survive. It re-quotes " and a backtick to ' before evaluating, so either inside a single-quoted description ends the string early. itemId lost its type, its required and its description and was silently re-inferred as an untyped string.

It reports each one and then prints Success in green. The generator now captures its diagnostics and makes them fatal — the same class as every other failure in this seam, except that here the tool did say something and nothing was listening.

The fifth needed a browser. Nineteen descriptions carried \' — correct JavaScript, and wrong here, because swagger-autogen does not evaluate the annotation as JS. The backslash survived into the fragment and Swagger UI rendered "the shard\'s published ruleset" to a reader. The fragment was valid JSON, the paths were right, every test passed. Only opening the page found it.

Also found while writing it: passing one shared doc to swagger-autogen six times. It renders components.schemas from an example object and writes the result back into what it was handed, so each pass re-wrapped the last — the fragment came out at 484 MB.

Verification

  • 409 server tests (+21), 40 client tests unchanged, check:imports clean
  • swagger-fragment.json — 69 paths covering all 72 routes, 0 undocumented, 0 unserved
  • routes.manifest.json — 72 routes; 0 of core's own routes removed or changed
  • assembled the bundle exactly as CI will, unpacked it into a real core at the pinned ref, and regenerated the manifest: loads, registers, 230 routes
  • booted a real core with this module installed and opened /api/docs: the module's operations render under their own tags, no dangling $refs, zero console errors

AI-assisted contribution. Written with Claude Code (Claude Opus). Commits carry Co-Authored-By: Claude <noreply@anthropic.com>.

🤖 Generated with Claude Code

The three artifacts that make this module installable and checkable, closing phase 3's extraction. **Nothing about what the module serves changes** — the same 72 URLs, the same behaviour. Pairs with **website#141** (core's half: the request-time merge into `/api/docs.json`, and the last of core's UO copy) and **docs#140**. Merge this one first. ## The OpenAPI fragment was never built, on either side `MODULE_API.md` §2.8 and §6.1a say every module ships `swagger-fragment.json` and core merges started modules' fragments into `/api/docs.json`. Neither half existed. The 417 `#swagger` annotations came across in slice 1 and went nowhere, and `src/app.js` served the committed spec flat — so **every route this module serves was in no OpenAPI spec at all**. That is core's standing rule ("never ship a route that isn't in the spec") being broken by the extraction rather than by a route, and it is why it is fixed here rather than left to phase 4. `server/scripts/swaggerFragment.js` generates it, and the prefixes are **derived**: the script runs the module's own `register()` against a recording `api` and asks `require.cache` which file each router object came from. A mount prefix therefore exists in one place — `server/index.js` — and not in a table beside it. The 31 schemas moved here from core's `swagger/swagger.js`, namespaced `Uo…` because core wins every key collision in the merge and two games' modules must be able to describe the same idea. `Error` and `ValidationError` stay referenced by **core's** names and are not redefined — they resolve in the merged document, which is the point of a fragment. ## The frozen manifest is derived by subtraction `MODULE_API.md` §5.3. The `frozen-manifest` job clones core at the ref pinned in `ci/core-ref.json`, generates its route manifest **without** this module and then **with** it, and takes the difference. Taking the difference rather than filtering by prefix buys the half of §5.3 that matters most for free: **a module that shadowed or displaced one of core's routes shows up as a removal**, not merely as an addition somewhere else. That is the frozen-URL promise §1.2 makes to the shipped Android app and the Discord bot. The same job is where the fragment meets ground truth — a real core reporting the URLs it actually serves. Every route must have an operation, and every operation must be a route. It is the only check in this repo that can tell whether "the prefix I register" and "the path I document" name the same URL; everything else here compares two strings in the same repo. ## Releases A merge to `main` that leaves `module.json` at a version with no release yet publishes `module-uo-<version>.tar.gz` plus `module-uo-<version>.json` carrying its `sha256`. - **The version is declared, not computed** from commit subjects, unlike `link` and `installer`. `module.json`'s version is what core records in `installed_modules`; two sources for one number is how they drift. - **The workflow never writes to a branch** — it tags and publishes — so `main` needs no push exception. That is the installer's model, adopted for the reason it was adopted there. - **The bundle is an include list, not an exclude list.** An exclude list ships whatever it forgot. ## Five annotation defects, inherited and invisible until now Nothing had ever generated a spec from these files, so nothing had ever read their annotations: - two `requestBody` literals a brace short — the route documented with an **empty** body; - two descriptions whose inner quoting swagger-autogen cannot survive. It re-quotes `"` and a backtick to `'` before evaluating, so either inside a single-quoted description ends the string early. `itemId` lost its type, its `required` and its description and was silently re-inferred as an untyped string. It reports each one and then prints `Success` in green. The generator now captures its diagnostics and makes them fatal — the same class as every other failure in this seam, except that here the tool did say something and nothing was listening. **The fifth needed a browser.** Nineteen descriptions carried `\'` — correct JavaScript, and wrong here, because swagger-autogen does not evaluate the annotation as JS. The backslash survived into the fragment and Swagger UI rendered *"the shard\\'s published ruleset"* to a reader. The fragment was valid JSON, the paths were right, every test passed. Only opening the page found it. Also found while writing it: passing one shared `doc` to swagger-autogen six times. It renders `components.schemas` from an **example object** and writes the result back into what it was handed, so each pass re-wrapped the last — the fragment came out at **484 MB**. ## Verification - **409 server tests** (+21), **40 client tests** unchanged, `check:imports` clean - `swagger-fragment.json` — 69 paths covering all 72 routes, 0 undocumented, 0 unserved - `routes.manifest.json` — 72 routes; **0 of core's own routes removed or changed** - assembled the bundle **exactly as CI will**, unpacked it into a real core at the pinned ref, and regenerated the manifest: loads, registers, 230 routes - booted a real core with this module installed and opened `/api/docs`: the module's operations render under their own tags, no dangling `$ref`s, zero console errors --- **AI-assisted contribution.** Written with Claude Code (Claude Opus). Commits carry `Co-Authored-By: Claude <noreply@anthropic.com>`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
wtclaude added 1 commit 2026-08-12 03:40:59 +00:00
feat(release): ship an OpenAPI fragment, a frozen manifest and a bundle (phase 3, slice 5)
All checks were successful
PR Checks / server-tests (pull_request) Successful in 19s
PR Checks / frozen-manifest (pull_request) Successful in 35s
PR Checks / client-build (pull_request) Successful in 8m49s
5cdcf0fbb6
The three artifacts that make this module installable and checkable, closing
phase 3's extraction. Nothing about what the module serves changes: the same 72
URLs, the same behaviour.

**The OpenAPI fragment (MODULE_API.md §2.8, §6.1a) was never built, on either
side.** The 417 `#swagger` annotations came across in slice 1 and went nowhere,
and core's /api/docs.json merged nothing — so every route this module serves was
in no spec at all, which is core's standing rule ("never ship a route that isn't
in the spec") being broken by the extraction rather than by a route.

`server/scripts/swaggerFragment.js` generates it. The prefixes are DERIVED: the
script runs the module's own `register()` against a recording api and asks
`require.cache` which file each router came from, so a mount prefix exists in one
place — `server/index.js` — and not in a table beside it. The 31 schemas moved
here from core's swagger.js, namespaced `Uo…` because core wins every key
collision in the merge; `Error` and `ValidationError` stay referenced by core's
names, since they resolve in the merged document.

**The frozen route manifest (§5.3)** is derived too, and by subtraction: CI
clones core at the ref pinned in ci/core-ref.json, generates its manifest without
this module and then with it, and the difference is what this module serves. That
buys the half of §5.3 that matters most for free — a module that shadowed or
displaced one of core's routes shows up as a REMOVAL, not merely as an addition
elsewhere. The same job checks the fragment against ground truth: every route
must have an operation and every operation must be a route.

**The release workflow** publishes `module-uo-<version>.tar.gz` plus a manifest
carrying its sha256. The version is declared in module.json rather than computed
from commit subjects, and the workflow never writes to a branch — it tags and
publishes — so `main` needs no push exception. The bundle is assembled from an
include list, because an exclude list ships whatever it forgot.

Four annotation defects, inherited from core and never visible until something
generated a spec from these files: two `requestBody` literals a brace short (the
route documented with an empty body), and two descriptions whose inner quoting
swagger-autogen cannot survive — it re-quotes `"` and a backtick to `'` before
evaluating, so either inside a single-quoted description ends the string early
and the annotation is dropped. It reports each one and then prints Success in
green, so the generator now captures its diagnostics and makes them fatal.

Also fixed while writing it: passing one shared `doc` to swagger-autogen six
times. It renders components.schemas from an EXAMPLE object and writes the result
back into what it was handed, so each pass re-wrapped the last and the fragment
came out at 484 MB.

- 409 server tests (+21), 40 client tests unchanged
- swagger-fragment.json: 69 paths covering all 72 routes
- routes.manifest.json: 72 routes; core's own surface unchanged, 0 removals
- verified end to end by assembling the bundle exactly as CI will, unpacking it
  into a real core and regenerating the manifest

Refs: docs/website/MODULE_SYSTEM.md §2.7.1, MODULE_API.md §2.8, §5.3, §6.1a

Co-Authored-By: Claude <noreply@anthropic.com>
wtclaude added 1 commit 2026-08-12 03:57:05 +00:00
fix(docs): repair the escaped apostrophes, and document this module's env vars
Some checks failed
PR Checks / client-build (pull_request) Successful in 14s
PR Checks / frozen-manifest (pull_request) Failing after 34s
PR Checks / server-tests (pull_request) Successful in 8m49s
044211fd41
Nineteen `#swagger` descriptions carried a `\'` inside a single-quoted string.
That is correct JavaScript and wrong here: swagger-autogen does not evaluate the
annotation as JS, so the backslash survives into the spec and Swagger UI renders
"the shard\'s published ruleset" to a reader. Replaced with a typographic
apostrophe, which the same files already use elsewhere.

Found by opening /api/docs in a browser against a real core with this module
installed — the fragment was valid JSON, the paths were right, every test passed,
and it was still wrong on screen. Nothing that reads the artifact can see this;
only reading the rendered page can.

Also documents the four environment variables this module reads
(UOLINK_BASE_URL / _WS_URL / _PROTOCOL, TOWNCRIER_DURATION_SEC). Core's
.env.example is dropping them in the paired website PR: they were never core's,
and a half-copy in two repos goes stale silently.

Co-Authored-By: Claude <noreply@anthropic.com>
wtclaude added 1 commit 2026-08-12 04:08:53 +00:00
ci: retry npm registry reads before failing a check
All checks were successful
PR Checks / client-build (pull_request) Successful in 14s
PR Checks / server-tests (pull_request) Successful in 18s
PR Checks / frozen-manifest (pull_request) Successful in 33s
a0c24456c7
The frozen-manifest job read ETIMEDOUT from the registry installing the client
deps, after it had already cloned core at the pin and proved core's own manifest
regenerates — a red X that meant nothing about this PR. There are five `npm ci`
calls across the three jobs and the runner is shared, so this will recur.

npm's own retry, turned up at the workflow level so every install gets it.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech merged commit e81b61d044 into main 2026-08-12 04:11:31 +00:00
whitlocktech deleted branch feature/close-phase3 2026-08-12 04:11:32 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: RunicGateway/Module-uo#6
No description provided.