docs(modules): phase 2 as built — packaging, release and the frozen manifest #254
@@ -979,7 +979,7 @@ Each phase ends with its findings written down, as every workstream here does.
|
||||
|---|---|---|---|
|
||||
| 0 | **The rig.** ✅ **Done 2026-09-15 — as built and findings in §12.** Updated to the current wipe (the script was fixed *again*, properly), Oxide re-laid, base set installed, the grant path proven end to end and both zone transitions observed live with a player connected. **Both criteria met** | docs | A current server boots with all four loaded, `oxide.grant` demonstrably gates something, and a test zone reports who is standing in it |
|
||||
| 1 | **Protocol 1, three skeletons, and every bundle seam at once.** ✅ **Done 2026-09-15 — as built and findings in §13.** Plugin, sidecar and module all exist and all three were exercised against the live rig; three org-lead decisions (§13.0), five defects only a running server found (§13.3), and a correction to §11.3 (§13.2). **Both criteria met** | all 3 + docs | One hello line travels game -> sidecar -> module; killing the sidecar does not stall the game; all five guards green on an untouched skeleton |
|
||||
| 2 | **Packaging and release.** `release.yml`, the install manifest, the `sha256`, the host allowlist — and a real install into a running core from a manifest URL | Module-Rust + docs | An operator installs the empty module from Admin -> Modules and it reaches `started` |
|
||||
| 2 | **Packaging and release.** ✅ **Done 2026-09-16 — as built and findings in §15.** `release.yml` *and* the gate that was missing entirely (`pr-checks.yml`, including the frozen-manifest job); the include list with two readers; `v0.1.0` published and installed into a running core from its manifest URL. Three org-lead decisions (§15.0), and the first proof by a core that `/rust` collides with nothing (§15.2). **Criterion met** | Module-Rust + docs | An operator installs the empty module from Admin -> Modules and it reaches `started` |
|
||||
| 3 | **The read path, on both frameworks.** First hook wave from [`HOOKS.md`](HOOKS.md); events and snapshots distinct at the wire; `wipe_id` **and server id** on every row (R8); all-time rollups (R12); every board re-emitted on connect. **First phase to run against the Carbon rig (R19/R21)** — it turns [`CARBON.md`](CARBON.md) from a source-read hypothesis into tested fact, including whether the 13 unlisted hook names are renames or holes | all 3 + docs | A restarted sidecar is fully populated within one connection, a wipe does not erase a player's history, and **the same plugin file does all of that on Oxide and on Carbon** |
|
||||
| 4 | **The first pages.** Server list as the landing page, `/rust/servers/:id` beneath it, killfeed, leaderboard; nav rows; the UI kit (`PublicLayout` `shell`, `PageHeader` props); `capabilities`; the `site.footer.status` slot (R13) | Module-Rust | The site renders the last thing each server said while every server is off |
|
||||
| 5 | **Android leg A** (R10). Capability-driven shell from `GET /api/v1/public/modules`, plus the phase-4 screens | Android-app | The app renders a Rust site it has never seen, and a UO site unchanged |
|
||||
@@ -2004,6 +2004,194 @@ API. Anything driving the console has to read a log to know, which is the same c
|
||||
reached about `oxide.reload` and the same shape R18's rollback needs.
|
||||
|
||||
|
||||
## 15. Phase 2 as built — packaging and release, 2026-09-16
|
||||
|
||||
The first phase with no game server in it, and the one that turns a directory somebody copied into a
|
||||
thing an operator can install. [`Module-Rust#2`][mr] is the whole of it.
|
||||
|
||||
**The starting position was worse than the phase row implied.** Phase 1 built five guards and ran
|
||||
every one of them by hand: the repository had no `.gitea/workflows/` at all. So nothing gated the
|
||||
branch that gets released, and there was no way to release it. This phase adds both halves, and the
|
||||
org lead widened it to include the gate rather than only the release.
|
||||
|
||||
### 15.0 The three decisions this phase needed
|
||||
|
||||
- **D4 — the full CI suite, not only `release.yml`.** The phase row names the release; the repo had
|
||||
no gate at all. Both ship, and the frozen-manifest job ships with them rather than waiting for a
|
||||
later phase — see §15.2 for what it found on its first run.
|
||||
- **D5 — the bundle carries no `node_modules`, and the emptiness is asserted rather than assumed.**
|
||||
The shipped half declares no runtime dependencies: everything it needs arrives on `ctx`. So the
|
||||
release runs no `npm ci` and packs no dependency tree. The whole value of that decision is that
|
||||
*the day it stops being true is a loud day*, so `checkBundle.js` fails the PR that adds a
|
||||
`dependencies` entry without also teaching the release to install and pack it. Module-uo ships
|
||||
`ws` and does the opposite; this is a different answer to the same question, not a divergence
|
||||
from a rule.
|
||||
- **D6 — no SonarQube for the three Rust repos yet.** Phase 2 is packaging; scanning is quality
|
||||
tooling and phase 19 is already the sweep where `CLAUDE.md`'s project-key table gets updated.
|
||||
Three half-populated projects while the repos are skeletons buy nothing.
|
||||
|
||||
### 15.1 What is in a release, and what decides its number
|
||||
|
||||
A release is **not source**. It is the directory core's loader expects at `modules/rust/`, already
|
||||
assembled — the prebuilt client chunk, the schema fragment and the OpenAPI fragment, packed as they
|
||||
will be unpacked — because an operator never builds anything (`MODULE_SYSTEM.md` §1.14).
|
||||
|
||||
| Asset | What it is |
|
||||
|---|---|
|
||||
| `module-rust-<version>.tar.gz` | the assembled directory, one top-level entry, no `node_modules` |
|
||||
| `module-rust-<version>.json` | the install manifest: id, name, version, `coreApi`, url, size, **`sha256`** |
|
||||
| `SHA256SUMS` | the same hash, in the shape every other repo here publishes |
|
||||
|
||||
The version is **derived**, using the engine `link`, `installer` and `Module-uo` already run:
|
||||
conventional-commit subjects since the newest `v*` tag decide major/minor/patch, nothing releasable
|
||||
cuts no release, and `module.json`'s version survives as a **floor** rather than as a record. The
|
||||
number that ships is the **tag**, and CI stamps it into the bundle's own `module.json` at assembly
|
||||
time — which is why the release also asserts that the assembled `module.json` carries the version
|
||||
being released, since a bundle that still declared the floor would install under a number that is
|
||||
not the one it came from.
|
||||
|
||||
Module-uo is the argument for deriving rather than declaring: it released only on a hand-edited
|
||||
version line, and between 2026-08-12 and 2026-08-19 that cost it *every* bundle, because nine phases
|
||||
of work landed without anyone touching that line. `workflow_dispatch` survives as the backdoor for
|
||||
the case the rules cannot reach — a widened `coreApi`, a new mount, a capability, with no releasable
|
||||
code behind it.
|
||||
|
||||
**Two failure modes are guarded before anything is built.** The credential check runs first, so a
|
||||
repository without `REGISTRY_TOKEN` fails with a named error instead of pushing a tag and then 401ing
|
||||
on the release API — the state `servuo-plugins` got stuck in on its own first release. And a tag that
|
||||
exists with **no release behind it** is deliberately not treated as "nothing to do": that is the
|
||||
signature of the same half-failure, and standing down on the tag alone would make it permanent.
|
||||
|
||||
### 15.2 The frozen manifest answers the question phase 1 had to take on trust
|
||||
|
||||
`§13`'s registration comment says `/rust` "collides with nothing on any of the three tiers, checked
|
||||
against core's mount tables rather than assumed" — and then names the limit of that check: **core
|
||||
answers several public routes mounted at the tier root rather than under a prefix**, `/status` and
|
||||
`/version` among them, which the loader's own collision probe cannot see.
|
||||
|
||||
The `frozen-manifest` job is the thing that can. It clones core at the sha pinned in
|
||||
`ci/core-ref.json`, generates core's route table **without** this module and then **with** it, and
|
||||
takes the difference. That difference is what the module serves, and it is checked three ways: it
|
||||
must match the committed `routes.manifest.json`, every route in it must have an operation in
|
||||
`swagger-fragment.json` *and* every operation must be a route, and — the half that matters most —
|
||||
**no core route may have been removed or changed**. A module whose mount displaced a core route
|
||||
cannot show up as an addition, because the URL is unchanged; only the diff sees it.
|
||||
|
||||
Run locally against the pinned ref before it was committed, and again in CI against a fresh clone,
|
||||
both agreeing:
|
||||
|
||||
```
|
||||
route manifest up to date (280 routes)
|
||||
wrote routes.manifest.json (282 public + 4 internal)
|
||||
routes.manifest.json is current — 6 routes, all documented
|
||||
```
|
||||
|
||||
Six routes, all documented, nothing of core's moved. **`/rust` is now free by proof rather than by
|
||||
reading**, and it stays that way on every pull request.
|
||||
|
||||
**The pin starts on `main`, unlike Module-uo's.** That repo spent the whole Event System window
|
||||
pinned to `edge`, because it depended on contract members that had not reached `main` yet. This
|
||||
module needs `MODULE_API` 1.10.0, which the Event System cutover already put there, so
|
||||
`ci/core-ref.json` names `efa9db7` on `main` and should stay on `main` until this module comes to
|
||||
depend on something unreleased.
|
||||
|
||||
### 15.3 One declaration, two readers — and it was verified by breaking it
|
||||
|
||||
`ci/bundle.json` is an **include** list, never an exclude list: an exclude list ships whatever it
|
||||
forgot, and the day somebody adds `server/tools/` with a scratch credential in it, an exclude list
|
||||
packs it and nobody finds out. The cost of that choice is the opposite failure — a new directory
|
||||
silently drops *out* of every release — which is exactly what happened to Module-uo between v0.3.0
|
||||
and v1.0.0, where `server/commands/` arrived in a cutover, the list did not learn about it, and the
|
||||
module installed cleanly and then died at the register stage on the operator's box.
|
||||
|
||||
Nothing caught it there because the PR checks copy the **whole repo** into core: they only ever
|
||||
exercised a tree that had the file. **The subset exists only in the release.** So the list has two
|
||||
readers — `release.yml`, which packs from it, and `server/scripts/checkBundle.js`, which asks on
|
||||
every PR whether it still covers everything `server/index.js` can reach.
|
||||
|
||||
It reaches requires written **inside `register()`**, which is not a detail: this module's entry point
|
||||
requires its routers inside the function on purpose, because `core.init(ctx)` has to run before
|
||||
anything under `router/` is required. A check that only saw file-scope requires would have missed
|
||||
every router the module has.
|
||||
|
||||
Verified the only way a check is worth anything — by breaking it. Dropping `"model"` from the list:
|
||||
|
||||
```
|
||||
ci/bundle.json does not ship everything server/index.js reaches.
|
||||
server/model (2 files reachable)
|
||||
Add "model" to ci/bundle.json's server[].
|
||||
```
|
||||
|
||||
It names the exact edit, in the units the list is written in.
|
||||
|
||||
### 15.4 The release, as published
|
||||
|
||||
`v0.1.0`, cut by the first push to `main`, with no tag before it — so the engine took the first-run
|
||||
branch and shipped what `module.json` declared. Three assets:
|
||||
|
||||
| Asset | Size |
|
||||
|---|---|
|
||||
| `module-rust-0.1.0.tar.gz` | 41,310 bytes |
|
||||
| `module-rust-0.1.0.json` | 355 bytes |
|
||||
| `SHA256SUMS` | 91 bytes |
|
||||
|
||||
Downloaded and hashed independently of CI, the artifact is
|
||||
`7296c76b988c6191840a1dc4ed1a77d96ad35e8c26f9b6b55e88fe4c10878b32` — byte-for-byte the `sha256` the
|
||||
manifest declares.
|
||||
|
||||
**`REGISTRY_TOKEN` was already configured on the repository**, which was the one prerequisite this
|
||||
work could not verify for itself: the bot identity can read neither repository nor organisation
|
||||
Actions secrets (`user should be the owner of the repo`). The credential check exists precisely
|
||||
because the answer was unknowable from here, and the first release ran clean through it.
|
||||
|
||||
### 15.5 The acceptance criterion, walked
|
||||
|
||||
> *An operator installs the empty module from Admin → Modules and it reaches `started`.*
|
||||
|
||||
**Met.** Walked as an operator would, against the local core on `edge`, with the phase-1 state
|
||||
deliberately torn down first — the hand-copied `modules/rust/` directory moved aside and its
|
||||
`installed_modules` row deleted, so this was a first install and not an upgrade. Before it,
|
||||
`GET /api/v1/public/rust/servers` answered **404**.
|
||||
|
||||
1. Admin → Modules, with no `rust` row and `gitea.whitlocktech.com` on the allowlist.
|
||||
2. Pasted the release's `module-rust-0.1.0.json` URL and pressed Install.
|
||||
→ *"Installed Rust v0.1.0. Restart to load it."*, and a row reading **Restart to start —
|
||||
installed, it mounts when the server next starts**, carrying the manifest URL and
|
||||
`sha256 7296c76b988c…`.
|
||||
3. Restarted.
|
||||
→ `registered module "rust" v0.1.0`, `schema ensured for module "rust" {"statements":2}`,
|
||||
`[rust:boot] booted {"refreshMs":30000}`, `module "rust" started`.
|
||||
|
||||
The row is now `state = started` with `started_at` set, the source URL and the released hash — where
|
||||
phase 1's row had `source` and `sha256` **null**, because a directory somebody copied has no
|
||||
provenance to record. The screen reads **Running — mounted and serving**.
|
||||
|
||||
**What the volume received is exactly the include list and nothing else: twenty files.** No tests, no
|
||||
`server/scripts/`, no `ci/`, no `client/src/`, no `node_modules`, and no dotfiles. `client/dist/entry.js`
|
||||
is the chunk CI built.
|
||||
|
||||
And the three checks that prove it is *serving* rather than merely loaded:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `GET /api/v1/public/rust/servers` | **200**, answering with the rig's `main` server |
|
||||
| `GET /api/v1/player/rust/servers` · `GET /api/v1/admin/rust/servers` | **401** — the tier gates are on, not bypassed |
|
||||
| `/api/docs.json` | carries all **five** `/rust` paths: the fragment merged into core's own spec |
|
||||
|
||||
**Two things this walk is honest about.** The first is that the *restart* was done from the shell
|
||||
rather than with the screen's own **Restart the server** button: that button runs the same graceful
|
||||
shutdown a `SIGTERM` does and relies on a supervisor to bring the process back, which the shipped
|
||||
`docker-compose.yml` provides and a bare `npm start` does not. The button was not exercised, and it
|
||||
is core's, not this module's. The second is that the module's two tables were left in place when the
|
||||
row was deleted, so the rig's `main` server row survived the reinstall — the schema fragment replayed
|
||||
on boot (`statements: 2`) exactly as R12 requires of a fragment that runs every time.
|
||||
|
||||
**The one operational finding, and it is about the rig rather than the module.** Docker Desktop's
|
||||
Linux engine was found dead — its WSL distribution stopped, the `uomm-db` container exited, and
|
||||
`docker` answered every call with `500 Internal Server Error` rather than anything naming the cause.
|
||||
Restarting Docker Desktop and the container fixed it. Worth writing down because the failure presents
|
||||
as the *website* being broken (`ECONNREFUSED` to a database that is simply not there), and because
|
||||
`CLAUDE.md` points every smoketest at that one container.
|
||||
---
|
||||
|
||||
[rl]: https://gitea.whitlocktech.com/RunicGateway/Rust-Link
|
||||
|
||||
Reference in New Issue
Block a user