25 Commits

Author SHA1 Message Date
f865660c7c Merge pull request 'feat(doctor): the one host prerequisite the Asset Bridge added (Asset Bridge cutover, 4 of 5)' (#26) from edge into main
All checks were successful
sync-project-tree / sync (push) Successful in -1m23s
Release installer / release (push) Successful in 5m56s
Compose bundle / compose (push) Successful in -1m32s
Reviewed-on: #26
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-14 23:11:53 +00:00
2285fff759 Merge pull request 'feat(doctor): the one host prerequisite the Asset Bridge added (Phase 9a)' (#25) from feat/asset-bridge-p9 into edge
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m10s
Reviewed-on: #25
2026-09-14 22:25:52 +00:00
ead105d5bf feat(doctor): the one host prerequisite the Asset Bridge added (Phase 9a)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m39s
`libgdiplus`, on Linux hosts only. ServUO targets net48, so on Linux it runs
under Mono, and Mono's `System.Drawing` is a thin layer over that library -- which
sits in the DECODE path, not merely the encode: `Ultima.Frame` writes ARGB1555
through a LockBits pointer, so without it the shard cannot read one sprite out of
the operator's UO client.

docs/link/v8.md §17.2 took all three answers to this rather than one -- the
prerequisite in SHARD_PREREQS.md, a named NO_IMAGING status instead of an error,
and a `doctor` check. The first two shipped in phase 1. This is the third, and
SHARD_PREREQS.md has been claiming it existed since then.

Three things about the row are deliberate:

- **Linux only, and absent elsewhere.** A Windows host ships System.Drawing with
  .NET Framework and has nothing to check, so there is no row rather than a row
  saying "not applicable" on three quarters of the hosts that run this.
- **A warning, never a failure.** Names and the shard's own spawn files have no
  pixels in them, so a host without this library still runs a useful bridge, and
  `doctor`'s exit code -- which monitoring reads -- must not go red over one
  absent feature.
- **`ldconfig -p` first, paths second.** The loader's own cache is the same
  question Mono asks at runtime, so a distro that puts the file somewhere unusual
  answers correctly; the path probe is the fallback for a slim container with no
  ldconfig, and a missing ldconfig degrades to it rather than taking doctor down.

The verdict is split from the detection so the mark and the wording are testable
on a host that has the library and one that does not; all three tests are
Linux-gated, which is where CI runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-14 13:07:23 -05:00
1996a32153 Merge pull request 'ci(release): sweep every tag for a missing release, not just this run's' (#24) from ci/release-orphan-tag-sweep into main
All checks were successful
Release installer / release (push) Successful in 8s
sync-project-tree / sync (push) Successful in 7s
Compose bundle / compose (push) Successful in -59s
Reviewed-on: #24
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-24 19:42:02 +00:00
65998692ae ci(release): sweep every tag for a missing release, not just this run's
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m28s
The first commit on this branch said the plan step "CAN recover an orphan, but
only on a run that reaches it". Checking link and servuo-plugins for the same
gaps showed that understated it.

The recovery is VERSION-SCOPED. It computes VERSION from the newest tag plus the
conventional-commit bump, then only checks refs/tags/v${VERSION}. So it recovers
an orphan on the very next run and is useless afterwards: once any releasable
commit lands, the next run computes a NEW version and never looks at the old tag
again. The orphan becomes permanent and silent.

servuo-plugins proved it, and the proof is pointed. Its v0.1.0 had been orphaned
since 2026-08-04 -- tag present, no release, no assets -- while v0.1.1, v0.2.0
and v1.0.0 all published normally. The commit that ADDED the recovery to that
repo was itself typed "fix(release): preflight credentials and recover the
orphaned v0.1.0 tag", so it bumped to v0.1.1, and the run that introduced the
recovery stepped straight past the tag it was written to rescue.

The retry added in the previous commit makes an orphan much less likely, but it
does not make one impossible -- a cancelled job or a dying runner produces the
same state with no 500 anywhere -- and until now nothing would ever have
mentioned it again.

So the plan step now sweeps every v* tag and warns about any without a release.

It WARNS rather than recovers, on the org lead's decision. Publishing an old
version would mean building today's tree and shipping it under a tag whose tree
it is not, which is worse than the inconsistency it fixes; and a routine push
silently republishing ancient history is not a thing this pipeline should be
able to do. Recovery stays limited to the version the run computed.

It also never fails the run. A sweep that can break a good release is a sweep
someone will delete.

Verified by running the loop against the real repositories rather than a stub,
since the only thing worth proving is that it tells a clean repo from a dirty
one:

  link (9 tags): clean
  servuo-plugins (4 tags): :⚠️:Tags with no release: v0.1.0
  installer (2 tags): clean

and again after servuo-plugins#15 deleted that tag, where all three report
clean. Every run block bash -n clean, the YAML parses, and no empty template
token.

Companion PRs: link#33 and servuo-plugins#15.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 13:57:59 -05:00
094da1776b Merge pull request 'ci(release): show the error body, and retry the release POST' (#23) from ci/release-post-retry-and-error-body into main
All checks were successful
sync-project-tree / sync (push) Successful in 7s
Release installer / release (push) Successful in -59s
Reviewed-on: #23
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-24 17:32:27 +00:00
188e6eb882 ci(release): show the error body, and retry the release POST
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m32s
Run 75 built every artifact, pushed tag v0.1.1, then took a 500 from
POST /releases one second later and exited 22. The tag was left orphaned with
no release and no binaries, so the handoff fix in #22 reached no operator until
the workflow was re-run by hand today.

Re-running published the same four assets untouched, via the orphan-tag
recovery the plan step already has. So the 500 was a race with the tag push --
Gitea had not finished processing the pushed tag when the POST arrived -- and
not a bad request.

Two separate gaps made that worse than it needed to be.

`curl -sSf` prints no response body on an error status. All the log carried was
"curl: (22) ... error: 500", so the cause had to be inferred from timestamps
rather than read. Every call in this step now captures the body and prints it
on failure, including the asset uploads.

And nothing retried. The plan step can recover an orphan tag, but only on a run
that reaches it, and a later push with no releasable commits stands down before
it gets there -- which is why this one sat until someone looked. The POST now
retries five times with a 5/10/15/20s backoff.

4xx is deliberately not retried: a bad token or a malformed body will not
improve by being sent again, and retrying would turn a clear failure into a
slow one. A give-up message names the orphan tag and says a re-run republishes.

The asset uploads get the same treatment, because a release whose SHA256SUMS
does not cover every binary it advertises is worse than no release -- that file
is the trust anchor for an unsigned download.

Verified by extracting the step's shell from the YAML and running the loop
against a stubbed curl: first-try success, 500-then-success (the case that
actually happened), two 500s then success, five 500s giving up, 403 and 404
aborting without retrying, and a 000 network failure being retried. bash -n
clean and the YAML parses.

Typed ci(...) rather than fix(...) on purpose: the plan step bumps on feat/fix,
and this changes no binary, so a release here would be an empty one.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 11:54:47 -05:00
9cc109910c Merge pull request 'fix(handoff): print the shard screen's real path' (#22) from fix/admin-shard-path into main
Some checks failed
sync-project-tree / sync (push) Successful in 6s
Release installer / release (push) Failing after 4m3s
Reviewed-on: #22
2026-08-24 16:30:49 +00:00
6da385425e fix(handoff): print the shard screen's real path
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m39s
The end-of-run block told operators to paste the four values at
`<site>/admin/shard`. That page moved when the shard screens became part of the
`uo` module: a module owns one path segment wherever it appears (website
`MODULE_SYSTEM.md` §2.8), so it is `/admin/uo/link`, labelled "Shard (uo-link)".

The old path is worse than a 404. The SPA has no route for it, so it sends the
operator to the dashboard — the link looks like it worked, and the values they
were told to paste have nowhere to go.

- The path is now a named constant, `ADMIN_SHARD_PATH`, carrying why it is not
  the obvious string and the fact that API routes are NOT affected by the module
  namespacing rule (they keep `/api/v1/admin/shard/*`).
- Both handoff tests assert the new path, so this cannot regress quietly.
- The two user-facing labels that name the screen — the `--site-url` help text
  and `update`'s protocol-change instruction — say "Admin → Shard (uo-link)",
  matching what the sidebar actually reads.

Found while writing the runicgateway.com installation journey, by pasting the
printed link into a real deployment and landing on the dashboard.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 11:22:45 -05:00
5d4c68eaf5 Merge pull request 'docs(backup): correct why the sidecar database is not backed up (Teams cutover 3/6)' (#21) from edge into main
All checks were successful
Release installer / release (push) Successful in -51s
sync-project-tree / sync (push) Successful in 9s
Compose bundle / compose (push) Successful in 23s
Reviewed-on: #21
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-19 08:56:06 +00:00
c3771d22f2 Merge pull request 'docs(backup): correct why the sidecar database is not backed up' (#20) from feat/teams-phase1-guild-roster into edge
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m46s
Reviewed-on: #20
2026-08-17 19:28:25 +00:00
6c49217e9c docs(backup): correct why the sidecar database is not backed up
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m49s
backup.rs justified skipping the sidecar's database on two claims. Protocol 4
falsifies one and reveals the other was already wrong.

It said the database is safe because store.rs creates every table IF NOT EXISTS.
That held only while every schema change added a whole table — which, up to and
including Protocol 3.0, every one of them did. Protocol 4 adds a COLUMN to a table
that already exists, which IF NOT EXISTS cannot do, so link now carries a real
migration. A run can change the database's structure, not only its contents.

It also said every table holds state the sweeps repopulate. `events` does not: it
is never pruned, and the website backfills what it missed from GET /history on
every reconnect. So a lost database costs the gap-recovery window for whatever
happened while the site was down. That claim was untrue before this workstream
existed.

The behaviour does not change — the database is still not copied — because the
argument against backing up unbounded bulk survives both corrections: `events`
grows without limit, the migration is transactional and additive, and the website
holds its own durable copy of everything already ingested. Only the reasoning was
wrong, and a wrong reason left in place is what lets the next person extend it to
a case it never covered.

Whether that unbounded table should be pruned or protected belongs to link, on its
own merits, rather than being settled inside a backup policy.

No logic change; docs only.

Refs: docs/website/TEAMS.md Part 12 Phase 1

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 12:58:24 -05:00
7758724eb5 Merge pull request 'docs(readme): describe a released installer, not an unreleased one' (#19) from docs/installer-first-setup into main
All checks were successful
sync-project-tree / sync (push) Successful in 7s
Release installer / release (push) Successful in -35s
Compose bundle / compose (push) Successful in 20s
Reviewed-on: #19
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-07 21:30:39 +00:00
484f00ddee docs(readme): describe a released installer, not an unreleased one
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m17s
The status section still said "Phases 1 to 4 are built, on edge. Nothing
is released yet" and told visitors the way to install was by hand — both
untrue as of v0.1.0.

- Add an "Install a shard with it" section up top: verify, run, paste the
  four values. That is what a visitor to this repo is here for.
- Status: released, all five phases, both cutover gates recorded.
- Fix a broken link: bundles/current.json is no longer on main, it lives
  on the bundles branch.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 16:05:56 -05:00
f81cbcdd04 Merge pull request 'fix(release): actually build the arm64 binary it packages' (#18) from fix/release-arm64-build into main
All checks were successful
sync-project-tree / sync (push) Successful in 7s
Release installer / release (push) Successful in 3m39s
Reviewed-on: #18
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-07 20:35:53 +00:00
007791c4fc fix(release): actually build the arm64 binary it packages
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m56s
The first release attempt failed at packaging:

  cp: cannot stat 'target/aarch64-unknown-linux-gnu/release/runicgateway-installer':
      No such file or directory

installer#10 added linux-aarch64 in three of the four places it belongs — the
rustup target, the `cp` into dist/, and the SHA256SUMS line — but never added a
build step for it. Nothing ever produced the binary, so the run got all the way
to packaging before noticing. No tag or release was created, so a retry is clean.

Two changes:

- Build arm64, with the same linker/CC/AR env pattern the Windows cross build
  already uses.
- Name `libc6-dev-arm64-cross` in the apt install. gcc-aarch64-linux-gnu only
  *recommends* it and this step runs --no-install-recommends, so without it the
  Rust half builds and then `ring` (under ureq's rustls) dies compiling C on a
  missing bits/libc-header-start.h.

Verified by reproducing CI in rust:1-slim-bookworm — the same apt line including
--no-install-recommends, then the same cargo invocation. Builds clean and emits
a 4.6 MB binary at exactly the path the packaging step reads.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 15:30:55 -05:00
1173a10049 Merge pull request 'Cutover: promote the installer from edge to main' (#17) from edge into main
Some checks failed
Release installer / release (push) Failing after 3m21s
sync-project-tree / sync (push) Successful in -22s
Reviewed-on: #17
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-07 20:05:42 +00:00
84c1106d58 Merge branch 'main' into edge
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m59s
Brings main's publishing fixes onto edge so the cutover PR is a clean merge:
bundle.yml publishing to the `bundles` branch, release.yml going tag-only, and
the removal of bundles/*.json from main.

One conflict, resolved in favour of edge: main deleted bundles/bundle-2026.08.04.json
while edge had renamed it to tests/fixtures/published-bundle.json. Both changes say
the same thing — published bundles no longer live on main — so the fixture is kept.
It stays frozen at 2026.08.04 on purpose: it is the crate's test input, not a mirror
of what is currently published.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 14:44:12 -05:00
d6f0bcf2cf Merge pull request 'fix(release): tag only, before the cutover fires this for the first time' (#14) from fix/release-tag-only-installer into main
All checks were successful
Release installer / release (push) Successful in 6s
sync-project-tree / sync (push) Successful in -34s
Reviewed-on: #14
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-07 19:16:47 +00:00
065edab8cd Merge branch 'main' into fix/release-tag-only-installer
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 4s
2026-08-07 19:16:32 +00:00
09eafe2911 Merge pull request 'fix(bundle): publish to a bundles branch, and unbreak the stale check' (#13) from ci/bundle-require-aarch64 into main
All checks were successful
Release installer / release (push) Successful in 5s
sync-project-tree / sync (push) Successful in -14s
Reviewed-on: #13
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-07 19:12:36 +00:00
ea7e491ba3 fix(release): tag only, before the cutover fires this for the first time
All checks were successful
PR Checks / rust-gates (pull_request) Successful in -35s
The same two faults link/release.yml has, in the copy this repo was
forked from -- and this one has never run at all, so the cutover would
have been its first execution.

An empty template expression written literally in a comment makes the
runner fail to build the "Commit version bump and push tag" step and
skip it WITHOUT failing the job. link carried that for six releases,
which is why its Cargo.toml still says 0.1.0 while its tags reach
v1.1.1; the tags exist because the release API creates one when it
publishes.

And the step pushes to main, which is protected -- the bundle job
proved that today with `pre-receive hook declined`. A first release
must not depend on a write to a protected branch.

So the tag is the version, as in servuo-plugins. The version is still
written into Cargo.toml before building, so a released binary
self-reports correctly; it is simply not committed back.

The prerequisites header said `main` must accept a direct push from the
CI user. It does not, and it should not; that line is replaced with the
reason.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 17:18:42 -05:00
7db58031c7 fix(bundle): publish to a bundles branch, and unbreak the stale check
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 6s
Three things, all found by the first compose run that ever had a bundle
to write.

1. `main` is protected, so the push was declined by the pre-receive
   hook -- twice, since the retry rebases and pushes to the same place.
   Every bundle since v1.1.1 has been composed correctly and thrown
   away. Bundles now go to a `bundles` branch of their own, at its
   root, which needs no protection exception and keeps everything the
   original choice was for: a reviewable diff, a git history of the
   compat matrix, plain anonymous raw URLs, no credentials on the shard
   host. The header's claim that this push "needs no new
   branch-protection exception" was simply false.

2. A `${{ }}` written literally in a shell comment silently disabled
   the entire stale-component check. The runner scans a step's script
   for template expressions before running it, fails to parse the empty
   one, and skips the step WITHOUT failing the job -- so the dispatch
   that is supposed to fire a component's release workflow has never
   run once. Reworded, with a warning not to write that token in a
   comment again. (link/release.yml and this repo's release.yml carry
   the same bug in their bump-and-tag step; handled separately.)

3. linux-aarch64 is now a REQUIRED platform key, which was step 3 of
   PLAN.md §5.2 and was waiting on link publishing one. v1.1.1 does, so
   from here a dropped target reddens this job instead of vanishing
   from every bundle.

The published bundles are materialized into a worktree at `published/`,
so the ".2 suffix" scan and the idempotence check read what is actually
published rather than a stale copy on main. The branch is created from
an empty-tree root commit on first use, so it carries no history that
has nothing to do with the compat matrix; it has been seeded already
with bundle 2026.08.04, because every bundle is kept forever and the
move must not lose the one that exists.

bundles/*.json is deleted from main -- it is now a stale copy of data
that lives elsewhere, and a wrong "current" is worse than none. The
README stays and documents the branch.

Verified by running the whole job in a container against a bare repo
standing in for the remote: first run creates the branch and publishes
both files with all three asset keys, second and third runs report
"identical to the published current.json -- nothing to publish" and
push nothing, and the stale check now runs and reports both components
as having nothing releasable.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 17:14:57 -05:00
ae53546446 Merge pull request 'ci(bundle): recognize a linux-aarch64 link asset' (#9) from ci/bundle-aarch64-key into main
Some checks failed
sync-project-tree / sync (push) Successful in 6s
Release installer / release (push) Successful in -32s
Compose bundle / compose (push) Failing after -15s
Reviewed-on: #9
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-05 17:51:06 +00:00
fd59a74912 ci(bundle): recognize a linux-aarch64 link asset
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 5s
Step 1 of PLAN.md §5.2's four, and it has to be first. Two rules in this
job are strict in opposite directions: an unrecognized link asset name
fails the run, and a missing REQUIRED platform key fails it too. So the
name must be taught before the release that carries it, and the key can
only be required after one exists -- requiring it first would fail every
bundle for as long as the gap lasts.

This is therefore the mapping only. linux-aarch64 is not in REQUIRED
yet; step 3 promotes it once a link release actually ships the binary,
after which a dropped target reddens CI instead of vanishing silently
from every bundle.

The compose step needed no change: it builds the asset map from the
platform TSV, so a third key costs it nothing.

Edited on `main` and deliberately not on `edge`. The compose job runs
from `main`, and leaving `edge`'s copy untouched means the eventual
cutover merge has nothing to conflict over.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 05:22:59 -05:00
9 changed files with 451 additions and 114 deletions

View File

@@ -13,15 +13,23 @@
# byte-identical.
#
# ── Where it is published, and why not as a release ──────────────────────────
# Bundles are COMMITTED to this repo under bundles/:
# Bundles are COMMITTED to this repo, on their own `bundles` branch, at its root:
#
# bundles/current.json the bundle the installer uses by default
# bundles/bundle-<tag>.json every bundle ever published, kept for --bundle
# current.json the bundle the installer uses by default
# bundle-<tag>.json every bundle ever published, kept for --bundle
#
# so the installer's two fetches are plain anonymous raw URLs on a public repo:
#
# https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/main/bundles/current.json
# https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/main/bundles/bundle-2026.08.04.json
# https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles/current.json
# https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles/bundle-2026.08.04.json
#
# A BRANCH, not `main`, because `main` is protected and this job is unattended:
# the pre-receive hook declines a push from CI, which is not a thing a nightly
# cron can resolve. Publishing to a branch of its own keeps everything the
# original choice was for — a reviewable diff, a git history of the compat
# matrix, plain raw URLs, no auth on the shard host — and needs no protection
# exception. The alternative, whitelisting a scheduled job for pushes to the
# default branch, buys nothing this does not.
#
# The obvious alternative — one Gitea release per bundle — was rejected because
# it collides with this repo's own product. release.yml publishes the installer
@@ -30,8 +38,8 @@
# intermittently resolve to a release containing no installer binary. Committing
# also gets a reviewable diff and a git history of the compat matrix for free.
#
# The push to `main` needs no new branch-protection exception: release.yml's
# version-bump commit already requires REGISTRY_USER to be able to push here.
# `main` is never pushed to by this workflow. (release.yml does not push to it
# either — it tags and lets the release API do the rest.)
#
# ── Triggers (PLAN.md §7.2) ──────────────────────────────────────────────────
# workflow_dispatch — POSTed by link's and servuo-plugins' release workflows
@@ -86,14 +94,44 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
# Full history: the push step rebases onto main if release.yml's version
# bump landed while this job was composing, and a depth-1 clone has no
# base to rebase onto.
- name: Check out the bundles directory
# Full history: the publish step rebases onto the bundles branch if another
# run landed while this one was composing, and a depth-1 clone has no base
# to rebase onto.
- name: Check out the repository
uses: actions/checkout@v4
with:
fetch-depth: 0
# The published bundles live on their own branch (see the header), so they
# are materialized into a worktree rather than being part of the checkout.
# Everything downstream reads and writes `published/`, which means the
# ".2 suffix" scan and the idempotence check both see what is actually
# published rather than a stale copy on main.
- name: Materialize the bundles branch
run: |
set -euo pipefail
git config user.name "installer-ci"
git config user.email "ci@whitlocktech.com"
# `prune` matters on a re-run in an existing checkout: removing the
# directory leaves the worktree registered, and `worktree add` then
# refuses the path. CI checks out fresh every time, so this only shows
# up when driving the job by hand — which is how it is tested.
rm -rf published
git worktree prune
if git ls-remote --exit-code --heads origin bundles >/dev/null 2>&1; then
git fetch origin bundles
git worktree add -B bundles published origin/bundles
echo "==> bundles branch: $(ls published/*.json 2>/dev/null | wc -l) published bundle(s)"
else
# First run. A root commit with an empty tree gives the worktree a
# branch to sit on without inheriting main's history, which has
# nothing to do with the compat matrix.
EMPTY_TREE="$(git hash-object -t tree /dev/null)"
ROOT="$(git commit-tree "$EMPTY_TREE" -m 'chore(bundle): start the bundles branch')"
git worktree add -B bundles published "$ROOT"
echo "==> bundles branch does not exist yet; it will be created by the first publish"
fi
- name: Install jq and curl
run: |
set -euo pipefail
@@ -173,22 +211,33 @@ jobs:
# Map link's binaries onto platform keys. The pattern is asserted, not
# assumed: an unrecognized asset name is a hard failure so that adding
# a target to link's release.yml (aarch64, macOS) surfaces here as a
# red run, rather than being silently dropped from every bundle.
# a target to link's release.yml (macOS, a Windows arm64) surfaces here
# as a red run, rather than being silently dropped from every bundle.
#
# linux-aarch64 was recognized here one merge BEFORE link published one
# (PLAN.md §5.2, steps 1 and 3). That order was forced by the two rules
# below being strict in opposite directions: an unknown name fails the
# run, and a missing REQUIRED key fails it too. So the name had to be
# taught before the release that carried it, and the key could only be
# required after — requiring it first would have failed every bundle
# for as long as the gap lasted. link v1.1.1 ships the binary, so the
# key is now required: a dropped target reddens this job instead of
# vanishing from every bundle.
: > work/link-platforms.tsv
while IFS="$(printf '\t')" read -r NAME URL; do
[ -n "$NAME" ] || continue
case "$NAME" in
*-linux-x86_64) PLAT=linux-x86_64 ;;
*-linux-aarch64) PLAT=linux-aarch64 ;;
*-windows-x86_64.exe) PLAT=windows-x86_64 ;;
*) fail "unrecognized link asset '${NAME}' — bundle.yml does not know what platform to file it under. Teach it this name or the bundle would silently omit the asset." ;;
esac
printf '%s\t%s\t%s\t%s\n' "$PLAT" "$NAME" "$URL" \
"$(sha256sum "work/link/${NAME}" | cut -d' ' -f1)" >> work/link-platforms.tsv
done < work/link/asset-list.tsv
for REQUIRED in linux-x86_64 windows-x86_64; do
for REQUIRED in linux-x86_64 linux-aarch64 windows-x86_64; do
grep -q "^${REQUIRED}$(printf '\t')" work/link-platforms.tsv \
|| fail "link release is missing a ${REQUIRED} binary; the installer ships for both"
|| fail "link release is missing a ${REQUIRED} binary; the installer ships for all three"
done
# The overlay release carries exactly one artifact: the tarball.
@@ -330,8 +379,8 @@ jobs:
# commit a dated duplicate of the same matrix forever. Compare only
# what the installer would actually act on.
CHANGED=true
if [ -f bundles/current.json ]; then
if jq -S 'del(.bundle, .generated)' bundles/current.json > work/old-content.json \
if [ -f published/current.json ]; then
if jq -S 'del(.bundle, .generated)' published/current.json > work/old-content.json \
&& jq -S '.' work/content.json > work/new-content.json \
&& cmp -s work/old-content.json work/new-content.json; then
CHANGED=false
@@ -340,7 +389,7 @@ jobs:
echo "changed=${CHANGED}" >> "$GITHUB_OUTPUT"
if [ "$CHANGED" = false ]; then
echo "==> identical to bundles/current.json — nothing to publish."
echo "==> identical to the published current.json — nothing to publish."
exit 0
fi
@@ -350,15 +399,14 @@ jobs:
# always names exactly one matrix and `--bundle` stays reproducible.
BASE="$(date -u +%Y.%m.%d)"
TAG="$BASE"; N=1
while [ -f "bundles/bundle-${TAG}.json" ]; do
while [ -f "published/bundle-${TAG}.json" ]; do
N=$((N+1)); TAG="${BASE}.${N}"
done
mkdir -p bundles
jq --arg bundle "$TAG" --arg generated "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{ schema: .schema, bundle: $bundle, generated: $generated } + del(.schema)' \
work/content.json > "bundles/bundle-${TAG}.json"
cp "bundles/bundle-${TAG}.json" bundles/current.json
work/content.json > "published/bundle-${TAG}.json"
cp "published/bundle-${TAG}.json" published/current.json
echo "bundle_tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "==> composed bundle ${TAG}"
@@ -387,9 +435,11 @@ jobs:
set -euo pipefail
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')"
# Warnings go to a FILE, not a step output. The job summary below
# reads it with `cat`; interpolating a multi-line `${{ }}` value into
# reads it with `cat`; interpolating a multi-line template value into
# a shell string there would let any character in a commit-derived
# message change what that script does.
# message change what that script does. (Do not write that token
# literally in a comment: the runner parses it, fails, and silently
# skips the whole step.)
: > work/stale-warnings.md
for pair in "${LINK_REPO}:${{ steps.resolve.outputs.link_tag }}" \
@@ -470,27 +520,25 @@ jobs:
# cannot be parsed").
CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')"
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
git config user.name "installer-ci"
git config user.email "ci@whitlocktech.com"
git remote set-url origin "https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${REPO}.git"
git add bundles
cd published
git add -A
git commit -m "chore(bundle): publish ${TAG} (link ${{ steps.resolve.outputs.link_tag }}, overlay ${{ steps.resolve.outputs.overlay_tag }}, protocol ${{ steps.protocol.outputs.protocol }}) [skip ci]"
# The checkout is a detached snapshot of main; push the commit at HEAD
# to the branch the installer reads its raw URLs from. release.yml
# pushes its version-bump commit to the same branch, so losing the
# race is normal rather than exceptional — rebase and retry once
# instead of failing and leaving the bundle unpublished until the
# next cron. Only bundles/ is touched here, so a rebase over a bump
# commit cannot conflict.
if ! git push origin "HEAD:main"; then
echo "::warning::push rejected (main moved during compose) — rebasing and retrying once"
git fetch origin main
git rebase origin/main
git push origin "HEAD:main"
# Two runs can compose at once — a component release dispatches this
# while the nightly cron is mid-flight — so losing the race is normal
# rather than exceptional. Rebase and retry once instead of failing and
# leaving the bundle unpublished until tomorrow. Every file here is a
# bundle nobody else edits, and a bundle tag names exactly one matrix,
# so a rebase cannot conflict.
if ! git push origin bundles; then
echo "::warning::push rejected (the bundles branch moved during compose) — rebasing and retrying once"
git fetch origin bundles
git rebase origin/bundles
git push origin bundles
fi
echo "==> published bundles/bundle-${TAG}.json and bundles/current.json"
echo "==> published bundle-${TAG}.json and current.json on the bundles branch"
- name: Job summary
if: always()

View File

@@ -43,11 +43,11 @@
# Prerequisites (Settings → Actions → Secrets on RunicGateway/installer):
# REGISTRY_USER — Gitea username the token below belongs to
# REGISTRY_TOKEN — Gitea access token with `write:repository`, so it can push
# the bump commit + tag and create the release.
# Also: `main` must accept a direct push from that user (disable branch
# protection for it, or add it as an exception) — the bump commit lands on main.
# the release tag and create the release.
#
# The bump commit carries `[skip ci]`, so it does not re-trigger this workflow.
# `main` needs NO push exception: this workflow tags and publishes, and never
# writes to a branch. Keeping it that way is deliberate — a first release that
# depends on a write to a protected branch fails at the worst possible moment.
name: Release installer
@@ -73,7 +73,8 @@ env:
jobs:
release:
runs-on: ubuntu-latest
# Don't loop on our own bump commit (belt-and-suspenders with [skip ci]).
# Vestigial since this workflow stopped writing a bump commit, and kept as
# belt-and-braces in case one ever returns.
# Quoted because the expression contains a colon (`chore(release):`), which an
# unquoted YAML scalar would misparse as a mapping value.
if: "${{ !contains(github.event.head_commit.message, 'chore(release): bump version') }}"
@@ -164,6 +165,39 @@ jobs:
fi
fi
# ── Orphan sweep ────────────────────────────────────────────────
#
# The check above is VERSION-SCOPED: it only ever asks about the one
# version this run computed. That is enough to recover an orphan on
# the very next run, and useless afterwards — once any releasable
# commit lands, the next run computes a NEW version, never looks at
# the old tag again, and the orphan becomes permanent and silent.
#
# servuo-plugins v0.1.0 is the proof, and the proof is pointed: the
# commit that ADDED the recovery above was itself typed
# `fix(release): ... recover the orphaned v0.1.0 tag`, so it bumped to
# v0.1.1 — and the run that introduced the recovery stepped straight
# past the tag it was written to rescue. That tag is still orphaned.
#
# So every v* tag is checked, and anything missing a release is
# WARNED about. Deliberately not recovered: publishing an old version
# would mean building today's tree and shipping it under a tag whose
# tree it is not, which is worse than the inconsistency it fixes.
# A human decides whether to recover or drop it.
#
# Never fails the run. A sweep that can break a good release is a
# sweep someone will delete.
ORPHANS=""
for T in $(git tag -l 'v*' --sort=-v:refname); do
T_HTTP="$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: token $(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" \
"https://${GITEA_HOST}/api/v1/repos/${REPO}/releases/tags/${T}" || echo 000)"
[ "$T_HTTP" = "404" ] && ORPHANS="${ORPHANS} ${T}"
done
if [ -n "${ORPHANS}" ]; then
echo "::warning::Tags with no release:${ORPHANS} — a run failed after tagging. Publish or delete them; this job will not do either."
fi
# Changelog range. A recovery run has nothing after the tag, so
# summarize what the tag itself contains rather than emitting an empty
# list: the range that produced it, i.e. previous-tag..this-tag.
@@ -246,8 +280,13 @@ jobs:
set -euo pipefail
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo"
$SUDO apt-get update
# libc6-dev-arm64-cross is named explicitly on purpose: gcc-aarch64-linux-gnu only
# *recommends* it, and this install runs --no-install-recommends. Without it the Rust
# half of the arm64 build succeeds and then `ring` (under ureq's rustls) dies compiling
# C, on a missing bits/libc-header-start.h.
$SUDO apt-get install -y --no-install-recommends \
build-essential gcc-mingw-w64-x86-64 curl ca-certificates git jq
build-essential gcc-mingw-w64-x86-64 gcc-aarch64-linux-gnu libc6-dev-arm64-cross \
curl ca-certificates git jq
if ! command -v cargo >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
@@ -295,6 +334,18 @@ jobs:
AR_x86_64_pc_windows_gnu: x86_64-w64-mingw32-ar
run: cargo build --release --locked --target "${WINDOWS_TARGET}"
# The installer has to run wherever the sidecar it installs can run, and link publishes an
# arm64 Linux binary (PLAN.md §5.2). Without this step the target is installed and the
# artifact is packaged, but nothing ever builds it — which is exactly how the first release
# attempt failed, at `cp: cannot stat target/aarch64-unknown-linux-gnu/release/...`.
- name: cargo build --release (Linux arm64, cross)
if: ${{ steps.plan.outputs.release == 'true' }}
env:
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
CC_aarch64_unknown_linux_gnu: aarch64-linux-gnu-gcc
AR_aarch64_unknown_linux_gnu: aarch64-linux-gnu-ar
run: cargo build --release --locked --target "${ARM64_TARGET}"
# ── RUST ADAPTER: package artifacts (+ checksums) ────────────────────
# SHA256SUMS is the trust anchor for these unsigned binaries (PLAN.md §3),
# so it ships with every release and the docs lead with the verify command.
@@ -312,33 +363,45 @@ jobs:
ls -l dist && echo "----" && cat dist/SHA256SUMS
# ── RELEASE ENGINE: commit the bump, tag, push ───────────────────────
- name: Commit version bump and push tag
# Tag only — `main` is never pushed to.
#
# This step used to commit the version bump back to main first, and it has
# never executed in any repo that carries it: an EMPTY template expression
# written literally in the comment below (the `$`+`{{ }}` token, spelled
# out here for that reason) makes the runner fail to build the script and
# skip the step WITHOUT failing the job. link/release.yml carried the same
# bug for six releases, which is why its Cargo.toml still says 0.1.0 while
# its tags reach v1.1.1 — the release API creates the tag when it
# publishes, so the pipeline worked by accident.
#
# It also would have been declined if it had run: `main` is protected, and
# the bundle job proved that on 2026-08-05 (`pre-receive hook declined`).
# A first release must not depend on a write to a protected branch.
#
# So the tag is the version, as in servuo-plugins. The version is still
# written into Cargo.toml before building, so a released binary
# self-reports correctly; it is simply not committed back. The next
# version is computed from the newest tag, never from the file.
- name: Push the release tag
if: ${{ steps.plan.outputs.release == 'true' }}
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
VERSION="${{ steps.plan.outputs.version }}"
TAG="${{ steps.plan.outputs.tag }}"
# Secrets can arrive with a trailing newline (depending on how they were
# pasted); a stray CR/LF corrupts the remote URL ("credential url cannot
# be parsed"). Strip line breaks before building the URL. Passing them via
# env (not inline ${{ }}) also keeps a newline from breaking this script.
# be parsed"). Strip line breaks before building the URL. They are passed
# via env rather than interpolated into this script, so a newline cannot
# break it — do NOT write a template token literally in a comment here,
# or the runner will skip this step without failing the job.
CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')"
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
git config user.name "installer-ci"
git config user.email "ci@whitlocktech.com"
git remote set-url origin \
"https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${REPO}.git"
git add Cargo.toml Cargo.lock
if ! git diff --cached --quiet; then
git commit -m "chore(release): bump version to ${TAG} [skip ci]"
git push origin "HEAD:main"
else
echo "Version unchanged (first release) — no bump commit needed."
fi
# The tag may already exist when finishing a run that died after
# tagging (see the plan step). `git tag` on an existing name fails
# under `set -e`; pushing an identical existing tag is a harmless
@@ -365,17 +428,74 @@ jobs:
# corrupt the Authorization header.
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
REL_ID="$(curl -sSf -X POST "${API}/releases" \
PAYLOAD="$(jq -n --arg tag "$TAG" --arg body "$BODY" \
'{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')"
# This POST is the step that orphaned tag v0.1.1 (run 75): it landed one
# second after the tag push and Gitea answered 500, having not finished
# processing the pushed tag. Re-running the workflow published the same
# four assets untouched, so the failure was a race, not a bad request.
#
# Two things went wrong there, and both are fixed here.
#
# 1. `curl -sSf` prints NO response body on an error status, so all the
# log carried was "curl: (22) ... error: 500" and the cause had to be
# inferred from timestamps. Capture the body and print it.
# 2. Nothing retried, so a transient 5xx became a permanent orphan tag.
# The plan step CAN recover one, but only on a run that reaches it --
# and a later push with no releasable commits stands down before it
# gets there, so in practice the tag sits until a human notices.
#
# 4xx is deliberately NOT retried: a bad token or a malformed body does
# not improve by being sent again, and retrying only turns a clear
# failure into a slow one.
REL_ID=""
for attempt in 1 2 3 4 5; do
HTTP="$(curl -s -o /tmp/rel.json -w '%{http_code}' -X POST "${API}/releases" \
-H "Authorization: token ${CI_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg tag "$TAG" --arg body "$BODY" \
'{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')" \
| jq -r '.id')"
-d "${PAYLOAD}" || echo 000)"
if [ "$HTTP" = "201" ] || [ "$HTTP" = "200" ]; then
REL_ID="$(jq -r '.id' /tmp/rel.json)"
break
fi
echo "::warning::POST /releases attempt ${attempt} returned HTTP ${HTTP}"
echo "--- response body ---"
cat /tmp/rel.json || true
echo
echo "---------------------"
case "$HTTP" in
4*) echo "::error::HTTP ${HTTP} is a client error - not retrying."; exit 1 ;;
esac
if [ "$attempt" = 5 ]; then
echo "::error::POST /releases still failing after 5 attempts. Tag ${TAG} is pushed but has no release."
echo "::error::Re-run this workflow - the plan step detects the orphan tag and republishes it."
exit 1
fi
sleep $(( attempt * 5 ))
done
if [ -z "$REL_ID" ] || [ "$REL_ID" = "null" ]; then
echo "::error::Release created but no id came back; refusing to upload assets blind."
exit 1
fi
echo "Created release ${TAG} (id=${REL_ID})"
for f in "${BIN}-linux-x86_64" "${BIN}-linux-aarch64" "${BIN}-windows-x86_64.exe" SHA256SUMS; do
curl -sSf -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \
# Same treatment. An upload that fails quietly leaves a release whose
# SHA256SUMS does not cover every binary it advertises, which is worse
# than no release at all -- that file IS the trust anchor.
HTTP="$(curl -s -o /tmp/asset.json -w '%{http_code}' -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \
-H "Authorization: token ${CI_TOKEN}" \
-F "attachment=@dist/${f}" >/dev/null
-F "attachment=@dist/${f}" || echo 000)"
if [ "$HTTP" != "201" ] && [ "$HTTP" != "200" ]; then
echo "::error::uploading ${f} returned HTTP ${HTTP}"
cat /tmp/asset.json || true
exit 1
fi
echo " uploaded ${f}"
done

View File

@@ -25,9 +25,48 @@ It also **does not replace ServUO startup behavior.** ServUO keeps running throu
its existing release/start scripts; the installer never writes a launcher and
never restarts the shard.
## Install a shard with it
Grab a binary and `SHA256SUMS` from the
[releases page](https://gitea.whitlocktech.com/RunicGateway/installer/releases),
verify the checksum, and run it as Administrator/root against a **stopped** shard:
```bash
sha256sum -c SHA256SUMS --ignore-missing
chmod +x runicgateway-installer-linux-x86_64
sudo ./runicgateway-installer-linux-x86_64 install
```
```powershell
# Windows, from an elevated PowerShell
.\runicgateway-installer-windows-x86_64.exe install
```
It ends by printing the four values to paste into **Admin → Shard** on your site.
The full operator guide — what it asks, where it writes, the patch tier, day-two
commands and troubleshooting — is
[`installer/INSTALL.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md).
Prefer to place everything yourself, or on a host that cannot run the binary?
[INSTALL.md Appendix A](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md#appendix-a--installing-by-hand)
is the same deployment done with `curl`, `tar` and `systemctl`, and stays
supported.
## Status
**Phases 1 to 4 are built, on the `edge` branch. Nothing is released yet.**
**Released.** All five phases are built and the `edge → main` cutover (#17) cut
the first release, [`v0.1.0`](https://gitea.whitlocktech.com/RunicGateway/installer/releases),
publishing `linux-x86_64`, `linux-aarch64` and `windows-x86_64.exe` with
`SHA256SUMS`.
| Phase | State |
|---|---|
| 0 — prerequisites in the other repos | ✅ merged |
| 1 — installer core: bundle resolution, ServUO detection, overlay sync, `install.json` | ✅ released |
| 2 — uo-link install + service registration | ✅ released |
| 3 — the opt-in stock-file patch tier | ✅ released |
| 4 — `doctor`, `update`, `uninstall` | ✅ released |
| 5 — packaging polish: Linux `aarch64`, backup before overwrite | ✅ released |
The binary does everything
[`installer/INSTALL.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md)
@@ -40,39 +79,18 @@ The design of record is
in the docs repo: phases, locked decisions, and the Phase 0 prerequisites in other
repos (a `servuo-plugins` release workflow, a non-interactive config read-back in
`link`, and the bundle-manifest CI here), all of which have landed —
[`bundles/current.json`](bundles/current.json) names the current protocol-checked
sidecar + overlay combination, recomposed on every component release and nightly
(see [`bundles/README.md`](bundles/README.md)).
[`bundles/current.json`](https://gitea.whitlocktech.com/RunicGateway/installer/src/branch/bundles/current.json)
names the current protocol-checked sidecar + overlay combination, recomposed on
every component release and nightly (see [`bundles/README.md`](bundles/README.md)).
| Phase | State |
|---|---|
| 0 — prerequisites in the other repos | ✅ merged |
| 1 — installer core: bundle resolution, ServUO detection, overlay sync, `install.json` | ✅ on `edge` |
| 2 — uo-link install + service registration | ✅ on `edge` |
| 3 — the opt-in stock-file patch tier | ✅ on `edge` |
| 4 — `doctor`, `update`, `uninstall` | ✅ on `edge` |
| 5 — packaging polish: Linux `aarch64`, backup before overwrite | in progress |
**Why `edge`:** `release.yml` publishes an installer binary on every push to
`main`, so nothing lands there until the whole tool is worth handing to an
operator. The `edge → main` cutover cuts the first release. PRs into `edge` run
the same gates as PRs into `main`.
**What the cutover is waiting on**, per PLAN.md §5:
1. **Phase 5**, packaging polish — deliberately *before* the first release rather
than after it, because it changes the release layout, and shipping first would
mean a first release immediately superseded by the next. There is no `.deb`
and no MSI: both would give the sidecar binary, its service unit and its
service account a second owner beside this tool.
2. **The Windows SCM half verified on a real host.** `sc create`, the virtual
service account, the failure actions and the token-file ACL have never been
executed anywhere. Running the *systemd* half for real is what turned up a bug
no unit test had, so this is not a formality.
Until the cutover, the way to install is by hand —
[INSTALL.md Appendix A](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md#appendix-a--installing-by-hand)
is the same deployment done with `curl`, `tar` and `systemctl`.
**`main` publishes.** `release.yml` cuts a release from every push to `main`, which
is why the crate was integrated on `edge` until it was worth handing to an
operator. Both cutover gates were met first: Phase 5 (its scope settled as **no
`.deb` and no MSI** — either would give the sidecar binary, its service unit and
its service account a second owner beside this tool), and the **Windows SCM half
verified on a real host**. That second one earned its place: `sc start` failed
with 1053 on its first real run and needed a sidecar fix (link#29) before it
passed 13/13.
## Related repos

View File

@@ -13,7 +13,10 @@ nightly, so a missed dispatch self-heals. A run that finds nothing changed write
See `docs/installer/PLAN.md` §7 for the design.
## Layout
## Where they live: the `bundles` branch
**The JSON documents are not in this directory.** They are published to a branch of their own,
[`bundles`](https://gitea.whitlocktech.com/RunicGateway/installer/src/branch/bundles), at its root:
| File | What it is |
|---|---|
@@ -24,14 +27,24 @@ Tags are UTC dates — `2026.08.04`. A second bundle on the same day (a sidecar
morning, an overlay release in the afternoon) becomes `2026.08.04.2`, so one tag always names
exactly one matrix.
**Why a branch rather than `main`.** `main` is protected and this job is unattended: the pre-receive
hook declines a push from CI, which is not something a nightly cron can resolve. A branch of its own
keeps everything the original choice was for — a reviewable diff, a git history of the compat
matrix, plain anonymous raw URLs, no credentials on the shard host — and needs no protection
exception. Whitelisting a scheduled job for pushes to the default branch would buy nothing this does
not.
This directory keeps the documentation, because that is what belongs on `main`: the branch carries
data, and only data.
## How the installer fetches these
Plain anonymous `GET`s against a public repo. The shard host gets no git and no Gitea credentials
(`PLAN.md` §1), so nothing here may require auth:
```
https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/main/bundles/current.json
https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/main/bundles/bundle-2026.08.04.json
https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles/current.json
https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles/bundle-2026.08.04.json
```
Bundles are committed rather than published as Gitea releases because this repo's *own* releases are
@@ -56,8 +69,9 @@ protocol) or to either component's release version. All three move independently
"tag": "v1.1.0",
"version": "1.1.0",
"protocol": 3,
"assets": { // per-platform: the installer runs on both
"assets": { // per-platform: the installer runs on each
"linux-x86_64": { "name": "…", "url": "…", "sha256": "…" },
"linux-aarch64": { "name": "…", "url": "…", "sha256": "…" },
"windows-x86_64": { "name": "…", "url": "…", "sha256": "…" }
}
},

View File

@@ -3,10 +3,27 @@
//! ## Scoped by what cannot be fetched again
//!
//! Most of what this installer writes is replaceable: the sidecar binary and every overlay file are
//! re-downloadable and hash-named in the bundle, and the sidecar's database is a cache with a schema
//! — `link`'s `store.rs` creates every table `IF NOT EXISTS` and every one of them holds shard state
//! the sweeps repopulate. Backing those up would be bulk with no recovery value, and the bulk is not
//! free: it would bury the two things that matter.
//! re-downloadable and hash-named in the bundle, and the sidecar's database is overwhelmingly a
//! projection of shard state that the sweeps repopulate. Backing it up would be bulk with little
//! recovery value, and the bulk is not free: it would bury the two things that matter.
//!
//! That reasoning used to be stated two ways that are no longer true, and the correction is worth
//! keeping rather than quietly deleting:
//!
//! - It said the database is safe because `store.rs` creates every table `IF NOT EXISTS`. That held
//! only while every schema change added a whole *table*. Protocol 4 adds a *column* to a table
//! that already exists, which `IF NOT EXISTS` cannot do, so `link` now carries a real migration
//! (`PRAGMA user_version` steps). A run can therefore change the database's structure, not just
//! its contents.
//! - It said every table holds state the sweeps repopulate. `events` does not: it is never pruned,
//! and the website backfills the events it missed from `GET /history` on every reconnect. So a
//! lost database costs the gap-recovery window for anything that happened while the site was down.
//!
//! The decision is unchanged — this still does not copy the database — because the argument against
//! backing up unbounded bulk survives both corrections: `events` grows without limit, the migration
//! is transactional and additive, and the website holds its own durable copy of everything it has
//! already ingested. Only the *reason* was wrong. Whether that table should be pruned or protected
//! is a question for `link`, on its own merits, not something to settle inside a backup policy.
//!
//! What a run can destroy irrecoverably is short:
//!

View File

@@ -77,7 +77,7 @@ pub struct Cli {
pub patches_unsupported_servuo: bool,
/// `--host <name>`: the hostname to print in the website URLs.
pub host: Option<String>,
/// `--site-url <url>`: the site's base URL, for the Admin → Shard link.
/// `--site-url <url>`: the site's base URL, for the Admin → Shard (uo-link) link.
pub site_url: Option<String>,
/// `--yes`: assume the default answer to every prompt.
pub assume_yes: bool,
@@ -137,7 +137,7 @@ Options:
--host <NAME> install. The hostname to print in the
website URLs.
--site-url <URL> install. Your site's base URL, for the
Admin → Shard link.
Admin → Shard (uo-link) link.
--yes Assume the default answer to every prompt.
On uninstall it means yes: that prompt
defaults to no, and typing `uninstall

View File

@@ -158,6 +158,10 @@ pub fn run(cli: &Cli) -> Result<i32> {
// ── The bundle ───────────────────────────────────────────────────────────
rows.push(bundle_row(&record));
// ── The host ─────────────────────────────────────────────────────────────
// Linux only, and absent entirely elsewhere (see `imaging_row`).
rows.extend(imaging_row());
// ── Backups ──────────────────────────────────────────────────────────────
rows.push(backup_row(&layout));
@@ -755,6 +759,71 @@ fn bundle_row(record: &InstallRecord) -> Row {
Row::warn("Bundle", detail).note("run `update` to move both halves to one checked combination")
}
/// `libgdiplus` on a Linux shard host — the one host prerequisite the Asset Bridge added
/// (docs/link/v8.md §4.4, docs/link/SHARD_PREREQS.md).
///
/// ServUO targets `net48`, so on Linux it runs under Mono, and Mono's `System.Drawing` is a thin
/// layer over this library — which sits in the **decode** path, not merely the encode: without it
/// the shard cannot read a single sprite out of the operator's UO client. Windows hosts ship
/// `System.Drawing` with .NET Framework and need nothing, which is why this row exists only on
/// Linux rather than reporting "not applicable" on three quarters of the hosts that run it.
///
/// **A `⚠`, never a `✗`.** Everything else on this plane works without it: the cliloc table and
/// the shard's own spawn files have no pixels in them, and a bridge that serves names and an atlas
/// but no artwork is a working bridge with one feature missing. It is also not the last word — the
/// shard reports `NO_IMAGING` on the asset plane itself, from inside the process that would do the
/// decoding. This row exists to move that discovery from "the bestiary is empty, weeks later" to
/// "the host is missing a package, now".
#[cfg(target_os = "linux")]
fn imaging_row() -> Option<Row> {
Some(imaging_verdict(imaging_present()))
}
/// Is the library on this host? Two answers, in the order that is most likely to be right.
#[cfg(target_os = "linux")]
fn imaging_present() -> bool {
// `ldconfig -p` is the loader's own cache, which is the same question Mono asks at runtime —
// strictly better than probing paths, because a distro that puts the file somewhere unusual has
// told the loader about it and would otherwise read here as missing.
let cached = crate::util::run("ldconfig", &["-p"])
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).contains("libgdiplus.so"))
.unwrap_or(false);
// The fallback is for a host with no `ldconfig` on PATH (a slim container, mostly), where a
// present library would otherwise be reported absent.
cached
|| [
"/usr/lib/libgdiplus.so",
"/usr/lib64/libgdiplus.so",
"/usr/lib/x86_64-linux-gnu/libgdiplus.so",
"/usr/lib/aarch64-linux-gnu/libgdiplus.so",
"/usr/local/lib/libgdiplus.so",
]
.iter()
.any(|p| Path::new(p).exists())
}
/// The operator-visible half, split out so the wording and the mark are testable on a host that
/// has the library and on one that does not — which the detection itself is not.
#[cfg(target_os = "linux")]
fn imaging_verdict(found: bool) -> Row {
if found {
return Row::ok("Imaging (libgdiplus)", "present");
}
Row::warn("Imaging (libgdiplus)", "not found on this host")
.note("this shard cannot decode artwork out of its UO client — creature portraits and")
.note("item pictures will be absent; names and the spawn atlas are unaffected")
.note("install it: apt-get install libgdiplus / dnf install libgdiplus")
.note("see docs/link/SHARD_PREREQS.md — Windows hosts need nothing")
}
/// Windows and macOS hosts do not need it, so there is no row to print.
#[cfg(not(target_os = "linux"))]
fn imaging_row() -> Option<Row> {
None
}
#[cfg(test)]
mod tests {
use super::*;
@@ -913,4 +982,39 @@ mod tests {
assert_eq!(row.mark, Mark::Ok);
assert!(row.detail.contains("operator-owned"), "{}", row.detail);
}
// ── The host row (Linux only; see `imaging_row`) ─────────────────────────
#[cfg(target_os = "linux")]
#[test]
fn a_missing_libgdiplus_warns_and_names_the_package() {
let row = imaging_verdict(false);
// A ⚠, never a ✗: the cliloc table and the spawn atlas have no pixels in them, so a host
// without this library still runs a useful bridge. `doctor`'s exit code must not turn red
// over one absent feature.
assert_eq!(row.mark, Mark::Warn);
let notes = row.notes.join(" ");
assert!(notes.contains("apt-get install libgdiplus"), "{notes}");
assert!(notes.contains("SHARD_PREREQS.md"), "{notes}");
}
#[cfg(target_os = "linux")]
#[test]
fn a_present_libgdiplus_is_one_quiet_ok_line() {
let row = imaging_verdict(true);
assert_eq!(row.mark, Mark::Ok);
assert!(
row.notes.is_empty(),
"a satisfied prerequisite needs no advice"
);
}
#[cfg(target_os = "linux")]
#[test]
fn detection_answers_rather_than_panicking_on_a_host_with_no_ldconfig() {
// The value depends on the host and is not asserted — what is asserted is that a missing
// `ldconfig` degrades to the path probe instead of taking `doctor` down, which is the rule
// every row in this module follows.
let _ = imaging_present();
}
}

View File

@@ -294,6 +294,18 @@ fn port_of(bind: &str) -> &str {
}
}
/// Where the shard settings live in the website's admin panel.
///
/// NOT `/admin/shard`, which is what this printed until 2026-08-24 and what an operator who ran
/// an older build still has in their scrollback. Those screens belong to the `uo` MODULE now, and
/// a module owns one path segment wherever it appears (website `MODULE_SYSTEM.md` §2.8), so the
/// page moved. The old path does not 404 — the SPA sends it to the dashboard, which is the worst
/// way for a link in a handoff to be wrong, because it looks like it worked.
///
/// API routes are NOT affected by that rule and keep `/api/v1/admin/shard/*`. This is the SPA URL
/// a person types.
const ADMIN_SHARD_PATH: &str = "/admin/uo/link";
/// The end-of-run block from PLAN.md §6 — the one manual step the installer cannot do.
///
/// Returned as a string rather than printed so it can be tested, and so the caller decides where it
@@ -312,12 +324,13 @@ pub fn handoff(doc: &ConfigDoc, host: &str, site_url: Option<&str>) -> String {
Protocol version {protocol}\n \
Auth token {token}\n \
(also in {config})\n\n\
Paste these into Admin → Shard on your Runic Gateway site:\n \
{site}/admin/shard\n\n\
Paste these into Admin → Shard (uo-link) on your Runic Gateway site:\n \
{site}{admin_path}\n\n\
The token is write-only once saved — the site will never show it back to you.\n",
protocol = doc.protocol,
token = doc.web.auth_token,
config = doc.config_path,
admin_path = ADMIN_SHARD_PATH,
)
}
@@ -401,7 +414,7 @@ mod tests {
assert!(block.contains(&doc.web.auth_token), "{block}");
// The trailing slash on the site URL must not produce a double slash in the link.
assert!(
block.contains("https://my-site.example/admin/shard"),
block.contains("https://my-site.example/admin/uo/link"),
"{block}"
);
assert!(block.contains("/etc/runicgateway/sidecar.toml"), "{block}");
@@ -412,7 +425,10 @@ mod tests {
// An unattended run has nobody to ask, and the token is far too useful to withhold over a
// link the operator does not need.
let block = handoff(&doc(), "shard", None);
assert!(block.contains("https://<your-site>/admin/shard"), "{block}");
assert!(
block.contains("https://<your-site>/admin/uo/link"),
"{block}"
);
assert!(block.contains("4f9c"), "{block}");
}

View File

@@ -81,7 +81,7 @@ pub fn closing(prior: Option<&InstallRecord>, bundle: &Bundle, now: &InstallReco
println!();
ui::warn(&format!(
"The protocol version changed: {} → {}.\n \
Update the Protocol version field in Admin → Shard on your website. Nothing else \
Update the Protocol version field in Admin → Shard (uo-link) on your website. Nothing else \
changed —\n the URLs and the auth token are the same, and the sidecar answers a \
website still set to\n {} with 409 rather than mis-parsing it.",
previous_protocol.unwrap_or(bundle.protocol),