8 Commits
v0.1.0 ... main

Author SHA1 Message Date
0eda2d3a97 Merge pull request 'docs: make the installer the documented way to deploy the overlay' (#11) from docs/installer-first-setup into main
All checks were successful
Release overlay / release (push) Successful in 5s
Reviewed-on: #11
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-07 21:32:13 +00:00
48b16dc70e docs: make the installer the documented way to deploy the overlay
"## Deploy" led with deploy.ps1 and mentioned the installer only
afterwards, which is backwards now that the installer is released.

- Deploy leads with the installer, with the by-hand overlay copy
  (INSTALL.md Appendix A2) as the supported alternative.
- deploy.ps1 gets its own subsection as the developer path: it deploys
  from a working tree, which is the one thing the installer cannot do,
  and it installs no sidecar and checks no protocol pairing.
- CONTRIBUTING: note that changes reach shards through a release, so a
  change that only works when deploy.ps1 copies it does not ship.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 16:05:56 -05:00
c045bdd566 Merge pull request 'feat(patches): declare the patch tier in tier.json and the manifest' (#10) from feat/patch-tier-metadata into main
All checks were successful
Release overlay / release (push) Successful in 11s
Reviewed-on: #10
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-05 01:08:31 +00:00
8828382e41 feat(patches): declare the patch tier in tier.json and the manifest
A .patch file does not carry enough for an installer to run the tier safely.
The installer additionally needs to know which patches form one all-or-nothing
unit (the two vendor-sale patches are useless apart), which companion .cs may
only be copied once that unit has landed, whether the change needs a core
solution rebuild or just ServUO's dynamic script build, and what capability an
operator loses by declining. None of that is derivable from the diffs.

patches/tier.json declares it, and release.yml folds it into manifest.json as
`patch_tier` — so a new or changed patch regenerates release metadata rather
than requiring an installer release, which is the same rule §7.1 already
applies to the bundle. The staged copy is removed from patches/ so the tarball
carries exactly one statement of the table.

The release gate now checks the table in both directions: every .patch
described by exactly one feature, every named patch and companion present,
every declared target equal to the file the diff actually edits, and every
rebuild kind one the installer understands. All four were previously invisible
until someone ran the tier on a live shard.

Refs: docs/installer/PLAN.md §2.2, §7.0

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 19:30:20 -05:00
7fa8953ffa Merge pull request 'ci(release): recompose the installer bundle after publishing' (#9) from ci/dispatch-bundle into main
All checks were successful
Release overlay / release (push) Successful in 7s
Reviewed-on: #9
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-04 16:18:27 +00:00
4720a214a2 ci(release): recompose the installer bundle after publishing
Phase 0 item 3 of docs/installer/PLAN.md wired up from this side. The installer
does not resolve "latest" at run time — it installs the exact combination named
by a published bundle manifest (PLAN.md §7.1), so until now a new overlay release
was invisible to operators until the installer repo's nightly cron noticed it.

Adds a final step that POSTs to RunicGateway/installer's bundle workflow-dispatch
endpoint. That job re-reads this tarball's manifest.json and checks its declared `protocol`
against the sidecar's PROTOCOL_VERSION before publishing anything (gate 1) — the
check this repo cannot perform for itself, since the C# plugin announces no
version on the wire. It replaces the TODO the header has carried since #7, which
was deliberately left unimplemented while there was nothing to dispatch.

Dispatch, don't wait (PLAN.md §7.3): Gitea's dispatch endpoint returns no run
handle, so there is nothing to poll — a waiting step would have to guess which
run is its own while holding a runner idle. The bundle job runs its own gates
regardless of who started it.

A dispatch failure is a warning, never a failure of this job. By the time this
step runs the release is published and correct, so failing the run would
misreport that; the installer's nightly cron recomposes from whatever the latest
releases actually are, making a dropped dispatch cost latency rather than
correctness. That also means REGISTRY_TOKEN having write on the installer repo
is a nicety, not a new hard requirement — noted in the header.

Verified the workflow still parses and that the new step is last, gated on
release=='true', and contains no path that can exit non-zero.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 11:13:47 -05:00
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
5 changed files with 334 additions and 31 deletions

View File

@@ -51,14 +51,22 @@
# #
# Prerequisites (Settings → Actions → Secrets on RunicGateway/servuo-plugins): # Prerequisites (Settings → Actions → Secrets on RunicGateway/servuo-plugins):
# REGISTRY_TOKEN — Gitea access token with `write:repository`, to push the # REGISTRY_TOKEN — Gitea access token with `write:repository`, to push the
# tag and create the release. # tag and create the release. The final step also dispatches
# RunicGateway/installer's bundle workflow, so the token
# ideally has write there too — a nicety, not a requirement:
# 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. # REGISTRY_USER — the Gitea username that token belongs to.
# #
# TODO (Phase 0 item 3): once the installer repo's bundle workflow exists, append # These are checked by an explicit preflight step rather than left to fail
# a final step here that POSTs to its workflow-dispatch endpoint, so a new # wherever they happen to be used first — see the comment on that step for why
# overlay release recomposes the bundle immediately instead of waiting for the # an absent token does NOT simply fail the tag push.
# 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. # The final step POSTs to the installer repo's bundle workflow, so a new overlay
# release recomposes the compat matrix immediately instead of waiting for that
# repo's nightly cron (PLAN.md §7.2). It was deliberately absent until Phase 0
# item 3 landed something to dispatch — a step that 404s on every release is
# worse than no step.
name: Release overlay name: Release overlay
@@ -80,6 +88,9 @@ env:
# house style set by link (pre-1.0; the release version is independent of the # house style set by link (pre-1.0; the release version is independent of the
# protocol version, which lives in overlay.toml). # protocol version, which lives in overlay.toml).
SEED_VERSION: "0.1.0" SEED_VERSION: "0.1.0"
# Notified after a release so the installer's compat matrix picks up this
# overlay immediately rather than at its next nightly run (PLAN.md §7.2).
INSTALLER_REPO: RunicGateway/installer
jobs: jobs:
release: release:
@@ -93,6 +104,8 @@ jobs:
# ── RELEASE ENGINE: decide the next version + changelog ────────────── # ── RELEASE ENGINE: decide the next version + changelog ──────────────
- name: Plan the release (version + changelog) - name: Plan the release (version + changelog)
id: plan id: plan
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: | run: |
set -euo pipefail set -euo pipefail
mkdir -p dist mkdir -p dist
@@ -129,28 +142,101 @@ jobs:
VERSION="$(bump "${LAST_TAG#v}" "$BUMP")" VERSION="$(bump "${LAST_TAG#v}" "$BUMP")"
fi 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 if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
echo "Tag v${VERSION} already exists — nothing to release." REL_HTTP="$(curl -s -o /dev/null -w '%{http_code}' \
RELEASE=false -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 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 "## ${ARTIFACT} v${VERSION}"
echo echo
FEATS="$(echo "$SUBJECTS" | grep -E '^feat' || true)" FEATS="$(echo "$CL_SUBJECTS" | grep -E '^feat' || true)"
FIXES="$(echo "$SUBJECTS" | grep -E '^(fix|perf)' || true)" FIXES="$(echo "$CL_SUBJECTS" | grep -E '^(fix|perf)' || true)"
[ -n "$FEATS" ] && { echo "### Features"; echo "$FEATS" | sed 's/^/- /'; echo; } [ -n "$FEATS" ] && { echo "### Features"; echo "$FEATS" | sed 's/^/- /'; echo; }
[ -n "$FIXES" ] && { echo "### Fixes"; echo "$FIXES" | sed 's/^/- /'; echo; } [ -n "$FIXES" ] && { echo "### Fixes"; echo "$FIXES" | sed 's/^/- /'; echo; }
echo "### All changes" echo "### All changes"
if [ -n "$LAST_TAG" ]; then echo "Since ${LAST_TAG}:"; fi if [ -n "$SINCE" ]; then echo "Since ${SINCE}:"; fi
echo "$SUBJECTS" | sed 's/^/- /' echo "$CL_SUBJECTS" | sed 's/^/- /'
} > dist/CHANGELOG.md } > dist/CHANGELOG.md
echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT" echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
echo "release=${RELEASE}" >> "$GITHUB_OUTPUT" echo "release=${RELEASE}" >> "$GITHUB_OUTPUT"
echo "bump=${BUMP}" >> "$GITHUB_OUTPUT" echo "bump=${BUMP}" >> "$GITHUB_OUTPUT"
echo "==> release=${RELEASE} version=${VERSION} bump=${BUMP} last_tag=${LAST_TAG:-<none>}" 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 - name: Install jq
if: ${{ steps.plan.outputs.release == 'true' }} if: ${{ steps.plan.outputs.release == 'true' }}
@@ -177,6 +263,10 @@ jobs:
# needing the target files present. # needing the target files present.
# • each patch's companion .cs must exist, since it references symbols # • each patch's companion .cs must exist, since it references symbols
# the patch introduces and is meaningless without it (PLAN.md §2.2). # the patch introduces and is meaningless without it (PLAN.md §2.2).
# • patches/tier.json must describe every .patch and nothing but. That
# table is what tells the installer which patches form one unit, which
# companion follows which, and whether a CORE rebuild is needed — a
# patch added without it would be shipped and silently never offered.
- name: Validate the overlay and patch tier - name: Validate the overlay and patch tier
if: ${{ steps.plan.outputs.release == 'true' }} if: ${{ steps.plan.outputs.release == 'true' }}
run: | run: |
@@ -197,11 +287,46 @@ jobs:
git apply --stat "$p" || fail "${p} is not a parseable unified diff" git apply --stat "$p" || fail "${p} is not a parseable unified diff"
done done
# Companion files that can only be copied after their patch lands. # The tier table, checked in BOTH directions. A patch missing from
for f in patches/BridgeVendorSale.cs patches/BridgeModerationAudit.cs; do # tier.json ships but is never offered to an operator; a tier.json
[ -f "$f" ] || fail "${f} is missing (a patch's companion source)" # entry naming a file that is not there makes the installer report a
# feature it cannot apply. Neither surfaces until someone runs the
# tier on a live shard, so both fail the release here instead.
[ -f patches/tier.json ] || fail "patches/tier.json is missing (the patch-tier declaration)"
jq -e . patches/tier.json >/dev/null || fail "patches/tier.json is not valid JSON"
DESCRIBED="$(jq -r '.features[].patches[].file' patches/tier.json | LC_ALL=C sort)"
PRESENT="$(cd patches && ls *.patch | LC_ALL=C sort)"
if [ "$DESCRIBED" != "$PRESENT" ]; then
echo "described by tier.json:"; echo "$DESCRIBED" | sed 's/^/ /'
echo "present in patches/:"; echo "$PRESENT" | sed 's/^/ /'
fail "patches/tier.json and patches/*.patch disagree — every patch must be described by exactly one feature"
fi
# Each patch's declared target must be the file its diff actually
# edits. The installer cross-checks the same pair at install time and
# refuses on a mismatch, so catching it here saves an operator the run.
while IFS=$'\t' read -r PFILE PTARGET; do
DIFF_TARGET="$(sed -n 's|^+++ b/||p' "patches/${PFILE}" | head -1 | tr -d '\r')"
[ "$DIFF_TARGET" = "$PTARGET" ] \
|| fail "patches/${PFILE} edits ${DIFF_TARGET} but tier.json declares ${PTARGET}"
done < <(jq -r '.features[].patches[] | [.file, .target] | @tsv' patches/tier.json)
# Companions can only be copied after their feature's patches land, so
# they live here rather than in overlay/ — and a missing one turns a
# successfully patched shard into one that does not compile.
for f in $(jq -r '.features[].companions[].file' patches/tier.json); do
[ -f "patches/${f}" ] || fail "patches/${f} is missing (a feature's companion source)"
done done
for r in $(jq -r '.features[].rebuild' patches/tier.json); do
case "$r" in
core|scripts) ;;
*) fail "tier.json declares rebuild=\"${r}\"; only \"core\" or \"scripts\" are understood" ;;
esac
done
echo "patch tier: $(jq -r '.features | length' patches/tier.json) feature(s), $(echo "$PRESENT" | wc -l) patch(es)"
[ -f overlay.toml ] || fail "overlay.toml is missing (protocol + ServUO declarations)" [ -f overlay.toml ] || fail "overlay.toml is missing (protocol + ServUO declarations)"
# ── OVERLAY ADAPTER: stage, manifest, package ──────────────────────── # ── OVERLAY ADAPTER: stage, manifest, package ────────────────────────
@@ -224,6 +349,12 @@ jobs:
cp -r overlay "${STAGE}/overlay" cp -r overlay "${STAGE}/overlay"
cp -r patches "${STAGE}/patches" cp -r patches "${STAGE}/patches"
# tier.json is folded into manifest.json below, so the staged copy is
# removed: shipping it twice would give the tarball two statements of
# the same table, one of which nothing reads and both of which are
# free to drift.
rm -f "${STAGE}/patches/tier.json"
# Declarations from overlay.toml. Read, don't hardcode — the point of # Declarations from overlay.toml. Read, don't hardcode — the point of
# that file is that the protocol number lives in one place. # 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')" PROTOCOL="$(grep -m1 -E '^protocol[[:space:]]*=' overlay.toml | sed -E 's/[^0-9]//g')"
@@ -234,6 +365,18 @@ jobs:
[ -n "$PATCHED_AGAINST" ] || { echo "::error::could not read patches_verified_against 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}" echo "==> protocol=${PROTOCOL} min_servuo=${MIN_SERVUO} patches_verified_against=${PATCHED_AGAINST}"
# The patch tier, folded in verbatim minus its comment block. Paths are
# rewritten to be relative to the tarball root (`patches/<file>`), which
# is where the installer will find them after extraction — tier.json
# names them relative to patches/ because that is where a maintainer
# editing it is looking.
TIER="$(jq '
del(._comment)
| .features |= map(
.patches |= map(.file |= "patches/" + .)
| .companions |= map(.file |= "patches/" + .)
)' patches/tier.json)"
# Per-file SHA256 of everything shipped, as a {path: sha} object. The # Per-file SHA256 of everything shipped, as a {path: sha} object. The
# installer records these in install.json so a later `doctor` can tell # installer records these in install.json so a later `doctor` can tell
# "operator edited a deployed file" from "the overlay drifted". # "operator edited a deployed file" from "the overlay drifted".
@@ -257,6 +400,7 @@ jobs:
--argjson protocol "${PROTOCOL}" \ --argjson protocol "${PROTOCOL}" \
--arg min_servuo "${MIN_SERVUO}" \ --arg min_servuo "${MIN_SERVUO}" \
--arg patched_against "${PATCHED_AGAINST}" \ --arg patched_against "${PATCHED_AGAINST}" \
--argjson tier "${TIER}" \
--argjson files "${FILES}" \ --argjson files "${FILES}" \
'{ '{
component: $component, component: $component,
@@ -268,6 +412,7 @@ jobs:
min_version: $min_servuo, min_version: $min_servuo,
patches_verified_against: $patched_against patches_verified_against: $patched_against
}, },
patch_tier: $tier,
files: $files files: $files
}' > "${STAGE}/manifest.json" }' > "${STAGE}/manifest.json"
@@ -303,7 +448,18 @@ jobs:
git config user.email "ci@whitlocktech.com" git config user.email "ci@whitlocktech.com"
git remote set-url origin \ git remote set-url origin \
"https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${REPO}.git" "https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${REPO}.git"
git tag "${TAG}"
# 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}" git push origin "${TAG}"
# ── RELEASE ENGINE: create the Gitea release + upload assets ───────── # ── RELEASE ENGINE: create the Gitea release + upload assets ─────────
@@ -335,3 +491,45 @@ jobs:
-F "attachment=@dist/${f}" >/dev/null -F "attachment=@dist/${f}" >/dev/null
echo " uploaded ${f}" echo " uploaded ${f}"
done done
# ── Recompose the installer's bundle manifest ────────────────────────
# The installer does not resolve "latest" at run time — it deploys the
# exact overlay named by a published bundle (docs/installer/PLAN.md §7.1).
# An overlay 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 the sidecar's PROTOCOL_VERSION before publishing
# anything (PLAN.md §7.1, gate 1) — which is the check this repo cannot
# perform for itself, since the C# plugin announces no version on the wire.
#
# 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

