# 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 DECLARED, not derived ──────────────────────────────────── # # Unlike RunicGateway/link and RunicGateway/installer, whose release engines read # conventional-commit subjects to compute the next version, this repo already has # one authoritative version — `module.json`'s, which is the version core records # in `installed_modules` and shows on the admin screen, and which sits beside the # `coreApi` range a bump usually has to be considered against. Two sources for one # number is how they drift, so: **a release happens when a merge to `main` leaves # `module.json` at a version that has no release yet.** Bumping the version is an # ordinary reviewed PR; publishing is this file's business. # # It follows that this workflow never writes to a branch — it tags and publishes, # nothing else — so `main` needs no push exception. That is the installer's model, # adopted here for the reason it was adopted there: `main` is protected, and a # release engine that has to push to it is a release engine that stops working the # day someone tightens the rule. # # Re-running on a version that is already released is a no-op, so a rerun after an # unrelated failure is safe. # # Prerequisites (Settings → Actions → Secrets on RunicGateway/Module-uo): # REGISTRY_TOKEN — Gitea access token with `write:repository`, to push the tag # and create the release. name: Release on: push: branches: [main] concurrency: group: release-module-uo cancel-in-progress: false env: GITEA_HOST: gitea.whitlocktech.com REPO: RunicGateway/Module-uo jobs: release: runs-on: ubuntu-latest timeout-minutes: 30 steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: actions/setup-node@v4 with: node-version: 20 - name: Decide whether this commit releases id: plan env: REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | set -euo pipefail VERSION="$(node -p "require('./module.json').version")" echo "module.json version: ${VERSION}" # Does a release already exist for this version? A 404 means no, a 200 # means yes, and anything else — a network failure, a bad token — is not # evidence of absence. Guessing "no" would publish over a good release, # so refuse instead. (The installer learned this one the expensive way.) HTTP="$(curl -s -o /dev/null -w '%{http_code}' \ -H "Authorization: token $(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" \ "https://${GITEA_HOST}/api/v1/repos/${REPO}/releases/tags/v${VERSION}" || echo 000)" case "$HTTP" in 404) RELEASE=true ;; 200) RELEASE=false; echo "v${VERSION} is already released — nothing to do." ;; *) echo "::error::Could not determine whether v${VERSION} is released (HTTP ${HTTP}). Refusing to guess."; exit 1 ;; esac echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT" echo "release=${RELEASE}" >> "$GITHUB_OUTPUT" # Before anything is built or tagged, so a repo without secrets fails # legibly rather than half-publishing: the tag push can succeed on the # credential actions/checkout left in the local git config while the # release API call 401s, leaving the repo tagged and unreleased. - name: Verify release credentials are configured if: ${{ steps.plan.outputs.release == 'true' }} env: REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | set -euo pipefail if [ -z "$(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" ]; then echo "::error::Missing Actions secret REGISTRY_TOKEN (needs write:repository) on ${REPO}." exit 1 fi echo "Release credentials present." - name: Build the client chunk if: ${{ steps.plan.outputs.release == 'true' }} run: | npm ci --prefix client npm run build --prefix client # `--omit=dev` and then PACKED: express, express-validator and swagger-autogen # are build- and test-time only — the shipped half is handed express on `ctx` # (MODULE_API.md §2.3) — and `ws` is the one runtime dependency. Node resolves # it by walking up from `modules/uo/server/`, which is why it ships inside the # tarball rather than being installed on the operator's box. - name: Install the shipped runtime dependency if: ${{ steps.plan.outputs.release == 'true' }} run: npm ci --omit=dev --prefix server # ── Assemble exactly what an operator's volume gets ────────────────── # # Stated as an INCLUDE list, not an exclude list. An exclude list ships # whatever it forgot: the day someone adds `server/tools/` with a scratch # credential in it, an exclude list packs it and nobody finds out. - name: Assemble the bundle if: ${{ steps.plan.outputs.release == 'true' }} run: | set -euo pipefail VERSION="${{ steps.plan.outputs.version }}" OUT="dist/module-uo-${VERSION}" rm -rf dist && mkdir -p "$OUT" # The manifest core reads, the two fragments, and the licence the code # is under — a bundle that ships GPL code without its licence is not # distributable. cp module.json swagger-fragment.json LICENSE.md README.md "$OUT/" # The server half, minus what never runs inside core's process. mkdir -p "$OUT/server" for d in boot.js core.js index.js config data db model router utils; do cp -r "server/$d" "$OUT/server/" done cp server/package.json "$OUT/server/" cp -r server/node_modules "$OUT/server/" # The client half is the BUILT chunk only. `client/src` is 5,000 lines # of source an operator has no use for and core will never read. mkdir -p "$OUT/client/dist" cp client/dist/entry.js "$OUT/client/dist/" # Prove the bundle is loadable before it is published: these are the # paths core's loader resolves out of module.json, and a release whose # entry point is missing fails on an operator's box with a # `startup_failed` row instead of here. node -e ' const fs = require("fs"), path = require("path"); const root = process.argv[1]; const m = JSON.parse(fs.readFileSync(path.join(root, "module.json"), "utf8")); for (const p of [m.server, m.schema, m.purge, m.client.entry, "swagger-fragment.json"]) { if (!fs.existsSync(path.join(root, p))) { console.error("bundle is missing " + p); process.exit(1); } } console.log("bundle contents check: ok"); ' "$OUT" tar -C dist -czf "dist/module-uo-${VERSION}.tar.gz" "module-uo-${VERSION}" rm -rf "$OUT" SHA="$(sha256sum "dist/module-uo-${VERSION}.tar.gz" | cut -d' ' -f1)" SIZE="$(stat -c%s "dist/module-uo-${VERSION}.tar.gz")" # The install manifest. Same shape as the installer's bundle JSON — a # per-asset sha256 fetched over HTTPS, no signatures — because that is # the model this project already has and a second one would be a second # thing to get right (MODULE_SYSTEM.md §1.11). jq -n \ --arg id "$(node -p "require('./module.json').id")" \ --arg name "$(node -p "require('./module.json').name")" \ --arg version "$VERSION" \ --arg coreApi "$(node -p "require('./module.json').coreApi")" \ --arg artifact "module-uo-${VERSION}.tar.gz" \ --arg sha256 "$SHA" \ --argjson size "$SIZE" \ --arg url "https://${GITEA_HOST}/${REPO}/releases/download/v${VERSION}/module-uo-${VERSION}.tar.gz" \ '{schema:1, id:$id, name:$name, version:$version, coreApi:$coreApi, artifact:$artifact, url:$url, sha256:$sha256, size:$size}' \ > "dist/module-uo-${VERSION}.json" echo "${SHA} module-uo-${VERSION}.tar.gz" > dist/SHA256SUMS cat "dist/module-uo-${VERSION}.json" - name: Write the changelog if: ${{ steps.plan.outputs.release == 'true' }} run: | set -euo pipefail VERSION="${{ steps.plan.outputs.version }}" LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)" RANGE="${LAST_TAG:+${LAST_TAG}..}HEAD" { echo "## module-uo v${VERSION}" echo echo "Install from the website's Admin → Modules screen, or unpack onto the" echo "modules volume as \`modules/uo/\`. Requires a core whose \`MODULE_API_VERSION\`" echo "satisfies \`$(node -p "require('./module.json').coreApi")\`." echo echo "### Changes" if [ -n "$LAST_TAG" ]; then echo "Since ${LAST_TAG}:"; fi git log --no-merges --format='- %s' $RANGE || true echo echo "### Verifying this download" echo echo "Releases are **unsigned** — the \`sha256\` in \`module-uo-${VERSION}.json\` is the" echo "trust anchor, and the website verifies it before unpacking." echo echo '```bash' echo "sha256sum -c SHA256SUMS --ignore-missing" echo '```' } > dist/CHANGELOG.md - name: Tag the release if: ${{ steps.plan.outputs.release == 'true' }} env: REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | set -euo pipefail TAG="${{ steps.plan.outputs.tag }}" git config user.name 'Runic Gateway CI' git config user.email 'ci@whitlocktech.net' git tag -a "$TAG" -m "module-uo ${TAG}" git push origin "$TAG" - name: Create the Gitea release and upload the bundle if: ${{ steps.plan.outputs.release == 'true' }} env: REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | set -euo pipefail TAG="${{ steps.plan.outputs.tag }}" VERSION="${{ steps.plan.outputs.version }}" API="https://${GITEA_HOST}/api/v1/repos/${REPO}" CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')" REL_ID="$(curl -sSf -X POST "${API}/releases" \ -H "Authorization: token ${CI_TOKEN}" \ -H "Content-Type: application/json" \ -d "$(jq -n --arg tag "$TAG" --arg body "$(cat dist/CHANGELOG.md)" \ '{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')" \ | jq -r '.id')" echo "Created release ${TAG} (id=${REL_ID})" for f in "module-uo-${VERSION}.tar.gz" "module-uo-${VERSION}.json" SHA256SUMS; do curl -sSf -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \ -H "Authorization: token ${CI_TOKEN}" \ -F "attachment=@dist/${f}" >/dev/null echo " uploaded ${f}" done