6 Commits
v0.1.1 ... edge

Author SHA1 Message Date
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
2 changed files with 202 additions and 8 deletions

View File

@@ -165,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.
@@ -395,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" \
-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')"
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 "${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

@@ -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();
}
}