6 Commits

Author SHA1 Message Date
16cfbe194d Merge pull request 'ci(release): derive the version from commit subjects, and add a manual backdoor' (#17) from feature/release-per-merge into main
All checks were successful
Release / release (push) Successful in -30s
SonarQube / analysis (push) Successful in 1m47s
Reviewed-on: #17
2026-08-19 18:07:55 +00:00
8ec21086b5 ci(release): derive the version from commit subjects, and add a manual backdoor
All checks were successful
PR Checks / client-build (pull_request) Successful in 18s
PR Checks / server-tests (pull_request) Successful in 20s
PR Checks / frozen-manifest (pull_request) Successful in 56s
A bundle used to cost a second pull request whose entire content was a number.
The workflow published only when a merge to `main` left `module.json` at a
version with no release yet, so a merge that did not touch that line released
nothing. Measured rather than argued: v0.3.0 (2026-08-12) is the only release
this repo has ever cut, while the nine Teams phases and the cutover landed on
`main` in the week since.

Adopt the engine `link` and `installer` already run - feat!/BREAKING CHANGE ->
major, feat -> minor, fix|perf -> patch, nothing releasable -> no release. The
number that ships is the tag, and CI writes it into the `module.json` inside the
bundle, with an assertion that the rewrite happened: a bundle carrying the wrong
version would install under a number that is not the one it was released as.

`module.json`'s version is kept as a floor rather than deleted - a version above
the newest tag still releases at that version - so the declared model survives as
the special case it always was, and is still how a `coreApi` bump overrules the
subjects. `workflow_dispatch` covers what the rules cannot reach: leave `version`
blank to bump the newest tag by `bump`, or name an exact version. Where the log
and the input disagree the larger bump wins, because a button pressed on a log
full of `feat:` would otherwise publish its `patch` default over a minor's worth
of work.

Two things carried over from `link` at the same time: a tag pushed without a
release behind it is now recovered rather than making that state permanent, and
the changelog moved into the plan step (so the assemble step clears `$OUT`, not
`dist/`, which now holds it).

On this repo's `main` the engine computes v0.4.0. The workflow still never
writes to a branch.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 13:01:31 -05:00
637121bce3 Merge pull request 'feat(guilds): a UO guild is a Team (Teams cutover 5/6)' (#16) from edge into main
All checks were successful
SonarQube / analysis (push) Successful in 2m18s
Release / release (push) Successful in 7s
Reviewed-on: #16
2026-08-19 09:02:38 +00:00
fe176920c5 ci(core-ref): pin to the Teams cutover, not a phase 3 edge sha
All checks were successful
PR Checks / client-build (pull_request) Successful in 25s
PR Checks / frozen-manifest (pull_request) Successful in 49s
PR Checks / server-tests (pull_request) Successful in 8m40s
The pin said "edge @ Teams phase 3" and edge is about to stop existing. More to
the point, this module has since gained the Team provider, three declared slots,
the /guild command and the contribution names on those slots - none of which the
pinned core knew about, so the frozen-manifest job has been proving this module
against a core older than half of it.

routes.manifest.json does not move: the job compares core-with-module against
core-without-module, so core's own Teams routes cancel out and what is left is
this module's 73, unchanged. Verified locally against the cutover core before
moving the pin rather than after.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 04:00:01 -05:00
d98f0c1a3d Merge pull request 'feat(guilds): name the core contribution each declared slot wants' (#15) from feature/teams-slot-contributions into edge
Reviewed-on: #15
2026-08-19 06:19:52 +00:00
1a13f680f5 feat(guilds): name the core contribution each declared slot wants
Some checks failed
PR Checks / client-build (pull_request) Successful in 27s
PR Checks / frozen-manifest (pull_request) Failing after 47s
PR Checks / server-tests (pull_request) Failing after 13m23s
Core no longer fills a slot by name - it offers a contribution and the module
that owns the page says where each one goes (MODULE_API 1.6.0, amended). The
three slot names are unchanged and stay this module's own vocabulary; what is
new is the second argument saying which of core's three contributions belongs
in each place.

Nothing here worked differently before. The change is for every game that is
not this one: core used to fill the literal name uo.guild.detail, so a second
module declaring a place under its own id got an empty page and no error.

The registration fake gained the same validation core does, including the
contribution catalogue - written down rather than imported, since this suite
runs against the built chunk with no core in the process, which makes it a
claim about core that has to be re-read when core's list changes.

42 client tests, 437 server tests.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 01:15:49 -05:00
5 changed files with 286 additions and 95 deletions

View File

@@ -11,25 +11,52 @@
# 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 ────────────────────────────────────
# ── The version is DERIVED, and the declaration is a floor ──────────────────
#
# 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.
# This file used to release only when a merge to `main` left `module.json` at a
# version with no release yet — the version DECLARED, never computed, on the
# argument that two sources for one number is how they drift. That was true and
# it was still the wrong trade: it makes every bundle cost a second reviewed PR
# whose entire content is a number, and between 2026-08-12 and 2026-08-19 it cost
# this repo *every* bundle — v0.3.0 was the only release while nine phases of
# Teams work landed, because nothing in them touched that line.
#
# 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.
# So the engine `link` and `installer` already run is adopted here (MODULE_SYSTEM
# §2.7.1, decision 19 as amended):
#
# feat!: / BREAKING CHANGE -> major feat: -> minor fix|perf: -> patch
# nothing releasable -> no release is cut
# (first ever run, no tag) -> releases what module.json declares
#
# **The declared version is kept as a floor, not deleted.** If `module.json` names
# a version above the newest tag, that version releases — which is the old model
# exactly, surviving as the special case it always was. Raising it by hand is
# still how you say "this one is a minor, whatever the subjects imply", and it is
# still the natural place to move when a `coreApi` bump forces the question. What
# no longer happens is a merge full of `feat:` producing nothing.
#
# The number that ships is therefore the TAG, and CI writes it into the
# `module.json` inside the bundle at assembly time. The committed `module.json` is
# a floor and a starting point, not a record of the last release — `link` reached
# the same arrangement with `Cargo.toml`, for the same reason: a release engine
# that has to commit a bump back to `main` stops working the day someone protects
# the branch, and this one is protected.
#
# ── The backdoor ────────────────────────────────────────────────────────────
#
# `workflow_dispatch` publishes on demand, for the case the rules above cannot
# reach: `module.json` changed in a way worth shipping — a widened `coreApi`, a
# new mount, a capability — with no releasable code behind it. Leave `version`
# blank to bump the newest tag by `bump` (default `patch`), or name an exact
# version to publish that. A dispatch releases even when nothing in the log is
# releasable; that is the entire point of pressing the button.
#
# Re-running on a version that is already released is a no-op, so a rerun after an
# unrelated failure is safe.
# unrelated failure is safe. A tag that exists with no release behind it is NOT a
# no-op — see the recovery branch in the plan step.
#
# This workflow still never writes to a branch. It tags and publishes, so `main`
# needs no push exception.
#
# Prerequisites (Settings → Actions → Secrets on RunicGateway/Module-uo):
# REGISTRY_TOKEN — Gitea access token with `write:repository`, to push the tag
@@ -40,6 +67,16 @@ name: Release
on:
push:
branches: [main]
workflow_dispatch:
inputs:
version:
description: 'Exact version to publish (e.g. 0.4.1). Blank = bump the newest tag by the level below.'
required: false
default: ''
bump:
description: 'Bump level when version is blank: patch | minor | major'
required: false
default: 'patch'
concurrency:
group: release-module-uo
@@ -54,6 +91,8 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
# Full history: the plan step reads every tag and every subject since the
# newest one, and a shallow clone has neither.
- uses: actions/checkout@v4
with:
fetch-depth: 0
@@ -62,32 +101,169 @@ jobs:
with:
node-version: 20
- name: Decide whether this commit releases
- name: Plan the release (version + changelog)
id: plan
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
EVENT: ${{ github.event_name }}
IN_VERSION: ${{ github.event.inputs.version }}
IN_BUMP: ${{ github.event.inputs.bump }}
run: |
set -euo pipefail
VERSION="$(node -p "require('./module.json').version")"
echo "module.json version: ${VERSION}"
mkdir -p dist
git fetch --tags --force >/dev/null 2>&1 || true
# 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)"
DECLARED="$(node -p "require('./module.json').version")"
LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)"
CURRENT="${LAST_TAG#v}"
RANGE="${LAST_TAG:+${LAST_TAG}..}HEAD"
echo "module.json declares ${DECLARED}; newest tag is ${LAST_TAG:-<none>}"
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 ;;
SUBJECTS="$(git log --no-merges --format='%s' $RANGE || true)"
BODIES="$(git log --no-merges --format='%B' $RANGE || true)"
BUMP=none
if echo "$BODIES" | grep -qE 'BREAKING[ -]CHANGE' ; then BUMP=major; fi
if echo "$SUBJECTS" | grep -qE '^[a-z]+(\([^)]+\))?!:' ; then BUMP=major; fi
if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^feat(\([^)]+\))?:' ; then BUMP=minor; fi
if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^(fix|perf)(\([^)]+\))?:' ; then BUMP=patch; fi
bump() { # <x.y.z> <major|minor|patch> -> bumped
IFS=. read -r MA MI PA <<< "$1"
case "$2" in
major) echo "$((MA+1)).0.0" ;;
minor) echo "${MA}.$((MI+1)).0" ;;
patch) echo "${MA}.${MI}.$((PA+1))" ;;
esac
}
# `sort -V` orders version strings, so the higher of two is its last
# line. Used rather than a hand-rolled field compare because 0.10.0 vs
# 0.9.0 is exactly the comparison a string sort gets wrong.
higher() { printf '%s\n%s\n' "$1" "$2" | sort -V | tail -1; }
rank() { case "$1" in major) echo 3 ;; minor) echo 2 ;; patch) echo 1 ;; *) echo 0 ;; esac; }
bigger_bump() { if [ "$(rank "$1")" -ge "$(rank "$2")" ]; then echo "$1"; else echo "$2"; fi; }
VERSION=""
if [ -n "${IN_VERSION:-}" ]; then
# The backdoor's exact form. Deliberately unvalidated against the log:
# a human typed it, and the already-released check below is the only
# guard that matters.
VERSION="${IN_VERSION}"
echo "dispatch: publishing the requested version ${VERSION}"
else
LEVEL="$BUMP"
# A dispatch with nothing releasable in the log still releases — that
# is what the button is for. Where the log DOES say something, the
# larger of the two wins rather than the input: pressing the button on
# a log full of `feat:` without touching the dropdown would otherwise
# publish its `patch` default over a minor's worth of work, and a
# version that undersells its own contents cannot be taken back.
if [ "${EVENT:-}" = workflow_dispatch ]; then
LEVEL="$(bigger_bump "$LEVEL" "${IN_BUMP:-patch}")"
if [ "$BUMP" = none ]; then
echo "dispatch: nothing releasable in the log, bumping ${LEVEL} anyway"
elif [ "$LEVEL" != "$BUMP" ]; then
echo "dispatch: the log says ${BUMP}, the run asked for ${LEVEL} — taking ${LEVEL}"
fi
fi
if [ -z "$CURRENT" ]; then
VERSION="$DECLARED" # first ever release: ship what is declared
elif [ "$LEVEL" != none ]; then
VERSION="$(bump "$CURRENT" "$LEVEL")"
fi
# The floor. A `module.json` above the newest tag releases at that
# version even when the log says nothing and even when the log says
# patch — which is the pre-2026-08-19 model, kept as a special case.
if [ -n "$CURRENT" ] && [ "$DECLARED" != "$CURRENT" ] \
&& [ "$(higher "$DECLARED" "$CURRENT")" = "$DECLARED" ]; then
if [ -z "$VERSION" ] || [ "$(higher "$DECLARED" "$VERSION")" = "$DECLARED" ]; then
echo "module.json declares ${DECLARED}, above both ${CURRENT} and the derived version — releasing that."
VERSION="$DECLARED"
fi
fi
fi
RELEASE=true
if [ -z "$VERSION" ]; then
RELEASE=false
VERSION="$CURRENT"
echo "Nothing releasable since ${LAST_TAG} (no feat/fix/perf/breaking subject) — standing down."
fi
# An existing tag is NOT automatically "nothing to do". A tag with no
# release behind it means a previous run tagged and then died before
# publishing — which is what happened on servuo-plugins' first release,
# where absent secrets took the release API call to 401 after the tag
# had already been pushed. Standing down on the tag alone makes that
# state permanent. Note this deliberately OVERRIDES the RELEASE=false
# above: with the tag in place there is nothing releasable after it, so
# the normal path would stand down, which is why it could never
# self-heal. Anything other than 200/404 — a network failure, a bad
# token — is not evidence of absence, and guessing "no" would publish
# over a good release, so refuse instead.
REUSE_TAG=false
if [ -n "$VERSION" ] && git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')"
REL_HTTP="$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: token ${CI_TOKEN}" \
"https://${GITEA_HOST}/api/v1/repos/${REPO}/releases/tags/v${VERSION}" || echo 000)"
case "$REL_HTTP" in
200) echo "v${VERSION} is already released — nothing to do."; RELEASE=false ;;
404) echo "::warning::Tag v${VERSION} exists but has no release — a previous run failed after tagging. Reusing the tag and publishing the release it is missing."
REUSE_TAG=true; RELEASE=true ;;
*) echo "::error::Could not determine whether v${VERSION} is released (HTTP ${REL_HTTP}). Refusing to guess."; exit 1 ;;
esac
fi
# Changelog range. A recovery run has nothing after the tag, so
# summarize what the tag itself contains rather than emitting an empty
# list: the range that produced it, i.e. previous-tag..this-tag.
if [ "$REUSE_TAG" = true ]; then
PREV_TAG="$(git describe --tags --match 'v*' --abbrev=0 "v${VERSION}^" 2>/dev/null || true)"
CL_RANGE="${PREV_TAG:+${PREV_TAG}..}v${VERSION}"
SINCE="$PREV_TAG"
else
CL_RANGE="$RANGE"
SINCE="$LAST_TAG"
fi
CL_SUBJECTS="$(git log --no-merges --format='%s' $CL_RANGE || true)"
{
echo "## module-uo v${VERSION}"
echo
echo "Install from the website's Admin → Modules screen by pasting the URL of"
echo "\`module-uo-${VERSION}.json\`, or unpack the tarball onto the modules volume"
echo "as \`modules/uo/\`. Requires a core whose \`MODULE_API_VERSION\` satisfies"
echo "\`$(node -p "require('./module.json').coreApi")\`."
echo
FEATS="$(echo "$CL_SUBJECTS" | grep -E '^feat' || true)"
FIXES="$(echo "$CL_SUBJECTS" | grep -E '^(fix|perf)' || true)"
[ -n "$FEATS" ] && { echo "### Features"; echo "$FEATS" | sed 's/^/- /'; echo; }
[ -n "$FIXES" ] && { echo "### Fixes"; echo "$FIXES" | sed 's/^/- /'; echo; }
echo "### All changes"
if [ -n "$SINCE" ]; then echo "Since ${SINCE}:"; fi
echo "$CL_SUBJECTS" | sed 's/^/- /'
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
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
echo "release=${RELEASE}" >> "$GITHUB_OUTPUT"
echo "reuse_tag=${REUSE_TAG}" >> "$GITHUB_OUTPUT"
echo "bump=${BUMP}" >> "$GITHUB_OUTPUT"
echo "==> release=${RELEASE} version=${VERSION} bump=${BUMP} declared=${DECLARED} last_tag=${LAST_TAG:-<none>}"
# Before anything is built or tagged, so a repo without secrets fails
# legibly rather than half-publishing: the tag push can succeed on the
@@ -131,12 +307,19 @@ jobs:
set -euo pipefail
VERSION="${{ steps.plan.outputs.version }}"
OUT="dist/module-uo-${VERSION}"
rm -rf dist && mkdir -p "$OUT"
rm -rf "$OUT" && 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 manifest core reads — with the RELEASED version written into it.
# The committed `module.json` is a floor, not a record of the last
# release (see the header), so copying it verbatim would ship a bundle
# whose `installed_modules` row and admin screen disagree with the tag
# it came from. This is the one place the derived number becomes the
# module's own.
jq --arg v "$VERSION" '.version = $v' module.json > "$OUT/module.json"
# The two fragments, and the licence the code is under — a bundle that
# ships GPL code without its licence is not distributable.
cp swagger-fragment.json LICENSE.md README.md "$OUT/"
# The server half, minus what never runs inside core's process.
mkdir -p "$OUT/server"
@@ -154,16 +337,22 @@ jobs:
# 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.
# `startup_failed` row instead of here. The version assertion guards the
# rewrite above — a bundle that still carries the declared version would
# install under a number that is not the one it was released as.
node -e '
const fs = require("fs"), path = require("path");
const root = process.argv[1];
const [root, want] = process.argv.slice(1);
const m = JSON.parse(fs.readFileSync(path.join(root, "module.json"), "utf8"));
if (m.version !== want) {
console.error(`bundle declares ${m.version}, but this is release ${want}`);
process.exit(1);
}
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"
' "$OUT" "$VERSION"
tar -C dist -czf "dist/module-uo-${VERSION}.tar.gz" "module-uo-${VERSION}"
rm -rf "$OUT"
@@ -191,36 +380,10 @@ jobs:
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
# Skipped on a recovery run: the tag is already there and is the thing being
# published against.
- name: Tag the release
if: ${{ steps.plan.outputs.release == 'true' }}
if: ${{ steps.plan.outputs.release == 'true' && steps.plan.outputs.reuse_tag != 'true' }}
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |

View File

@@ -134,11 +134,20 @@ website. Module delivery is website-side only.
### Releases
A merge to `main` that leaves `module.json` at a version with no release yet publishes one. The
version is **declared**, not computed from commit subjects: `module.json`'s version is what core
records in `installed_modules` and shows on the admin screen, and it sits beside the `coreApi` range
a bump usually has to be weighed against — two sources for one number is how they drift. Bumping it
is an ordinary reviewed PR.
**Every merge to `main` that carries a releasable commit publishes a bundle.** The next version is
computed from conventional-commit subjects since the newest `v*` tag, as in `link` and `installer`:
`feat!:` or `BREAKING CHANGE` is a major, `feat:` a minor, `fix:` or `perf:` a patch, and a `main`
that gained none of those cuts no release. The number that ships is the **tag**, and CI writes it
into the `module.json` inside the bundle.
`module.json`'s version survives as a **floor**: name a version there above the newest tag and that
version is what releases, which is how you overrule the subjects — when a `coreApi` bump forces a
minor, say. What no longer happens is a `main` full of `feat:` producing nothing because a separate
PR to move one number had not been merged yet.
For a change with nothing releasable behind it — a widened `coreApi`, a new mount, a capability —
run the **Release** workflow by hand (Actions → Release → Run workflow). Leave `version` blank to
bump the newest tag by `bump` (default `patch`), or type an exact version to publish that.
Each release carries:

View File

@@ -1,6 +1,6 @@
{
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/uo and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves. Pinned rather than tracking `edge` on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together.",
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
"ref": "7ed2ac99838f4bd64e1df324fe4961673648b0e6",
"refName": "edge @ Teams phase 3 (website#152)"
"ref": "963d734dcc09580a7d8bb676370b4faf9b8727b2",
"refName": "main @ the Teams cutover (website#161)"
}

View File

@@ -189,10 +189,13 @@ registry.registerExtension(ID, 'player.invite.accepted', InviteGameAccountStep)
// populates, but core does not own the word "guild" and publishes no Team page of
// its own — so the page is ours and core contributes the activity feed to it.
//
// Declared under this module's own namespace, which core enforces. Core's fill is
// applied after every module chunk has evaluated, so declaring it here is early
// enough; on a core that knows nothing of Teams it simply stays empty.
registry.declareModuleSlot(ID, 'uo.guild.detail')
// Declared under this module's own namespace, which core enforces. The second
// argument is what gets core's content into the place: **core offers a
// CONTRIBUTION and never names a slot**, so this module says where each one goes
// and keeps its own word for the place. Core's fills are applied after every
// module chunk has evaluated, so declaring here is early enough; on a core that
// knows nothing of Teams the slot simply stays empty.
registry.declareModuleSlot(ID, 'uo.guild.detail', { core: 'team.activity' })
// A SECOND place on the same page, for core's Team forum (TEAMS.md Part 5). Two
// declarations rather than one, because a slot holds one component and this module
@@ -200,14 +203,14 @@ registry.declareModuleSlot(ID, 'uo.guild.detail')
// the feed reads as part of the guild's story, the forum is a room you go into.
// Neither knows the other exists, and a core that fills only one leaves the other
// empty.
registry.declareModuleSlot(ID, 'uo.guild.forum')
registry.declareModuleSlot(ID, 'uo.guild.forum', { core: 'team.forum' })
// And a THIRD, at the top of the same page, for core's per-Team notification
// control (TEAMS.md §6.3). Same reasoning as the other two and a different place:
// muting a guild is an action ON this page, so it sits with the page's heading
// rather than after its content. Core resolves whether this viewer is in the
// Team at all — this module neither knows nor asks.
registry.declareModuleSlot(ID, 'uo.guild.header')
registry.declareModuleSlot(ID, 'uo.guild.header', { core: 'team.notify' })
// `module.json`'s `coreApi` range is checked by the loader before this file is
// ever served, so there is nothing to re-check here. It is logged because a

View File

@@ -39,12 +39,18 @@ const CHUNK = path.resolve(HERE, '..', 'dist', 'entry.js')
// nothing here renders, so a named stub is enough to be imported and passed on.
const stub = (name) => Object.assign(() => null, { displayName: name })
// Core's contribution catalogue, as of MODULE_API 1.6.0. Written down rather than
// imported — this suite runs against the BUILT chunk with no core in the process
// — which means it is a claim about core that has to be re-read when core's list
// changes. That is the same trade the rest of this fake makes.
const CORE_CONTRIBUTIONS = ['team.activity', 'team.forum', 'team.notify']
function fakeRg() {
const routes = { public: [], admin: [], player: [] }
const nav = { public: [], admin: [], player: [] }
const providers = new Map()
const extensions = new Map()
const declaredSlots = new Set()
const declaredSlots = new Map()
return {
version: '1.3.0',
react,
@@ -74,13 +80,18 @@ function fakeRg() {
extensions.set(slot, { id, Component })
},
// The INVERTED direction (core API 1.6.0): this module declares a place on
// its OWN page and core fills it. Core enforces the namespace, so the fake
// does too — a chunk that declared an unnamespaced slot would pass here and
// throw in a browser.
declareModuleSlot(id, name) {
// its OWN page and core fills it. Core enforces the namespace and the
// contribution name, so the fake does too — a chunk that declared an
// unnamespaced slot, or asked for a contribution core does not offer, would
// pass here and throw in a browser.
declareModuleSlot(id, name, options = {}) {
if (!name.startsWith(`${id}.`)) throw new Error(`declareModuleSlot: "${name}" must be namespaced "${id}."`)
if (declaredSlots.has(name)) throw new Error(`extension slot "${name}" already declared`)
declaredSlots.add(name)
const wants = options.core ?? null
if (wants !== null && !CORE_CONTRIBUTIONS.includes(wants)) {
throw new Error(`declareModuleSlot: "${name}" asks for core contribution "${wants}", which core does not offer`)
}
declaredSlots.set(name, wants)
},
routesFor: (area) => routes[area],
navFor: (area) => nav[area],
@@ -209,7 +220,7 @@ it('registers under exactly one module id, matching the manifest', () => {
assert.deepEqual([...owners], [manifest.id])
})
it('declares its own guild slots, for core to fill', () => {
it('declares its own guild slots, each naming the core contribution it wants', () => {
// The inverted direction (TEAMS.md Part 3). Teams are a core primitive with no
// core page: core owns the activity feed and the forum, this module owns the
// word "guild", so this module declares the places and core puts them in.
@@ -219,10 +230,15 @@ it('declares its own guild slots, for core to fill', () => {
// away this module's ability to place them separately on its own page — and it
// does place them separately, the control above the roster and the other two
// below it.
assert.deepEqual([...registered.declaredSlots], [
'uo.guild.detail',
'uo.guild.forum',
'uo.guild.header',
//
// The second argument is what actually gets core's content here. **Core offers
// a contribution and never names a slot** — the first cut of this reached only
// this module, because core filled the literal name `uo.guild.detail` and any
// other game's page went empty with no error.
assert.deepEqual([...registered.declaredSlots.entries()], [
['uo.guild.detail', 'team.activity'],
['uo.guild.forum', 'team.forum'],
['uo.guild.header', 'team.notify'],
])
})
@@ -230,7 +246,7 @@ it('every declared slot is rendered by the page that owns it', () => {
// A slot nothing renders is a slot core fills into the void. Asserted against
// the source rather than the chunk, since the chunk is minified.
const page = fs.readFileSync(path.resolve(HERE, '..', 'src', 'routes', 'public', 'Guild.jsx'), 'utf8')
for (const name of registered.declaredSlots) {
for (const name of registered.declaredSlots.keys()) {
assert.match(page, new RegExp(`name="${name.replace(/\./g, '\.')}"`))
}
})