Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c045bdd566 | |||
| 8828382e41 | |||
| 7fa8953ffa | |||
| 4720a214a2 |
@@ -51,18 +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.
|
||||||
#
|
#
|
||||||
# These are checked by an explicit preflight step rather than left to fail
|
# 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
|
# 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.
|
# an absent token does NOT simply fail the tag push.
|
||||||
#
|
#
|
||||||
# TODO (Phase 0 item 3): once the installer repo's bundle workflow exists, append
|
# The final step POSTs to the installer repo's bundle workflow, so a new overlay
|
||||||
# a final step here that POSTs to its workflow-dispatch endpoint, so a new
|
# release recomposes the compat matrix immediately instead of waiting for that
|
||||||
# overlay release recomposes the bundle immediately instead of waiting for the
|
# repo's nightly cron (PLAN.md §7.2). It was deliberately absent until Phase 0
|
||||||
# nightly cron (PLAN.md §7.2). Deliberately absent until there is something to
|
# item 3 landed something to dispatch — a step that 404s on every release is
|
||||||
# dispatch — a step that 404s every release is worse than no step.
|
# worse than no step.
|
||||||
|
|
||||||
name: Release overlay
|
name: Release overlay
|
||||||
|
|
||||||
@@ -84,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:
|
||||||
@@ -256,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: |
|
||||||
@@ -276,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 ────────────────────────
|
||||||
@@ -303,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')"
|
||||||
@@ -313,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".
|
||||||
@@ -336,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,
|
||||||
@@ -347,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"
|
||||||
|
|
||||||
@@ -425,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
|
||||||
|
|||||||
@@ -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