6 Commits
v1.1.0 ... main

Author SHA1 Message Date
915f0296a9 Merge pull request 'fix(release): recover a tag whose release never published' (#27) from fix/release-tag-without-release into main
All checks were successful
sync-project-tree / sync (push) Successful in 6s
SonarQube / analysis (push) Successful in 44s
Release sidecar / release (push) Successful in 9m6s
Reviewed-on: #27
2026-08-05 18:09:39 +00:00
07021d38c9 fix(release): recover a tag whose release never published
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m15s
An existing tag was treated as "nothing to release", unconditionally.
That is wrong in the one case it matters: a tag with no release behind
it means an earlier run tagged and then died before publishing -- which
is exactly what happened on servuo-plugins' first release, where absent
REGISTRY_* secrets took the release API call to 401 after the tag had
been pushed. Standing down on the tag alone makes that permanent. Every
later run sees the tag, sets RELEASE=false, and the release never
appears; the version is unpublishable forever.

The tag now decides nothing on its own. The API does:

  200 -> a release exists, stand down
  404 -> tag without release, reuse the tag and publish what is missing
  else -> refuse and exit 1

The last arm matters as much as the others. A 000 from a network failure
or a 401 from a bad token is not evidence of absence, and guessing "no
release" would republish over a good one.

Note the 404 arm deliberately overrides the RELEASE=false decided just
above it: with the tag in place there are no releasable commits after
it, so the normal path always stands down -- which is why this could
never self-heal on its own.

Two consequences handled with it. The changelog range is now
previous-tag..this-tag on a recovery run, since a run finishing an
earlier one has nothing after the tag and would otherwise publish an
empty change list. And the tagging step tolerates the tag already
existing, because `git tag` on an existing name fails under `set -e`
while pushing an identical tag is a harmless no-op -- a push that does
fail there means the remote tag points somewhere else, which should
stop the run.

This is the same handling installer/release.yml already carries; link
was the copy that still had the trap.

Verified by extracting this step and driving it through four scenarios
against a real clone with the live tags, with curl stubbed to return
each status: a normal patch bump (v1.1.1, release=true), a tag with no
release (recovers, release=true), a tag with a release (stands down),
and an unreachable API (exit 1, publishes nothing).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 13:05:31 -05:00
2724c292a0 Merge pull request 'ci(release): build and publish a linux-aarch64 sidecar' (#26) from ci/release-linux-aarch64 into main
All checks were successful
sync-project-tree / sync (push) Successful in 6s
Release sidecar / release (push) Successful in -33s
SonarQube / analysis (push) Successful in 44s
Reviewed-on: #26
2026-08-05 17:52:40 +00:00
65b12f815b ci(release): build and publish a linux-aarch64 sidecar
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m17s
Step 2 of installer PLAN.md §5.2. The shard dials the sidecar out on
loopback, so wherever ServUO runs this binary has to run too -- and
Ampere/Graviton instances and Pi-class boxes are a realistic ServUO
home. Until now the release cross-compiled x86_64 Linux and Windows
only, so the installer refused every arm64 host by name.

Cross-compiling this crate is not Rust-only: sqlx's sqlite feature
pulls libsqlite3-sys, which compiles bundled SQLite from C. The build
therefore needs a CC/AR pair as well as a linker, the same shape the
Windows step already has.

libc6-dev-arm64-cross is named explicitly because gcc-aarch64-linux-gnu
only recommends it and this install passes --no-install-recommends: with
the compiler alone, SQLite's build dies on

  /usr/include/stdio.h:27: fatal error: bits/libc-header-start.h

Both the failure and the fix were reproduced in a rust:1-slim-bookworm
container against this crate before the workflow was written.

The new binary is added to SHA256SUMS and to the upload list. Every
artifact has to appear in both: the installer verifies its download
against those sums, and `sha256sum -c` passes silently over a file the
sums list does not mention.

Merge order matters -- installer#9 teaches the bundle CI this asset
name, and must land first. An unrecognized link asset is a hard failure
there, by design, so a release published before it would redden the
compose job.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 05:29:12 -05:00
eb78059bb5 Merge pull request 'ci(release): recompose the installer bundle after publishing' (#25) from ci/dispatch-bundle into main
All checks were successful
Release sidecar / release (push) Successful in 6s
sync-project-tree / sync (push) Successful in 7s
SonarQube / analysis (push) Successful in 42s
Reviewed-on: #25
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-04 16:18:52 +00:00
f8d80c07db ci(release): recompose the installer bundle after publishing
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 12m18s
Phase 0 item 3 of docs/installer/PLAN.md wired up from this side. The installer
does not resolve "latest" at run time — it installs the exact combination named
by a published bundle manifest (PLAN.md §7.1), so until now a new sidecar release
was invisible to operators until the installer repo's nightly cron noticed it.

Adds a final step that POSTs to RunicGateway/installer's bundle workflow-dispatch
endpoint. The bundle job re-reads PROTOCOL_VERSION from sidecar/src/main.rs at the new
release tag and checks it against the overlay's declared protocol before
publishing anything (gate 1), so a bump that lands without its plugin half is
caught at compose time instead of on an operator's shard.

Dispatch, don't wait (PLAN.md §7.3): Gitea's dispatch endpoint returns no run
handle, so there is nothing to poll — a waiting step would have to guess which
run is its own while holding a runner idle. The bundle job runs its own gates
regardless of who started it.

A dispatch failure is a warning, never a failure of this job. By the time this
step runs the release is published and correct, so failing the run would
misreport that; the installer's nightly cron recomposes from whatever the latest
releases actually are, making a dropped dispatch cost latency rather than
correctness. That also means REGISTRY_TOKEN having write on the installer repo
is a nicety, not a new hard requirement — noted in the header.

Verified the workflow still parses and that the new step is last, gated on
release=='true', and contains no path that can exit non-zero.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 11:13:51 -05:00

View File

@@ -28,6 +28,11 @@
# write:package; THIS workflow additionally needs # write:package; THIS workflow additionally needs
# `write:repository` so it can push the bump commit + tag # `write:repository` so it can push the bump commit + tag
# and create the release. Grant that scope to the token. # and create the release. Grant that scope to the token.
# The final step also dispatches RunicGateway/installer's
# bundle workflow, so the token ideally has write there too
# — but that is a nicety, not a requirement: without it the
# step warns and the installer's nightly cron picks the
# release up instead.
# Also: `main` must accept a direct push from that user (disable branch # 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. # protection for it, or add it as an exception) — the bump commit lands on main.
# #
@@ -51,6 +56,13 @@ env:
BIN: uo-link-sidecar BIN: uo-link-sidecar
LINUX_TARGET: x86_64-unknown-linux-gnu LINUX_TARGET: x86_64-unknown-linux-gnu
WINDOWS_TARGET: x86_64-pc-windows-gnu WINDOWS_TARGET: x86_64-pc-windows-gnu
# Ampere/Graviton instances and Pi-class boxes are a realistic ServUO home,
# and the shard dials the sidecar out on loopback — so wherever the shard
# runs, this binary has to run too (installer PLAN.md §5.2).
ARM64_TARGET: aarch64-unknown-linux-gnu
# Notified after a release so the installer's compat matrix picks up this
# version immediately rather than at its next nightly run (PLAN.md §7.2).
INSTALLER_REPO: RunicGateway/installer
jobs: jobs:
release: release:
@@ -68,6 +80,8 @@ jobs:
# ── RELEASE ENGINE: decide the next version + changelog ────────────── # ── RELEASE ENGINE: decide the next version + changelog ──────────────
- name: Plan the release (version + changelog) - name: Plan the release (version + changelog)
id: plan id: plan
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: | run: |
set -euo pipefail set -euo pipefail
mkdir -p dist mkdir -p dist
@@ -105,21 +119,59 @@ jobs:
VERSION="$(bump "${LAST_TAG#v}" "$BUMP")" VERSION="$(bump "${LAST_TAG#v}" "$BUMP")"
fi fi
# An existing tag is NOT automatically "nothing to do". A tag with no
# release behind it means a previous run tagged and then died before
# publishing — which is exactly what happened on servuo-plugins' first
# release, where absent REGISTRY_* secrets took the release API call to
# 401 after the tag had already been pushed. Standing down on the tag
# alone makes that state permanent: every later run sees the tag, sets
# RELEASE=false, and the release never appears. Note this deliberately
# OVERRIDES the RELEASE=false decided just above — with the tag in
# place there are no releasable commits after it, so the normal path
# would stand down, which is exactly why it could never self-heal.
REUSE_TAG=false
if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
echo "Tag v${VERSION} already exists — nothing to release." CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')"
REL_HTTP="$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: token ${CI_TOKEN}" "https://${GITEA_HOST}/api/v1/repos/${REPO}/releases/tags/v${VERSION}" || echo 000)"
if [ "$REL_HTTP" = "200" ]; then
echo "Tag v${VERSION} already has a release — nothing to do."
RELEASE=false RELEASE=false
elif [ "$REL_HTTP" = "404" ]; then
echo "::warning::Tag v${VERSION} exists but has no release — a previous run failed after tagging. Reusing the tag and publishing the release it is missing."
REUSE_TAG=true
RELEASE=true
else
# Anything else (000 from a network failure, 401/403 from a bad
# token) is not evidence of absence. Guessing "no release" would
# re-publish over a good one, so refuse instead.
echo "::error::Could not determine whether a release exists for v${VERSION} (HTTP ${REL_HTTP}). Refusing to guess."
exit 1
fi fi
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.
if [ "$REUSE_TAG" = true ]; then
PREV_TAG="$(git describe --tags --match 'v*' --abbrev=0 "v${VERSION}^" 2>/dev/null || true)"
if [ -n "$PREV_TAG" ]; then CL_RANGE="${PREV_TAG}..v${VERSION}"; else CL_RANGE="v${VERSION}"; fi
SINCE="$PREV_TAG"
else
CL_RANGE="$RANGE"
SINCE="$LAST_TAG"
fi
CL_SUBJECTS="$(git log --no-merges --format='%s' $CL_RANGE || true)"
{ {
echo "## ${BIN} v${VERSION}" echo "## ${BIN} v${VERSION}"
echo echo
FEATS="$(echo "$SUBJECTS" | grep -E '^feat' || true)" FEATS="$(echo "$CL_SUBJECTS" | grep -E '^feat' || true)"
FIXES="$(echo "$SUBJECTS" | grep -E '^(fix|perf)' || true)" FIXES="$(echo "$CL_SUBJECTS" | grep -E '^(fix|perf)' || true)"
[ -n "$FEATS" ] && { echo "### Features"; echo "$FEATS" | sed 's/^/- /'; echo; } [ -n "$FEATS" ] && { echo "### Features"; echo "$FEATS" | sed 's/^/- /'; echo; }
[ -n "$FIXES" ] && { echo "### Fixes"; echo "$FIXES" | sed 's/^/- /'; echo; } [ -n "$FIXES" ] && { echo "### Fixes"; echo "$FIXES" | sed 's/^/- /'; echo; }
echo "### All changes" echo "### All changes"
if [ -n "$LAST_TAG" ]; then echo "Since ${LAST_TAG}:"; fi if [ -n "$SINCE" ]; then echo "Since ${SINCE}:"; fi
echo "$SUBJECTS" | sed 's/^/- /' echo "$CL_SUBJECTS" | sed 's/^/- /'
} > dist/CHANGELOG.md } > dist/CHANGELOG.md
echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
@@ -129,14 +181,26 @@ jobs:
echo "==> release=${RELEASE} version=${VERSION} bump=${BUMP} last_tag=${LAST_TAG:-<none>}" echo "==> release=${RELEASE} version=${VERSION} bump=${BUMP} last_tag=${LAST_TAG:-<none>}"
# ── RUST ADAPTER: toolchain + cross-compile deps ───────────────────── # ── RUST ADAPTER: toolchain + cross-compile deps ─────────────────────
- name: Install Rust toolchain, Windows target, and MinGW linker - name: Install Rust toolchain, cross targets, and their linkers
if: ${{ steps.plan.outputs.release == 'true' }} if: ${{ steps.plan.outputs.release == 'true' }}
run: | run: |
set -euo pipefail set -euo pipefail
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo" SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo"
$SUDO apt-get update $SUDO apt-get update
# The arm64 cross toolchain is not optional for this crate: sqlx's
# sqlite feature pulls libsqlite3-sys, which compiles bundled SQLite
# from C, so a Rust-only cross build fails at the first .c file.
#
# libc6-dev-arm64-cross is named explicitly because gcc-aarch64-linux-gnu
# only *recommends* it, and this install is --no-install-recommends: the
# compiler arrives without arm64 libc headers and SQLite's build dies on
# `bits/libc-header-start.h: No such file or directory`. Verified by
# reproducing both the failure and the fix in a rust:1-slim-bookworm
# container.
$SUDO apt-get install -y --no-install-recommends \ $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 if ! command -v cargo >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
@@ -146,6 +210,7 @@ jobs:
export PATH="${HOME}/.cargo/bin:${PATH}" export PATH="${HOME}/.cargo/bin:${PATH}"
rustup component add rustfmt rustup component add rustfmt
rustup target add "${WINDOWS_TARGET}" rustup target add "${WINDOWS_TARGET}"
rustup target add "${ARM64_TARGET}"
- name: Set the crate version to match the release - name: Set the crate version to match the release
if: ${{ steps.plan.outputs.release == 'true' }} if: ${{ steps.plan.outputs.release == 'true' }}
@@ -187,14 +252,30 @@ jobs:
AR_x86_64_pc_windows_gnu: x86_64-w64-mingw32-ar AR_x86_64_pc_windows_gnu: x86_64-w64-mingw32-ar
run: cargo build --release --locked --target "${WINDOWS_TARGET}" run: cargo build --release --locked --target "${WINDOWS_TARGET}"
# Same shape as the Windows step: a linker for Rust's output and a CC/AR
# pair for the cc-crate build of bundled SQLite.
- name: cargo build --release (Linux arm64, cross via aarch64-linux-gnu)
if: ${{ steps.plan.outputs.release == 'true' }}
working-directory: sidecar
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) ──────────────────── # ── RUST ADAPTER: package artifacts (+ checksums) ────────────────────
- name: Package artifacts and SHA256SUMS - name: Package artifacts and SHA256SUMS
if: ${{ steps.plan.outputs.release == 'true' }} if: ${{ steps.plan.outputs.release == 'true' }}
run: | run: |
set -euo pipefail set -euo pipefail
cp "${WORKDIR}/target/${LINUX_TARGET}/release/${BIN}" "dist/${BIN}-linux-x86_64" cp "${WORKDIR}/target/${LINUX_TARGET}/release/${BIN}" "dist/${BIN}-linux-x86_64"
cp "${WORKDIR}/target/${ARM64_TARGET}/release/${BIN}" "dist/${BIN}-linux-aarch64"
cp "${WORKDIR}/target/${WINDOWS_TARGET}/release/${BIN}.exe" "dist/${BIN}-windows-x86_64.exe" cp "${WORKDIR}/target/${WINDOWS_TARGET}/release/${BIN}.exe" "dist/${BIN}-windows-x86_64.exe"
( cd dist && sha256sum "${BIN}-linux-x86_64" "${BIN}-windows-x86_64.exe" > SHA256SUMS ) # Every artifact must appear here: the installer verifies its download
# against these sums, and `sha256sum -c` passes silently over a file
# this list does not mention.
( cd dist && sha256sum "${BIN}-linux-x86_64" "${BIN}-linux-aarch64" \
"${BIN}-windows-x86_64.exe" > SHA256SUMS )
ls -l dist && echo "----" && cat dist/SHA256SUMS ls -l dist && echo "----" && cat dist/SHA256SUMS
# ── RELEASE ENGINE: commit the bump, tag, push ─────────────────────── # ── RELEASE ENGINE: commit the bump, tag, push ───────────────────────
@@ -225,7 +306,16 @@ jobs:
else else
echo "Version unchanged (first release) — no bump commit needed." echo "Version unchanged (first release) — no bump commit needed."
fi 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 no-op. A
# push that fails here means the remote tag points somewhere else,
# which SHOULD stop the run.
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
echo "Tag ${TAG} already exists — reusing it."
else
git tag "${TAG}" git tag "${TAG}"
fi
git push origin "${TAG}" git push origin "${TAG}"
# ── RELEASE ENGINE: create the Gitea release + upload assets ───────── # ── RELEASE ENGINE: create the Gitea release + upload assets ─────────
@@ -250,9 +340,51 @@ jobs:
| jq -r '.id')" | jq -r '.id')"
echo "Created release ${TAG} (id=${REL_ID})" echo "Created release ${TAG} (id=${REL_ID})"
for f in "${BIN}-linux-x86_64" "${BIN}-windows-x86_64.exe" SHA256SUMS; do 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}" \ curl -sSf -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \
-H "Authorization: token ${CI_TOKEN}" \ -H "Authorization: token ${CI_TOKEN}" \
-F "attachment=@dist/${f}" >/dev/null -F "attachment=@dist/${f}" >/dev/null
echo " uploaded ${f}" echo " uploaded ${f}"
done done
# ── Recompose the installer's bundle manifest ────────────────────────
# The installer does not resolve "latest" at run time — it installs the
# exact combination named by a published bundle (docs/installer/PLAN.md
# §7.1). So a sidecar release that nobody recomposes around is a release
# no operator will ever be offered. This step tells the installer repo to
# rebuild that manifest now, instead of leaving the new version invisible
# until its nightly cron.
#
# DISPATCH, DON'T WAIT (PLAN.md §7.3). Gitea's workflow-dispatch endpoint
# returns no run handle, so there is nothing to poll: a waiting step would
# have to guess which run is its own and hold a runner idle to do it. The
# bundle job runs its own gates regardless of who started it.
#
# A failure here is a WARNING, never a failure of this job. The release is
# already published and correct by this point; failing the run would
# misreport that. The installer's nightly cron recomposes from whatever
# the latest releases actually are, so a dropped dispatch self-heals — it
# costs latency, not correctness.
#
# `repository_dispatch` is deliberately not used: support for it is
# uncertain on this Gitea version, while dispatching an existing
# workflow_dispatch workflow via the API works today.
- name: Ask the installer repo to recompose its bundle
if: ${{ steps.plan.outputs.release == 'true' }}
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
HTTP="$(curl -s -o /dev/null -w '%{http_code}' -X POST \
-H "Authorization: token ${CI_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"ref":"main"}' \
"https://${GITEA_HOST}/api/v1/repos/${INSTALLER_REPO}/actions/workflows/bundle.yml/dispatches" || echo 000)"
case "$HTTP" in
20*) echo "Dispatched ${INSTALLER_REPO} bundle.yml (HTTP ${HTTP}) — not waiting for it." ;;
403|404)
echo "::warning::Could not dispatch ${INSTALLER_REPO} bundle.yml (HTTP ${HTTP}). REGISTRY_TOKEN likely lacks write:repository on that repo. Release ${{ steps.plan.outputs.tag }} is published and fine; its bundle will be composed by the installer's nightly cron instead." ;;
*)
echo "::warning::Dispatching ${INSTALLER_REPO} bundle.yml returned HTTP ${HTTP}. Release ${{ steps.plan.outputs.tag }} is published and fine; the nightly cron will recompose the bundle." ;;
esac