feat(ci): packaging, release and the frozen manifest
Phase 2 of docs/modules/rust/PLAN.md. Phase 1 built five guards and ran them by hand; this repo had no workflows at all, so nothing gated the branch that gets released and there was no way to release it. Three pieces: - **release.yml** — the derived-version engine link, installer and Module-uo already run (conventional-commit subjects since the newest tag; module.json's version survives as a floor; workflow_dispatch as the backdoor), assembling the bundle from an include list and publishing the tarball, the install manifest carrying its sha256, and SHA256SUMS. The tag is the number that ships and CI stamps it into the bundle's own module.json. - **pr-checks.yml** — server tests, check:imports, check:bundle, check:swagger, the client build, client tests and check:externals, plus frozen-manifest. - **frozen-manifest** — clones core at the sha pinned in ci/core-ref.json, generates its route table without this module and with it, and takes the difference. It ran locally against that exact ref: six routes, all documented, no core route moved. That is the first proof by a running core that /rust collides with nothing — phase 1 could only check it by reading, because core mounts /status and /version at a tier root where the loader's own collision probe cannot see them. The bundle carries no node_modules, because the shipped half declares no runtime dependencies (org lead, phase 2). checkBundle.js holds both halves of that: the include list still covers everything server/index.js reaches, and no dependency has appeared without the release learning to pack it. Verified by breaking it — dropping "model" from the list names the exact edit and exits 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
273
server/test/checkBundle.test.js
Normal file
273
server/test/checkBundle.test.js
Normal file
@@ -0,0 +1,273 @@
|
||||
// The bundle check, checked.
|
||||
//
|
||||
// `scripts/checkBundle.js` exists because of a failure this module has not had
|
||||
// and does not intend to: Module-uo's v1.0.0 shipped without `server/commands/`
|
||||
// and died at the register stage on the operator's box. A check written in
|
||||
// response to one bug is worth exactly as much as its coverage of that bug, so
|
||||
// the first two tests below are that bug, in both modes — a list that has stopped
|
||||
// covering what the entry point reaches, and a tarball with the file missing from
|
||||
// it — and the third pair is this module's own version of it, a runtime
|
||||
// dependency declared and not packed.
|
||||
//
|
||||
// **Every fixture is a template literal, and that is load-bearing** — the same
|
||||
// reason checkImports.test.js gives. `scripts/checkImports.js` scans this
|
||||
// directory too, so an ordinary quoted string holding a relative require would
|
||||
// make this file fail that check. Templates are blanked by the stripper.
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
|
||||
const {
|
||||
reachable,
|
||||
resolveFile,
|
||||
checkDeclaration,
|
||||
checkBundle,
|
||||
declaredServerPaths,
|
||||
runtimeDependencies,
|
||||
} = require('../scripts/checkBundle')
|
||||
|
||||
/**
|
||||
* Write a throwaway module tree: `files` under server/, `bundle` as its
|
||||
* ci/bundle.json, `pkg` as its server/package.json. Returns the module root.
|
||||
*/
|
||||
function fixture(files, bundle = { server: ['index.js'] }, pkg = null) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'module-rust-bundle-'))
|
||||
for (const [name, source] of Object.entries(files)) {
|
||||
const file = path.join(root, 'server', name)
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true })
|
||||
fs.writeFileSync(file, source)
|
||||
}
|
||||
fs.mkdirSync(path.join(root, 'ci'), { recursive: true })
|
||||
fs.writeFileSync(path.join(root, 'ci', 'bundle.json'), JSON.stringify(bundle))
|
||||
if (pkg) {
|
||||
fs.mkdirSync(path.join(root, 'server'), { recursive: true })
|
||||
fs.writeFileSync(path.join(root, 'server', 'package.json'), JSON.stringify(pkg))
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
const cleanup = (root) => fs.rmSync(root, { recursive: true, force: true })
|
||||
|
||||
// ── The regression this script was written for ─────────────────────────────
|
||||
|
||||
test('--check catches a directory the include list has stopped covering', () => {
|
||||
const root = fixture(
|
||||
{
|
||||
'index.js': `const r = require('./router/public/rust.router')`,
|
||||
'router/public/rust.router.js': `module.exports = {}`,
|
||||
},
|
||||
{ server: ['index.js'] }, // `router` missing — exactly Module-uo's v1.0.0
|
||||
)
|
||||
try {
|
||||
const { uncovered } = checkDeclaration(root)
|
||||
assert.strictEqual(uncovered.size, 1)
|
||||
assert.ok(uncovered.has('server/router'))
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
test('--bundle catches the file missing from an assembled tarball', () => {
|
||||
const root = fixture({ 'index.js': `require('./router/public/rust.router')` })
|
||||
try {
|
||||
const { missing } = checkBundle(root)
|
||||
assert.strictEqual(missing.length, 1)
|
||||
assert.strictEqual(missing[0].specifier, './router/public/rust.router')
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
// ── This module's own version of that failure ──────────────────────────────
|
||||
//
|
||||
// The release packs no node_modules because the shipped half declares no
|
||||
// dependencies (org lead, phase 2). The value of that decision is entirely in
|
||||
// the day it stops being true being a LOUD day, so both modes ask.
|
||||
|
||||
test('--check reports a runtime dependency the release would not pack', () => {
|
||||
const root = fixture({ 'index.js': `module.exports = 1` }, { server: ['index.js'] }, {
|
||||
name: 'x',
|
||||
dependencies: { ws: '^8.21.0' },
|
||||
})
|
||||
try {
|
||||
assert.deepStrictEqual(checkDeclaration(root).dependencies, ['ws'])
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
test('--bundle reports a dependency the assembled bundle declares and does not carry', () => {
|
||||
const root = fixture({ 'index.js': `module.exports = 1` }, { server: ['index.js'] }, {
|
||||
name: 'x',
|
||||
dependencies: { ws: '^8.21.0' },
|
||||
})
|
||||
try {
|
||||
assert.deepStrictEqual(checkBundle(root).dependencies, ['ws'])
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
test('devDependencies are not runtime dependencies', () => {
|
||||
// express, express-validator and swagger-autogen are all here and none of them
|
||||
// ships: the shipped half is handed express on `ctx` (§2.3). A check that
|
||||
// confused the two would fail on a correct repo, which is the one way to make
|
||||
// everyone stop reading it.
|
||||
const root = fixture({ 'index.js': `module.exports = 1` }, { server: ['index.js'] }, {
|
||||
name: 'x',
|
||||
devDependencies: { express: '^4.19.2' },
|
||||
})
|
||||
try {
|
||||
assert.deepStrictEqual(runtimeDependencies(root), [])
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
// ── It has to reach requires that are not at the top level ─────────────────
|
||||
|
||||
test('follows requires written inside a function', () => {
|
||||
// index.js requires inside `register()` because require order is load-bearing:
|
||||
// `core.init(ctx)` has to run before anything under router/ is required. A
|
||||
// check that only saw file-scope requires would miss every router this module
|
||||
// has.
|
||||
const root = fixture(
|
||||
{
|
||||
'index.js': `module.exports = function register(ctx) { const r = require('./router/a') }`,
|
||||
'router/a.js': `module.exports = {}`,
|
||||
},
|
||||
{ server: ['index.js', 'router'] },
|
||||
)
|
||||
try {
|
||||
assert.strictEqual(checkDeclaration(root).uncovered.size, 0)
|
||||
assert.strictEqual(checkBundle(root).missing.length, 0)
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
test('follows requires transitively, not just one hop', () => {
|
||||
const root = fixture(
|
||||
{
|
||||
'index.js': `require('./a')`,
|
||||
'a.js': `require('./b')`,
|
||||
'b.js': `require('./deep/c')`,
|
||||
'deep/c.js': `module.exports = {}`,
|
||||
},
|
||||
{ server: ['index.js', 'a.js', 'b.js'] }, // `deep` missing
|
||||
)
|
||||
try {
|
||||
assert.ok(checkDeclaration(root).uncovered.has('server/deep'))
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
// ── Resolution has to match Node's, or it invents failures ─────────────────
|
||||
|
||||
test('resolves a directory to its index.js', () => {
|
||||
const root = fixture(
|
||||
{ 'index.js': `require('./boot')`, 'boot/index.js': `module.exports = {}` },
|
||||
{ server: ['index.js', 'boot'] },
|
||||
)
|
||||
try {
|
||||
assert.strictEqual(checkDeclaration(root).uncovered.size, 0)
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
test('resolves a .json dependency, and does not try to parse it for requires', () => {
|
||||
// server/index.js's last line requires ../module.json, which is why this case
|
||||
// is not hypothetical and why `generated` is in the declared list at all.
|
||||
const root = fixture(
|
||||
{ 'index.js': `require('./data/atlas.json')`, 'data/atlas.json': `{"a":1}` },
|
||||
{ server: ['index.js', 'data'] },
|
||||
)
|
||||
try {
|
||||
const { uncovered, missing } = checkDeclaration(root)
|
||||
assert.strictEqual(missing.length, 0)
|
||||
assert.strictEqual(uncovered.size, 0)
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
test('survives a require cycle', () => {
|
||||
const root = fixture(
|
||||
{ 'index.js': `require('./a')`, 'a.js': `require('./index')` },
|
||||
{ server: ['index.js', 'a.js'] },
|
||||
)
|
||||
try {
|
||||
assert.strictEqual(checkDeclaration(root).uncovered.size, 0)
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
test('a specifier that resolves to nothing is reported, not thrown', () => {
|
||||
const root = fixture({ 'index.js': `require('./gone')` })
|
||||
try {
|
||||
const { missing } = checkDeclaration(root)
|
||||
assert.strictEqual(missing.length, 1)
|
||||
assert.strictEqual(missing[0].specifier, './gone')
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
test('prose describing a require is not a require', () => {
|
||||
// The failure mode checkImports.js hit the first time it ran: index.js's own
|
||||
// header explains why it must never require express, and comments in this repo
|
||||
// name module paths constantly.
|
||||
const root = fixture(
|
||||
{ 'index.js': `// this file used to require('./router/gone')\nmodule.exports = 1` },
|
||||
{ server: ['index.js'] },
|
||||
)
|
||||
try {
|
||||
assert.strictEqual(checkDeclaration(root).missing.length, 0)
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
test("node_modules inside a bundle is npm's business, not this check's", () => {
|
||||
// Nothing ships one today. The skip stays so that the day a dependency does
|
||||
// arrive, this is not also the thing that breaks.
|
||||
const root = fixture({
|
||||
'index.js': `module.exports = 1`,
|
||||
'node_modules/ws/index.js': `require('./lib/that-npm-owns')`,
|
||||
})
|
||||
try {
|
||||
assert.strictEqual(checkBundle(root).missing.length, 0)
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
// ── And the real repo, which is the check that actually gates a release ────
|
||||
|
||||
test('the real ci/bundle.json covers everything the real entry point reaches', () => {
|
||||
const { uncovered, missing, reached, dependencies } = checkDeclaration()
|
||||
assert.deepStrictEqual([...uncovered.keys()], [])
|
||||
assert.deepStrictEqual(missing, [])
|
||||
assert.deepStrictEqual(dependencies, [])
|
||||
assert.ok(reached > 1, 'the walk should reach more than the entry point itself')
|
||||
})
|
||||
|
||||
test('every path ci/bundle.json declares exists', () => {
|
||||
// A list naming a path that has moved packs nothing and says nothing — `cp` in
|
||||
// the release would fail, but only after the tag had been pushed.
|
||||
for (const p of declaredServerPaths()) {
|
||||
assert.ok(fs.existsSync(p), `ci/bundle.json names ${p}, which does not exist`)
|
||||
}
|
||||
})
|
||||
|
||||
test('the entry point is reachable from the declared list', () => {
|
||||
const entry = path.resolve(__dirname, '..', 'index.js')
|
||||
assert.ok(reachable(entry).files.includes(entry))
|
||||
assert.ok(resolveFile(path.dirname(entry), './core'))
|
||||
})
|
||||
177
server/test/frozenManifest.test.js
Normal file
177
server/test/frozenManifest.test.js
Normal file
@@ -0,0 +1,177 @@
|
||||
// The frozen manifest's derivation, checked.
|
||||
//
|
||||
// `scripts/frozenManifest.js` runs in one place — a CI job with a whole core
|
||||
// checked out beside it — so it is the least-exercised piece of machinery in this
|
||||
// repo, and it is the piece that decides whether the URLs this module claims are
|
||||
// the URLs it serves (MODULE_API.md §5.3). Its three answers are pure functions of
|
||||
// two manifests and a fragment, so all three are asked here, with fixtures rather
|
||||
// than a clone.
|
||||
//
|
||||
// What is deliberately NOT asserted here: the numbers. `routes.manifest.json`'s
|
||||
// routes are proved by the job that generates them from a real core, and a copy of
|
||||
// that count in this file would only ever be a second thing to update.
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const { diffManifests, coverage, MANIFEST, FRAGMENT } = require('../scripts/frozenManifest')
|
||||
|
||||
const manifest = (public_ = [], internal = []) => ({ public: public_, internal })
|
||||
const get = (p) => ({ method: 'GET', path: p })
|
||||
|
||||
test("the module's routes are the ones a core gains by loading it", () => {
|
||||
const before = manifest([get('/api/v1/public/settings')])
|
||||
const after = manifest([get('/api/v1/public/settings'), get('/api/v1/public/rust/servers')])
|
||||
|
||||
const { added, removed } = diffManifests(before, after)
|
||||
assert.deepStrictEqual(removed, [])
|
||||
assert.deepStrictEqual(added, [{ method: 'GET', path: '/api/v1/public/rust/servers', tier: 'public' }])
|
||||
})
|
||||
|
||||
test('a route core loses to the module is reported, not quietly absorbed', () => {
|
||||
// The failure this exists for, and the one phase 1 could only check by reading:
|
||||
// core mounts several routes at the TIER ROOT (/status, /version) that the
|
||||
// loader's collision probe cannot see, so a module whose mount displaced one
|
||||
// would not show up as an addition — the URL is unchanged — and a check that
|
||||
// only looked at what appeared would call it clean.
|
||||
const before = manifest([get('/api/v1/public/settings'), get('/api/v1/public/status')])
|
||||
const after = manifest([get('/api/v1/public/settings')])
|
||||
|
||||
const { removed } = diffManifests(before, after)
|
||||
assert.deepStrictEqual(removed, ['public GET /api/v1/public/status'])
|
||||
})
|
||||
|
||||
test('a route whose METHOD changed counts as removed and added', () => {
|
||||
const { added, removed } = diffManifests(
|
||||
manifest([{ method: 'POST', path: '/api/v1/admin/thing' }]),
|
||||
manifest([{ method: 'PUT', path: '/api/v1/admin/thing' }]),
|
||||
)
|
||||
assert.deepStrictEqual(removed, ['public POST /api/v1/admin/thing'])
|
||||
assert.strictEqual(added.length, 1)
|
||||
})
|
||||
|
||||
test('the internal app is diffed too, and keeps its own tier', () => {
|
||||
const { added } = diffManifests(
|
||||
manifest([], [get('/internal/health')]),
|
||||
manifest([], [get('/internal/health'), get('/internal/rust/thing')]),
|
||||
)
|
||||
assert.deepStrictEqual(added, [{ method: 'GET', path: '/internal/rust/thing', tier: 'internal' }])
|
||||
})
|
||||
|
||||
test('added routes are sorted, so the committed file does not churn on traversal order', () => {
|
||||
const { added } = diffManifests(
|
||||
manifest([]),
|
||||
manifest([get('/b'), get('/a'), { method: 'POST', path: '/a' }]),
|
||||
)
|
||||
assert.deepStrictEqual(
|
||||
added.map((r) => `${r.method} ${r.path}`),
|
||||
['GET /a', 'GET /b', 'POST /a'],
|
||||
)
|
||||
})
|
||||
|
||||
// ── coverage: the route ⇄ fragment agreement ────────────────────────────────
|
||||
|
||||
const fragment = (paths) => ({ paths })
|
||||
|
||||
test('a served route with no documented operation is named', () => {
|
||||
const { undocumented, unserved } = coverage([get('/api/v1/public/rust/servers')], fragment({}))
|
||||
assert.deepStrictEqual(undocumented, ['GET /api/v1/public/rust/servers'])
|
||||
assert.deepStrictEqual(unserved, [])
|
||||
})
|
||||
|
||||
test('a documented operation nobody serves is named too', () => {
|
||||
// The direction core's own spec has no check for, which is how it accumulated
|
||||
// orphan tags and schemas describing routes that had moved out of it. A
|
||||
// documented URL nobody serves is a client following the docs into a 404.
|
||||
const { undocumented, unserved } = coverage([], fragment({ '/api/v1/public/rust/gone': { get: {} } }))
|
||||
assert.deepStrictEqual(undocumented, [])
|
||||
assert.deepStrictEqual(unserved, ['GET /api/v1/public/rust/gone'])
|
||||
})
|
||||
|
||||
test('express :params and OpenAPI {params} are the same route', () => {
|
||||
const { undocumented, unserved } = coverage(
|
||||
[{ method: 'DELETE', path: '/api/v1/admin/rust/servers/:id' }],
|
||||
fragment({ '/api/v1/admin/rust/servers/{id}': { delete: {} } }),
|
||||
)
|
||||
assert.deepStrictEqual(undocumented, [])
|
||||
assert.deepStrictEqual(unserved, [])
|
||||
})
|
||||
|
||||
test('methods are matched, not just paths', () => {
|
||||
const { undocumented, unserved } = coverage(
|
||||
[{ method: 'POST', path: '/api/v1/admin/rust/servers/:id/test' }],
|
||||
fragment({ '/api/v1/admin/rust/servers/{id}/test': { get: {} } }),
|
||||
)
|
||||
assert.deepStrictEqual(undocumented, ['POST /api/v1/admin/rust/servers/{id}/test'])
|
||||
assert.deepStrictEqual(unserved, ['GET /api/v1/admin/rust/servers/{id}/test'])
|
||||
})
|
||||
|
||||
// ── the committed artifacts, against each other ─────────────────────────────
|
||||
//
|
||||
// These two files are generated together by a job that has a real core; here
|
||||
// there is no core, so what can still be asked is whether they agree with each
|
||||
// other. If they do not, one of them was committed without the other.
|
||||
|
||||
test('every route in the committed manifest has a committed operation', () => {
|
||||
const { routes } = JSON.parse(fs.readFileSync(MANIFEST, 'utf8'))
|
||||
const spec = JSON.parse(fs.readFileSync(FRAGMENT, 'utf8'))
|
||||
const { undocumented, unserved } = coverage(routes, spec)
|
||||
assert.deepStrictEqual(undocumented, [], 'routes.manifest.json lists routes swagger-fragment.json does not document')
|
||||
assert.deepStrictEqual(unserved, [], 'swagger-fragment.json documents operations routes.manifest.json does not list')
|
||||
})
|
||||
|
||||
test('the fragment carries only the three sections §6.1a allows', () => {
|
||||
const spec = JSON.parse(fs.readFileSync(FRAGMENT, 'utf8'))
|
||||
assert.deepStrictEqual(Object.keys(spec).sort(), ['components', 'paths', 'tags'])
|
||||
assert.deepStrictEqual(Object.keys(spec.components), ['schemas'])
|
||||
})
|
||||
|
||||
test("the fragment defines only namespaced schemas, and redefines none of core's", () => {
|
||||
const spec = JSON.parse(fs.readFileSync(FRAGMENT, 'utf8'))
|
||||
for (const name of Object.keys(spec.components.schemas)) {
|
||||
assert.match(name, /^Rust[A-Z]/, `${name} is not namespaced — core wins the collision and drops it (§6.1a)`)
|
||||
}
|
||||
// Anything this fragment REFERENCES and does not define has to be one of
|
||||
// core's shared schemas, which resolve in the merged document — that is the
|
||||
// whole point of a fragment. A typo'd $ref is otherwise invisible until a
|
||||
// reader opens /api/docs.json and finds a dangling pointer.
|
||||
const refs = JSON.stringify(spec.paths).match(/#[/]components[/]schemas[/]([A-Za-z0-9_]+)/g) || []
|
||||
const shared = ['Error', 'ValidationError']
|
||||
for (const name of new Set(refs.map((r) => r.split('/').pop()))) {
|
||||
const resolvable = Object.hasOwn(spec.components.schemas, name) || shared.includes(name)
|
||||
assert.ok(resolvable, `$ref to ${name} resolves to nothing — not defined here, not one of core's shared schemas`)
|
||||
}
|
||||
})
|
||||
|
||||
test('every path in the fragment is fully qualified', () => {
|
||||
const spec = JSON.parse(fs.readFileSync(FRAGMENT, 'utf8'))
|
||||
for (const p of Object.keys(spec.paths)) {
|
||||
// §6.1a: core merges the fragment verbatim and never re-derives a prefix, so
|
||||
// a router-relative path here is a path nothing serves.
|
||||
assert.match(p, /^\/api\/v1\/(public|admin|player)\//, `${p} is not a fully-qualified URL`)
|
||||
assert.doesNotMatch(p, /\/$/, `${p} has a trailing slash — no client calls that URL`)
|
||||
}
|
||||
})
|
||||
|
||||
test("the manifest and the module's declared mounts agree", () => {
|
||||
const { routes } = JSON.parse(fs.readFileSync(MANIFEST, 'utf8'))
|
||||
const { mounts } = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'module.json'), 'utf8'))
|
||||
|
||||
const declared = []
|
||||
for (const [tier, prefixes] of Object.entries(mounts)) {
|
||||
for (const prefix of prefixes) declared.push(`/api/v1/${tier}${prefix}/`)
|
||||
}
|
||||
|
||||
// Every route this module serves is under a prefix it declared. There is no
|
||||
// exception here yet, and that is the point of asserting it now: phase 6 adds
|
||||
// the `admin.users.detail` extension slot, whose routes live under core's
|
||||
// `/api/v1/admin/users/` rather than under any mount of ours (§2.4). When that
|
||||
// arrives this test must grow the exception deliberately, rather than a route
|
||||
// outside every declared mount arriving unnoticed.
|
||||
for (const route of routes) {
|
||||
const under = declared.some((d) => route.path.startsWith(d))
|
||||
assert.ok(under, `${route.method} ${route.path} is served from outside every mount module.json declares`)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user