@@ -33,6 +33,13 @@ under `overlay/` (or `patches/` for changes to stock ServUO files) and deploy:
.\deploy.ps1 -ServerPath C:\path\to\servuo .\deploy.ps1 -ServerPath C:\path\to\servuo
``` ```
`deploy.ps1` deploys from *this working tree*, which is what you want while
developing. It is not how a shard is set up: operators run the
[Runic Gateway installer](https://gitea.whitlocktech.com/RunicGateway/installer),
which syncs the released overlay tarball and installs the sidecar alongside it.
Changes here reach shards through a [release](README.md#releases), so a change
that only works when `deploy.ps1` copies it is a change that does not ship.
- `overlay/` — copied over an install (the only thing `deploy.ps1` deploys). - `overlay/` — copied over an install (the only thing `deploy.ps1` deploys).
- `patches/` — unified diffs against stock ServUO for files we must modify. - `patches/` — unified diffs against stock ServUO for files we must modify.
- `tools/` — never deployed: test scaffolding and stub sidecars. - `tools/` — never deployed: test scaffolding and stub sidecars.

View File

@@ -26,7 +26,7 @@ integration guide, protocol spec, research — with full history preserved).
| `overlay/` | Mirrors the ServUO server root. Everything here — and **only** this — copies over an install. | | `overlay/` | Mirrors the ServUO server root. Everything here — and **only** this — copies over an install. |
| `patches/` | Unified diffs against stock ServUO for files we must modify rather than add. | | `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. | | `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. | | `deploy.ps1` | **Developer tool** — copies `overlay/` from this working tree into a server root. `-Verify` diffs instead of writing. Operators use the [installer](https://gitea.whitlocktech.com/RunicGateway/installer); see [Deploy](#deploy). |
| `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). | | `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`. | | `.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. | | [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. |
@@ -39,14 +39,17 @@ Anything under `overlay/` is authoritative. Do not edit files in the server tree
## Sidecar & deployment ## Sidecar & deployment
The Rust sidecar is the other half of the bridge and lives in **[RunicGateway/link](https://gitea.whitlocktech.com/RunicGateway/link)**. The Rust sidecar is the other half of the bridge and lives in **[RunicGateway/link](https://gitea.whitlocktech.com/RunicGateway/link)**.
The two are deployed **together** but built **independently**: The two are deployed **together** — by the
[installer](https://gitea.whitlocktech.com/RunicGateway/installer), in one run — but built
**independently**:
- **This plugin** is deployed as *source* `deploy.ps1` copies `overlay/` into the ServUO server - **This plugin** is deployed as *source*: `overlay/` is copied into the ServUO server root and
root, and ServUO compiles it at boot (`Scripts.csproj`; see [Phase 0](#phase-0--what-it-fixes)). ServUO compiles it at boot (`Scripts.csproj`; see [Phase 0](#phase-0--what-it-fixes)). There is
There is **no CI build** — it cannot be compiled standalone without the ServUO reference **no CI build** — it cannot be compiled standalone without the ServUO reference assemblies. CI
assemblies. CI does publish a *source* tarball for the installer to fetch; see publishes a *source* tarball, which is what the installer fetches and syncs; see
[Releases](#releases). [Releases](#releases).
- **The sidecar** is a standalone Rust binary, released from its own repo. - **The sidecar** is a standalone Rust binary, released from its own repo and installed from that
release.
The **only** coupling is the loopback JSON protocol (the shard dials out to the sidecar on The **only** coupling is the loopback JSON protocol (the shard dials out to the sidecar on
`127.0.0.1`). Compatibility is a **protocol** concern, not a build-order one: keep the event/command `127.0.0.1`). Compatibility is a **protocol** concern, not a build-order one: keep the event/command
@@ -57,14 +60,32 @@ without the sidecar running.
## Deploy ## Deploy
**On a shard, use the [Runic Gateway installer](https://gitea.whitlocktech.com/RunicGateway/installer).**
One binary syncs this overlay from the release tarball below, offers the patch tier, installs the
uo-link sidecar as a service, and prints the values your website needs — cross-platform, with a
`doctor` afterwards to tell a copied file from a working bridge:
```bash
sudo ./runicgateway-installer-linux-x86_64 install
```
Guide: [installer/INSTALL.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md).
To place the overlay yourself instead — a host that cannot run the binary, or you want to see every
file land — [Appendix A2](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md#a2-deploy-the-plugin-overlay)
is the same copy done by hand, and stays supported.
### `deploy.ps1` — the developer path
`deploy.ps1` deploys from a **working tree**, which is what you want while writing plugin code and
is the one thing the installer cannot do (it deploys from a release):
```powershell ```powershell
.\deploy.ps1 -ServerPath <servuo> -Verify # show what would change .\deploy.ps1 -ServerPath <servuo> -Verify # show what would change
.\deploy.ps1 -ServerPath <servuo> # write .\deploy.ps1 -ServerPath <servuo> # write
``` ```
`deploy.ps1` is the **developer-facing** tool and stays that way. Operators get the It is Windows-only and stays developer-facing; it never installs the sidecar, registers a service,
[Runic Gateway installer](https://gitea.whitlocktech.com/RunicGateway/installer), which does the or checks the protocol pairing. Nothing shipped to an operator depends on it.
same sync cross-platform from the release tarball below.
## Releases ## Releases

View File

@@ -9,6 +9,14 @@ git apply --check patches/<name>.patch # dry run
git apply patches/<name>.patch git apply patches/<name>.patch
``` ```
## `tier.json` — adding or changing a patch
A `.patch` file does not say enough on its own. The Runic Gateway installer's patch tier also has to know which patches form **one all-or-nothing unit**, which companion `.cs` may only be copied once that unit has landed, whether the change needs a **core** solution rebuild or just the dynamic script build, and what the operator loses by declining. None of that is derivable from a diff, so it is declared in [`tier.json`](tier.json).
**Adding a patch means adding it there in the same PR.** The release workflow checks the table in both directions — every `.patch` described by exactly one feature, every named patch and companion present, every `target` equal to the file the diff actually edits — so a patch without an entry fails the release rather than shipping a tier that silently never offers it.
`tier.json` is folded into the tarball's `manifest.json` as `patch_tier` and removed from the staged `patches/` directory, so the artifact carries exactly one copy of the table and it is the one the installer reads. Installers older than this key ignore it; an installer newer than the overlay it is deploying falls back to a built-in copy. See `docs/installer/PLAN.md` §2.2 and §7.0.
## Phase 7 — player-vendor sale (a coupled unit) ## Phase 7 — player-vendor sale (a coupled unit)
Player-vendor purchases raise **no** EventSink. `ValidVendorPurchase` / `ValidVendorSell` cover NPC vendors only. The commit point is `PlayerVendorBuyGump.OnResponse`, the only place where buyer, vendor **owner**, price, and commission are all in scope — exactly what cheat detection needs. See [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §6. Player-vendor purchases raise **no** EventSink. `ValidVendorPurchase` / `ValidVendorSell` cover NPC vendors only. The commit point is `PlayerVendorBuyGump.OnResponse`, the only place where buyer, vendor **owner**, price, and commission are all in scope — exactly what cheat detection needs. See [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §6.

69
patches/tier.json Normal file
View File

@@ -0,0 +1,69 @@
{
"_comment": [
"The patch tier, described for the Runic Gateway installer.",
"",
"A .patch file on its own does not say enough to run the tier safely. The installer",
"additionally has to know which patches form ONE all-or-nothing unit (the two",
"vendor-sale patches are useless apart), which companion .cs may only be copied once",
"that unit has landed, whether the change needs a CORE solution rebuild or just the",
"dynamic script build, and what capability the operator loses by declining. None of",
"that is derivable from the diffs, so it is declared here.",
"",
"This file is the maintainer-facing source of truth. release.yml folds it into",
"manifest.json as `patch_tier` and removes it from the staged patches/ directory, so",
"the tarball carries exactly one copy and it is the one the installer reads",
"(docs/installer/PLAN.md §7.0). CI also asserts that every .patch here is named by",
"exactly one feature and every named patch and companion exists — adding a patch",
"without describing it fails the release rather than shipping a tier that silently",
"ignores it.",
"",
"Older installers ignore `patch_tier` entirely, and an installer newer than the",
"overlay it is deploying falls back to its own built-in copy of this table."
],
"features": [
{
"name": "vendor-sale",
"summary": "vendor.sale events — player-vendor purchases with buyer, owner, item, price and commission",
"lost": "no vendor.sale events",
"rebuild": "core",
"patches": [
{
"name": "playervendor-sale-eventsink",
"file": "playervendor-sale-eventsink.patch",
"target": "Server/EventSink.cs"
},
{
"name": "playervendor-sale-gump",
"file": "playervendor-sale-gump.patch",
"target": "Scripts/Gumps/PlayerVendorGumps.cs"
}
],
"companions": [
{
"file": "BridgeVendorSale.cs",
"install_to": "Scripts/Custom/Bridge/BridgeVendorSale.cs"
}
]
},
{
"name": "moderation-audit",
"summary": "in-game moderation actions ([ban, [kick, [bcast) forwarded to the website as admin.audit",
"lost": "no in-game moderation audit forwarding",
"rebuild": "scripts",
"patches": [
{
"name": "commandlogging-event",
"file": "commandlogging-event.patch",
"target": "Scripts/Commands/Logging.cs"
}
],
"companions": [
{
"file": "BridgeModerationAudit.cs",
"install_to": "Scripts/Custom/Bridge/BridgeModerationAudit.cs"
}
]
}
]
}