Files
Module-uo/.gitea/workflows/pr-checks.yml
wtclaude 3c179e3338
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
fix(release): ship server/commands, and check that the bundle is complete
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
2026-08-19 13:25:57 -05:00

228 lines
10 KiB
YAML

# Gate every pull request into `main` on a fast, DB-free check suite, so a broken
# build or a failing test can't reach the branch that gets released.
#
# Mirrors RunicGateway/website's pr-checks.yml — this module is two npm packages
# shaped like that repo's `server/` and `client/`, and it is loaded into that
# repo's process, so it is checked the same way with the same Node version.
#
# ── What each job is really asking ───────────────────────────────────────────
#
# The tests are the ordinary half. The two `check:*` scripts are the interesting
# one, because they are the acceptance criteria of the module contract itself
# (docs/website/MODULE_API.md Part 5) rather than of this module's behaviour:
#
# • `server: check:imports` — no relative path escapes the module root, and no
# shipped file resolves a bare specifier. A module that reaches into core's
# tree works right up until core moves a file, and the whole boundary is
# worth exactly as much as this check is (§5.1).
#
# • `server: check:bundle` — the release ships everything the entry point can
# reach. Every other job here runs against the whole repo, but a release is a
# SUBSET of it (release.yml assembles from the include list in
# `ci/bundle.json`), and nothing compared the two. On 2026-08-19 they
# disagreed: `server/commands/` arrived with the Teams cutover, the include
# list did not learn about it, and v1.0.0 installed and then died at the
# register stage on the operator's box with "Cannot find module
# './commands/guild.command'". Green here, broken there — because the subset
# only exists in the release. This asks, on the PR that adds the directory,
# whether the list still covers what index.js reaches.
#
# • `client: check:externals` — the BUILT chunk has no bare imports left. That
# failure is invisible in source: `import { useState } from 'react'` is
# correct in every file, and whether it becomes core's React or a bare
# specifier no browser can resolve is decided by vite.config.js. It has to
# be asked of the artifact, so it runs after the build. (The other half —
# a shared dependency being BUNDLED — fails the build itself, from a
# resolution-time guard inside vite.config.js.)
#
# Building the chunk in CI is not only a check: it is how the chunk that ships is
# produced, since an operator never builds (MODULE_SYSTEM.md §1.14).
#
# • `server: check:swagger` — `swagger-fragment.json` describes the routes this
# module registers, today. Core has no way to generate it: core is a prebuilt
# image, this module arrived on a volume afterwards, and it mounts through a
# call no static parser can follow. So the fragment core merges into
# `/api/docs.json` is whatever this repo committed, and a stale one documents
# a URL surface that does not exist (§2.8).
#
# • `frozen-manifest` — the job with the interesting shape. It clones CORE at
# the ref pinned in `ci/core-ref.json`, generates its route manifest twice
# (without this module, then with) and takes the difference. That difference
# is what this module serves, and it is checked three ways: it must match the
# committed `routes.manifest.json`, it must not have REMOVED or changed one of
# core's own routes, and every route in it must have an operation in
# `swagger-fragment.json` — the per-module form of core's rule that a route
# which isn't in the spec doesn't ship (§5.3, §2.8).
#
# Nothing else can ask those questions. Every other check here runs against
# this repo alone, where a mount prefix is a string in `server/index.js` and a
# documented path is a string in a JSON file; whether they name the same URL
# is a fact about a running core, and this is the only job that has one.
#
# Still not here, deliberately: nothing. The release workflow is
# `.gitea/workflows/release.yml` and runs on a tag rather than on a PR.
#
# Enforcement (one-time, in the Gitea UI):
# Repository Settings → Branches → Branch Protection (rule for `main`)
# • Enable Status Check
# • Status check patterns: PR Checks / *
# Note: Gitea only lists a context in its dropdown after it has reported once,
# so let this workflow run on one PR first. The `PR Checks / *` glob matches
# without needing the dropdown, and keeps matching as jobs are added.
#
# Runner: the shared self-hosted `ubuntu-latest` runner. These jobs need only
# Node — no Docker socket, no database.
#
# Scope note: `edge` is gated as well as `main`. Multi-phase work lands there
# first, so gating only the `main` hop would run these checks for the first time
# at the cutover — the one moment a red build is most expensive to discover. This
# is the same call `RunicGateway/installer` made for the same reason, and it was
# taken here after a nine-PR Android workstream landed on an ungated `edge` with
# no CI at all. Adding a branch to the `branches:` list is the whole change.
name: PR Checks
on:
pull_request:
branches: [main, edge]
# A newer push to the same PR cancels the in-flight run.
concurrency:
group: pr-checks-${{ github.ref }}
cancel-in-progress: true
# npm's own retry, turned up. The shared runner reads ETIMEDOUT from the registry
# often enough to matter, and there are five `npm ci` calls across these jobs — a
# red X that means "the network hiccuped" costs a reviewer more than it costs the
# runner to retry, and teaches everyone to re-run rather than read a failure.
env:
NPM_CONFIG_FETCH_RETRIES: 5
NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: 20000
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: 120000
jobs:
server-tests:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: server/package-lock.json
# `npm ci` rather than `npm install`: it also proves the lockfile is in
# sync with package.json instead of silently updating it.
- name: Install server deps
run: npm ci --prefix server
- name: Run server tests
run: npm test --prefix server
- name: Check the module boundary (MODULE_API.md §5.1)
run: npm run check:imports --prefix server
- name: Check the release ships what the module requires
run: npm run check:bundle --prefix server
- name: Check the OpenAPI fragment is current (MODULE_API.md §2.8)
run: npm run check:swagger --prefix server
client-build:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: client/package-lock.json
- name: Install client deps
run: npm ci --prefix client
# The build comes FIRST, and that ordering is load-bearing as of slice 3.
# Two of the client tests read `dist/entry.js` — the chunk's externals, and
# what it registers when imported against a fake `window.__rg` — and both
# skip when there is no build. Run the other way round they skip silently
# in CI, which is the worst of both: green, and not asking the question.
- name: Build the client chunk
run: npm run build --prefix client
- name: Run client tests
run: npm test --prefix client
- name: Check the built chunk's externals (MODULE_API.md §3.6)
run: npm run check:externals --prefix client
# ── The URLs this module actually serves ──────────────────────────────────
#
# Everything above proves the module against itself. This proves it against a
# real core: the one place where "the prefix I register" and "the path I
# document" are the same fact rather than two strings that ought to agree.
#
# The module is COPIED into the core checkout, never symlinked — core's loader
# filters its scan with `entry.isDirectory()`, which reports a link as a link
# and skips it silently, so a symlinked module produces a manifest with no
# module routes in it and a diff that looks like the module registering
# nothing.
frozen-manifest:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
with:
path: module
- uses: actions/setup-node@v4
with:
node-version: 20
# Anonymous HTTPS, and a full clone rather than a shallow one: the pin is a
# commit sha, and `--depth 1` can only fetch a branch tip.
- name: Clone core at the pinned ref (MODULE_API.md §5.3)
run: |
REPO=$(node -p "require('./module/ci/core-ref.json').repo")
REF=$(node -p "require('./module/ci/core-ref.json').ref")
echo "core: $REPO @ $REF"
git clone --quiet "$REPO" core
git -C core checkout --quiet "$REF"
- name: Install core's server deps
run: npm ci --prefix core/server
# Core alone. `--check` first, so a pin that no longer regenerates its own
# committed manifest fails HERE, naming the pin, instead of showing up below
# as this module having removed a route it never touched.
- name: Generate core's manifest without this module
run: |
npm run routes:manifest --prefix core/server -- --check
cp core/server/routes.manifest.json before.json
# The chunk has to exist before the loader will accept the module at all —
# `client.entry` is validated during the manifest step of the scan, and a
# missing one is a load failure, not a warning.
- name: Build the client chunk
run: |
npm ci --prefix module/client
npm run build --prefix module/client
- name: Install the module into core
run: |
mkdir -p core/modules/uo
tar -C module --exclude=.git --exclude=node_modules -cf - . | tar -C core/modules/uo -xf -
npm ci --omit=dev --prefix core/modules/uo/server
- name: Generate core's manifest with this module
run: |
npm run routes:manifest --prefix core/server
cp core/server/routes.manifest.json after.json
- name: Check the frozen manifest and the fragment's coverage
working-directory: module
run: node server/scripts/frozenManifest.js --before ../before.json --after ../after.json --check