diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..365b486 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,337 @@ +# Automated release for the deployable ServUO overlay. +# +# Trigger: every push to `main` (i.e. every merged PR). +# +# Why this exists: the Runic Gateway installer deploys the plugin from a release +# tarball, not from git — the shard host gets no git and no Gitea credentials +# (docs/installer/PLAN.md §1, §5 Phase 0.1). Until this workflow, `link` was the +# only repo that published releases, so there was nothing for the installer to +# fetch. This is Phase 0 item 1. +# +# Flow (two conceptual halves, kept separate on purpose): +# +# ┌── RELEASE ENGINE (language-agnostic) ─────────────────────────────┐ +# │ reads: latest v* git tag + conventional-commit subjects │ +# │ produces: next version, changelog, and (at the end) the release │ +# └───────────────────────────────────────────────────────────────────┘ +# ┌── OVERLAY ADAPTER (the only repo-specific part) ──────────────────┐ +# │ consumes: the version │ +# │ produces: runicgateway-overlay-.tar.gz + SHA256SUMS │ +# └───────────────────────────────────────────────────────────────────┘ +# +# The engine is `link/.gitea/workflows/release.yml`'s, reused as its own header +# anticipated — the plan and release steps consume only {version, changelog, +# artifacts} and know nothing about what is inside the artifacts. +# +# ── Three differences from link's copy, all forced by this repo ────────────── +# +# 1. NO BUILD. The plugin ships as C# source and ServUO compiles it at boot; it +# needs ServUO reference assemblies, so there is no way to compile it here. +# The build gates are replaced by the structural gates below, which is the +# most this repo can honestly assert about an artifact. +# +# 2. NO BUMP COMMIT, and so no push to `main`. link has to write the version +# into Cargo.toml because the binary embeds it; a tarball embeds nothing but +# the manifest.json this job generates, so the git tag IS the version. That +# removes a failure mode outright: this workflow never needs `main` to accept +# a direct push, so no branch-protection exception is required for it. +# +# 3. A MANIFEST. The tarball carries manifest.json — version, commit, declared +# protocol version, ServUO compatibility, and a SHA256 for every file. The +# installer needs it because the plugin announces no version on the wire and +# none is queryable before ServUO boots (PLAN.md §2.6): the manifest is the +# only thing that lets the bundle CI verify sidecar/overlay protocol +# agreement BEFORE an operator installs the pair (PLAN.md §7.1, gate 1). +# +# Version bump (conventional commits since the last v* tag): +# feat!: / BREAKING CHANGE -> major feat: -> minor fix|perf: -> patch +# nothing releasable -> no release is cut (a docs:/chore:-only merge +# deliberately does NOT cut one — PLAN.md §7.3) +# (first ever run, no tag) -> releases SEED_VERSION below +# +# Prerequisites (Settings → Actions → Secrets on RunicGateway/servuo-plugins): +# REGISTRY_TOKEN — Gitea access token with `write:repository`, to push the +# tag and create the release. +# REGISTRY_USER — the Gitea username that token belongs to. +# +# TODO (Phase 0 item 3): once the installer repo's bundle workflow exists, append +# a final step here that POSTs to its workflow-dispatch endpoint, so a new +# overlay release recomposes the bundle immediately instead of waiting for the +# nightly cron (PLAN.md §7.2). Deliberately absent until there is something to +# dispatch — a step that 404s every release is worse than no step. + +name: Release overlay + +on: + push: + branches: [main] + workflow_dispatch: {} + +concurrency: + group: release-overlay + cancel-in-progress: false + +env: + GITEA_HOST: gitea.whitlocktech.com + REPO: RunicGateway/servuo-plugins + # Artifact naming per PLAN.md §3. + ARTIFACT: runicgateway-overlay + # Used only for the very first release, when no v* tag exists yet. Matches the + # house style set by link (pre-1.0; the release version is independent of the + # protocol version, which lives in overlay.toml). + SEED_VERSION: "0.1.0" + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Check out full history (need tags + commit log for the bump) + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # ── RELEASE ENGINE: decide the next version + changelog ────────────── + - name: Plan the release (version + changelog) + id: plan + run: | + set -euo pipefail + mkdir -p dist + git fetch --tags --force >/dev/null 2>&1 || true + + LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)" + if [ -n "$LAST_TAG" ]; then RANGE="${LAST_TAG}..HEAD"; else RANGE="HEAD"; fi + + 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 + } + + RELEASE=true + if [ -z "$LAST_TAG" ]; then + VERSION="$SEED_VERSION" # first release: seed + elif [ "$BUMP" = none ]; then + RELEASE=false # no feat/fix/breaking since last tag + VERSION="${LAST_TAG#v}" + else + VERSION="$(bump "${LAST_TAG#v}" "$BUMP")" + fi + + if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then + echo "Tag v${VERSION} already exists — nothing to release." + RELEASE=false + fi + + { + echo "## ${ARTIFACT} v${VERSION}" + echo + FEATS="$(echo "$SUBJECTS" | grep -E '^feat' || true)" + FIXES="$(echo "$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 "$LAST_TAG" ]; then echo "Since ${LAST_TAG}:"; fi + echo "$SUBJECTS" | sed 's/^/- /' + } > dist/CHANGELOG.md + + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT" + echo "release=${RELEASE}" >> "$GITHUB_OUTPUT" + echo "bump=${BUMP}" >> "$GITHUB_OUTPUT" + echo "==> release=${RELEASE} version=${VERSION} bump=${BUMP} last_tag=${LAST_TAG:-}" + + - name: Install jq + if: ${{ steps.plan.outputs.release == 'true' }} + run: | + set -euo pipefail + command -v jq >/dev/null 2>&1 && exit 0 + SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo" + $SUDO apt-get update -qq + $SUDO apt-get install -y -qq --no-install-recommends jq + + # ── OVERLAY ADAPTER: gates ─────────────────────────────────────────── + # There is no compiler to run, so these assert the things that CAN be + # checked without a ServUO tree — and each one has actually been a way to + # ship a broken overlay: + # + # • overlay/ mirrors the server root; if Bridge.cfg or the Bridge scripts + # go missing the deploy silently no-ops (PLAN.md §2.1). + # • overlay/Scripts/Scripts.csproj is Phase 0 of the plugin itself — it + # overwrites a stock file to fix ServUO's silent script-build bug. An + # overlay shipped without it installs code that never compiles, and + # ServUO reports success anyway. + # • a malformed .patch is invisible until an operator runs the patch tier + # on their live shard. `git apply --stat` parses the diff without + # needing the target files present. + # • each patch's companion .cs must exist, since it references symbols + # the patch introduces and is meaningless without it (PLAN.md §2.2). + - name: Validate the overlay and patch tier + if: ${{ steps.plan.outputs.release == 'true' }} + run: | + set -euo pipefail + fail() { echo "::error::$*"; exit 1; } + + [ -f overlay/Config/Bridge.cfg ] || fail "overlay/Config/Bridge.cfg is missing" + [ -f overlay/Scripts/Scripts.csproj ] || fail "overlay/Scripts/Scripts.csproj is missing (the silent-build-bug fix)" + [ -d overlay/Scripts/Custom/Bridge ] || fail "overlay/Scripts/Custom/Bridge/ is missing" + + CS_COUNT="$(find overlay/Scripts/Custom/Bridge -name '*.cs' | wc -l)" + [ "$CS_COUNT" -gt 0 ] || fail "overlay/Scripts/Custom/Bridge/ contains no .cs files" + echo "overlay: ${CS_COUNT} bridge script(s)" + + for p in patches/*.patch; do + [ -e "$p" ] || fail "patches/ contains no .patch files" + echo "--- ${p}" + git apply --stat "$p" || fail "${p} is not a parseable unified diff" + done + + # Companion files that can only be copied after their patch lands. + for f in patches/BridgeVendorSale.cs patches/BridgeModerationAudit.cs; do + [ -f "$f" ] || fail "${f} is missing (a patch's companion source)" + done + + [ -f overlay.toml ] || fail "overlay.toml is missing (protocol + ServUO declarations)" + + # ── OVERLAY ADAPTER: stage, manifest, package ──────────────────────── + # The tarball has a FIXED top-level directory (runicgateway-overlay/), not a + # versioned one: the installer extracts and then looks for overlay/, + # patches/ and manifest.json at known paths, and a version-dependent prefix + # would make it parse the very version it is trying to read. + # + # tar flags pin ownership, mtime and member order so the same tree produces + # a byte-identical tarball — a checksum that changes only when content + # changes is worth more than one that changes every run. + - name: Build manifest.json and the release tarball + if: ${{ steps.plan.outputs.release == 'true' }} + run: | + set -euo pipefail + VERSION="${{ steps.plan.outputs.version }}" + STAGE="dist/stage/${ARTIFACT}" + + mkdir -p "${STAGE}" + cp -r overlay "${STAGE}/overlay" + cp -r patches "${STAGE}/patches" + + # Declarations from overlay.toml. Read, don't hardcode — the point of + # that file is that the protocol number lives in one place. + PROTOCOL="$(grep -m1 -E '^protocol[[:space:]]*=' overlay.toml | sed -E 's/[^0-9]//g')" + MIN_SERVUO="$(grep -m1 -E '^min_servuo_version[[:space:]]*=' overlay.toml | sed -E 's/.*"([^"]+)".*/\1/')" + PATCHED_AGAINST="$(grep -m1 -E '^patches_verified_against[[:space:]]*=' overlay.toml | sed -E 's/.*"([^"]+)".*/\1/')" + [ -n "$PROTOCOL" ] || { echo "::error::could not read protocol from overlay.toml"; exit 1; } + [ -n "$MIN_SERVUO" ] || { echo "::error::could not read min_servuo_version from overlay.toml"; exit 1; } + [ -n "$PATCHED_AGAINST" ] || { echo "::error::could not read patches_verified_against from overlay.toml"; exit 1; } + echo "==> protocol=${PROTOCOL} min_servuo=${MIN_SERVUO} patches_verified_against=${PATCHED_AGAINST}" + + # Per-file SHA256 of everything shipped, as a {path: sha} object. The + # installer records these in install.json so a later `doctor` can tell + # "operator edited a deployed file" from "the overlay drifted". + # The `\*?` is not paranoia: sha256sum marks binary mode by prefixing the + # path with `*` (` *path`) instead of the two-space text-mode + # separator. Coreutils on Linux defaults to text mode, but a build host + # that doesn't would otherwise put a leading `*` on EVERY key here and + # silently produce a manifest whose paths match nothing. + FILES="$(cd "${STAGE}" \ + && find overlay patches -type f -print0 | LC_ALL=C sort -z | xargs -0 sha256sum \ + | jq -R -s ' + split("\n") | map(select(length > 0)) + | map(capture("^(?[0-9a-f]+)[ \t]+\\*?(?.+)$")) + | map({ (.path): .sha }) | add')" + + jq -n \ + --arg component "servuo-plugins-overlay" \ + --arg version "${VERSION}" \ + --arg commit "${GITHUB_SHA}" \ + --arg repo "${REPO}" \ + --argjson protocol "${PROTOCOL}" \ + --arg min_servuo "${MIN_SERVUO}" \ + --arg patched_against "${PATCHED_AGAINST}" \ + --argjson files "${FILES}" \ + '{ + component: $component, + version: $version, + commit: $commit, + repo: $repo, + protocol: $protocol, + servuo: { + min_version: $min_servuo, + patches_verified_against: $patched_against + }, + files: $files + }' > "${STAGE}/manifest.json" + + echo "----- manifest.json (files elided) -----" + jq 'del(.files) + {file_count: (.files | length)}' "${STAGE}/manifest.json" + + TARBALL="${ARTIFACT}-${VERSION}.tar.gz" + tar --sort=name --mtime='UTC 1970-01-01' \ + --owner=0 --group=0 --numeric-owner \ + -czf "dist/${TARBALL}" -C dist/stage "${ARTIFACT}" + + ( cd dist && sha256sum "${TARBALL}" > SHA256SUMS ) + echo "tarball=${TARBALL}" >> "$GITHUB_OUTPUT" + ls -l dist && echo "----" && cat dist/SHA256SUMS + id: package + + # ── RELEASE ENGINE: tag ────────────────────────────────────────────── + # Tag only — no bump commit, so `main` is never pushed to (see header). + - name: Push the release tag + if: ${{ steps.plan.outputs.release == 'true' }} + env: + REGISTRY_USER: ${{ secrets.REGISTRY_USER }} + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -euo pipefail + TAG="${{ steps.plan.outputs.tag }}" + # Secrets can arrive with a trailing newline (depending on how they were + # pasted); a stray CR/LF corrupts the remote URL ("credential url cannot + # be parsed"). Strip line breaks before building the URL. + CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')" + CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')" + git config user.name "servuo-plugins-ci" + git config user.email "ci@whitlocktech.com" + git remote set-url origin \ + "https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${REPO}.git" + git tag "${TAG}" + git push origin "${TAG}" + + # ── RELEASE ENGINE: create the Gitea release + upload assets ───────── + - name: Create Gitea release and upload assets + if: ${{ steps.plan.outputs.release == 'true' }} + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -euo pipefail + TAG="${{ steps.plan.outputs.tag }}" + TARBALL="${{ steps.package.outputs.tarball }}" + API="https://${GITEA_HOST}/api/v1/repos/${REPO}" + BODY="$(cat dist/CHANGELOG.md)" + # Same newline hygiene as the tag step: a stray CR/LF in the token would + # corrupt the Authorization header. + 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 "$BODY" \ + '{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')" \ + | jq -r '.id')" + echo "Created release ${TAG} (id=${REL_ID})" + + for f in "${TARBALL}" 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 diff --git a/.gitignore b/.gitignore index 1a6da89..f47f46a 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ obj/ *.exe *.pdb *.log +dist/ diff --git a/README.md b/README.md index b14ea23..91aac1a 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ integration guide, protocol spec, research — with full history preserved). | `patches/` | Unified diffs against stock ServUO for files we must modify rather than add. | | `tools/` | Never deployed. Test scaffolding (C# probes + PowerShell stub sidecars) and anything else that must not reach a server. | | `deploy.ps1` | Copies `overlay/` into a server root. `-Verify` diffs instead of writing. | +| `overlay.toml` | Release metadata: the wire-protocol version this overlay speaks, and its ServUO compatibility. Read by CI into the release manifest — see [Releases](#releases). | +| `.gitea/workflows/release.yml` | Publishes `runicgateway-overlay-.tar.gz` on every merge to `main`. | | [INTEGRATION.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md) | **Website integration guide** — the WebSocket feed, REST endpoints, auth, event catalog, and examples. | | [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) | Implementation plan, measured performance budget, and the full data catalog. | | [RESEARCH.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/RESEARCH.md) | Original source-level research. Partly superseded — see the corrections table in `PLAN.md` §8. | @@ -41,8 +43,9 @@ The two are deployed **together** but built **independently**: - **This plugin** is deployed as *source* — `deploy.ps1` copies `overlay/` into the ServUO server root, and ServUO compiles it at boot (`Scripts.csproj`; see [Phase 0](#phase-0--what-it-fixes)). - There is no separate build artifact and no CI build — it cannot be compiled standalone without the - ServUO reference assemblies. + There is **no CI build** — it cannot be compiled standalone without the ServUO reference + assemblies. CI does publish a *source* tarball for the installer to fetch; see + [Releases](#releases). - **The sidecar** is a standalone Rust binary, released from its own repo. The **only** coupling is the loopback JSON protocol (the shard dials out to the sidecar on @@ -59,6 +62,54 @@ without the sidecar running. .\deploy.ps1 -ServerPath # write ``` +`deploy.ps1` is the **developer-facing** tool and stays that way. Operators get the +[Runic Gateway installer](https://gitea.whitlocktech.com/RunicGateway/installer), which does the +same sync cross-platform from the release tarball below. + +## Releases + +Every merge to `main` that carries a releasable conventional commit (`feat:`, `fix:`, `perf:`, or a +breaking change — a `docs:`/`chore:`-only merge deliberately cuts nothing) publishes a Gitea release: + +``` +runicgateway-overlay-.tar.gz +└── runicgateway-overlay/ + ├── manifest.json + ├── overlay/ # exactly what deploy.ps1 would copy + └── patches/ # the opt-in stock-file diffs + their companion sources +SHA256SUMS +``` + +This is a **source** tarball, not a build — nothing here is compiled. It exists so the installer can +deploy the plugin onto a shard host that has no git and no Gitea credentials. + +`manifest.json` is what makes the tarball self-describing: + +```json +{ + "component": "servuo-plugins-overlay", + "version": "0.1.0", + "commit": "968b526…", + "protocol": 3, + "servuo": { "min_version": "57.4", "patches_verified_against": "57.4" }, + "files": { "overlay/Config/Bridge.cfg": "32718424…", … } +} +``` + +- **`protocol`** comes from `overlay.toml` and is the plugin half of the compatibility contract. The + plugin announces no version on the wire and none is queryable before ServUO boots, so this + declaration is the only way the installer can check it against the sidecar's `PROTOCOL_VERSION` + *before* an operator installs the pair. **When the protocol changes, bump it in the same PR that + changes the emitters.** +- **`files`** carries a SHA256 per shipped file, so a deployment can later tell "an operator edited + this" from "the overlay moved on". + +The version is derived from git tags — there is no version to maintain by hand and no bump commit, +so this workflow never pushes to `main`. + +The tarball is byte-reproducible for a given tree (`tar --sort=name`, pinned mtime and ownership), so +its checksum changes only when its contents do. + ## Status | Phase | State | diff --git a/overlay.toml b/overlay.toml new file mode 100644 index 0000000..034eae3 --- /dev/null +++ b/overlay.toml @@ -0,0 +1,42 @@ +# Release metadata for the deployable overlay. +# +# Consumed by .gitea/workflows/release.yml, which folds these values into the +# manifest.json shipped inside runicgateway-overlay-.tar.gz. The Runic +# Gateway installer reads that manifest to decide what it is deploying and +# whether it is compatible with the sidecar it is about to install +# (docs/installer/PLAN.md §5 Phase 0, §7.1). +# +# There is deliberately NO version key here. The release version is derived from +# git tags and conventional commits by the release workflow, so there is no bump +# commit to keep in sync and no way for this file to disagree with the tag. + +# ── The loopback wire-protocol version this overlay speaks ─────────────────── +# +# This is the plugin half of the compatibility contract. It MUST equal the +# sidecar's PROTOCOL_VERSION (link/sidecar/src/main.rs) for a deployment to +# work: the sidecar rejects a mismatch with 409 rather than mis-parsing. +# +# The C# plugin has no queryable version before ServUO boots — it does not +# announce one on the wire — so this declaration is the only thing that lets the +# installer's bundle CI check the pair BEFORE an operator installs them +# (docs/installer/PLAN.md §2.6, §7.1 gate 1). Keeping it honest is therefore a +# manual duty: when the protocol changes, bump it here in the same PR that +# changes the emitters, exactly as link bumps PROTOCOL_VERSION. +# +# Current: 3 — see docs/link/v3.md (world.ruleset, points.board, vendor.listing). +protocol = 3 + +# ── ServUO compatibility ───────────────────────────────────────────────────── +# +# The base overlay (Config/Bridge.cfg + Scripts/Custom/Bridge/*.cs) only ADDS +# files and is expected to work on any reasonably current ServUO. This is the +# oldest version it is known good on. +min_servuo_version = "57.4" + +# The patches/ tier is a different matter: those are unified diffs against STOCK +# ServUO files, so they are verified against exactly one version and nothing +# else. On any other version the installer skips the whole tier with a warning +# and completes the base install (docs/installer/PLAN.md §1, §2.2) — losing +# vendor.sale events and in-game moderation-audit forwarding, but never +# half-patching an unknown tree. +patches_verified_against = "57.4"