Cutover: promote the installer from edge to main #17

Merged
whitlocktech merged 24 commits from edge into main 2026-08-07 20:07:39 +00:00
Member

What this is

The edge → main cutover. Merging this fires release.yml and cuts the first public installer release.

edge has been the integration branch since Phase 1, because release.yml publishes on every push to main and merging the crate there early would have shipped a binary that syncs the overlay but cannot install the sidecar. That reason is now spent: all five phases are built and both entry criteria are met.

24 commits — Phases 1 through 5, plus the merge that brings main's publishing fixes onto edge.

Both cutover gates are met

Gate State
Phase 5 complete Scope settled (no .deb, no MSI, Linux aarch64, backup-before-overwrite) and built — installer#9–#12, link#26, docs#96
Windows SCM half verified Done 2026-08-07. It failed first (1053), was root-caused and fixed, and is now verified end to end

The Windows half is worth spelling out, since it was the long-standing unknown. sc start failed with 1053 on the first real run: the SCM waited 30 seconds for a handshake the sidecar could not perform, because it was a plain console program. Fixed in link#29 (v1.2.0) and verified against a live SCM service — 13/13 checks, start in 1s, RUNNING, /health served, service log written, clean stop. installer#16 in this range makes the installer diagnose that error correctly instead of blaming the config file.

systemd registration was verified separately on 2026-08-05 against a real privileged systemd container.

The bundle is ready

bundles/current.json on the bundles branch now names:

bundle   2026.08.07     protocol 3
link     v1.2.1         linux-x86_64, linux-aarch64, windows-x86_64
overlay  v0.2.0

v1.2.1 is v1.2.0 plus link#28 (a CI-only change), so the shipped sidecar carries the Windows service fix. Both halves declare protocol 3.

The merge commit in this range

