Documentation half of installer Phase 0.3. Code PRs: RunicGateway/installer#3, RunicGateway/link#25, RunicGateway/servuo-plugins#9. Phase 0 item 3 asked for a compose job, a nightly cron, and a dispatch step in each component. All three landed, and building them settled questions §7 had left open — so those sections are corrected rather than appended to. Status: Phase 0 is now all but complete. 0.2 is merged and released as link v1.1.0; 0.3 is in review; 0.4 (INSTALL.md) is the remaining item, and the shape it was waiting on has settled. Phase 0 item 3 gains an "As built" subsection matching items 1 and 2, recording what was chosen rather than inherited: why gate 1 reads the sidecar's protocol from source at the release tag instead of asking the binary via --print-config (it only answers for v1.1.0+, and --bundle <tag> must be able to recompose an older pair); why gate 2 records the hash CI computed itself and separately checks for assets absent from SHA256SUMS; why release metadata is read anonymously; why an unrecognized asset name is a hard failure; and why a run that changes nothing writes nothing. §7.1 is corrected in two places. The manifest shape now shows link.assets as a map keyed by platform — the single sha256 this section sketched could only ever have described one of the two binaries link publishes — plus schema/generated and the servuo block. And it gains a subsection naming where bundles are published: committed under bundles/ in the installer repo, NOT one Gitea release per bundle, because that repo's own releases are the installer binaries and /releases/latest returns whichever release is newest regardless of kind. §7.2 records that the dispatch steps now exist, and that a failed dispatch is a warning rather than a failed release — which is what keeps the installer repo's token out of the components' hard requirements. §7.3 records that the releasable-commit rule must also exclude merge commits, whose subject quotes the real one: without that, merging a docs: branch whose title mentions a fix: would re-dispatch a declining release workflow nightly. Co-Authored-By: Claude <noreply@anthropic.com>
39 KiB
Runic Gateway Installer — plan
Status: Phase 0 all but complete — every prerequisite in another repo has landed, and the
installer repo now publishes the bundle manifest, so what the installer will install is already
released and composed ahead of the binary that installs it. No installer code exists yet; 0.4
(INSTALL.md) is the remaining item, then Phase 1. This document is the design of record;
it supersedes the informal overview it grew out of, which described a ServUO integration that does
not match how servuo-plugins actually ships (see
Corrections).
| Phase 0 item | State |
|---|---|
0.1 servuo-plugins release workflow |
✅ Merged — servuo-plugins#7 + #8; first overlay release is v0.1.1 |
0.2 link installable (data paths + --print-config) |
✅ Merged — link#24 (docs half docs#84); released as v1.1.0 |
| 0.3 Bundle CI in the installer repo | 🟨 In review — installer#3, plus the dispatch step in each component (link#25, servuo-plugins#9). First bundle: 2026.08.04 |
0.4 This file + INSTALL.md |
🟦 This file exists; INSTALL.md is next — the shape has now settled |
| — Repo bootstrap (governance + CI) | ✅ RunicGateway/installer created; workflows merged (installer#1, #2) |
1. Purpose
Take a stock ServUO installation and configure it for Runic Gateway with minimal manual steps, while keeping the components separated and independently maintainable.
The installer handles environment detection, ServUO overlay deployment, the optional stock-file patch tier, uo-link installation and service registration, version tracking, diagnostics, and updates from Gitea releases.
It is a deployment tool, not a hosted bootstrapper. There is no curl | bash, no installer
service, and no hosted bootstrap script. Artifacts are downloaded from a Gitea release page and run.
It does not replace ServUO startup behavior. ServUO keeps running through its existing release/start scripts. The installer never writes a launcher.
Decisions locked
| Question | Decision |
|---|---|
| Audience | Public — any ServUO operator, not just shards we run |
| Code signing | Unsigned. SHA256SUMS is the trust anchor; SmartScreen/Gatekeeper warnings are expected and documented, as with most self-hosted tooling |
| Language | Rust — single static binary per OS, reuses the cross-compile pattern already proven in link/.gitea/workflows/release.yml |
| Plugin source | Release tarball artifact — no git and no Gitea credentials on the shard host |
| Composition | Published bundle manifest (§7.1). CI names an exact, protocol-checked combination of component versions; the installer fetches it at run time and --bundle <tag> pins one. Component releases regenerate JSON, not the installer binary |
| Token handoff | Print token + prefilled admin URL at the end of the run |
| Repo | New repo, RunicGateway/installer. It deploys both other components, so living inside link/ would invert the dependency |
| ServUO version | Warn and skip. Patches are verified against stock 57.4 only; on anything else the base install proceeds and the patch tier is skipped with a warning. Forks are the norm in a public audience — refusing outright would block most operators |
| Uninstall | Never touches the ServUO tree. Removes uo-link and its service entry, then prints the overlay files to delete and the patch hunks to revert. Reverting is the operator's call |
2. Corrections to the original overview
These are not wording nits — each one changes what the installer has to do.
2.1 There is no RunicGateway.dll and no Plugins/ directory
The plugin ships as C# source and ServUO compiles it at boot. The real deployable is
servuo-plugins/overlay/, which mirrors the server root:
overlay/
├── Config/Bridge.cfg
└── Scripts/
├── Scripts.csproj # Phase 0 — whole-file overwrite of a stock file
└── Custom/Bridge/*.cs # 22 files
So the plugin step is a hash-compare file sync, not a DLL drop — mechanically easier than the
overview assumed. The sting is that a successful copy does not mean a working bridge. Per
link/SHARD_PREREQS.md, ScriptCompiler.Compile() shells out to dotnet build, prints the output,
ignores the exit code, and reloads the existing Scripts.dll. A broken script build is
invisible: the shard boots clean on stale code. Diagnostics must therefore verify post-boot state,
never treat "files copied" as success.
2.2 Stock ServUO files are modified — by an optional tier
servuo-plugins/patches/ holds unified diffs against stock ServUO 57.4, plus two .cs files that
can only be copied after their patch lands (they reference symbols the patch introduces):
| Patch | Target | Companion file | Rebuild required |
|---|---|---|---|
playervendor-sale-eventsink.patch |
Server/EventSink.cs |
BridgeVendorSale.cs |
Core — dotnet build ServUO.sln; the dynamic script build is not enough |
playervendor-sale-gump.patch |
Scripts/Gumps/PlayerVendorGumps.cs |
(same unit as above) | script build |
commandlogging-event.patch |
Scripts/Commands/Logging.cs |
BridgeModerationAudit.cs |
script build |
Plus overlay/Scripts/Scripts.csproj, which overwrites a stock file (Phase 0 — it fixes the silent
ServUO build bug above).
This is the hardest part of the installer. git apply against a hand-modified shard will fail, and
most real shards are hand-modified. Therefore:
- The patch tier is opt-in and skippable. The base install must complete without it.
- Always dry-run (
git apply --check) before applying, and report per-patch. - When skipped or failed, say plainly what is lost: no
vendor.saleevents, no in-game moderation audit forwarding. - The
EventSink.cspatch must warn loudly that a core solution rebuild is required, not just a shard restart. - On any ServUO version other than stock 57.4, skip the whole tier with a warning and continue with the base install. Do not attempt to apply unverified diffs to an unknown tree.
- Record applied patches in
install.json, and cache the applied.patchfiles next to it (/etc/runicgateway/patches/,%ProgramData%\RunicGateway\patches\). Re-runs stay idempotent, and uninstall can print the exact hunks offline long after the release tarball is gone (§5, Phase 4).
2.3 Config paths collide with what the sidecar actually reads
The sidecar reads $UOLINK_CONFIG, else sidecar.toml in the working directory
(link/sidecar/src/config.rs), with keys [shard].bind, [web].bind, [web].auth_token,
[store].path. The overview proposed a config.toml with [updates], [link], [servuo] — keys
the sidecar cannot read.
Two files, two owners:
| File | Owner | Contents |
|---|---|---|
/etc/runicgateway/sidecar.toml |
uo-link | The sidecar's own schema, unchanged. Service sets UOLINK_CONFIG to this path |
/etc/runicgateway/install.json |
installer | Deployed versions, file hashes, applied patches, ServUO path, timestamps |
Working-directory trap: the sidecar wrote both sidecar.toml and uo-link.db relative to CWD.
Under C:\Program Files\ that fails or silently lands in VirtualStore. Phase 0.2 fixed the second
half in the sidecar — a relative [store].path now resolves against the directory holding
sidecar.toml, so pinning the config alone is enough to put the database somewhere deterministic —
but the config path itself is still CWD-relative by default, and "deterministic" is not the same as
"where this install wants it". The service definitions therefore still pin UOLINK_CONFIG and
UOLINK_DB_PATH explicitly:
- Linux: config
/etc/runicgateway/sidecar.toml, db/var/lib/runicgateway/uo-link.db, dedicated service user - Windows: binary under
%ProgramFiles%\RunicGateway\, data under%ProgramData%\RunicGateway\
2.4 The token handoff was missing entirely
The whole point is the website reaching the sidecar, and today that is manual and undocumented in the install flow: the sidecar generates a token on first run and logs it, then a human pastes base URL, WS URL, token, and protocol version into Admin → Shard, where it is AES-GCM encrypted and becomes write-only. This is the largest "I installed it and nothing happened" failure mode.
The installer closes it by printing a copy-paste block at the end of a successful run — see §6.
Phase 0.2 supplied the missing half of that: uo-link-sidecar --print-config provisions the config
if absent and prints the resolved settings — token, both binds, ws_path, protocol version, db
path — as JSON. The installer reads the block it prints out of that one call. It never parses the
log, which was the alternative and would have made the handoff depend on a log format that is not
a contract.
2.5 deploy.ps1 cannot be the cross-platform deployer
It is PowerShell-only; a Linux ServUO host running .NET typically has no pwsh. It also hard-throws
when the ServUO process is running — correct behavior, and the installer must inherit it (detect and
refuse, rather than corrupt a live Scripts.dll). The installer reimplements the sync natively; it
is a short hash-compare-and-copy that never deletes.
deploy.ps1 stays in servuo-plugins as the developer-facing tool. The installer is for
operators.
2.6 Prerequisites the overview assumed away
servuo-pluginshad no release workflow. Onlylinkdid. "Pull latest repository" is replaced by a release tarball, which had to be built first — Phase 0 item 1, now in review (servuo-plugins#7).- arm64 is not buildable today.
link/release.ymlcross-compiles onlyx86_64-unknown-linux-gnuandx86_64-pc-windows-gnu. An arm64.debneeds another cross toolchain. - The compat matrix has no home.
PROTOCOL_VERSIONlives inlink/sidecar/src/main.rs. The sidecar publishes it viaX-UOLink-Versionand/health, and the website stores an expected value — but the plugin's protocol version is not queryable before boot. Phase 0 item 1 gives it a home:servuo-plugins/overlay.toml, declared into the overlay manifest. See §7.0 / §7.1.
3. Distribution model
Components are published as Gitea release artifacts. Operators download from the release page
(browser, curl/wget, or scp to the server) and run the binary.
Runic Gateway Installer v1.0.0
├── runicgateway-installer-windows-x86_64.exe
├── runicgateway-installer-linux-x86_64
└── SHA256SUMS
uo-link v0.x.y (existing release, extended)
├── uo-link-sidecar-windows-x86_64.exe
├── uo-link-sidecar-linux-x86_64
├── runicgateway-link_<ver>_amd64.deb (Phase 5)
└── SHA256SUMS
servuo-plugins v<ver> (new release, Phase 0)
├── runicgateway-overlay-<ver>.tar.gz # overlay/ + patches/ + manifest.json
└── SHA256SUMS
Binding those together is the bundle manifest (§7.1) — published by the installer repo's CI, not by any component, and the thing the installer actually resolves against.
scp runicgateway-installer-linux-x86_64 user@server:/tmp/
chmod +x runicgateway-installer-linux-x86_64
sudo ./runicgateway-installer-linux-x86_64
Unsigned-binary posture
Because releases are unsigned, trust is anchored on checksums and the operator's own verification. The docs must state this up front rather than let users discover it as a scary dialog:
- Every release publishes
SHA256SUMS; the install docs lead with the verification command for both OSes. - Windows will show a SmartScreen "unrecognized app" prompt. Documented, with the exact click path.
- The installer verifies the SHA256 of everything it downloads (overlay tarball, sidecar binary)
against the release's
SHA256SUMSand refuses on mismatch. Self-verification is not optional just because the installer itself is unsigned. - Revisit signing if it ever becomes affordable; the release layout should not have to change.
4. Component architecture
Runic Gateway Installer (Rust, one binary per OS)
│
┌───────────────┴────────────────┐
▼ ▼
ServUO integration uo-link
│ │
┌────────┴────────┐ ┌────────┴────────┐
▼ ▼ ▼ ▼
overlay sync patch tier (opt-in) binary install service registration
(never deletes) (git apply + guard) + config + data (systemd / Windows SCM)
Each component keeps its own lifecycle. ServUO's existing startup process is untouched.
5. Phases
Phase 0 — prerequisites (no installer code)
Repo work that must land before an installer can exist.
-
servuo-plugins: add.gitea/workflows/release.yml. Retarget the release engine half oflink/release.yml(its header comment explicitly anticipates this — the plan/release steps consume only{version, changelog, artifacts}). The adapter half producesrunicgateway-overlay-<ver>.tar.gzcontainingoverlay/,patches/, and amanifest.json(version, commit, per-file SHA256, declared protocol version, minimum ServUO version).As built (servuo-plugins#7), with three deviations from
link's copy that each fell out of the repo rather than being chosen:- No build gates, structural gates instead. Nothing in that repo can be compiled without
ServUO reference assemblies, so CI asserts what it honestly can:
Bridge.cfgand the Bridge scripts present,Scripts.csprojpresent (its absence ships code that never compiles while ServUO reports success — §2.1), every.patchparseable viagit apply --stat, and each patch's companion.cspresent. - No bump commit, so no push to
main.linkwrites the version intoCargo.tomlbecause the binary embeds it; the tarball embeds nothing but the generated manifest, so the tag is the version. That workflow needs no branch-protection exception. overlay.tomlat the repo root holds the declaredprotocoland the ServUO compatibility values, read by CI into the manifest. It exists because the number needs one maintained home — see §7 for why the plugin cannot simply be asked.
The tarball uses a fixed top-level directory,
runicgateway-overlay/, not a versioned one: the installer looks foroverlay/,patches/andmanifest.jsonat known paths rather than parsing the version it is trying to read. Member order, mtime and ownership are pinned, so a given tree yields a byte-identical tarball and its checksum moves only when its contents do. - No build gates, structural gates instead. Nothing in that repo can be compiled without
ServUO reference assemblies, so CI asserts what it honestly can:
-
link: make the sidecar installable. Confirm/settle default data paths, and add a way to read back config non-interactively (e.g.--print-configemitting JSON: bind addresses, token, protocol version, db path) so the installer does not have to scrape logs for the token.As built (link#24) — the sidecar had no CLI at all before this, so the shape was chosen rather than inherited:
- Four flags, hand-rolled:
--print-config,--config <PATH>,--version,--help. No argument-parsing crate — it would be larger than the code it replaced — and deliberately no flags that duplicate a config key, sosidecar.tomlstays the single place settings live. An unrecognized argument exits2; silently ignoring a typo'd flag would start a sidecar that is not the one the installer asked for. --print-configperforms first-run setup rather than only reporting. It runs the same load path a normal start does, so a missing config file is written and a blank token is generated and saved. That collapses "provision the sidecar" and "find out its token" into one non-interactive call — which is exactly the sequence §6 needs.config_createdandtoken_generatedsay whether this run did either, because the values alone cannot distinguish a fresh install from a re-read of an existing one, and a re-run must not report a token as newly minted.- The document is the whole of stdout. The log subscriber writes to stdout, so it is not
started in this mode.
ws_pathis emitted from the same constant the route is registered with, so the installer's WebSocket URL cannot drift from the server's. - Relative
[store].pathnow anchors to the config file's directory, not the CWD — see §2.3, which this half-closes on the sidecar side. Absolute paths are used as written; parent directories are created;:memory:andfile:URIs are left alone. - The db path is handed to sqlx as a path, not a
sqlite://URL. The URL spelling is parsed as one: it percent-decodes the path and splits it on?, so an installed path containing%20opened a different file than the operator named. - No platform data directories are compiled in. That is the "settle" half of this item, and
the answer is that the installer owns layout (§2.3) and pins
UOLINK_CONFIG/UOLINK_DB_PATHin the service definition. Baking/etcand%ProgramData%defaults into the binary would give the same paths two owners and breakcargo runin a working tree.
- Four flags, hand-rolled:
-
Bundle CI in the installer repo (§7). Compose job (read both repos' latest releases → run the two gates → publish
bundle.json), the nightly cron, and the dispatch step appended to each component's release workflow. This must exist before Phase 1 is useful, since the installer resolves what to install from the bundle.As built (installer#3, link#25, servuo-plugins#9) —
installer/.gitea/workflows/bundle.yml, with the decisions §7 had left open:- Bundles are committed to the installer repo, not published as releases — see §7.1 for where and why. That was the one genuinely open question here, and the deciding factor is that this repo's own releases are the installer binaries.
- Gate 1 reads the sidecar's protocol from source at the release tag, not from the binary.
--print-config(Phase 0.2) would answer authoritatively, but only for releases fromv1.1.0onward, and--bundle <tag>has to be able to recompose a bundle from an older pair. Readingsidecar/src/main.rsat the tag the release was built from works uniformly, needs no execution of a downloaded artifact, and does not provision a throwaway config whose auth token would then be sitting in a CI log. A constant that has moved or been renamed is a hard failure — treating "could not read" as "matches" is exactly how a mismatched pair would ship. - Gate 2 records the hash CI computed itself, after verifying the download against the
publishing repo's
SHA256SUMS. It also asserts the reverse direction — an asset with noSHA256SUMSentry — becausesha256sum -csilently passes over a file the sums file does not mention, which would put an unverified artifact in the bundle. - Release metadata is read anonymously, on purpose: those are exactly the requests the shipped installer makes on a host with no Gitea credentials, so a repo flipped to private fails CI here instead of on an operator's machine.
- An unrecognized asset name is a hard failure. link's binaries are mapped onto platform keys by suffix; adding a target (aarch64, macOS) to its release workflow therefore reddens this job rather than silently omitting the new binary from every bundle.
- A run that changes nothing writes nothing — the comparison excludes
bundleandgenerated, which are metadata about the run. Without that the nightly cron would commit a dated duplicate of the same matrix every morning.
The workflow's compose steps were run against the live releases before merge, producing the first bundle (
2026.08.04: linkv1.1.0+ overlayv0.1.1, protocol 3), which is committed so the manifest exists ahead of the binary that reads it. -
docs: this file, plusdocs/installer/INSTALL.md(the operator-facing guide) once the shape is settled.
Phase 1 — installer core
- ServUO root detection and validation (
ServUO.exe,Scripts/,Config/), with version detection and an explicit refusal when the ServUO process is running. - Overlay sync: fetch tarball → verify SHA256 → hash-compare against the server tree → add/change,
never delete. Port of
deploy.ps1semantics including its-Verifydry run (--verify). - Write
install.json: component, version, source commit, per-file hashes, applied patches, timestamp. - Idempotent re-runs; a second run with no upstream change reports "unchanged" and writes nothing.
Phase 2 — uo-link install and service
- Linux: binary →
/usr/bin/runicgateway-link, config →/etc/runicgateway/sidecar.toml, db →/var/lib/runicgateway/, systemd unit with a dedicated user,enable+start. - Windows:
%ProgramFiles%\RunicGateway\, data in%ProgramData%\RunicGateway\, service registration with automatic start and restart-on-failure. - Both:
UOLINK_CONFIGandUOLINK_DB_PATHpinned in the service definition (§2.3). - Token surfacing (§6): run the installed binary once as
uo-link-sidecar --print-config --config <the pinned path>before registering the service. That both writes the config the service will read and returns the token to print, so the service never starts against a config that does not exist yet.
Phase 3 — patch tier (opt-in)
Everything in §2.2. Detect applicability, dry-run, apply, record, warn about the core rebuild, and degrade loudly rather than silently.
Phase 4 — diagnostics and updates
runicgateway doctor — the command that makes the whole thing supportable:
✓ ServUO found /opt/ServUO (57.4)
✓ Overlay in sync 30 files, all hashes match install.json
⚠ Patch tier 1 of 3 applied — vendor.sale unavailable
✓ uo-link installed 0.3.0
✓ Service running, enabled
✓ Sidecar reachable 127.0.0.1:8080 /health ok
✓ Protocol sidecar 3 = overlay manifest 3
✗ Shard connected no shard has dialed in since boot
The last check matters most: it is the only thing that distinguishes "files copied" from "the bridge actually works" (§2.1).
Three of those rows are answered by the sidecar's own CLI rather than by inspecting the filesystem:
--version prints uo-link-sidecar <ver> (protocol <n>), and --print-config gives the config and
db paths the installed service resolves — so doctor reports what the binary would actually do,
not what install.json believes it was told to do. The protocol row compares that number against
the overlay manifest's declared one (§7.0).
runicgateway update — resolves the current bundle (§7.1), then acts asymmetrically by component,
deliberately:
- uo-link: compare the bundle's version against what is installed → download → verify checksum → replace binary → restart service.
- plugin overlay: download the bundle's overlay tarball → verify → re-sync → record commit → tell the operator ServUO must restart (the installer does not restart the shard).
Because both come from one bundle, an update always moves to a combination whose protocol versions were checked together, rather than to two independently-latest artifacts that may disagree.
runicgateway uninstall — removes only what it exclusively owns, and never edits the ServUO
tree. The installer cannot know what the operator has changed in those files since deployment, so
a clever automatic revert risks silently eating their work. It removes and it reports:
| Action | Scope |
|---|---|
| Removed | uo-link binary, its service entry (systemd unit / Windows service), install.json and the cached patch set |
| Kept | sidecar.toml and uo-link.db (config and history survive; --purge to drop them) |
| Printed, not done | Every overlay file deployed into the ServUO tree, listed by path, for the operator to delete |
| Printed, not done | The exact hunks each applied patch added to EventSink.cs, PlayerVendorGumps.cs, Logging.cs, rendered from the cached .patch files, for the operator to revert by hand |
The printed report is also written to a file, so it survives the terminal scrollback of a long uninstall.
Phase 5 — packaging polish
.deb packaging, Windows MSI, arm64 cross build, and optional automated backup before upgrade.
Deliberately last: v1 can register services directly (sc create / a written systemd unit) and ship
plain binaries. Nothing in Phases 1–4 should have to change to add these.
6. Token handoff (the end of a successful run)
Runic Gateway is installed.
One manual step remains — connect the website to this sidecar:
Base URL http://<this-host>:8080
WebSocket URL ws://<this-host>:8080/ws
Protocol version 3
Auth token 4f9c... (also in /etc/runicgateway/sidecar.toml)
Paste these into Admin → Shard on your Runic Gateway site:
https://<your-site>/admin/shard
The token is write-only once saved — the site will never show it back to you.
Every value in that block except the host and the site URL comes from one
uo-link-sidecar --print-config call (§2.4): web.auth_token, protocol, and web.bind +
web.ws_path for the two URLs. Only the host is substituted — web.bind is frequently
0.0.0.0, which is not something to hand a website — so the installer composes the URLs from the
host it detects or prompts for, rather than echoing the bind address.
The installer prompts for the site URL only to build that link; it never contacts the website. A future "installer registers itself with the website" flow (claim code + authenticated endpoint) is explicitly out of scope — it is real backend work in a security-sensitive area and can be added later without changing anything here.
The printed token is a secret in transit: --print-config output must go to the operator's
terminal and the config file, never into an installer log file or a support bundle.
7. Version tracking, the bundle, and release orchestration
Three components version independently, bound by a protocol contract:
- sidecar —
PROTOCOL_VERSIONinlink/sidecar/src/main.rs, exposed on/healthand asX-UOLink-Versionon every response; a mismatch is rejected409. - website — stores an expected protocol version in
uoLinkConfig(admin-managed). - plugin overlay — has no queryable version before ServUO boots. The overlay release
manifest.jsondeclares it, andinstall.jsonrecords what was deployed.
7.0 The overlay manifest
Shipped inside every runicgateway-overlay-<ver>.tar.gz, generated by that repo's release workflow:
{
"component": "servuo-plugins-overlay",
"version": "0.1.0",
"commit": "968b526…",
"repo": "RunicGateway/servuo-plugins",
"protocol": 3,
"servuo": { "min_version": "57.4", "patches_verified_against": "57.4" },
"files": { "overlay/Config/Bridge.cfg": "32718424…", "patches/…": "…" }
}
version and commit come from the release engine; protocol and the servuo block are read from
servuo-plugins/overlay.toml; files is a SHA256 per shipped file.
Two of these carry weight beyond documentation:
protocolis a hand-maintained declaration, and has to be. The plugin announces no version on the wire and none is queryable before ServUO boots, so nothing in CI can derive it — which makes this line the only thing §7.1's gate 1 has to compare the sidecar against. The duty is stated inoverlay.tomland in that repo's README: bump it in the same PR that changes the emitters, the waylinkbumpsPROTOCOL_VERSION.filesis what makesdoctorable to tell "the operator edited a deployed file" from "the overlay moved on" (§5, Phase 4). The installer copies these hashes intoinstall.jsonat deploy time; a later mismatch against both the manifest andinstall.jsonmeans upstream changed, a mismatch againstinstall.jsonalone means local edits.
min_version and patches_verified_against are separate on purpose. The base overlay only adds
files and is expected to work broadly; the patch tier diffs stock ServUO files and is verified
against exactly one version (§2.2).
7.1 The bundle manifest
The bundle is the compat matrix. Rather than the installer hardcoding versions or blindly resolving "latest", CI publishes a small manifest naming an exact, checked combination:
{
"schema": 1,
"bundle": "2026.08.04",
"generated": "2026-08-04T16:07:13Z",
"protocol": 3,
"link": {
"repo": "RunicGateway/link", "tag": "v1.1.0", "version": "1.1.0", "protocol": 3,
"assets": {
"linux-x86_64": { "name": "uo-link-sidecar-linux-x86_64", "url": "…", "sha256": "27d491ef…" },
"windows-x86_64": { "name": "uo-link-sidecar-windows-x86_64.exe", "url": "…", "sha256": "fbefd886…" }
}
},
"overlay": {
"repo": "RunicGateway/servuo-plugins", "tag": "v0.1.1", "version": "0.1.1",
"commit": "3a52abb…", "protocol": 3,
"servuo": { "min_version": "57.4", "patches_verified_against": "57.4" },
"asset": { "name": "runicgateway-overlay-0.1.1.tar.gz", "url": "…", "sha256": "75dc6d6c…" }
}
}
Note link.assets is a map keyed by platform, not the single sha256 this section originally
sketched: link publishes a Linux binary and a Windows .exe, and the installer runs on both, so one
hash could only ever have described one of them. schema versions this document's shape and is
independent of protocol and of either component's release version — all three move separately.
The installer fetches the current bundle at run time; --bundle <tag> pins an older one for a
reproducible install. Because the bundle is data, a new link release regenerates ~30 lines of
JSON and leaves the installer binary untouched — operators do not re-download the installer to
pick up a sidecar patch, and the installer does not accumulate releases whose code is byte-identical.
Two gates run at compose time, both cheap and both worth it:
- The sidecar's
PROTOCOL_VERSIONmust equal the overlay manifest's declared protocol version. This is the check that catches anedge/mainprotocol mismatch before it reaches an operator. The two halves are read from different places because they are different: the overlay's frommanifest.jsoninside the tarball (the only statement of it that exists — §7.0), the sidecar's fromsidecar/src/main.rsat the release tag (see Phase 0 item 3 for why not from the binary). - Every referenced asset must exist and its SHA256 must match the publishing repo's
SHA256SUMS. The hash recorded in the bundle is the one CI computed from the asset it downloaded, after that check — and the installer verifies every download against it. These artifacts are deliberately unsigned (§3), so the checksum is the whole trust anchor; a hash copied from a file nobody verified would make the chain decorative.
Where bundles are published
Committed to the installer repo under bundles/, so the installer's fetch is a plain anonymous
GET against a public repo — the shard host has no Gitea credentials (§1):
bundles/current.json → …/RunicGateway/installer/raw/branch/main/bundles/current.json
bundles/bundle-<tag>.json → …/raw/branch/main/bundles/bundle-2026.08.04.json (--bundle)
Every bundle is kept forever, so --bundle stays reproducible. Tags are UTC dates; a second bundle
on the same day — a sidecar release in the morning and an overlay release in the afternoon is the
normal way that happens — becomes 2026.08.04.2, so one tag always names exactly one matrix.
Not one Gitea release per bundle, which was the obvious alternative. This repo's own releases
are the installer binaries, and /releases/latest returns whichever release is newest regardless
of kind — interleaving bundle releases would make "latest" intermittently resolve to a release
carrying no installer binary. Committing also yields a reviewable diff and a git history of the
compat matrix, and needs no new branch-protection exception: release.yml's version-bump commit
already requires the CI user to be able to push to main.
7.2 What triggers a bundle
| Trigger | Why |
|---|---|
link publishes a release |
Its release job POSTs to the installer repo's workflow-dispatch endpoint as its final step |
servuo-plugins publishes a release |
Same. Phase 0 item 1 gave it the release workflow; the dispatch step was left as a marked TODO until there was something to dispatch, and landed with the bundle CI it calls (item 3) — a step that 404s on every release is worse than no step |
| Nightly cron on the installer repo | Recomputes from whatever the latest releases actually are, so a missed or failed dispatch self-heals instead of silently pinning operators to a stale sidecar |
repository_dispatch is deliberately avoided — support for it is uncertain on this Gitea version,
whereas dispatching an existing workflow_dispatch workflow via the API works today.
A failed dispatch is a warning, never a failed release. By the time that step runs the component
release is published and correct; failing the job would misreport it. This also keeps the dispatch
from becoming a new hard credential requirement — REGISTRY_TOKEN having write on the installer
repo is a nicety, and without it the nightly cron picks the release up anyway. A dropped dispatch
costs latency, not correctness, which is the whole reason the cron exists.
7.3 Stale-overlay handling: dispatch, don't wait
Each component self-releases on merge to its own main, using the same conventional-commit
engine. Note that "updated since the last release" must mean releasable commits — the engine sets
RELEASE=false when nothing but docs:/chore: has landed, so a docs typo correctly does not
cut an overlay release, and the bundle keeps using the existing one.
The compose job's copy of that rule additionally excludes merge commits, whose subject is
Merge pull request '<the real subject>'. Without that, every squash-free merge of a feat: branch
would be counted twice, and worse, a merge of a docs: branch whose title happens to quote a
fix: would be read as releasable — re-dispatching, every night, a release workflow that correctly
declines to run.
So by the time the installer's CI looks, the release normally already exists. If it finds
servuo-plugins main ahead of its latest release with releasable commits, it:
- fires that repo's release workflow via workflow-dispatch and does not wait for it,
- composes this bundle from the assets that exist right now,
- writes a loud warning into the job summary.
The new overlay release lands minutes later on its own and the nightly cron folds it into the next bundle. This gets the automation without the flaky part: dispatching another repo's workflow is fine — that workflow still runs its own gates — but polling it is not, because Gitea's dispatch endpoint returns no run handle, so the job would have to guess which run is its own and hold a runner idle meanwhile. The warning exists so a genuinely broken release workflow surfaces once rather than being silently retriggered every night forever.
7.4 Open risk
Settled as of the v3 cutover. Protocol work landed on edge branches and the edge → main
cutover has now merged, so main speaks protocol 3 consistently across the repos. The rule it
motivated stands regardless and is not a temporary measure: the installer hardcodes no protocol
version anywhere. It reads what the artifacts declare, and §7.1's gate 1 is what stops a
mismatched pair from being published as a bundle — which is the mechanism that will matter at the
next protocol bump, not just this one. See docs/link/v3.md.
8. Open questions
- Windows service mechanism —
sc createagainst the plain console binary (simplest, works today), a bundled WinSW/NSSM shim, or a native--servicemode in the sidecar using thewindows-servicecrate (cleanest, but changeslink). Recommendation:sc createfor v1, revisit if restart semantics prove inadequate. - Does the installer manage ServUO stop/start? Currently it refuses while ServUO runs and tells the operator to restart afterward. Offering to stop/start would be friendlier but means owning another shard's process lifecycle, and the shard's own start scripts vary.
- Co-location assumption — the shard dials out to the sidecar on loopback
127.0.0.1:7788, so sidecar and ServUO must share a host. Should the installer support installing only uo-link on a different host, or hard-assume co-location? Resolved and moved into §1 / §2.2 / §5: uninstall scope, and minimum ServUO version.
Resolved — branch targeting for the new repo (was question 4). The v3 cutover landed:
servuo-plugins#6 merged, so that repo's main and edge agree at protocol 3. The release
workflow targets main, and the installer repo starts clean on main. §7.4's caution still applies
in principle — the installer hardcodes no protocol version, it reads what the artifacts declare —
but the specific edge/main disagreement that motivated it is gone.
9. Administrator experience
Before:
find plugins → copy files → edit ServUO → download bridge → start bridge
→ configure startup → find the token → troubleshoot paths
After:
download artifact → verify checksum → run installer → select ServUO directory
→ install components → paste 4 values into Admin → Shard → start ServUO normally