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>
This commit is contained in:
2026-08-05 01:08:31 +00:00
3 changed files with 139 additions and 3 deletions

View File

@@ -263,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: |
@@ -283,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 ────────────────────────
@@ -310,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')"
@@ -320,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".
@@ -343,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,
@@ -354,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"

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"
}
]
}
]
}