edge had diverged from main by 7 commits (the publishing fixes: bundle.yml publishing to the bundles branch, release.yml going tag-only, and bundles/*.json removed from main). 84c1106 merges those onto edge so this PR is conflict-free.

It had 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 deliberately: it is the crate's test input, not a mirror of what is published. .gitea/workflows/release.yml auto-merged cleanly.

What merging this does

  1. Fires release.yml on maintag-only as of installer#14, so it tags and publishes without pushing a version-bump commit to a protected branch.
  2. Publishes three installer binaries + SHA256SUMS: linux-x86_64, linux-aarch64, windows-x86_64.exe.
  3. Makes INSTALL.md's status banner wrong — it still says the binary is unreleased. Worth a follow-up docs PR once the release is out.

How it was tested

The merge result was built and gated on both platforms before this PR was opened:

  • Linux (rust:1-slim-bookworm, matching the CI runner): cargo fmt --check OK, cargo clippy --locked --all-targets -- -D warnings clean, cargo test --locked 140 unit + 9 integration passed. This is the one that matters — half the crate is cfg(unix), so a Windows-only pass proves nothing about CI.
  • Windows: same three gates, 140 passed.

Merged-tree sanity checks: bundles/ retains only README.md as the pointer, and tests/fixtures/published-bundle.json is present and parses.

Checklist

  • I have read CONTRIBUTING.md.
  • The change builds and existing tests/checks pass locally.
  • I have added or updated tests/docs where it makes sense.
  • My commits are reasonably scoped with clear messages.

AI-assisted contributions (required)

  • AI tools were used. Tool(s): Claude Code (Opus 5). I have reviewed and understand
    every change, and take responsibility for it. AI-authored commits are
    marked with a Co-Authored-By trailer.

License

  • I agree that my contribution is licensed under this project's license
    (GNU GPL v3.0 or later), and I have the right to contribute it.
## What this is The **`edge → main` cutover**. Merging this fires `release.yml` and cuts the **first public installer release**. `edge` has been the integration branch since Phase 1, because `release.yml` publishes on every push to `main` and merging the crate there early would have shipped a binary that syncs the overlay but cannot install the sidecar. That reason is now spent: all five phases are built and both entry criteria are met. 24 commits — Phases 1 through 5, plus the merge that brings `main`'s publishing fixes onto `edge`. ## Both cutover gates are met | Gate | State | |---|---| | **Phase 5 complete** | Scope settled (no `.deb`, no MSI, Linux aarch64, backup-before-overwrite) and built — installer#9–#12, link#26, docs#96 | | **Windows SCM half verified** | Done 2026-08-07. It failed first (1053), was root-caused and fixed, and is now verified end to end | The Windows half is worth spelling out, since it was the long-standing unknown. `sc start` failed with **1053** on the first real run: the SCM waited 30 seconds for a handshake the sidecar could not perform, because it was a plain console program. Fixed in `link#29` (v1.2.0) and verified against a live SCM service — 13/13 checks, start in 1s, `RUNNING`, `/health` served, service log written, clean stop. `installer#16` in this range makes the installer diagnose that error correctly instead of blaming the config file. systemd registration was verified separately on 2026-08-05 against a real privileged systemd container. ## The bundle is ready `bundles/current.json` on the `bundles` branch now names: ``` bundle 2026.08.07 protocol 3 link v1.2.1 linux-x86_64, linux-aarch64, windows-x86_64 overlay v0.2.0 ``` v1.2.1 is v1.2.0 plus link#28 (a CI-only change), so the shipped sidecar carries the Windows service fix. Both halves declare protocol 3. ## The merge commit in this range `edge` had diverged from `main` by 7 commits (the publishing fixes: `bundle.yml` publishing to the `bundles` branch, `release.yml` going tag-only, and `bundles/*.json` removed from `main`). `84c1106` merges those onto `edge` so this PR is conflict-free. It had **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 deliberately: it is the crate's test input, not a mirror of what is published. `.gitea/workflows/release.yml` auto-merged cleanly. ## What merging this does 1. Fires `release.yml` on `main` — **tag-only** as of installer#14, so it tags and publishes without pushing a version-bump commit to a protected branch. 2. Publishes three installer binaries + `SHA256SUMS`: `linux-x86_64`, `linux-aarch64`, `windows-x86_64.exe`. 3. Makes INSTALL.md's status banner wrong — it still says the binary is unreleased. Worth a follow-up docs PR once the release is out. ## How it was tested The merge result was built and gated on **both** platforms before this PR was opened: - **Linux** (`rust:1-slim-bookworm`, matching the CI runner): `cargo fmt --check` OK, `cargo clippy --locked --all-targets -- -D warnings` clean, `cargo test --locked` **140 unit + 9 integration passed**. This is the one that matters — half the crate is `cfg(unix)`, so a Windows-only pass proves nothing about CI. - **Windows**: same three gates, 140 passed. Merged-tree sanity checks: `bundles/` retains only `README.md` as the pointer, and `tests/fixtures/published-bundle.json` is present and parses. ## Checklist - [x] I have read [CONTRIBUTING.md](CONTRIBUTING.md). - [x] The change builds and existing tests/checks pass locally. - [x] I have added or updated tests/docs where it makes sense. - [x] My commits are reasonably scoped with clear messages. ## AI-assisted contributions (required) - [x] AI tools were used. Tool(s): `Claude Code (Opus 5)`. I have reviewed and understand every change, and take responsibility for it. AI-authored commits are marked with a `Co-Authored-By` trailer. ## License - [x] I agree that my contribution is licensed under this project's license (**GNU GPL v3.0 or later**), and I have the right to contribute it.
wtclaude added 24 commits 2026-08-07 20:01:08 +00:00
feat(installer): implement Phase 1 — the installer core
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m31s
dff4ad41c9
Adds the Rust crate at the repo root and implements `install` end to end for
the overlay half of a deployment: resolve the published bundle, find and
validate the ServUO root, refuse to deploy under a running shard, sync the
plugin overlay, and record what was deployed in install.json.

`doctor`, `update` and `uninstall` parse and answer with the phase they arrive
in rather than "unrecognized command", and the run states plainly that the
uo-link sidecar (Phase 2) and the patch tier (Phase 3) were not installed —
`--patches` in particular reports REQUESTED BUT NOT APPLIED, since a quiet
completion would be read as a patched shard.

Landing on `edge` rather than `main`: release.yml publishes a binary on every
push to main, and an installer that deploys the overlay but cannot install the
sidecar is not something to hand an operator. pr-checks.yml now gates PRs into
edge on the same rules, so the branch the work happens on is not the ungated
one.

Notable decisions, all documented in docs/installer/PLAN.md §5 Phase 1:

- The code lives in a library called `rgdeploy` with a thin binary that keeps
  the published name. Windows' UAC installer detection refuses to launch an
  unsigned executable whose file name contains "install" (os error 740), and
  Cargo names test harnesses after their target — so a target under that name
  makes `cargo test` unrunnable on Windows.
- The running-shard check matches processes by path, not by process name:
  on Linux a live shard is `mono`/`dotnet` with ServUO.exe as an argument, and
  a name match would report "not running" for a shard that is running.
- install.json records a state (`deployed` / `kept-operator-modified`), not the
  run's verb, so an unchanged re-run produces an identical record and writes
  nothing.
- The Bridge.cfg keep rule compares against the hash the installer last
  deployed, not the last hash it saw — otherwise a kept file is overwritten on
  the very next run.
- Downloads are verified against the bundle's SHA256 while being written, then
  every extracted file is re-hashed against the release's own manifest.json,
  whose protocol and version are cross-checked against the bundle.

Verified against a real ServUO 57.4 tree and end to end into a scratch tree:
24 files deployed, an unchanged re-run that writes nothing, an edited
Bridge.cfg kept across repeated runs while code files are overwritten, bundle
pinning, and a refusal with a shard running out of the tree.

Co-Authored-By: Claude <noreply@anthropic.com>
Reviewed-on: #4
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
feat(installer): implement Phase 2 — uo-link install and service
Some checks failed
PR Checks / rust-gates (pull_request) Failing after 1m15s
2228e0848b
Adds the sidecar half of a deployment to the same `install` run: download and
verify the bundle's binary, provision its config, register and start a service,
and print the token handoff PLAN.md §6 specifies. `src/sidecar.rs` owns the
binary and the config document; `src/service.rs` owns systemd and the Windows
SCM.

The order is fixed by PLAN.md §5 and matters: stop anything running the old
binary, replace it, then `--print-config` (which writes the config the service
will be pointed at), then register. Registering first points a service at a file
that does not exist yet.

Decisions worth a reviewer's attention:

- Both platforms run the sidecar as a dedicated unprivileged identity. Linux gets
  the `runicgateway` system user the plan already specified; Windows gets a
  virtual service account, `sc create ... obj= "NT SERVICE\RunicGatewayLink"`,
  which the SCM creates itself and which has no password. Plain `sc create` runs
  as LocalSystem — the most privileged local identity there is, for a process
  listening on two TCP ports while its Linux twin deliberately does not run as
  root.
- `sidecar.toml` holds the auth token and neither default location protects it:
  /etc is world-readable and %ProgramData% grants Users read by inheritance, so a
  stock install would leave the shard's token readable by any local account. The
  lockdown straddles registration because it has to — on Windows the service
  account does not exist until `sc create` creates it, so the file is first cut
  down to SYSTEM + Administrators, and the account's read grant comes after.
- Only Linux pins UOLINK_DB_PATH. On Windows config and data share a directory
  and the sidecar anchors a relative [store] path to its config's directory, so
  the pin is redundant — and `sc.exe` has no per-service environment, only a
  machine-wide one that every process inherits and that outlives an uninstall.
  The config path rides in the service's own binPath instead.
- `--verify` runs no part of the sidecar half. `--print-config` provisions: it
  writes the config and mints a token, so a dry run that called it would create
  the state it claims not to. It also carries an existing `link` section of
  install.json through untouched, so a dry run cannot make a service disappear
  from the record.
- The installed binary's protocol version is checked against the bundle before
  the service is registered. Gate 1 read that number from source at the release
  tag; this is the same check applied to the binary that will actually answer the
  website.
- RUNICGATEWAY_STATE_DIR now relocates the sidecar binary as well, and suppresses
  service registration and the file-permission hardening. There is no such thing
  as a relocated systemd unit, and hardening a scratch config against the only
  account that will ever read it just breaks the next test run.
- A host with no systemd, or where the service user cannot be created, still gets
  a working binary and config plus the exact unit and commands. There is no
  fallback to User=root or LocalSystem: a service quietly running with more
  privilege than its documentation promises is worse than one that was not
  registered.
- install.json never records the token. The `link` section carries versions, the
  binary's hash, the config and database paths, and the service's name, unit path
  and account.

Docs half: docs#91.

Tested: cargo fmt --check, clippy --all-targets -D warnings, 72 tests. End to end
on Windows against a relocated layout — bundle sidecar downloaded and verified,
config provisioned, handoff printed with URLs composed from the host rather than
the bind address, second run reporting unchanged with install.json byte-identical,
--verify over an installed host writing nothing and preserving the link section,
and a tampered binary detected by hash and replaced with no staging file left.

Co-Authored-By: Claude <noreply@anthropic.com>
fix(installer): make the Linux half of Phase 2 compile
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m56s
7bfb033957
Three faults in code that only compiles under cfg(unix), none of which the
Windows build could see:

- `run(...).map(...) == Ok(true)` compared two `Result<_, anyhow::Error>`
  values, and anyhow::Error is not PartialEq. Replaced with `is_ok_and`.
- `command_line` is used only by the Windows registration path, so importing it
  unconditionally is an unused-import error under `-D warnings`. Qualified at
  its call site instead.
- A cfg(not(windows)) assertion block had ended up in the wrong test, leaving it
  referencing a binding from its original one.

Caught by running the same gates the CI runner does inside a rust:1-slim
container against this working tree — fmt, clippy --all-targets -D warnings, and
cargo test --locked all pass there now, as they do on Windows.

Co-Authored-By: Claude <noreply@anthropic.com>
Reviewed-on: #5
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
feat(installer): implement Phase 3 — the patch tier
Some checks failed
PR Checks / rust-gates (pull_request) Failing after 46s
52d330167b
Two features need edits to stock ServUO sources, because the events they
depend on do not exist. This adds the rung ladder of PLAN.md §2.2.1, the
unsupported-version path of §2.2.2, and the record and cache Phase 4 will read.

Three decisions were not settled by the plan:

* The engine is fully native, with no `git`. §2.2.1 wrote rung 1 as "apply
  verbatim with git apply", but §1 chose the release tarball specifically so
  there would be no git on the shard host, and rung 2 needs a native applier
  regardless. Rung 1 keeps its distinct, stronger verdict — the whole file
  reproduced the diff's `index` pre-image, computed as a git blob SHA1 in
  process — while the write goes through the same code path as rung 2. On the
  real trees here that is not academic: the shipped .patch files are CRLF in a
  Windows checkout and two of their three targets are LF, so `git apply`
  refuses patches this applies correctly.

* Per-patch metadata is declared by the release, with a built-in fallback.
  Which patches form one all-or-nothing unit, which companion .cs follows
  which, whether a CORE rebuild is needed and what declining costs are not
  derivable from a diff. servuo-plugins now declares them; overlay v0.1.1 is in
  the current bundle and declares nothing, so a built-in copy stands in for it.
  A checked-in fixture of the release workflow's own jq output asserts the two
  descriptions are identical, so the repos cannot drift quietly.

* Pre-images are cached in the state directory. The tier edits files the
  operator owns, and `/etc/runicgateway/patches/originals/` is what turns "here
  are the hunks we added" into a revert anyone can verify — kept out of the
  ServUO tree, which uninstall has promised never to clean up.

Everything else follows §2.2.1: exact matching with only line-ending and
trailing-whitespace normalization, exactly one occurrence or it fails,
all-or-nothing per patch file and again per feature, and a byte-preserving
splice so nothing outside a hunk can be reformatted.

Verified against the ServUO 57.4 tree on this machine across four scratch
roots: a hand-patched tree (rung 0), a reverse-applied stock one (rung 1 on the
real EventSink.cs, its blob matching the patch's declared pre-image), a
mixed-rung feature, a tree with edits inside two patched regions (rung 3 —
nothing written, nothing held back applied, no companions copied), and a
non-57.4 tree both with and without the extra consent flag. Three consecutive
runs left install.json byte-identical and the cached pre-image still pre-patch.

Three reporting defects the live runs caught are fixed with tests: a dry run
and a held-back patch both claimed to be "applied", the core-rebuild warning
fired when nothing had been written and named a Scripts file as core, and a
declined tier announced the loss of features install.json showed as applied.
Refused patches are now cached too, since the refusal message names that path.

Refs: docs/installer/PLAN.md §2.2, §5 Phase 3

Co-Authored-By: Claude <noreply@anthropic.com>
fix(installer): satisfy clippy's unnecessary_sort_by on the CI toolchain
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 58s
02c5ad9839
The two descending sorts in the applier used an explicit comparator. CI runs
clippy 1.97, where `unnecessary_sort_by` flags that and `-D warnings` turns it
into a build failure; the local toolchain here is 1.94, which does not have the
lint. `sort_by_key` with `Reverse` says the same thing.

Co-Authored-By: Claude <noreply@anthropic.com>
docs(installer): drop phase references that are now this build's behaviour
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m8s
265911a58f
Five comments described the patch tier as work a later phase would do. It is
this phase, so they read as stale the moment the code landed.

Co-Authored-By: Claude <noreply@anthropic.com>
Reviewed-on: #6
feat(installer): implement Phase 4 — doctor, update and uninstall
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 59s
80b1c0da24
Completes the command surface INSTALL.md §2 published before the binary
existed. With this, `edge` cuts a binary that does everything that guide
describes.

doctor (src/doctor.rs)
  Reads only. Every row is answered by asking the thing itself — the
  installed binary (--version, --print-config), the service manager, and
  the sidecar's /health — because the record says what `install` did,
  which is a different question from what is true now. --print-config is
  run ONLY when the config already exists: that flag provisions, and a
  diagnosis must not create the state it reports on. It is also run under
  the environment the service pins (UOLINK_DB_PATH), so the config and
  database it names are the ones the service opens, not the ones the
  binary would pick on its own.

  Exit 1 when any row failed, so a monitoring script can read it; a ⚠
  never does that. A stopped shard is therefore a ⚠, not a ✗ — "you have
  not started it" and "it is running and the bridge is dead" are
  different problems and only the second is broken. Offline is a ⚠ too:
  a shard host with no route to Gitea is a supported way to run this.

  The patch row re-resolves each recorded patch against the tree from the
  cached .patch, so a core upgrade or a restored backup that silently
  removed the tier's edits is caught — nothing else here would notice.

update (src/update.rs, install.rs::Mode)
  The same pipeline as install, not a second one: PLAN.md describes it as
  "re-resolve the bundle, then move both components to it", which is what
  an install over an existing deployment already does. Writing it twice
  would give the sync rules and the protocol cross-checks two places to
  disagree. What differs is small and lives in Mode — a prior record is
  required, the tree comes from that record rather than detection, the
  patch tier's scope narrows, and the close is a diff instead of a
  handoff.

  The token is not reprinted: it has not changed and the website has it.
  A changed protocol number IS called out, because a stale value in
  Admin → Shard is answered with 409 and looks like the shard going
  offline.

  Tier scope: features an earlier run recorded are re-resolved without
  asking again (the record is the evidence of consent, including on an
  unsupported ServUO); anything new the release offers is named but not
  applied without --patches. A shard that declined stays declined.

uninstall (src/uninstall.rs, service::remove)
  Removes the binary, the service and install.json; prints the overlay
  files and the exact hunks, rendered from the cached patches with the
  rung each landed at. Files edited since deployment are flagged so
  nobody deletes their own work blind. The report is also written to a
  file in the working directory — it is the only thing still needed after
  the command exits, and it arrives at the end of the longest output this
  tool produces.

  Two deviations from PLAN.md §5, both deliberate:

  - The cached patch set and patches/originals/ SURVIVE. That table put
    them under "removed", but the report tells the operator to diff
    against those originals — advice the same command would have made
    impossible to follow. --purge removes them, with the config and the
    database.
  - --yes means yes here, not "take the default". The prompt defaults to
    no (destructive), but the operator typed the verb; reading --yes as
    "no" would leave an unattended uninstall unable to express itself,
    and a script that appears to succeed while removing nothing is the
    worse failure.

  Exit 1 if a step could not be carried out — everything else still was.

Verified on this machine against a scratch tree built from the real
ServUO 57.4 files: a healthy doctor (exit 0), one with a deleted overlay
file, an edited one and a reverted patch (all three found, exit 1), a
--verify update that wrote nothing, a real update that repaired all three
and left install.json byte-identical, uninstall with and without --purge,
a second uninstall, and doctor/update on a host with no record. Linux
fmt/clippy/tests run in Docker as well as the Windows host.

Co-Authored-By: Claude <noreply@anthropic.com>
Reviewed-on: #7
fix(installer): keep user_created sticky across re-runs
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m2s
060b8815cf
Found on the first real systemd host this installer has ever run on: a
container with systemd as PID 1, installing into /usr/bin, /etc and
/var/lib for real.

`service::prepare` answers "did THIS run create the service account",
which is false on every run after the first — by then the account exists.
Recording that verbatim made the field describe the run rather than the
state, with two consequences:

- `install.json` changed on an otherwise-identical second run, breaking
  the Phase 1 promise that a re-run with nothing new to do writes
  nothing.
- `uninstall` removes only an account it created, so after any second
  `install` it silently left behind the very user this tool had added.
  Reproduced before the fix: "left the runicgateway account alone — this
  installer did not create it", on a host where it plainly had.

The record now inherits `true` from a prior record naming the same
account, and only that account: inheriting across a rename would
authorize deleting a user this installer never made.

Not visible on Windows, where the SCM's virtual account is never created
by us and goes with the service — which is why three phases of Windows
smoke runs never showed it.

Verified after the fix on the same host: fresh install records
user_created true, an identical second run leaves install.json
byte-identical, and uninstall then removes the account, the unit, the
service and the binary — leaving sidecar.toml, the database and all 24
overlay files in the ServUO tree exactly where they were.

Co-Authored-By: Claude <noreply@anthropic.com>
Reviewed-on: #8
feat(installer): build for and install on linux-aarch64
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 54s
6941925fa5
Step 4 of PLAN.md §5.2, and the half that faces the operator: the
release now cross-compiles aarch64-unknown-linux-gnu, and platform_key()
resolves ("linux","aarch64") to the bundle key link publishes under
instead of refusing the host by name.

Same toolchain shape as the Windows step -- a linker plus a CC/AR pair,
because ring (under ureq's rustls) compiles C and assembly. And the same
packaging trap named in the sums comment: an artifact missing from
SHA256SUMS is one `sha256sum -c` passes over silently, so the new binary
is added to both the sums and the upload list.

Two test changes fall out of the asset map growing a key:

- The exact `assets.len() == 2` assertion is replaced by a check that
  each key CI requires is present and well-formed. An exact count would
  fail on the first bundle that adds arm64 -- reporting correct
  behaviour as a regression.
- The host-binary lookup now accepts either outcome, and says why.
  Bundles are kept unchanged forever so `--bundle` stays reproducible,
  which means one published before arm64 existed can never gain that
  key. On such a host the run must fail with the reason rather than
  something that reads like a corrupt document, so sidecar_asset()'s
  error now says so and the test asserts it.

Verified by cross-building this crate for aarch64 in a
rust:1-slim-bookworm container -- ELF 64-bit LSB pie executable, ARM
aarch64 -- and by running fmt, clippy -D warnings and the tests on both
Linux and the Windows host, since only half of service.rs compiles on
either.

Co-Authored-By: Claude <noreply@anthropic.com>
feat(installer): back up what a run is about to overwrite
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m59s
82900da939
PLAN.md §5.3. Before anything is written, every file this run will
replace is copied into <state>/backups/<utc-stamp>/ with a manifest
naming where each came from. --no-backup opts out; --verify takes none.

Scoped by what cannot be fetched again. The sidecar binary and the
overlay files are re-downloadable and hash-named in the bundle, and the
database is a cache with a schema -- link's store.rs creates every table
IF NOT EXISTS over shard state the sweeps repopulate. What a run can
destroy for good is an operator's edits to a deployed .cs file, which
Phase 1 overwrites unconditionally and by design, and sidecar.toml,
whose token the website already holds.

Two deviations from §5.3 as written, both found by building it:

- The trigger is "this run is about to overwrite something", not "an
  update, or an install over an existing record". §5.3 justified the
  latter with "a first install overwrites nothing" -- which is not true
  of a tree deployed by hand per INSTALL.md Appendix A2, a documented
  path. There the first install finds .cs files that differ, plans them
  as Change, and overwrites them with no record anywhere. The direct
  test covers that case and still writes nothing for a genuine first
  install, because there is nothing to copy.
- sidecar.toml joins a backup that is already being taken and is never
  the reason for one. Nothing here rewrites it, so making it a trigger
  would put a dated directory on disk after every no-op update; it is
  copied so a restored set of files comes with the token that matches
  them.

The directory is created lazily and the manifest is written last, so a
directory carrying one is a complete backup -- and pruning only
considers those, so a run interrupted mid-copy cannot evict a good
backup by being newer than it. Three are kept. uninstall keeps them and
names them in its report; --purge removes them, alongside the config,
the database and the cached patch set. doctor reports the newest.

Restoring stays printed rather than done, as the uninstall report is:
the installer cannot know what has changed since, and putting an old
.cs file back over a newer overlay eats work rather than saving it.

Verified live against two scratch ServUO trees built from the real 57.4
files: a clean first install leaving no backups directory at all, an
update after editing a deployed .cs (copy holds the edit, tree gets the
release's file, manifest lists both it and sidecar.toml), a no-op update
taking none, --no-backup and --verify each taking none, a fourth backup
pruning the oldest, doctor's row, uninstall keeping three and listing
them, --purge removing them, and a --patches run capturing the
pre-patch Logging.cs while the two rung-0 patches correctly captured
nothing. fmt, clippy -D warnings and 144 tests on both Linux and
Windows.

Co-Authored-By: Claude <noreply@anthropic.com>
docs(readme): describe the tool that exists, not Phase 1
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m0s
c79374ff06
PLAN.md §5.4. The status section still announced Phase 1 as built and
Phase 2 as next, four phases later -- it is the first thing a visitor to
this repo reads, and it has been wrong since Phase 2 merged.

- The phase table now shows 1-4 built on `edge` and 5 in progress, and
  the opening says what the binary actually does.
- "What the cutover is waiting on" is stated, because "nothing is
  released yet" invites the question: Phase 5, and the Windows SCM half
  never having been executed anywhere.
- "Planned commands" is now "Commands". All four are implemented.
- RUNICGATEWAY_STATE_DIR was described as relocating install.json. Since
  Phase 2 it relocates everything the installer writes, including the
  sidecar binary, and suppresses service registration -- an out-of-date
  description of where a tool writes is worse than none.
- A design-constraint bullet for the backup behaviour Phase 5 adds.

Co-Authored-By: Claude <noreply@anthropic.com>
Reviewed-on: #12
Reviewed-on: #10
Reviewed-on: #11
feat(installer): read bundles from the branch they are published to
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m58s
2787eaadff
`main` is protected, so the compose job cannot push a bundle there --
the pre-receive hook declines it, which is not something a nightly cron
can resolve. Bundles now publish to a branch of their own, at its root,
so BUNDLE_BASE follows them. Everything the original choice was for
survives the move: a reviewable diff, a git history of the compat
matrix, and a plain anonymous URL that needs no credentials on the
shard host.

The test's bundle is now a frozen fixture rather than an include of the
published file, which this checkout no longer carries. Frozen is the
honest shape anyway: a test that silently re-targeted whatever CI
published last would change meaning without a commit. It is still a
real CI-emitted document, copied verbatim.

Nothing is released from `edge`, so no shipped binary ever read the old
URL.

Co-Authored-By: Claude <noreply@anthropic.com>
fix(service): diagnose 1053 as a handshake, not a bad config
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m2s
b7d1bbbc78
Every failed `sc.exe start` was reported with "a service that exits immediately
usually cannot read its config", which for the one error code that actually
occurs is the wrong place to look. 1053 is the SCM giving up after 30 seconds
waiting for the process to identify itself; the process started fine and is
very likely serving traffic. A reader who follows the old sentence goes and
stares at a config file that is correct.

Replace it with windows_start_failure(), which names the real cause per code:

- 1053: a handshake failure, almost always a sidecar older than v1.2.0 (the
  first release that speaks the SCM protocol). Says how to check the version,
  and how to prove the binary is healthy by running it in the foreground.
- 1069: the virtual service account was refused, which is local policy rather
  than a bad credential, and points at INSTALL.md Appendix A4.
- anything else: does not guess, and hands over the event log, `sc query` for
  the service's own exit code, and the foreground command.

Pure and tested on both platforms, like windows_bin_path above it, so the text
is covered on the Linux CI runner that never sees an SCM.

Co-Authored-By: Claude <noreply@anthropic.com>
Reviewed-on: #16
Reviewed-on: #15
Merge branch 'main' into edge
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m59s
84c1106d58
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>
whitlocktech approved these changes 2026-08-07 20:05:25 +00:00
whitlocktech merged commit 1173a10049 into main 2026-08-07 20:07:39 +00:00
whitlocktech deleted branch edge 2026-08-07 20:07:40 +00:00
Sign in to join this conversation.
No description provided.