Files
Module-uo/.gitea/workflows/pr-checks.yml
wtclaude 2fa4d87a40
All checks were successful
PR Checks / client-build (pull_request) Successful in 15s
PR Checks / server-tests (pull_request) Successful in 19s
PR Checks / frozen-manifest (pull_request) Successful in 38s
feat(shard): ingest guild rosters and departures (protocol 4)
Protocol 2 gave the guild board a member *count* and nothing else, so the Guilds
page could say a guild had 155 members but never who they were, and
findGuildForActor deliberately answered only for leaders because membership for
rank-and-file was not in the feed at all. Protocol 4 puts it there.

`shard_guild_members` holds one row per member per guild, keyed on
(guild_id, serial). `guild.roster` replaces a guild's rows; `guild.leave` removes
one. A guild.remove now clears the membership too, so a disbanded guild does not
leave orphaned rows behind.

The chunking needs explaining. A roster over the shard's per-frame cap arrives as
several frames carrying seq/more/total. The sidecar reassembles them for its own
GET /guilds board, but the live WebSocket feed and the /history backfill both
carry the individual frames — so this ingest sees them unreassembled.

It copes without buffering, because a table expresses what the sidecar's single
JSON column could not: the frame carrying seq 0 clears the guild first, and every
frame then upserts its own rows. Upsert rather than insert because the /history
backfill replays stored frames on every reconnect, and a redelivery has to be a
no-op rather than a duplicate-key error. The cost is a sub-second window during a
multi-frame update where the table holds part of a roster; buffering to close it
would duplicate the sidecar's reassembly for a projection that is already only as
fresh as a 60s sweep.

On visibility: both kinds are mapped to the existing `guilds` feature. Without
that mapping rule 2 fails an unmapped kind closed to admin-only, which would have
quietly kept rosters off the public page forever. Mapping them is safe because a
roster is the first frame carrying locked fields inside an ARRAY of actors rather
than one nested actor, and the projection walker already recurses into arrays and
matches acct/webId by suffix — so a member's account name is stripped below admin
by exactly the rule that already strips guild.leader.acct. There is a test for
that specifically, because the difference is a public page listing character names
versus one publishing 150 account names.

`acct`/`web_id` are still stored, since that is what lets a linked member be
matched to a site user; they are just never projected below admin.

guild.leave is appended to the event log, as the departure counterpart to
guild.join and for the same reason — it is what a "so-and-so left" feed reads.
guild.roster stays out: it is board state like guild.update, and it is the one fat
frame on the wire, so logging it would put a full membership snapshot into
shard_events on every membership change.

The PUBLIC_KINDS guard test caught the addition, which is what it is for; its
expected set now carries a v4 group alongside the v3 one.

Refs: docs/website/TEAMS.md Part 12 Phase 1

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 12:57:26 -05:00

214 lines
9.5 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).
#
# • `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 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