ead105d5bf8cde21bc4a7905b8e664a296f99aca
13 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| 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>
|
|||
| 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> |
|||
| 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>
|
|||
| 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> |
|||
| 065edab8cd |
Merge branch 'main' into fix/release-tag-only-installer
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 4s
|
|||
| 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> |
|||
| 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>
|
|||
| 6941925fa5 |
feat(installer): build for and install on linux-aarch64
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 54s
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>
|
|||
| 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> |
|||
| dff4ad41c9 |
feat(installer): implement Phase 1 — the installer core
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m31s
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> |
|||
| c100d4a2d7 |
ci(bundle): compose and publish the bundle manifest
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 5s
Phase 0 item 3 of docs/installer/PLAN.md (§7.1-§7.3). The installer resolves what to install *from* the bundle, so this has to exist before Phase 1 code is useful. Both components it composes now have releases, which is what unblocked it. Adds .gitea/workflows/bundle.yml — resolve both components' latest releases, run the two compose-time gates, and publish bundles/current.json — plus the first real bundle (2026.08.04: link v1.1.0 + overlay v0.1.1, protocol 3). Bundles are COMMITTED under bundles/, not published as releases. This repo's own releases are the installer binaries, and /releases/latest returns whichever release is newest regardless of kind, so interleaving bundle releases would make "latest" intermittently resolve to a release carrying no installer binary. The push to main needs no new branch-protection exception: release.yml's version-bump commit already requires it. Gate 1 (protocol agreement) reads the sidecar's PROTOCOL_VERSION from sidecar/src/main.rs at the release tag, not from the binary. --print-config would answer, but only for releases from v1.1.0 on, and --bundle <tag> has to be able to recompose an older bundle. It also avoids executing a downloaded artifact and provisioning a throwaway config whose auth token would land in a CI log. The overlay half comes from manifest.json inside the tarball, which is the only statement of that version that exists. Gate 2 (assets) downloads every asset and verifies it against the SHA256SUMS its publishing repo shipped, then records the hash it computed itself. These artifacts are deliberately unsigned, so a hash copied from a file nobody checked would make the whole chain decorative. An asset with no SHA256SUMS entry is caught separately, since `sha256sum -c` passes right over it. Release reads are ANONYMOUS on purpose: they are exactly the requests the shipped installer makes on a host with no Gitea credentials, so a repo flipped to private fails here rather than on an operator's machine. Stale components (§7.3) are dispatched, never awaited — Gitea's dispatch endpoint returns no run handle. "Ahead of its release" counts only releasable commits and excludes merge commits, whose subject quotes the feat/fix title and would otherwise re-dispatch a workflow that correctly declines to run, every night. A run that finds nothing changed writes nothing, comparing everything except `bundle` and `generated` — that is what makes the nightly cron free rather than a dated duplicate every morning. Verified by running the workflow's exact compose steps in a Linux container against the live releases: both gates pass, the composed bundle is the file committed here, a re-run reports changed=false, and the stale-detection filter scores 1 releasable commit for link v1.0.0..main (excluding the merge that quotes it) and 0 for a docs-only range. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| 60ae6f1f75 |
fix(ci): preflight release credentials and recover from an orphan tag
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 5s
servuo-plugins hit both of these on its first real release run; this repo runs the same engine, so it has the same two defects latent. REGISTRY_USER / REGISTRY_TOKEN were empty there, yet the tag push SUCCEEDED: actions/checkout leaves an `http.<host>.extraheader` credential in the local git config, so `git remote set-url` to a URL with empty credentials still authenticated through that leftover header. The release API call had no such fallback and returned 401. Net result was the worst available outcome — the repo tagged, no release, and a failed job. Two fixes: A credential preflight, before anything is built or pushed, gated on the run actually intending to publish so a docs:/chore:-only merge (or this repo's pre-crate no-op) still passes on a repo with no secrets. It names the missing secrets and the scope they need instead of failing wherever they happen to be used first. Orphan-tag recovery. A tag with no release behind it means an earlier run died after tagging, and the old code treated any existing tag as "nothing to release" — so that state could never clear itself: every later run would see the tag and stand down, forever. The plan step now asks the API whether a release exists for the tag, and on 404 reuses the tag and publishes the release it is missing. This deliberately overrides the RELEASE=false the bump logic just decided, which is the whole point — with the tag in place there are no releasable commits after it. Anything other than 200/404 (network failure, bad token) is refused rather than guessed, since assuming "no release" would republish over a good one. The tag step now reuses an existing tag instead of failing on `git tag`, and the changelog for a recovery run summarizes what the tag contains (previous-tag..this-tag) rather than the empty range after it. sync-project-tree gets the same preflight: its first run on main failed with an opaque `git clone` error against `https://:@host/...` that said nothing about a missing secret. Verified by extracting every run block and exercising the paths: empty secrets fail the preflight with a legible message and populated ones pass; the no-Cargo.toml guard still short-circuits to release=false; a crate with no tag still takes the seed path; and against real repo state, a tag with a release stands down while an orphan tag recovers. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| ea9aad6e9b |
ci(installer): add pr-checks, release, and project-tree sync workflows
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 11s
Bring this repo's CI up to parity with the other Runic Gateway repos. All three are retargeted from RunicGateway/link, which is the closest analog (same Rust toolchain, same release engine, same runner). pr-checks.yml Gates PRs into main on cargo fmt --check, clippy -D warnings, and cargo test --locked, in that order, one job — mirroring release.yml's gates so a green PR implies a green release. release.yml The conventional-commit release engine from link/, with the Rust adapter retargeted: crate at the repo root, binary runicgateway-installer, cross-compiled for x86_64 Linux and Windows. Artifact names follow PLAN.md §3. The generated changelog now carries the checksum-verification block, because releases are deliberately unsigned and SHA256SUMS is the trust anchor (PLAN.md §3) — that makes the verify instructions part of the release, not a doc someone has to find. sync-project-tree.yml (+ .gitea/scripts/gen_tree.py) Regenerates docs/installer/PROJECT_TREE.md on every push to main and opens or force-updates a PR against the docs repo. Verbatim from link/ apart from the repo/path/label env block. Crate guard This repo has no Cargo project yet — Phase 1 creates it. Landing the workflows unguarded would red-X every governance and docs PR until then, and holding them back leaves the repo ungated exactly while its conventions are being set. So both Rust workflows check for a root Cargo.toml first: pr-checks skips its gates with a notice, and release.yml's plan step sets RELEASE=false and exits. Both arm themselves the moment Cargo.toml lands, with no edit here. Verified before pushing: all three files parse as YAML, every run block passes bash -n, and the release plan step was simulated against a throwaway git repo both without a crate (release=false, exit 0) and with one (first-release path -> v0.1.0 with the changelog rendered). Not included: the bundle-manifest workflow (PLAN.md §7) and the release-dispatch hook, which are Phase 0 item 3 and depend on servuo-plugins having a release workflow first. Note for setup: release.yml and sync-project-tree.yml need REGISTRY_USER and REGISTRY_TOKEN (write:repository, plus read/write on RunicGateway/docs) configured for this repo. Co-Authored-By: Claude <noreply@anthropic.com> |