feat(bridge): server id from the environment, and the first release workflow (phase 18) #13

Merged
whitlocktech merged 1 commits from feat/phase-18-release into edge 2026-09-26 00:34:25 -05:00
4 changed files with 586 additions and 3 deletions

View File

@@ -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-<ver>.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() { # <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
}
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:-<none>}"
# ── 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.<host>.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

View File

@@ -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-<version>.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

View File

@@ -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

View File

@@ -413,9 +413,47 @@ namespace Oxide.Plugins
public int MapMaxBases = MapDefaultMax;
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>The website's rule for a server id, `^[a-z0-9][a-z0-9-]{0,63}$`, without a Regex.</summary>
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()