# Build and publish the installable bundle: `module-uo-.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 DERIVED, and the declaration is a floor ────────────────── # # 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. # # 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. 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 # and create the release. 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 cancel-in-progress: false env: GITEA_HOST: gitea.whitlocktech.com REPO: RunicGateway/Module-uo jobs: release: 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 - uses: actions/setup-node@v4 with: node-version: 20 - 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 mkdir -p dist git fetch --tags --force >/dev/null 2>&1 || true 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:-}" 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() { # -> 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:-}" # 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. # # The list itself lives in `ci/bundle.json`, not here, because it has a # second reader: `server/scripts/checkBundle.js` runs in PR checks and asks # whether the list still covers everything `server/index.js` reaches. It # was hardcoded in this file until v1.0.0 shipped without `server/commands/` # — added by the Teams cutover, never added here — and the module died at # the register stage on the operator's box. One declaration, two readers, # so the next directory cannot go missing quietly. - 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 "$OUT" && mkdir -p "$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. for f in $(jq -r '.root[]' ci/bundle.json); do cp "$f" "$OUT/" done # The server half, minus what never runs inside core's process. mkdir -p "$OUT/server" for d in $(jq -r '.server[]' ci/bundle.json); do cp -r "server/$d" "$OUT/server/" done 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. 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, 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" "$VERSION" # ── And that it can actually LOAD ───────────────────────────────── # # The check above stats the paths `module.json` declares, which is a # real question but a shallow one: v1.0.0 passed it and was still # missing `server/commands/`, because a file reached only by a require # inside `register()` is named nowhere in `module.json`. This resolves # every relative require in the assembled tree and asserts the target is # in it — asked of the artifact, so it also catches a copy that half # failed or a list naming a path that has since moved. # # Run from the SOURCE tree (`server/scripts/` never ships) against the # assembled bundle. node server/scripts/checkBundle.js --bundle "$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" # 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' && steps.plan.outputs.reuse_tag != '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