feat(release): ship an OpenAPI fragment, a frozen manifest and a bundle (phase 3, slice 5)
The three artifacts that make this module installable and checkable, closing
phase 3's extraction. Nothing about what the module serves changes: the same 72
URLs, the same behaviour.
**The OpenAPI fragment (MODULE_API.md §2.8, §6.1a) was never built, on either
side.** The 417 `#swagger` annotations came across in slice 1 and went nowhere,
and core's /api/docs.json merged nothing — so every route this module serves was
in no spec at all, which is core's standing rule ("never ship a route that isn't
in the spec") being broken by the extraction rather than by a route.
`server/scripts/swaggerFragment.js` generates it. The prefixes are DERIVED: the
script runs the module's own `register()` against a recording api and asks
`require.cache` which file each router came from, so a mount prefix exists in one
place — `server/index.js` — and not in a table beside it. The 31 schemas moved
here from core's swagger.js, namespaced `Uo…` because core wins every key
collision in the merge; `Error` and `ValidationError` stay referenced by core's
names, since they resolve in the merged document.
**The frozen route manifest (§5.3)** is derived too, and by subtraction: CI
clones core at the ref pinned in ci/core-ref.json, generates its manifest without
this module and then with it, and the difference is what this module serves. That
buys the half of §5.3 that matters most for free — a module that shadowed or
displaced one of core's routes shows up as a REMOVAL, not merely as an addition
elsewhere. The same job checks the fragment against ground truth: every route
must have an operation and every operation must be a route.
**The release workflow** publishes `module-uo-<version>.tar.gz` plus a manifest
carrying its sha256. The version is declared in module.json rather than computed
from commit subjects, and the workflow never writes to a branch — it tags and
publishes — so `main` needs no push exception. The bundle is assembled from an
include list, because an exclude list ships whatever it forgot.
Four annotation defects, inherited from core and never visible until something
generated a spec from these files: two `requestBody` literals a brace short (the
route documented with an empty body), and two descriptions whose inner quoting
swagger-autogen cannot survive — it re-quotes `"` and a backtick to `'` before
evaluating, so either inside a single-quoted description ends the string early
and the annotation is dropped. It reports each one and then prints Success in
green, so the generator now captures its diagnostics and makes them fatal.
Also fixed while writing it: passing one shared `doc` to swagger-autogen six
times. It renders components.schemas from an EXAMPLE object and writes the result
back into what it was handed, so each pass re-wrapped the last and the fragment
came out at 484 MB.
- 409 server tests (+21), 40 client tests unchanged
- swagger-fragment.json: 69 paths covering all 72 routes
- routes.manifest.json: 72 routes; core's own surface unchanged, 0 removals
- verified end to end by assembling the bundle exactly as CI will, unpacking it
into a real core and regenerating the manifest
Refs: docs/website/MODULE_SYSTEM.md §2.7.1, MODULE_API.md §2.8, §5.3, §6.1a
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -27,11 +27,29 @@
|
||||
# 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).
|
||||
#
|
||||
# Not here yet, deliberately, because there is nothing for them to act on until
|
||||
# the extraction is further along: the release workflow (the
|
||||
# `module-uo-<version>.tar.gz` artifact and its sha256 manifest) and the module's
|
||||
# own frozen route manifest, which needs core checked out at a pinned ref
|
||||
# (MODULE_API.md §5.3). Each lands with the slice it checks.
|
||||
# • `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`)
|
||||
@@ -79,6 +97,9 @@ jobs:
|
||||
- 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
|
||||
@@ -107,3 +128,70 @@ jobs:
|
||||
|
||||
- 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
|
||||
|
||||
258
.gitea/workflows/release.yml
Normal file
258
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,258 @@
|
||||
# Build and publish the installable bundle: `module-uo-<version>.tar.gz` plus the
|
||||
# manifest carrying its sha256 (docs/website/MODULE_SYSTEM.md §2.3, §2.5).
|
||||
#
|
||||
# ── What a release IS here ──────────────────────────────────────────────────
|
||||
#
|
||||
# **An operator never builds anything** (MODULE_SYSTEM.md §1.14 — the constraint
|
||||
# the whole module system is shaped around). So a release is not source: it is the
|
||||
# directory core's loader expects to find at `modules/uo/`, already assembled —
|
||||
# the prebuilt client chunk, the one runtime dependency installed, the schema
|
||||
# fragment and the OpenAPI fragment — packed as it will be unpacked. Phase 4's
|
||||
# admin install downloads the tarball, verifies it against the `sha256` in the
|
||||
# manifest, and unpacks it onto the volume. Nothing runs `npm` on the way.
|
||||
#
|
||||
# ── The version is DECLARED, not derived ────────────────────────────────────
|
||||
#
|
||||
# Unlike RunicGateway/link and RunicGateway/installer, whose release engines read
|
||||
# conventional-commit subjects to compute the next version, this repo already has
|
||||
# one authoritative version — `module.json`'s, which is the version core records
|
||||
# in `installed_modules` and shows on the admin screen, and which sits beside the
|
||||
# `coreApi` range a bump usually has to be considered against. Two sources for one
|
||||
# number is how they drift, so: **a release happens when a merge to `main` leaves
|
||||
# `module.json` at a version that has no release yet.** Bumping the version is an
|
||||
# ordinary reviewed PR; publishing is this file's business.
|
||||
#
|
||||
# It follows that this workflow never writes to a branch — it tags and publishes,
|
||||
# nothing else — so `main` needs no push exception. That is the installer's model,
|
||||
# adopted here for the reason it was adopted there: `main` is protected, and a
|
||||
# release engine that has to push to it is a release engine that stops working the
|
||||
# day someone tightens the rule.
|
||||
#
|
||||
# Re-running on a version that is already released is a no-op, so a rerun after an
|
||||
# unrelated failure is safe.
|
||||
#
|
||||
# Prerequisites (Settings → Actions → Secrets on RunicGateway/Module-uo):
|
||||
# REGISTRY_TOKEN — Gitea access token with `write:repository`, to push the tag
|
||||
# and create the release.
|
||||
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: release-module-uo
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
GITEA_HOST: gitea.whitlocktech.com
|
||||
REPO: RunicGateway/Module-uo
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Decide whether this commit releases
|
||||
id: plan
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="$(node -p "require('./module.json').version")"
|
||||
echo "module.json version: ${VERSION}"
|
||||
|
||||
# Does a release already exist for this version? A 404 means no, a 200
|
||||
# means yes, and anything else — a network failure, a bad token — is not
|
||||
# evidence of absence. Guessing "no" would publish over a good release,
|
||||
# so refuse instead. (The installer learned this one the expensive way.)
|
||||
HTTP="$(curl -s -o /dev/null -w '%{http_code}' \
|
||||
-H "Authorization: token $(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" \
|
||||
"https://${GITEA_HOST}/api/v1/repos/${REPO}/releases/tags/v${VERSION}" || echo 000)"
|
||||
|
||||
case "$HTTP" in
|
||||
404) RELEASE=true ;;
|
||||
200) RELEASE=false; echo "v${VERSION} is already released — nothing to do." ;;
|
||||
*) echo "::error::Could not determine whether v${VERSION} is released (HTTP ${HTTP}). Refusing to guess."; exit 1 ;;
|
||||
esac
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "release=${RELEASE}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Before anything is built or tagged, so a repo without secrets fails
|
||||
# legibly rather than half-publishing: the tag push can succeed on the
|
||||
# credential actions/checkout left in the local git config while the
|
||||
# release API call 401s, leaving the repo tagged and unreleased.
|
||||
- name: Verify release credentials are configured
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "$(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" ]; then
|
||||
echo "::error::Missing Actions secret REGISTRY_TOKEN (needs write:repository) on ${REPO}."
|
||||
exit 1
|
||||
fi
|
||||
echo "Release credentials present."
|
||||
|
||||
- name: Build the client chunk
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
run: |
|
||||
npm ci --prefix client
|
||||
npm run build --prefix client
|
||||
|
||||
# `--omit=dev` and then PACKED: express, express-validator and swagger-autogen
|
||||
# are build- and test-time only — the shipped half is handed express on `ctx`
|
||||
# (MODULE_API.md §2.3) — and `ws` is the one runtime dependency. Node resolves
|
||||
# it by walking up from `modules/uo/server/`, which is why it ships inside the
|
||||
# tarball rather than being installed on the operator's box.
|
||||
- name: Install the shipped runtime dependency
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
run: npm ci --omit=dev --prefix server
|
||||
|
||||
# ── Assemble exactly what an operator's volume gets ──────────────────
|
||||
#
|
||||
# Stated as an INCLUDE list, not an exclude list. An exclude list ships
|
||||
# whatever it forgot: the day someone adds `server/tools/` with a scratch
|
||||
# credential in it, an exclude list packs it and nobody finds out.
|
||||
- name: Assemble the bundle
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ steps.plan.outputs.version }}"
|
||||
OUT="dist/module-uo-${VERSION}"
|
||||
rm -rf dist && mkdir -p "$OUT"
|
||||
|
||||
# The manifest core reads, the two fragments, and the licence the code
|
||||
# is under — a bundle that ships GPL code without its licence is not
|
||||
# distributable.
|
||||
cp module.json swagger-fragment.json LICENSE.md README.md "$OUT/"
|
||||
|
||||
# The server half, minus what never runs inside core's process.
|
||||
mkdir -p "$OUT/server"
|
||||
for d in boot.js core.js index.js config data db model router utils; do
|
||||
cp -r "server/$d" "$OUT/server/"
|
||||
done
|
||||
cp server/package.json "$OUT/server/"
|
||||
cp -r server/node_modules "$OUT/server/"
|
||||
|
||||
# The client half is the BUILT chunk only. `client/src` is 5,000 lines
|
||||
# of source an operator has no use for and core will never read.
|
||||
mkdir -p "$OUT/client/dist"
|
||||
cp client/dist/entry.js "$OUT/client/dist/"
|
||||
|
||||
# Prove the bundle is loadable before it is published: these are the
|
||||
# paths core's loader resolves out of module.json, and a release whose
|
||||
# entry point is missing fails on an operator's box with a
|
||||
# `startup_failed` row instead of here.
|
||||
node -e '
|
||||
const fs = require("fs"), path = require("path");
|
||||
const root = process.argv[1];
|
||||
const m = JSON.parse(fs.readFileSync(path.join(root, "module.json"), "utf8"));
|
||||
for (const p of [m.server, m.schema, m.purge, m.client.entry, "swagger-fragment.json"]) {
|
||||
if (!fs.existsSync(path.join(root, p))) { console.error("bundle is missing " + p); process.exit(1); }
|
||||
}
|
||||
console.log("bundle contents check: ok");
|
||||
' "$OUT"
|
||||
|
||||
tar -C dist -czf "dist/module-uo-${VERSION}.tar.gz" "module-uo-${VERSION}"
|
||||
rm -rf "$OUT"
|
||||
|
||||
SHA="$(sha256sum "dist/module-uo-${VERSION}.tar.gz" | cut -d' ' -f1)"
|
||||
SIZE="$(stat -c%s "dist/module-uo-${VERSION}.tar.gz")"
|
||||
|
||||
# The install manifest. Same shape as the installer's bundle JSON — a
|
||||
# per-asset sha256 fetched over HTTPS, no signatures — because that is
|
||||
# the model this project already has and a second one would be a second
|
||||
# thing to get right (MODULE_SYSTEM.md §1.11).
|
||||
jq -n \
|
||||
--arg id "$(node -p "require('./module.json').id")" \
|
||||
--arg name "$(node -p "require('./module.json').name")" \
|
||||
--arg version "$VERSION" \
|
||||
--arg coreApi "$(node -p "require('./module.json').coreApi")" \
|
||||
--arg artifact "module-uo-${VERSION}.tar.gz" \
|
||||
--arg sha256 "$SHA" \
|
||||
--argjson size "$SIZE" \
|
||||
--arg url "https://${GITEA_HOST}/${REPO}/releases/download/v${VERSION}/module-uo-${VERSION}.tar.gz" \
|
||||
'{schema:1, id:$id, name:$name, version:$version, coreApi:$coreApi,
|
||||
artifact:$artifact, url:$url, sha256:$sha256, size:$size}' \
|
||||
> "dist/module-uo-${VERSION}.json"
|
||||
|
||||
echo "${SHA} module-uo-${VERSION}.tar.gz" > dist/SHA256SUMS
|
||||
cat "dist/module-uo-${VERSION}.json"
|
||||
|
||||
- name: Write the changelog
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ steps.plan.outputs.version }}"
|
||||
LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)"
|
||||
RANGE="${LAST_TAG:+${LAST_TAG}..}HEAD"
|
||||
{
|
||||
echo "## module-uo v${VERSION}"
|
||||
echo
|
||||
echo "Install from the website's Admin → Modules screen, or unpack onto the"
|
||||
echo "modules volume as \`modules/uo/\`. Requires a core whose \`MODULE_API_VERSION\`"
|
||||
echo "satisfies \`$(node -p "require('./module.json').coreApi")\`."
|
||||
echo
|
||||
echo "### Changes"
|
||||
if [ -n "$LAST_TAG" ]; then echo "Since ${LAST_TAG}:"; fi
|
||||
git log --no-merges --format='- %s' $RANGE || true
|
||||
echo
|
||||
echo "### Verifying this download"
|
||||
echo
|
||||
echo "Releases are **unsigned** — the \`sha256\` in \`module-uo-${VERSION}.json\` is the"
|
||||
echo "trust anchor, and the website verifies it before unpacking."
|
||||
echo
|
||||
echo '```bash'
|
||||
echo "sha256sum -c SHA256SUMS --ignore-missing"
|
||||
echo '```'
|
||||
} > dist/CHANGELOG.md
|
||||
|
||||
- name: Tag the release
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ steps.plan.outputs.tag }}"
|
||||
git config user.name 'Runic Gateway CI'
|
||||
git config user.email 'ci@whitlocktech.net'
|
||||
git tag -a "$TAG" -m "module-uo ${TAG}"
|
||||
git push origin "$TAG"
|
||||
|
||||
- name: Create the Gitea release and upload the bundle
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ steps.plan.outputs.tag }}"
|
||||
VERSION="${{ steps.plan.outputs.version }}"
|
||||
API="https://${GITEA_HOST}/api/v1/repos/${REPO}"
|
||||
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
|
||||
|
||||
REL_ID="$(curl -sSf -X POST "${API}/releases" \
|
||||
-H "Authorization: token ${CI_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg tag "$TAG" --arg body "$(cat dist/CHANGELOG.md)" \
|
||||
'{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')" \
|
||||
| jq -r '.id')"
|
||||
echo "Created release ${TAG} (id=${REL_ID})"
|
||||
|
||||
for f in "module-uo-${VERSION}.tar.gz" "module-uo-${VERSION}.json" SHA256SUMS; do
|
||||
curl -sSf -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \
|
||||
-H "Authorization: token ${CI_TOKEN}" \
|
||||
-F "attachment=@dist/${f}" >/dev/null
|
||||
echo " uploaded ${f}"
|
||||
done
|
||||
Reference in New Issue
Block a user