diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 2b90211..821ed8f 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -263,6 +263,10 @@ jobs: # 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). + # • 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 if: ${{ steps.plan.outputs.release == 'true' }} run: | @@ -283,11 +287,46 @@ jobs: 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)" + # The tier table, checked in BOTH directions. A patch missing from + # tier.json ships but is never offered to an operator; a tier.json + # 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 + 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)" # ── OVERLAY ADAPTER: stage, manifest, package ──────────────────────── @@ -310,6 +349,12 @@ jobs: cp -r overlay "${STAGE}/overlay" 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 # 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')" @@ -320,6 +365,18 @@ jobs: [ -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}" + # The patch tier, folded in verbatim minus its comment block. Paths are + # rewritten to be relative to the tarball root (`patches/`), 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 # installer records these in install.json so a later `doctor` can tell # "operator edited a deployed file" from "the overlay drifted". @@ -343,6 +400,7 @@ jobs: --argjson protocol "${PROTOCOL}" \ --arg min_servuo "${MIN_SERVUO}" \ --arg patched_against "${PATCHED_AGAINST}" \ + --argjson tier "${TIER}" \ --argjson files "${FILES}" \ '{ component: $component, @@ -354,6 +412,7 @@ jobs: min_version: $min_servuo, patches_verified_against: $patched_against }, + patch_tier: $tier, files: $files }' > "${STAGE}/manifest.json" diff --git a/patches/README.md b/patches/README.md index 070d6bc..2fcd195 100644 --- a/patches/README.md +++ b/patches/README.md @@ -9,6 +9,14 @@ git apply --check patches/.patch # dry run git apply patches/.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) 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. diff --git a/patches/tier.json b/patches/tier.json new file mode 100644 index 0000000..0a29e4d --- /dev/null +++ b/patches/tier.json @@ -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" + } + ] + } + ] +}