# 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. # # Phase 1 built all of these checks and ran them BY HAND. That is the gap this # file closes: a guard nothing invokes is a guard whose state nobody knows. # # ── What each job is really asking ─────────────────────────────────────────── # # The tests are the ordinary half. The `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, and still declares no runtime dependency. 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 else # compares the two. Module-uo's v1.0.0 is the cautionary tale: `server/commands/` # arrived in a cutover, the include list did not learn about it, and the # module installed and then died at the register stage on the operator's box # with "Cannot find module './commands/guild.command'". Green in CI, broken # there — because the subset only exists in the release. # # • `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.) # # • `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 arrives 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. It is # also the only thing that can see the blind spot phase 1 had to check by # reading: core answers several public routes mounted at the TIER ROOT rather # than under a prefix (`/status`, `/version`), which the loader's own collision # probe cannot find, so `/rust` being free is now asserted by a core. # # 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`, though this repo has no `edge` # branch yet. Multi-phase work lands there first everywhere else in this project, # and 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. Naming the # branch before it exists costs nothing; an Android workstream that landed nine # PRs on an ungated `edge` is why it is here from the start. 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 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. 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 # No `npm ci` on the installed copy, because the shipped half declares no # runtime dependencies and the release packs no `node_modules` (org lead, # phase 2). `check:bundle` in the job above is what keeps that true; if it # ever stops being true, this step and release.yml both grow an install. - name: Install the module into core run: | mkdir -p core/modules/rust tar -C module --exclude=.git --exclude=node_modules -cf - . | tar -C core/modules/rust -xf - - 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