Compare commits
9 Commits
990a50b491
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 16cfbe194d | |||
| 8ec21086b5 | |||
| 637121bce3 | |||
| fe176920c5 | |||
| d98f0c1a3d | |||
| 1a13f680f5 | |||
| 7d0378842b | |||
| 466842c6f2 | |||
| 2d1d91e372 |
@@ -11,25 +11,52 @@
|
|||||||
# admin install downloads the tarball, verifies it against the `sha256` in the
|
# 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.
|
# 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
|
# This file used to release only when a merge to `main` left `module.json` at a
|
||||||
# conventional-commit subjects to compute the next version, this repo already has
|
# version with no release yet — the version DECLARED, never computed, on the
|
||||||
# one authoritative version — `module.json`'s, which is the version core records
|
# argument that two sources for one number is how they drift. That was true and
|
||||||
# in `installed_modules` and shows on the admin screen, and which sits beside the
|
# it was still the wrong trade: it makes every bundle cost a second reviewed PR
|
||||||
# `coreApi` range a bump usually has to be considered against. Two sources for one
|
# whose entire content is a number, and between 2026-08-12 and 2026-08-19 it cost
|
||||||
# number is how they drift, so: **a release happens when a merge to `main` leaves
|
# this repo *every* bundle — v0.3.0 was the only release while nine phases of
|
||||||
# `module.json` at a version that has no release yet.** Bumping the version is an
|
# Teams work landed, because nothing in them touched that line.
|
||||||
# ordinary reviewed PR; publishing is this file's business.
|
|
||||||
#
|
#
|
||||||
# It follows that this workflow never writes to a branch — it tags and publishes,
|
# So the engine `link` and `installer` already run is adopted here (MODULE_SYSTEM
|
||||||
# nothing else — so `main` needs no push exception. That is the installer's model,
|
# §2.7.1, decision 19 as amended):
|
||||||
# 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
|
# feat!: / BREAKING CHANGE -> major feat: -> minor fix|perf: -> patch
|
||||||
# day someone tightens the rule.
|
# 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
|
# 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):
|
# Prerequisites (Settings → Actions → Secrets on RunicGateway/Module-uo):
|
||||||
# REGISTRY_TOKEN — Gitea access token with `write:repository`, to push the tag
|
# REGISTRY_TOKEN — Gitea access token with `write:repository`, to push the tag
|
||||||
@@ -40,6 +67,16 @@ name: Release
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
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:
|
concurrency:
|
||||||
group: release-module-uo
|
group: release-module-uo
|
||||||
@@ -54,6 +91,8 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
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
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -62,32 +101,169 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
|
|
||||||
- name: Decide whether this commit releases
|
- name: Plan the release (version + changelog)
|
||||||
id: plan
|
id: plan
|
||||||
env:
|
env:
|
||||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
EVENT: ${{ github.event_name }}
|
||||||
|
IN_VERSION: ${{ github.event.inputs.version }}
|
||||||
|
IN_BUMP: ${{ github.event.inputs.bump }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
VERSION="$(node -p "require('./module.json').version")"
|
mkdir -p dist
|
||||||
echo "module.json version: ${VERSION}"
|
git fetch --tags --force >/dev/null 2>&1 || true
|
||||||
|
|
||||||
# Does a release already exist for this version? A 404 means no, a 200
|
DECLARED="$(node -p "require('./module.json').version")"
|
||||||
# means yes, and anything else — a network failure, a bad token — is not
|
LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)"
|
||||||
# evidence of absence. Guessing "no" would publish over a good release,
|
CURRENT="${LAST_TAG#v}"
|
||||||
# so refuse instead. (The installer learned this one the expensive way.)
|
RANGE="${LAST_TAG:+${LAST_TAG}..}HEAD"
|
||||||
HTTP="$(curl -s -o /dev/null -w '%{http_code}' \
|
echo "module.json declares ${DECLARED}; newest tag is ${LAST_TAG:-<none>}"
|
||||||
-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
|
SUBJECTS="$(git log --no-merges --format='%s' $RANGE || true)"
|
||||||
404) RELEASE=true ;;
|
BODIES="$(git log --no-merges --format='%B' $RANGE || 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"
|
BUMP=none
|
||||||
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
if echo "$BODIES" | grep -qE 'BREAKING[ -]CHANGE' ; then BUMP=major; fi
|
||||||
echo "release=${RELEASE}" >> "$GITHUB_OUTPUT"
|
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
|
# Before anything is built or tagged, so a repo without secrets fails
|
||||||
# legibly rather than half-publishing: the tag push can succeed on the
|
# legibly rather than half-publishing: the tag push can succeed on the
|
||||||
@@ -131,12 +307,19 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
VERSION="${{ steps.plan.outputs.version }}"
|
VERSION="${{ steps.plan.outputs.version }}"
|
||||||
OUT="dist/module-uo-${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
|
# The manifest core reads — with the RELEASED version written into it.
|
||||||
# is under — a bundle that ships GPL code without its licence is not
|
# The committed `module.json` is a floor, not a record of the last
|
||||||
# distributable.
|
# release (see the header), so copying it verbatim would ship a bundle
|
||||||
cp module.json swagger-fragment.json LICENSE.md README.md "$OUT/"
|
# 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.
|
# The server half, minus what never runs inside core's process.
|
||||||
mkdir -p "$OUT/server"
|
mkdir -p "$OUT/server"
|
||||||
@@ -154,16 +337,22 @@ jobs:
|
|||||||
# Prove the bundle is loadable before it is published: these are the
|
# 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
|
# 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
|
# 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 '
|
node -e '
|
||||||
const fs = require("fs"), path = require("path");
|
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"));
|
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"]) {
|
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); }
|
if (!fs.existsSync(path.join(root, p))) { console.error("bundle is missing " + p); process.exit(1); }
|
||||||
}
|
}
|
||||||
console.log("bundle contents check: ok");
|
console.log("bundle contents check: ok");
|
||||||
' "$OUT"
|
' "$OUT" "$VERSION"
|
||||||
|
|
||||||
tar -C dist -czf "dist/module-uo-${VERSION}.tar.gz" "module-uo-${VERSION}"
|
tar -C dist -czf "dist/module-uo-${VERSION}.tar.gz" "module-uo-${VERSION}"
|
||||||
rm -rf "$OUT"
|
rm -rf "$OUT"
|
||||||
@@ -191,36 +380,10 @@ jobs:
|
|||||||
echo "${SHA} module-uo-${VERSION}.tar.gz" > dist/SHA256SUMS
|
echo "${SHA} module-uo-${VERSION}.tar.gz" > dist/SHA256SUMS
|
||||||
cat "dist/module-uo-${VERSION}.json"
|
cat "dist/module-uo-${VERSION}.json"
|
||||||
|
|
||||||
- name: Write the changelog
|
# Skipped on a recovery run: the tag is already there and is the thing being
|
||||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
# published against.
|
||||||
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
|
- name: Tag the release
|
||||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
if: ${{ steps.plan.outputs.release == 'true' && steps.plan.outputs.reuse_tag != 'true' }}
|
||||||
env:
|
env:
|
||||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
19
README.md
19
README.md
@@ -134,11 +134,20 @@ website. Module delivery is website-side only.
|
|||||||
|
|
||||||
### Releases
|
### Releases
|
||||||
|
|
||||||
A merge to `main` that leaves `module.json` at a version with no release yet publishes one. The
|
**Every merge to `main` that carries a releasable commit publishes a bundle.** The next version is
|
||||||
version is **declared**, not computed from commit subjects: `module.json`'s version is what core
|
computed from conventional-commit subjects since the newest `v*` tag, as in `link` and `installer`:
|
||||||
records in `installed_modules` and shows on the admin screen, and it sits beside the `coreApi` range
|
`feat!:` or `BREAKING CHANGE` is a major, `feat:` a minor, `fix:` or `perf:` a patch, and a `main`
|
||||||
a bump usually has to be weighed against — two sources for one number is how they drift. Bumping it
|
that gained none of those cuts no release. The number that ships is the **tag**, and CI writes it
|
||||||
is an ordinary reviewed PR.
|
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:
|
Each release carries:
|
||||||
|
|
||||||
|
|||||||
@@ -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.",
|
"$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",
|
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
|
||||||
"ref": "7ed2ac99838f4bd64e1df324fe4961673648b0e6",
|
"ref": "963d734dcc09580a7d8bb676370b4faf9b8727b2",
|
||||||
"refName": "edge @ Teams phase 3 (website#152)"
|
"refName": "main @ the Teams cutover (website#161)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// 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
|
// Declared under this module's own namespace, which core enforces. The second
|
||||||
// applied after every module chunk has evaluated, so declaring it here is early
|
// argument is what gets core's content into the place: **core offers a
|
||||||
// enough; on a core that knows nothing of Teams it simply stays empty.
|
// CONTRIBUTION and never names a slot**, so this module says where each one goes
|
||||||
registry.declareModuleSlot(ID, 'uo.guild.detail')
|
// 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
|
// 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
|
// 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.
|
// 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
|
// Neither knows the other exists, and a core that fills only one leaves the other
|
||||||
// empty.
|
// 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
|
// 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:
|
// 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
|
// 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
|
// rather than after its content. Core resolves whether this viewer is in the
|
||||||
// Team at all — this module neither knows nor asks.
|
// 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
|
// `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
|
// ever served, so there is nothing to re-check here. It is logged because a
|
||||||
|
|||||||
@@ -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.
|
// nothing here renders, so a named stub is enough to be imported and passed on.
|
||||||
const stub = (name) => Object.assign(() => null, { displayName: name })
|
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() {
|
function fakeRg() {
|
||||||
const routes = { public: [], admin: [], player: [] }
|
const routes = { public: [], admin: [], player: [] }
|
||||||
const nav = { public: [], admin: [], player: [] }
|
const nav = { public: [], admin: [], player: [] }
|
||||||
const providers = new Map()
|
const providers = new Map()
|
||||||
const extensions = new Map()
|
const extensions = new Map()
|
||||||
const declaredSlots = new Set()
|
const declaredSlots = new Map()
|
||||||
return {
|
return {
|
||||||
version: '1.3.0',
|
version: '1.3.0',
|
||||||
react,
|
react,
|
||||||
@@ -74,13 +80,18 @@ function fakeRg() {
|
|||||||
extensions.set(slot, { id, Component })
|
extensions.set(slot, { id, Component })
|
||||||
},
|
},
|
||||||
// The INVERTED direction (core API 1.6.0): this module declares a place on
|
// 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
|
// its OWN page and core fills it. Core enforces the namespace and the
|
||||||
// does too — a chunk that declared an unnamespaced slot would pass here and
|
// contribution name, so the fake does too — a chunk that declared an
|
||||||
// throw in a browser.
|
// unnamespaced slot, or asked for a contribution core does not offer, would
|
||||||
declareModuleSlot(id, name) {
|
// 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 (!name.startsWith(`${id}.`)) throw new Error(`declareModuleSlot: "${name}" must be namespaced "${id}."`)
|
||||||
if (declaredSlots.has(name)) throw new Error(`extension slot "${name}" already declared`)
|
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],
|
routesFor: (area) => routes[area],
|
||||||
navFor: (area) => nav[area],
|
navFor: (area) => nav[area],
|
||||||
@@ -209,7 +220,7 @@ it('registers under exactly one module id, matching the manifest', () => {
|
|||||||
assert.deepEqual([...owners], [manifest.id])
|
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
|
// 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
|
// 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.
|
// 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
|
// 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
|
// does place them separately, the control above the roster and the other two
|
||||||
// below it.
|
// below it.
|
||||||
assert.deepEqual([...registered.declaredSlots], [
|
//
|
||||||
'uo.guild.detail',
|
// The second argument is what actually gets core's content here. **Core offers
|
||||||
'uo.guild.forum',
|
// a contribution and never names a slot** — the first cut of this reached only
|
||||||
'uo.guild.header',
|
// 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
|
// 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.
|
// the source rather than the chunk, since the chunk is minified.
|
||||||
const page = fs.readFileSync(path.resolve(HERE, '..', 'src', 'routes', 'public', 'Guild.jsx'), 'utf8')
|
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, '\.')}"`))
|
assert.match(page, new RegExp(`name="${name.replace(/\./g, '\.')}"`))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
201
server/commands/guild.command.js
Normal file
201
server/commands/guild.command.js
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
// ── `/guild` — the first chat command through the module contract ──────────
|
||||||
|
//
|
||||||
|
// Registered with `api.registerSlashCommands` (MODULE_API 1.6.0, TEAMS.md §7.1).
|
||||||
|
// The definition and this handler live here; the bot pulls the definition over
|
||||||
|
// the app's internal API and runs nothing of ours. Nothing in this file knows
|
||||||
|
// what Discord is — it is handed an `actor` and returns an envelope, and the
|
||||||
|
// same handler would serve a second platform unchanged.
|
||||||
|
//
|
||||||
|
// **Why `/guild` and not `/team`.** Teams are core's primitive and "guild" is
|
||||||
|
// this module's word for one; core does not own the word, so it does not publish
|
||||||
|
// the noun in a channel either. That is the same correction that deleted core's
|
||||||
|
// Team pages in phase 3, applied to the chat surface.
|
||||||
|
//
|
||||||
|
// **The audience rungs are enforced here, exactly as they are on the website.**
|
||||||
|
// A shard whose `guilds` feature is gated to staff does not become public
|
||||||
|
// because the question arrived over Discord — this handler resolves the caller's
|
||||||
|
// rung through the same `shardVisibility` config the routes use. It is the one
|
||||||
|
// piece of this file that is a security boundary rather than presentation.
|
||||||
|
const core = require('../core')
|
||||||
|
const db = require('../model/teamProvider/teamProvider.db')
|
||||||
|
const provider = require('../model/teamProvider/teamProvider.model')
|
||||||
|
const visibility = require('../utils/shardVisibility')
|
||||||
|
|
||||||
|
const log = core.logger('guild-command')
|
||||||
|
|
||||||
|
// How many guilds the no-argument form lists. A Discord embed takes 25 fields;
|
||||||
|
// ten is a summary a person reads rather than a table they scroll past.
|
||||||
|
const LIST_LIMIT = 10
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the caller sits on this module's ladder.
|
||||||
|
*
|
||||||
|
* The same resolution `projectRoster` does, and it is duplicated in shape rather
|
||||||
|
* than shared because the inputs differ: that one is handed a viewer core
|
||||||
|
* described, this one an actor. Both end at `viewerLevel`, and both answer
|
||||||
|
* `anonymous` DIRECTLY for a caller with no site account — handing `viewerLevel`
|
||||||
|
* a synthetic empty request makes it fall through to `auth.getUserFromRequest`,
|
||||||
|
* which expects real cookies and throws (the phase 3 bug).
|
||||||
|
*/
|
||||||
|
async function levelFor(actor) {
|
||||||
|
if (!actor || !actor.userId) return 'anonymous'
|
||||||
|
return visibility.viewerLevel({ user: { id: actor.userId, role: actor.role } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// The nudge §9 answer 5 asks for, and only when it is TRUE.
|
||||||
|
//
|
||||||
|
// **Linking reaches exactly two rungs and no further.** Signing in gets a caller
|
||||||
|
// to `logged_in` and linking a game account to `player`; `staff` and `admin` are
|
||||||
|
// roles an operator grants and no amount of linking will earn. So a shard that
|
||||||
|
// gates guilds to staff refuses an unlinked caller WITHOUT the invitation —
|
||||||
|
// telling them to link would be telling them to do something that changes
|
||||||
|
// nothing, which is worse than saying no.
|
||||||
|
//
|
||||||
|
// The live walk found this: gated to `staff`, the refusal still read "this shard
|
||||||
|
// shows guild information to linked players".
|
||||||
|
const LINKING_REACHES = new Set(['logged_in', 'player'])
|
||||||
|
|
||||||
|
function linkPrompt(actor, audience) {
|
||||||
|
if (actor.isLinked) return null
|
||||||
|
if (!LINKING_REACHES.has(audience)) return null
|
||||||
|
return 'Link your account on the site to see more — this shard shows guild information to linked players.'
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageUrl = (externalId) =>
|
||||||
|
`${core.baseUrl}${provider.pageUrlTemplate.replace('{externalId}', externalId)}`
|
||||||
|
|
||||||
|
// Match on abbreviation first, then an exact name, then a unique prefix. Players
|
||||||
|
// type the abbreviation — it is what appears over a character's head — and a
|
||||||
|
// wrong-guild answer is worse than "say which one".
|
||||||
|
function findByName(rows, wanted) {
|
||||||
|
const needle = wanted.trim().toLowerCase()
|
||||||
|
const byAbbr = rows.filter((r) => (r.abbr || '').toLowerCase() === needle)
|
||||||
|
if (byAbbr.length === 1) return { guild: byAbbr[0] }
|
||||||
|
const exact = rows.filter((r) => r.name.toLowerCase() === needle)
|
||||||
|
if (exact.length === 1) return { guild: exact[0] }
|
||||||
|
const partial = rows.filter((r) => r.name.toLowerCase().includes(needle))
|
||||||
|
if (partial.length === 1) return { guild: partial[0] }
|
||||||
|
if (partial.length > 1) return { ambiguous: partial.slice(0, LIST_LIMIT) }
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The counts for one guild, from the roster rather than the board's assertions. */
|
||||||
|
async function summarise(guild) {
|
||||||
|
const members = await db.listGuildMembers(guild.id)
|
||||||
|
const leaders = members
|
||||||
|
.filter((m) => Number(m.rank) >= db.LEADER_RANK)
|
||||||
|
.map((m) => m.name)
|
||||||
|
// The board's founder-leader is folded in as a floor, the same way
|
||||||
|
// getTeamLeaders does it: it arrives on a different frame, and a shard whose
|
||||||
|
// roster predates the rank amendment has no other leadership signal.
|
||||||
|
if (guild.leader_name && !leaders.includes(guild.leader_name)) leaders.push(guild.leader_name)
|
||||||
|
|
||||||
|
return {
|
||||||
|
// `members`/`online` are the BOARD's counts, which is what the shard asserts;
|
||||||
|
// the roster is what it enumerated, and the two legitimately disagree for the
|
||||||
|
// moment between a membership change and the sweep that reports it. The
|
||||||
|
// assertion is the more current of the two, so it is what is shown.
|
||||||
|
members: guild.members,
|
||||||
|
online: guild.online,
|
||||||
|
linked: members.filter((m) => provider.resolveUserId(m) !== null).length,
|
||||||
|
leaders,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detail(guild, actor, audience) {
|
||||||
|
const counts = await summarise(guild)
|
||||||
|
const fields = [
|
||||||
|
{ name: 'Members', value: String(counts.members ?? '—'), inline: true },
|
||||||
|
{ name: 'Online', value: String(counts.online ?? 0), inline: true },
|
||||||
|
{ name: 'Linked accounts', value: String(counts.linked), inline: true },
|
||||||
|
]
|
||||||
|
if (counts.leaders.length) {
|
||||||
|
fields.push({ name: 'Leaders', value: counts.leaders.join(', ') })
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: guild.abbr ? `${guild.name} [${guild.abbr}]` : guild.name,
|
||||||
|
text: guild.alliance ? `Alliance: ${guild.alliance}` : undefined,
|
||||||
|
fields,
|
||||||
|
url: pageUrl(guild.id),
|
||||||
|
notice: linkPrompt(actor, audience),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `/guild [name]` — one guild's summary, or the shard's largest guilds.
|
||||||
|
*
|
||||||
|
* Never throws for an ordinary miss: "no such guild" and "the shard is offline"
|
||||||
|
* are answers, and letting either become an exception would turn a routine
|
||||||
|
* question into "that command failed" with nothing an operator could act on.
|
||||||
|
*/
|
||||||
|
async function handler({ options, actor }) {
|
||||||
|
const config = await visibility.getConfig()
|
||||||
|
const feature = config.guilds
|
||||||
|
|
||||||
|
// An admin turned guilds off. The switch means "this shard does not publish
|
||||||
|
// guild data" — over any surface, to anyone, staff included.
|
||||||
|
if (!feature || !feature.enabled) {
|
||||||
|
return { text: 'This shard does not publish guild information.', ephemeral: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const level = await levelFor(actor)
|
||||||
|
if (!visibility.meets(level, feature.audience)) {
|
||||||
|
return {
|
||||||
|
text: 'Guild information on this shard is not shown to your account.',
|
||||||
|
ephemeral: true,
|
||||||
|
notice: linkPrompt(actor, feature.audience),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The provider's own staleness guard, asked before any board read: an
|
||||||
|
// unreachable sidecar means the board is a snapshot of unknown age, and
|
||||||
|
// reporting it as current here would contradict what every other surface says.
|
||||||
|
const ready = await provider.boardIsCurrent()
|
||||||
|
if (!ready.ok) {
|
||||||
|
log.info('guild command answered offline', { reason: ready.reason })
|
||||||
|
return { text: 'The shard is not connected right now, so guild information may be out of date.', ephemeral: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db.listGuilds()
|
||||||
|
if (!rows.length) return { text: 'No guilds are on the board yet.', ephemeral: true }
|
||||||
|
|
||||||
|
const wanted = options && typeof options.name === 'string' ? options.name : null
|
||||||
|
if (!wanted) {
|
||||||
|
const top = [...rows].sort((a, b) => (b.members || 0) - (a.members || 0)).slice(0, LIST_LIMIT)
|
||||||
|
return {
|
||||||
|
// Not "Guilds on <host>": `ctx.site` carries a base URL and no brand name,
|
||||||
|
// so naming the deployment here can only mean printing its hostname into
|
||||||
|
// an embed title, which is noise on a shard's own Discord server.
|
||||||
|
title: 'Guilds on this shard',
|
||||||
|
fields: top.map((g) => ({
|
||||||
|
name: g.abbr ? `${g.name} [${g.abbr}]` : g.name,
|
||||||
|
value: `${g.members || 0} members · ${g.online || 0} online`,
|
||||||
|
inline: true,
|
||||||
|
})),
|
||||||
|
notice: linkPrompt(actor, feature.audience),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { guild, ambiguous } = findByName(rows, wanted)
|
||||||
|
if (ambiguous) {
|
||||||
|
return {
|
||||||
|
text: `Several guilds match “${wanted}”: ${ambiguous.map((g) => g.name).join(', ')}`,
|
||||||
|
ephemeral: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!guild) return { text: `No guild matches “${wanted}”.`, ephemeral: true }
|
||||||
|
return detail(guild, actor, feature.audience)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name: 'guild',
|
||||||
|
description: 'Show a guild on this shard — members, who is online, and its leaders',
|
||||||
|
options: [
|
||||||
|
{ name: 'name', type: 'string', description: 'Guild name or abbreviation', required: false },
|
||||||
|
],
|
||||||
|
// Everyone, deliberately. The gate that matters is the shard's own audience
|
||||||
|
// rung, resolved inside the handler — `access: 'linked'` would hide the command
|
||||||
|
// from exactly the unlinked members §9 answer 5 wants to invite to link.
|
||||||
|
access: 'everyone',
|
||||||
|
handler,
|
||||||
|
}
|
||||||
@@ -46,6 +46,7 @@ module.exports = function register(ctx, api) {
|
|||||||
const shardStreams = require('./config/shardStreams')
|
const shardStreams = require('./config/shardStreams')
|
||||||
const townCrierLeg = require('./utils/shardAnnounce')
|
const townCrierLeg = require('./utils/shardAnnounce')
|
||||||
const teamProvider = require('./model/teamProvider/teamProvider.model')
|
const teamProvider = require('./model/teamProvider/teamProvider.model')
|
||||||
|
const guildCommand = require('./commands/guild.command')
|
||||||
const boot = require('./boot')
|
const boot = require('./boot')
|
||||||
/* eslint-enable global-require */
|
/* eslint-enable global-require */
|
||||||
|
|
||||||
@@ -96,6 +97,16 @@ module.exports = function register(ctx, api) {
|
|||||||
// the database, and registration must not.
|
// the database, and registration must not.
|
||||||
api.registerTeamProvider(teamProvider)
|
api.registerTeamProvider(teamProvider)
|
||||||
|
|
||||||
|
// `/guild` — the chat surface for the same guilds (MODULE_API 1.6.0, TEAMS.md
|
||||||
|
// §7.1). The definition travels to the bot; the handler stays here and runs in
|
||||||
|
// the website process, because the bot container has no `modules` volume and
|
||||||
|
// cannot load a line of this module's code.
|
||||||
|
//
|
||||||
|
// Core registers NO commands of its own. "Guild" is this module's word — core
|
||||||
|
// does not own it on a page (phase 3) and does not publish it in a channel
|
||||||
|
// either.
|
||||||
|
api.registerSlashCommands([guildCommand])
|
||||||
|
|
||||||
api.onBoot(boot.onBoot)
|
api.onBoot(boot.onBoot)
|
||||||
api.onShutdown(boot.onShutdown)
|
api.onShutdown(boot.onShutdown)
|
||||||
|
|
||||||
|
|||||||
@@ -331,4 +331,9 @@ async function projectRoster(externalId, members, viewer) {
|
|||||||
// data and not a callback.
|
// data and not a callback.
|
||||||
const pageUrlTemplate = '/uo/guilds/{externalId}'
|
const pageUrlTemplate = '/uo/guilds/{externalId}'
|
||||||
|
|
||||||
module.exports = { getTeams, getTeamMembers, getTeamLeaders, projectRoster, boardIsCurrent, pageUrlTemplate }
|
// `resolveUserId` is exported for the `/guild` chat command, which counts linked
|
||||||
|
// members and must decide "linked" by the same rule the roster does — a second
|
||||||
|
// copy of that two-source check is a copy that drifts.
|
||||||
|
module.exports = {
|
||||||
|
getTeams, getTeamMembers, getTeamLeaders, projectRoster, boardIsCurrent, pageUrlTemplate, resolveUserId,
|
||||||
|
}
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ function fakeApi() {
|
|||||||
streams: null,
|
streams: null,
|
||||||
legs: [],
|
legs: [],
|
||||||
teamProvider: null,
|
teamProvider: null,
|
||||||
|
slashCommands: [],
|
||||||
hooks: {},
|
hooks: {},
|
||||||
}
|
}
|
||||||
const called = new Set()
|
const called = new Set()
|
||||||
@@ -112,6 +113,10 @@ function fakeApi() {
|
|||||||
// deployment — a second registration is a collision there, so it has to be
|
// deployment — a second registration is a collision there, so it has to be
|
||||||
// one here too, or this suite would pass a shape core rejects at load.
|
// one here too, or this suite would pass a shape core rejects at load.
|
||||||
registerTeamProvider(provider) { once('registerTeamProvider'); record.teamProvider = provider },
|
registerTeamProvider(provider) { once('registerTeamProvider'); record.teamProvider = provider },
|
||||||
|
// MODULE_API 1.6.0, live since phase 7. `once` for the same reason core
|
||||||
|
// takes it: a second call is a module changing its mind halfway through
|
||||||
|
// register(), which core rejects.
|
||||||
|
registerSlashCommands(commands) { once('registerSlashCommands'); record.slashCommands = commands },
|
||||||
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
|
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
|
||||||
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
|
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
|
||||||
}
|
}
|
||||||
|
|||||||
148
server/test/guildCommand.test.js
Normal file
148
server/test/guildCommand.test.js
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
// `/guild` — the chat command registered through `api.registerSlashCommands`
|
||||||
|
// (TEAMS.md §7.1, MODULE_API 1.6.0).
|
||||||
|
//
|
||||||
|
// The properties worth pinning are all about the ANSWER being the same answer
|
||||||
|
// the website gives, because that is the whole risk of a second surface: the
|
||||||
|
// audience rungs are re-resolved here rather than assumed, the shard's own
|
||||||
|
// offline guard is honoured, and the link prompt appears only when linking would
|
||||||
|
// actually change what the caller is told.
|
||||||
|
|
||||||
|
const { test, afterEach } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const command = require('../commands/guild.command')
|
||||||
|
const db = require('../model/teamProvider/teamProvider.db')
|
||||||
|
const provider = require('../model/teamProvider/teamProvider.model')
|
||||||
|
const visibility = require('../utils/shardVisibility')
|
||||||
|
|
||||||
|
const originals = {
|
||||||
|
getConfig: visibility.getConfig,
|
||||||
|
viewerLevel: visibility.viewerLevel,
|
||||||
|
boardIsCurrent: provider.boardIsCurrent,
|
||||||
|
listGuilds: db.listGuilds,
|
||||||
|
listGuildMembers: db.listGuildMembers,
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
visibility.getConfig = originals.getConfig
|
||||||
|
visibility.viewerLevel = originals.viewerLevel
|
||||||
|
provider.boardIsCurrent = originals.boardIsCurrent
|
||||||
|
db.listGuilds = originals.listGuilds
|
||||||
|
db.listGuildMembers = originals.listGuildMembers
|
||||||
|
})
|
||||||
|
|
||||||
|
const GUILDS = [
|
||||||
|
{ id: 7, name: 'Knights of the Codex', abbr: 'KOC', alliance: 'The Accord', members: 12, online: 3, leader_name: 'Dain' },
|
||||||
|
{ id: 9, name: 'Knights Hospitaller', abbr: 'KH', alliance: null, members: 4, online: 0, leader_name: null },
|
||||||
|
]
|
||||||
|
|
||||||
|
const MEMBERS = [
|
||||||
|
{ serial: 1, name: 'Dain', rank: 4, web_id: '31', linked_user_id: null },
|
||||||
|
{ serial: 2, name: 'Elowen', rank: 4, web_id: null, linked_user_id: 44 },
|
||||||
|
{ serial: 3, name: 'Wat', rank: 2, web_id: null, linked_user_id: null },
|
||||||
|
]
|
||||||
|
|
||||||
|
function stub({ audience = 'anonymous', enabled = true, level = 'anonymous', current = true } = {}) {
|
||||||
|
visibility.getConfig = async () => ({ guilds: { enabled, audience } })
|
||||||
|
visibility.viewerLevel = async () => level
|
||||||
|
provider.boardIsCurrent = async () => (current ? { ok: true } : { ok: false, reason: 'socket down' })
|
||||||
|
db.listGuilds = async () => GUILDS
|
||||||
|
db.listGuildMembers = async () => MEMBERS
|
||||||
|
}
|
||||||
|
|
||||||
|
const anonymous = { platform: 'discord', platformUserId: '1', userId: null, role: null, isLinked: false, isStaff: false }
|
||||||
|
const linked = { platform: 'discord', platformUserId: '2', userId: 31, role: 'player', isLinked: true, isStaff: false }
|
||||||
|
|
||||||
|
test('the definition stays inside the option schema §7.1.1 allows', () => {
|
||||||
|
assert.equal(command.name, 'guild')
|
||||||
|
assert.equal(command.access, 'everyone')
|
||||||
|
for (const option of command.options) {
|
||||||
|
assert.ok(['string', 'integer', 'boolean', 'user'].includes(option.type))
|
||||||
|
assert.ok(option.description.length <= 100)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the guilds feature being off withholds everything, staff included', async () => {
|
||||||
|
stub({ enabled: false, level: 'admin' })
|
||||||
|
const res = await command.handler({ options: {}, actor: { ...linked, role: 'admin', isStaff: true } })
|
||||||
|
assert.match(res.text, /does not publish guild information/)
|
||||||
|
assert.equal(res.ephemeral, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// The reason this command is not a thin wrapper over a public route: a rung
|
||||||
|
// below the feature's audience must be refused HERE, or a shard that gates
|
||||||
|
// guilds to staff would publish them to a Discord channel.
|
||||||
|
test('a caller below the feature audience is refused', async () => {
|
||||||
|
stub({ audience: 'staff', level: 'anonymous' })
|
||||||
|
const res = await command.handler({ options: {}, actor: anonymous })
|
||||||
|
assert.match(res.text, /not shown to your account/)
|
||||||
|
assert.equal(res.ephemeral, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an unlinked caller is invited to link — but only when linking would change the answer', async () => {
|
||||||
|
stub({ audience: 'player', level: 'anonymous' })
|
||||||
|
const gated = await command.handler({ options: {}, actor: anonymous })
|
||||||
|
assert.match(gated.notice, /Link your account/)
|
||||||
|
|
||||||
|
// Public guilds: there is nothing more to see, so there is nothing to prompt.
|
||||||
|
stub({ audience: 'anonymous', level: 'anonymous' })
|
||||||
|
const open = await command.handler({ options: {}, actor: anonymous })
|
||||||
|
assert.equal(open.notice, null)
|
||||||
|
|
||||||
|
// Gated to staff: linking reaches `player` and stops there, so the invitation
|
||||||
|
// would be an instruction to do something that changes nothing. Found on the
|
||||||
|
// live rig, where a staff-gated shard still offered it.
|
||||||
|
stub({ audience: 'staff', level: 'anonymous' })
|
||||||
|
const unreachable = await command.handler({ options: {}, actor: anonymous })
|
||||||
|
assert.match(unreachable.text, /not shown to your account/)
|
||||||
|
assert.equal(unreachable.notice, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a stale board answers offline rather than reporting what it still holds', async () => {
|
||||||
|
stub({ current: false })
|
||||||
|
const res = await command.handler({ options: {}, actor: anonymous })
|
||||||
|
assert.match(res.text, /not connected right now/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('no argument lists the largest guilds', async () => {
|
||||||
|
stub()
|
||||||
|
const res = await command.handler({ options: {}, actor: anonymous })
|
||||||
|
assert.equal(res.title, 'Guilds on this shard')
|
||||||
|
assert.equal(res.fields.length, 2)
|
||||||
|
assert.match(res.fields[0].name, /Knights of the Codex/)
|
||||||
|
assert.match(res.fields[0].value, /12 members · 3 online/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a name resolves by abbreviation, then exactly, then by unique prefix', async () => {
|
||||||
|
stub()
|
||||||
|
const byAbbr = await command.handler({ options: { name: 'koc' }, actor: anonymous })
|
||||||
|
assert.match(byAbbr.title, /Knights of the Codex/)
|
||||||
|
|
||||||
|
const exact = await command.handler({ options: { name: 'Knights Hospitaller' }, actor: anonymous })
|
||||||
|
assert.match(exact.title, /Hospitaller/)
|
||||||
|
|
||||||
|
// "knights" hits both, and answering with either would be worse than asking.
|
||||||
|
const ambiguous = await command.handler({ options: { name: 'knights' }, actor: anonymous })
|
||||||
|
assert.match(ambiguous.text, /Several guilds match/)
|
||||||
|
assert.equal(ambiguous.ephemeral, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a miss is an answer, not a failure', async () => {
|
||||||
|
stub()
|
||||||
|
const res = await command.handler({ options: { name: 'nobody' }, actor: anonymous })
|
||||||
|
assert.match(res.text, /No guild matches/)
|
||||||
|
})
|
||||||
|
|
||||||
|
// `linked` counts BOTH sources the roster uses — the shard's asserted web id and
|
||||||
|
// the link table — because that is what "linked" means everywhere else here.
|
||||||
|
test('the detail carries the counts, the leaders and a link to the module page', async () => {
|
||||||
|
stub({ level: 'player' })
|
||||||
|
const res = await command.handler({ options: { name: 'KOC' }, actor: linked })
|
||||||
|
const field = (name) => res.fields.find((f) => f.name === name).value
|
||||||
|
assert.equal(field('Members'), '12')
|
||||||
|
assert.equal(field('Online'), '3')
|
||||||
|
assert.equal(field('Linked accounts'), '2')
|
||||||
|
assert.equal(field('Leaders'), 'Dain, Elowen')
|
||||||
|
assert.match(res.url, /\/uo\/guilds\/7$/)
|
||||||
|
assert.equal(res.notice, null)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user