From 497b7c4f64294aecf7a001653720008441ad5cbc Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 25 Sep 2026 22:54:38 -0500 Subject: [PATCH] feat(bridge): server id from the environment, and the first release workflow (phase 18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Module-rust phase 18, step 3 of docs/modules/rust/PLAN.md §34.2.7. The environment seam (D150): when the plugin writes its default config and RUSTLINK_SERVER_ID holds a valid id (the site's ^[a-z0-9][a-z0-9-]{0,63}$), the file's ServerId is that value. It is read nowhere else: after that write the file is canonical and the site's lock applies. An invalid value is logged and ignored. This is how the egg names a server; not a message change, so the protocol stays at 12. The release (D145, §34.2.1): release.yml reuses servuo-plugins' release engine unchanged (tag-only, secrets preflighted, orphan tags recovered and swept, the installer's bundle.yml dispatched at the end). The adapter re-runs checkPlugin.js and packages runicgateway-rust-plugin-.tar.gz with a framework-neutral layout: RunicGateway.cs, its [Info] version stamped to the release, and a manifest.json folding in overlay.toml. overlay.toml gains min_carbon_version = "2.0.259", the build R19 was proven on. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- .gitea/workflows/release.yml | 516 ++++++++++++++++++++++++++ README.md | 22 +- overlay.toml | 11 + overlay/oxide/plugins/RunicGateway.cs | 40 +- 4 files changed, 586 insertions(+), 3 deletions(-) create mode 100644 .gitea/workflows/release.yml diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..14c8eb9 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,516 @@ +# Automated release for the Rust bridge plugin. +# +# Trigger: every push to `main` (i.e. every merged PR — in practice the +# edge→main cutover), and by hand. +# +# Why this exists: the Runic Gateway installer (`--game rust`) and the +# Pterodactyl egg both deploy the plugin from a release tarball, never from git +# — a game host gets no git and no Gitea credentials. Until module-rust phase 18 +# (docs/modules/rust/PLAN.md §34.2.1, D145) this repository had never released, +# so there was nothing for either to fetch. +# +# Flow — the same two halves as servuo-plugins' release.yml, whose release +# engine is reused here unchanged: +# +# ┌── RELEASE ENGINE (language-agnostic) ─────────────────────────────┐ +# │ reads: latest v* git tag + conventional-commit subjects │ +# │ produces: next version, changelog, and (at the end) the release │ +# └───────────────────────────────────────────────────────────────────┘ +# ┌── PLUGIN ADAPTER (the only repo-specific part) ───────────────────┐ +# │ consumes: the version │ +# │ produces: runicgateway-rust-plugin-.tar.gz + SHA256SUMS │ +# └───────────────────────────────────────────────────────────────────┘ +# +# ── What differs from servuo-plugins' copy ─────────────────────────────────── +# +# 1. NO BUILD, for the same reason: the plugin ships as C# source and Oxide or +# Carbon compiles it against assemblies that exist only on a Rust server. +# The gates are the static checks PR Checks already runs, re-run on the +# exact commit being released. +# +# 2. A FRAMEWORK-NEUTRAL LAYOUT. The tarball holds one directory, +# `runicgateway-rust-plugin/`, with `RunicGateway.cs` and `manifest.json` at +# its top. It does NOT mirror `oxide/plugins/`: the same file goes to +# `oxide/plugins/` or `carbon/plugins/`, and the installer and the egg each +# decide which (§34.2.1). The fixed, unversioned prefix is so a reader never +# has to parse the version out of a path to find the manifest that states it. +# +# 3. THE VERSION IS STAMPED into the shipped copy's `[Info(…)]` attribute, so +# `oxide.plugins` / `c.plugins` on a server names the release it runs. The +# committed file keeps its placeholder; the manifest records the commit. +# +# The version is never the protocol. The protocol lives in overlay.toml and +# moves only when a message shape does; the version moves on every releasable +# commit. +# +# Version bump (conventional commits since the last v* tag): +# feat!: / BREAKING CHANGE -> major feat: -> minor fix|perf: -> patch +# nothing releasable -> no release is cut +# (first ever run, no tag) -> releases SEED_VERSION below (§34.4: the first +# release takes what the engine derives) +# +# Prerequisites (Settings → Actions → Secrets on RunicGateway/Rust-Plugins): +# REGISTRY_TOKEN — Gitea access token with `write:repository`, to push the +# tag and create the release. The final step also dispatches +# RunicGateway/installer's bundle workflow, so the token +# ideally has write there too — without it the step warns +# and that repo's nightly cron picks the release up instead. +# REGISTRY_USER — the Gitea username that token belongs to. + +name: Release plugin + +on: + push: + branches: [main] + workflow_dispatch: {} + +concurrency: + group: release-plugin + cancel-in-progress: false + +env: + GITEA_HOST: gitea.whitlocktech.com + REPO: RunicGateway/Rust-Plugins + ARTIFACT: runicgateway-rust-plugin + PLUGIN: overlay/oxide/plugins/RunicGateway.cs + SEED_VERSION: "0.1.0" + INSTALLER_REPO: RunicGateway/installer + +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 + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + 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 + + # 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 exactly what happened on servuo-plugins' first + # run, when missing REGISTRY_* secrets took the release API call to 401 + # after the tag had already been pushed. Standing down on the tag alone + # would make that state permanent: every later run would see the tag, + # set RELEASE=false, and the release would never appear. So distinguish + # the two cases and finish the job the earlier run started. + # Note this OVERRIDES the RELEASE=false decided just above. With the tag + # already in place there are no releasable commits after it, so the + # normal path stands down — which is precisely why the stuck state + # could never clear itself. Recovery has to be able to say "yes, + # publish" for a version the bump logic considers already done. + REUSE_TAG=false + if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then + REL_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)" + if [ "$REL_HTTP" = "200" ]; then + echo "Tag v${VERSION} already has a release — nothing to do." + RELEASE=false + elif [ "$REL_HTTP" = "404" ]; then + 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 + else + # Anything else (000 from a network failure, 401/403 from a bad + # token) is not evidence of absence. Guessing "no release" here + # would re-publish over a good one, so refuse instead. + echo "::error::Could not determine whether a release exists for v${VERSION} (HTTP ${REL_HTTP}). Refusing to guess." + exit 1 + fi + fi + + # ── Orphan sweep ──────────────────────────────────────────────── + # + # The check above is VERSION-SCOPED: it only ever asks about the one + # version this run computed. That is enough to recover an orphan on + # the very next run, and useless afterwards — once any releasable + # commit lands, the next run computes a NEW version, never looks at + # the old tag again, and the orphan becomes permanent and silent. + # + # servuo-plugins v0.1.0 is the proof: the commit that ADDED the + # recovery above was itself a `fix:`, so it bumped to v0.1.1 and the + # run that introduced the recovery stepped straight past the tag it + # was written to rescue. + # + # So every v* tag is checked, and anything missing a release is + # WARNED about. Deliberately not recovered: publishing an old version + # would mean building today's tree and shipping it under a tag whose + # tree it is not, which is worse than the inconsistency it fixes. + # A human decides whether to recover or drop it. + # + # Never fails the run. A sweep that can break a good release is a + # sweep someone will delete. + ORPHANS="" + for T in $(git tag -l 'v*' --sort=-v:refname); do + T_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/${T}" || echo 000)" + [ "$T_HTTP" = "404" ] && ORPHANS="${ORPHANS} ${T}" + done + if [ -n "${ORPHANS}" ]; then + echo "::warning::Tags with no release:${ORPHANS} — a run failed after tagging. Publish or delete them; this job will not do either." + 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)" + if [ -n "$PREV_TAG" ]; then CL_RANGE="${PREV_TAG}..v${VERSION}"; else CL_RANGE="v${VERSION}"; fi + SINCE="$PREV_TAG" + else + CL_RANGE="$RANGE" + SINCE="$LAST_TAG" + fi + CL_SUBJECTS="$(git log --no-merges --format='%s' $CL_RANGE || true)" + + { + echo "## ${ARTIFACT} v${VERSION}" + 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/^/- /' + } > 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 "reuse_tag=${REUSE_TAG}" >> "$GITHUB_OUTPUT" + echo "==> release=${RELEASE} version=${VERSION} bump=${BUMP} reuse_tag=${REUSE_TAG} last_tag=${LAST_TAG:-}" + + # ── Credential preflight ───────────────────────────────────────────── + # Runs BEFORE anything is built or pushed, and only when this run intends + # to publish, so a docs:/chore:-only merge stays green on a repo that has + # no secrets. + # + # This exists because of how servuo-plugins' first run failed. REGISTRY_USER and + # REGISTRY_TOKEN were empty, but the tag push SUCCEEDED anyway: + # actions/checkout leaves an `http..extraheader` credential in the + # local git config, so `git remote set-url` to a URL with empty + # credentials still authenticated through that leftover header. The + # release API call had no such fallback and returned 401 — so the run + # tagged the repo and then failed, which is the worst of both outcomes. + # Checking the secrets up front turns that into an immediate, legible + # failure instead of a half-published release. + - name: Verify release credentials are configured + if: ${{ steps.plan.outputs.release == 'true' }} + env: + REGISTRY_USER: ${{ secrets.REGISTRY_USER }} + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -euo pipefail + MISSING="" + [ -n "$(printf '%s' "${REGISTRY_USER:-}" | tr -d '\r\n')" ] || MISSING="${MISSING} REGISTRY_USER" + [ -n "$(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" ] || MISSING="${MISSING} REGISTRY_TOKEN" + if [ -n "$MISSING" ]; then + echo "::error::Missing Actions secret(s):${MISSING}. Set them under Settings → Actions → Secrets on ${REPO}. REGISTRY_TOKEN needs the write:repository scope to push the tag and create the release." + exit 1 + fi + echo "Release credentials present." + + - 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 + + # ── PLUGIN ADAPTER: gates ──────────────────────────────────────────── + # No compiler exists for this plugin outside a Rust server, so the gates + # are the ones PR Checks runs, re-run on the exact commit being released: + # every hook listed in ExpectedHooks, every hook void, and ProtocolVersion + # equal to overlay.toml. A cutover merge commit is a tree no PR check ran + # on as such, so it is worth asking again. + - uses: actions/setup-node@v4 + if: ${{ steps.plan.outputs.release == 'true' }} + with: + node-version: 20 + + - name: Validate the plugin + if: ${{ steps.plan.outputs.release == 'true' }} + run: | + set -euo pipefail + [ -f "$PLUGIN" ] || { echo "::error::${PLUGIN} is missing"; exit 1; } + [ -f overlay.toml ] || { echo "::error::overlay.toml is missing (protocol + framework declarations)"; exit 1; } + node scripts/checkPlugin.js + # The stamp below rewrites exactly one [Info(...)] line; zero or two + # would ship a plugin whose reported version is a lie. + COUNT="$(grep -cE '^[[:space:]]*\[Info\("Runic Gateway", "RunicGateway", "[^"]*"\)\]' "$PLUGIN" || true)" + [ "$COUNT" = 1 ] || { echo "::error::expected exactly one [Info(\"Runic Gateway\", \"RunicGateway\", ...)] attribute in ${PLUGIN}, found ${COUNT}"; exit 1; } + + # ── PLUGIN ADAPTER: stage, manifest, package ───────────────────────── + # 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 + id: package + if: ${{ steps.plan.outputs.release == 'true' }} + run: | + set -euo pipefail + VERSION="${{ steps.plan.outputs.version }}" + STAGE="dist/stage/${ARTIFACT}" + mkdir -p "${STAGE}" + + sed -E 's/^([[:space:]]*\[Info\("Runic Gateway", "RunicGateway", ")[^"]*("\)\])/\1'"${VERSION}"'\2/' \ + "$PLUGIN" > "${STAGE}/RunicGateway.cs" + grep -qF "[Info(\"Runic Gateway\", \"RunicGateway\", \"${VERSION}\")]" "${STAGE}/RunicGateway.cs" \ + || { echo "::error::stamping the version into [Info(...)] did not take"; exit 1; } + + # Declarations from overlay.toml. Read, don't hardcode — the point of + # that file is that the protocol number lives in one place. + toml_str() { grep -m1 -E "^$1[[:space:]]*=" overlay.toml | sed -E 's/.*"([^"]+)".*/\1/'; } + PROTOCOL="$(grep -m1 -E '^protocol[[:space:]]*=' overlay.toml | sed -E 's/[^0-9]//g')" + MIN_OXIDE="$(toml_str min_oxide_version)" + MIN_CARBON="$(toml_str min_carbon_version)" + # requires_plugins = ["Kits", "ZoneManager"] is already a JSON array. + # One line, quoted strings only; anything else fails the check below. + REQUIRES="$(grep -m1 -E '^requires_plugins[[:space:]]*=' overlay.toml | sed -E 's/^[^=]*=[[:space:]]*//')" + [ -n "$PROTOCOL" ] || { echo "::error::could not read protocol from overlay.toml"; exit 1; } + [ -n "$MIN_OXIDE" ] || { echo "::error::could not read min_oxide_version from overlay.toml"; exit 1; } + [ -n "$MIN_CARBON" ] || { echo "::error::could not read min_carbon_version from overlay.toml"; exit 1; } + jq -e 'type == "array" and all(type == "string")' <<<"$REQUIRES" >/dev/null \ + || { echo "::error::requires_plugins in overlay.toml is not a one-line array of strings"; exit 1; } + echo "==> protocol=${PROTOCOL} oxide>=${MIN_OXIDE} carbon>=${MIN_CARBON} requires=${REQUIRES}" + + jq -n \ + --arg component "rust-plugin" \ + --arg version "${VERSION}" \ + --arg commit "${GITHUB_SHA}" \ + --arg repo "${REPO}" \ + --argjson protocol "${PROTOCOL}" \ + --arg min_oxide "${MIN_OXIDE}" \ + --arg min_carbon "${MIN_CARBON}" \ + --argjson requires "${REQUIRES}" \ + --arg sha "$(sha256sum "${STAGE}/RunicGateway.cs" | cut -d' ' -f1)" \ + '{ + component: $component, + version: $version, + commit: $commit, + repo: $repo, + protocol: $protocol, + min_oxide_version: $min_oxide, + min_carbon_version: $min_carbon, + requires_plugins: $requires, + files: { "RunicGateway.cs": $sha } + }' > "${STAGE}/manifest.json" + echo "----- manifest.json -----" + cat "${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 + + # ── 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 "rust-plugins-ci" + git config user.email "ci@whitlocktech.com" + git remote set-url origin \ + "https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${REPO}.git" + + # The tag may already exist when we are finishing a run that died after + # tagging (see the plan step). `git tag` on an existing name fails under + # `set -e`, and pushing an identical existing tag is a harmless no-op — + # so create it only if it is new, then push either way. A push that + # fails here means the remote tag points somewhere else, which SHOULD + # stop the run. + if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then + echo "Tag ${TAG} already exists — reusing it." + else + git tag "${TAG}" + fi + 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')" + + PAYLOAD="$(jq -n --arg tag "$TAG" --arg body "$BODY" \ + '{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')" + + # installer#22's release run failed exactly here: it landed one second + # after the tag push and Gitea answered 500, having not finished + # processing the pushed tag. Re-running published the same artifacts + # untouched, so it was a race, not a bad request — but the tag sat + # orphaned until a human noticed. + # + # Two things made that worse than it needed to be. + # + # 1. `curl -sSf` prints NO response body on an error status, so all the + # log carried was "curl: (22) ... error: 500" and the cause had to be + # inferred from timestamps. Capture the body and print it. + # 2. Nothing retried, so a transient 5xx became a permanent orphan. + # + # 4xx is deliberately NOT retried: a bad token or a malformed body does + # not improve by being sent again, and retrying only turns a clear + # failure into a slow one. + REL_ID="" + for attempt in 1 2 3 4 5; do + HTTP="$(curl -s -o /tmp/rel.json -w '%{http_code}' -X POST "${API}/releases" \ + -H "Authorization: token ${CI_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "${PAYLOAD}" || echo 000)" + + if [ "$HTTP" = "201" ] || [ "$HTTP" = "200" ]; then + REL_ID="$(jq -r '.id' /tmp/rel.json)" + break + fi + + echo "::warning::POST /releases attempt ${attempt} returned HTTP ${HTTP}" + echo "--- response body ---" + cat /tmp/rel.json || true + echo + echo "---------------------" + + case "$HTTP" in + 4*) echo "::error::HTTP ${HTTP} is a client error - not retrying."; exit 1 ;; + esac + + if [ "$attempt" = 5 ]; then + echo "::error::POST /releases still failing after 5 attempts. Tag ${TAG} is pushed but has no release." + echo "::error::Re-run this workflow - the plan step detects the orphan tag and republishes it." + exit 1 + fi + sleep $(( attempt * 5 )) + done + + if [ -z "$REL_ID" ] || [ "$REL_ID" = "null" ]; then + echo "::error::Release created but no id came back; refusing to upload assets blind." + exit 1 + fi + echo "Created release ${TAG} (id=${REL_ID})" + + for f in "${TARBALL}" SHA256SUMS; do + # Same treatment. An upload that fails quietly leaves a release whose + # SHA256SUMS does not cover every artifact it advertises, which is + # worse than no release at all -- that file is the trust anchor. + HTTP="$(curl -s -o /tmp/asset.json -w '%{http_code}' -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \ + -H "Authorization: token ${CI_TOKEN}" \ + -F "attachment=@dist/${f}" || echo 000)" + if [ "$HTTP" != "201" ] && [ "$HTTP" != "200" ]; then + echo "::error::uploading ${f} returned HTTP ${HTTP}" + cat /tmp/asset.json || true + exit 1 + fi + echo " uploaded ${f}" + done + + # ── Recompose the installer's bundle manifest ──────────────────────── + # Neither the installer nor the egg resolves "latest" at run time — both + # deploy the exact plugin named by a published bundle + # (docs/modules/rust/PLAN.md §34.2.2). A plugin release that nobody + # recomposes around is therefore a release no operator will ever be + # offered. This tells the installer repo to + # rebuild that manifest now rather than leaving the new version invisible + # until its nightly cron. + # + # That job re-reads this tarball's manifest.json and checks its declared + # `protocol` against Rust-Link's PROTOCOL_VERSION before publishing + # anything (gate 1) — the check this repo cannot perform for itself, and + # the one that matters most here: the game link has no 409, so a + # mismatched plugin would mis-parse rather than be refused. + # + # DISPATCH, DON'T WAIT (PLAN.md §7.3). Gitea's workflow-dispatch endpoint + # returns no run handle, so there is nothing to poll: a waiting step would + # have to guess which run is its own and hold a runner idle to do it. + # + # A failure here is a WARNING, never a failure of this job. The release is + # already published and correct by this point, and failing the run would + # misreport that. The installer's nightly cron recomposes from whatever the + # latest releases actually are, so a dropped dispatch costs latency, not + # correctness. + - name: Ask the installer repo to recompose its bundle + if: ${{ steps.plan.outputs.release == 'true' }} + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -euo pipefail + CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')" + HTTP="$(curl -s -o /dev/null -w '%{http_code}' -X POST \ + -H "Authorization: token ${CI_TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{"ref":"main"}' \ + "https://${GITEA_HOST}/api/v1/repos/${INSTALLER_REPO}/actions/workflows/bundle.yml/dispatches" || echo 000)" + case "$HTTP" in + 20*) echo "Dispatched ${INSTALLER_REPO} bundle.yml (HTTP ${HTTP}) — not waiting for it." ;; + 403|404) + echo "::warning::Could not dispatch ${INSTALLER_REPO} bundle.yml (HTTP ${HTTP}). REGISTRY_TOKEN likely lacks write:repository on that repo. Release ${{ steps.plan.outputs.tag }} is published and fine; its bundle will be composed by the installer's nightly cron instead." ;; + *) + echo "::warning::Dispatching ${INSTALLER_REPO} bundle.yml returned HTTP ${HTTP}. Release ${{ steps.plan.outputs.tag }} is published and fine; the nightly cron will recompose the bundle." ;; + esac diff --git a/README.md b/README.md index 16da40f..bdb5b7f 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,27 @@ Oxide compiles and loads it on the write, and writes `oxide/config/RunicGateway. is deliberately **not** derived from the hostname: an operator renames a server for a season, and the site must not lose its history for it. +**When that first config is written**, and only then, a valid `RUSTLINK_SERVER_ID` in the server's +environment becomes its `ServerId` (an invalid one is logged and ignored). That is how the +Pterodactyl egg names a server, since an egg can set a variable but cannot reliably write a +framework's config directory. After that the file is canonical and the website locks the value, so +changing the variable later changes nothing. + +On Carbon the same file goes to `carbon/plugins/` and its config to `carbon/configs/`. + That is the developer's loop. An operator uses the -[installer](https://gitea.whitlocktech.com/RunicGateway/installer), which syncs the released overlay -tarball and installs the sidecar alongside it. +[installer](https://gitea.whitlocktech.com/RunicGateway/installer) (`--game rust`) or the Pterodactyl +egg, both of which deploy a **released** plugin and install the sidecar beside it. + +### Releases + +Every merge to `main` that carries a `feat`, `fix` or `perf` commit cuts a release +(`.gitea/workflows/release.yml`): `runicgateway-rust-plugin-.tar.gz` and `SHA256SUMS`. The +tarball holds one directory, `runicgateway-rust-plugin/`, with `RunicGateway.cs` (its `[Info]` +version stamped to the release) and a `manifest.json` folding in `overlay.toml`: the protocol, the +Oxide and Carbon floors, and `requires_plugins`. The layout names no framework; the installer and +the egg decide where the file goes. A release then asks the installer repo to recompose its Rust +bundle. ### Prerequisites diff --git a/overlay.toml b/overlay.toml index 6193f2f..2ba4501 100644 --- a/overlay.toml +++ b/overlay.toml @@ -46,6 +46,17 @@ protocol = 12 # simpler than the ServUO one. min_oxide_version = "2.0.7585" +# ── Carbon compatibility ───────────────────────────────────────────────────── +# +# The same file runs unchanged on Carbon (docs/modules/rust/PLAN.md R19); the +# installer and the egg decide whether it lands in `oxide/plugins/` or +# `carbon/plugins/`. This is the build R19 was proven on (PLAN.md §34.4). +# +# Neither floor is measured from a DLL by `doctor`: a plugin that compiled and +# connected on this framework is the fact a floor only approximates, so a +# connected plugin is the pass, and the floors are printed with any failure. +min_carbon_version = "2.0.259" + # The `oxide/plugins/` files this overlay expects to find already installed. They # are not shipped here — they are third-party plugins an operator installs from # uMod — and the installer's `doctor` reports a missing one rather than diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 3ffa50b..d16d02e 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -413,9 +413,47 @@ namespace Oxide.Plugins public int MapMaxBases = MapDefaultMax; } + /// + /// Runs only when this plugin has no config file yet — so it is the one moment a server id + /// can arrive from outside the file (D150). A Pterodactyl egg cannot write a framework's + /// config directory (Carbon lets an operator move it), but it can set an environment + /// variable, and `RUSTLINK_SERVER_ID` is the one the sidecar already reads for its + /// cross-check. After this write the file is canonical and the website's lock applies, + /// so changing the variable later changes nothing — which is what keeps a server's + /// history attached to one id. + /// protected override void LoadDefaultConfig() { - Config.WriteObject(new PluginConfig(), true); + var config = new PluginConfig(); + + string fromEnv = Environment.GetEnvironmentVariable("RUSTLINK_SERVER_ID"); + if (!string.IsNullOrEmpty(fromEnv)) + { + if (IsValidServerId(fromEnv)) + config.ServerId = fromEnv; + else + PrintWarning($"RUSTLINK_SERVER_ID '{fromEnv}' is not a valid server id " + + $"(lowercase letters, digits and '-', 1-64, not starting with '-'); " + + $"the new config keeps '{config.ServerId}'"); + } + + Config.WriteObject(config, true); + } + + /// The website's rule for a server id, `^[a-z0-9][a-z0-9-]{0,63}$`, without a Regex. + private static bool IsValidServerId(string id) + { + if (string.IsNullOrEmpty(id) || id.Length > 64 || id[0] == '-') + return false; + + foreach (char c in id) + { + bool ok = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-'; + if (!ok) + return false; + } + + return true; } private void LoadConfigValues() -- 2.49.1