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>
This commit is contained in:
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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
69
patches/tier.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user