5 Commits

Author SHA1 Message Date
3a52abbd77 Merge pull request 'fix(release): preflight credentials and recover the orphaned v0.1.0 tag' (#8) from fix/release-credential-preflight into main
All checks were successful
Release overlay / release (push) Successful in 10s
Reviewed-on: #8
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-04 15:26:54 +00:00
eebc74ac8d fix(release): preflight credentials and recover the orphaned v0.1.0 tag
The first release run tagged the repo and then failed, leaving v0.1.0 with
no release behind it and no way to ever get one.

REGISTRY_USER and REGISTRY_TOKEN are not configured on this repo, 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 (visible in the run log as `REGISTRY_USER:` / `REGISTRY_TOKEN:`
with empty values). So the run got exactly far enough to do the one thing
that is hard to undo.

Worse, that state was self-perpetuating. The plan step treated any existing
tag as "nothing to release", so every subsequent push to main would see
v0.1.0, set RELEASE=false, and stand down — the release would never appear,
and no amount of re-running would fix it.

Two fixes:

  A credential preflight, before anything is built or pushed, gated on the
  run intending to publish so a docs:/chore:-only merge still passes on a
  repo without secrets. It names the missing secrets and the scope they
  need, rather than failing at whichever step happens to use them first.

  Orphan-tag recovery. The plan step now asks the API whether a release
  exists for the tag: 200 means stand down, 404 means an earlier run died
  after tagging, so reuse the tag and publish the release it is missing.
  This deliberately overrides the RELEASE=false the bump logic just decided
  — with the tag already in place there are no releasable commits after it,
  which is precisely why the stuck state could not clear itself.

  Anything other than 200/404 (network failure, bad token) is refused
  rather than guessed, because assuming "no release" would republish over a
  good one.

  The tag step reuses an existing tag instead of failing on `git tag`, and a
  recovery run's changelog summarizes what the tag contains
  (previous-tag..this-tag) instead of the empty range after it.

Once REGISTRY_USER / REGISTRY_TOKEN are set, the next push to main will
finish the release that the first run started — v0.1.0, from the same
commit it already points at.

Verified against the live repo state: the plan step now reports
release=true reuse_tag=true for the orphaned v0.1.0 and renders the correct
changelog; a tag that does have a release (checked against link's v0.3.0)
still stands down; a fresh repo still takes the seed path; and the
preflight fails loudly on empty secrets and passes on populated ones.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 10:15:13 -05:00
724262548b Merge pull request 'ci(release): publish the overlay as a release tarball with a manifest' (#7) from ci/overlay-release-workflow into main
Some checks failed
Release overlay / release (push) Failing after 10s
Reviewed-on: #7
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-04 14:26:46 +00:00
ebbfab51fc ci(release): publish the overlay as a release tarball with a manifest
Phase 0 item 1 of the installer plan (docs/installer/PLAN.md §5). The
installer deploys the plugin from a release tarball rather than from git,
because the shard host gets neither git nor Gitea credentials — but this
repo published no releases at all, so there was nothing for it to fetch.
`link` was the only repo with a release workflow.

Reuses link/.gitea/workflows/release.yml's conventional-commit engine, as
that file's own header anticipated: the plan and release steps consume only
{version, changelog, artifacts}. Three things had to change, each forced by
this repo rather than chosen:

  No build. The plugin ships as C# source and ServUO compiles it at boot;
  it needs ServUO reference assemblies, so nothing here can be compiled in
  CI. The build gates are replaced by structural ones that assert what can
  honestly be asserted without a ServUO tree: Bridge.cfg and the Bridge
  scripts are present, Scripts.csproj (the silent-build-bug fix) is present,
  every .patch parses as a unified diff via `git apply --stat`, and each
  patch's companion .cs exists. Each of those has a way of shipping broken
  and only failing on an operator's live shard.

  No bump commit, so no push to main. link writes the version into
  Cargo.toml because the binary embeds it; a tarball embeds nothing but the
  manifest CI generates, so the git tag is the version. This workflow
  therefore never needs main to accept a direct push — no branch-protection
  exception for it.

  A manifest. The tarball carries manifest.json: version, commit, declared
  protocol version, ServUO compatibility, and a SHA256 per shipped file.

The manifest matters more than it looks. The plugin announces no version on
the wire and none is queryable before ServUO boots (PLAN.md §2.6), so its
declared protocol version is the ONLY thing that lets the installer's bundle
CI verify sidecar/overlay agreement before an operator installs the pair
(PLAN.md §7.1 gate 1). That declaration lives in the new overlay.toml
alongside the ServUO compatibility values, so it is one commented line to
maintain rather than a literal buried in a workflow — currently protocol 3,
per docs/link/v3.md.

Tarball layout uses a FIXED top-level directory (runicgateway-overlay/)
rather than a versioned one, so the installer can find overlay/, patches/
and manifest.json at known paths instead of parsing the version it is trying
to read. tar's member order, mtime and ownership are pinned, so a given tree
produces a byte-identical tarball and its checksum changes only when the
contents do.

Verified locally against the real tree before pushing: YAML parses, all six
run blocks pass bash -n, the plan step produces v0.1.0 from actual history,
the gates pass (22 bridge scripts, all three patches parse), the manifest
renders with protocol=3 and 30 file hashes, and two consecutive builds of
the tarball produce the same SHA256.

One real bug caught by running it rather than reading it: sha256sum marks
binary mode by prefixing the path with `*` instead of the two-space
text-mode separator, which would have put a leading `*` on every key in the
manifest. The capture now tolerates both.

Not included: the workflow-dispatch call into the installer's bundle CI
(PLAN.md §7.2). That is Phase 0 item 3 and there is nothing to dispatch yet;
the insertion point is marked in the header. A step that 404s on every
release is worse than no step.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 09:23:56 -05:00
968b526fac Merge pull request 'feat(bridge)!: Protocol 3.0 cutover — world.ruleset, points.board, vendor.listing' (#6) from edge into main
Reviewed-on: #6
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-01 06:33:27 +00:00
4 changed files with 523 additions and 2 deletions

View File

@@ -0,0 +1,427 @@
# 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-<ver>.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.
#
# These are checked by an explicit preflight step rather than left to fail
# wherever they happen to be used first — see the comment on that step for why
# an absent token does NOT simply fail the tag push.
#
# 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
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 the first run here,
# when the 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
# 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 the 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
# ── 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 `*` (`<hash> *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("^(?<sha>[0-9a-f]+)[ \t]+\\*?(?<path>.+)$"))
| 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"
# 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')"
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

1
.gitignore vendored
View File

@@ -7,3 +7,4 @@ obj/
*.exe
*.pdb
*.log
dist/

View File

@@ -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-<ver>.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 <servuo> # 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-<ver>.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 |

42
overlay.toml Normal file
View File

@@ -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-<ver>.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"