ci(bundle): compose schema 2 for ServUO and Rust beside schema 1
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m12s

Module-rust phase 18, step 2 of docs/modules/rust/PLAN.md §34.2.7 (D146,
D147). The compose job now writes three streams on the bundles branch:

  current.json         schema 1, ServUO, exactly as before
  v2/servuo/           schema 2, game "servuo"
  v2/rust/             schema 2, game "rust" (Rust-Link + Rust-Plugins)

The gates move into .gitea/scripts/compose-bundles.sh, so they can be run by
hand against the real release API or a mock without a runner. Each game
composes independently: a Rust failure still lets a ServUO bundle publish, and
the run goes red afterwards. A game whose repos have never released composes
nothing and does not fail, which is where Rust is until its cutover.

A ServUO matrix carries the same tag at both schemas, so the first run
publishes v2/servuo/bundle-2026.09.15.json rather than a second name for the
pair. Schema 1 stops being composed on 2027-01-01 and is left frozen.

Tested in debian:12 against the real releases (v2/servuo published as
2026.09.15, schema 1 unchanged, Rust skipped; rerun is a no-op; past the
retirement date schema 1 is frozen) and against a mock Gitea for Rust
(document composed, protocol mismatch refused, missing manifest key refused,
rerun a no-op, ServUO failing independently).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-25 22:49:47 -05:00
parent f865660c7c
commit 4f268f7ffb
3 changed files with 600 additions and 327 deletions

View File

@@ -1,50 +1,64 @@
# Compose and publish the bundle manifest.
# Compose and publish the bundle manifests.
#
# This is Phase 0 item 3 of docs/installer/PLAN.md (§7.1–§7.3).
# This is Phase 0 item 3 of docs/installer/PLAN.md (§7.1–§7.3), extended by
# module-rust phase 18 (docs/modules/rust/PLAN.md §34.2.2) to a second game.
#
# ── What a bundle is ─────────────────────────────────────────────────────────
# The bundle IS the compat matrix. The installer does not hardcode component
# versions and does not resolve "latest" at run time; it fetches one small JSON
# document naming an exact, protocol-checked combination of a uo-link release
# and a servuo-plugins overlay release, and installs that. Because the bundle is
# document naming an exact, protocol-checked combination of a sidecar release
# and a game-side payload release, and installs that. Because the bundle is
# data, a new sidecar release regenerates ~30 lines of JSON and leaves the
# installer binary untouched: operators do not re-download the installer to pick
# up a sidecar patch, and this repo does not accumulate releases whose code is
# byte-identical.
#
# ── Where it is published, and why not as a release ──────────────────────────
# Bundles are COMMITTED to this repo, on their own `bundles` branch, at its root:
# ── The streams ──────────────────────────────────────────────────────────────
# Bundles are COMMITTED to this repo, on their own `bundles` branch:
#
# current.json the bundle the installer uses by default
# bundle-<tag>.json every bundle ever published, kept for --bundle
# current.json, bundle-<tag>.json schema 1, ServUO: link + overlay
# v2/servuo/current.json, bundle-<tag>.json schema 2, game "servuo"
# v2/rust/current.json, bundle-<tag>.json schema 2, game "rust": Rust-Link + Rust-Plugins
#
# so the installer's two fetches are plain anonymous raw URLs on a public repo:
# Schema 2 (D146) names ONE game, with a `game` discriminant and a `payload`
# that is an overlay for ServUO and a plugin for Rust. Schema 1 (D147) is still
# composed beside it because every installer in the field reads only the root
# `current.json` and refuses any other schema; on 2027-01-01 it stops being
# composed and is left frozen, never deleted, so old installers still resolve
# and every bundle-<tag>.json stays pinnable. A ServUO matrix carries the same
# tag at both schemas.
#
# The installer's fetches are plain anonymous raw URLs on a public repo:
#
# https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles/current.json
# https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles/bundle-2026.08.04.json
# https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles/v2/rust/current.json
#
# A BRANCH, not `main`, because `main` is protected and this job is unattended:
# the pre-receive hook declines a push from CI, which is not a thing a nightly
# cron can resolve. Publishing to a branch of its own keeps everything the
# original choice was for — a reviewable diff, a git history of the compat
# matrix, plain raw URLs, no auth on the shard host — and needs no protection
# exception. The alternative, whitelisting a scheduled job for pushes to the
# default branch, buys nothing this does not.
# exception.
#
# The obvious alternative — one Gitea release per bundle — was rejected because
# it collides with this repo's own product. release.yml publishes the installer
# BINARIES as v* releases, and `/releases/latest` returns whichever release is
# newest regardless of kind; interleaving bundle releases would make "latest"
# intermittently resolve to a release containing no installer binary. Committing
# also gets a reviewable diff and a git history of the compat matrix for free.
# intermittently resolve to a release containing no installer binary.
#
# `main` is never pushed to by this workflow. (release.yml does not push to it
# either — it tags and lets the release API do the rest.)
#
# ── Where the logic lives ────────────────────────────────────────────────────
# .gitea/scripts/compose-bundles.sh holds the gates and the documents, so it can
# be run by hand against the real release API (or a mock) without a runner. This
# file owns what needs the runner: checkout, the stale-component dispatch, and
# the push.
#
# ── Triggers (PLAN.md §7.2) ──────────────────────────────────────────────────
# workflow_dispatch — POSTed by link's and servuo-plugins' release workflows
# as their final step, so a new release recomposes the
# bundle immediately.
# workflow_dispatch — POSTed by link's, servuo-plugins', Rust-Link's and
# Rust-Plugins' release workflows as their final step,
# so a new release recomposes the bundle immediately.
# schedule (nightly) — recomputes from whatever the latest releases actually
# are, so a missed or failed dispatch self-heals instead
# of silently pinning operators to a stale sidecar.
@@ -55,11 +69,11 @@
# ── Prerequisites (Settings → Actions → Secrets on RunicGateway/installer) ───
# REGISTRY_USER — Gitea username the token below belongs to
# REGISTRY_TOKEN — Gitea access token with `write:repository`. It needs write
# on THIS repo (to push the bundle commit) and on
# RunicGateway/link + RunicGateway/servuo-plugins (to fire
# their release workflows for the stale case below). A token
# without the latter degrades to a warning, not a failure —
# the bundle it composes is still valid.
# on THIS repo (to push the bundle commit) and on the four
# component repos (to fire their release workflows for the
# stale case below). A token without the latter degrades to
# a warning, not a failure — the bundle it composes is still
# valid.
#
# The bundle commit carries `[skip ci]`, so it does not re-trigger release.yml.
@@ -73,7 +87,7 @@ on:
# Two component releases landing together dispatch this twice. Serialize rather
# than cancel: a cancelled run is a bundle that never got composed, and the
# second run would otherwise race the first on the push to main.
# second run would otherwise race the first on the push.
concurrency:
group: compose-bundle
cancel-in-progress: false
@@ -81,13 +95,6 @@ concurrency:
env:
GITEA_HOST: gitea.whitlocktech.com
REPO: RunicGateway/installer
LINK_REPO: RunicGateway/link
OVERLAY_REPO: RunicGateway/servuo-plugins
# Fixed top-level directory inside the overlay tarball. Deliberately NOT
# versioned (servuo-plugins/.gitea/workflows/release.yml) — a versioned prefix
# would mean parsing the version out of a path in order to read the manifest
# that declares the version.
OVERLAY_PREFIX: runicgateway-overlay
jobs:
compose:
@@ -104,9 +111,8 @@ jobs:
# The published bundles live on their own branch (see the header), so they
# are materialized into a worktree rather than being part of the checkout.
# Everything downstream reads and writes `published/`, which means the
# ".2 suffix" scan and the idempotence check both see what is actually
# published rather than a stale copy on main.
# The compose script reads and writes `published/`, so the ".2 suffix"
# scan and the idempotence check both see what is actually published.
- name: Materialize the bundles branch
run: |
set -euo pipefail
@@ -121,7 +127,7 @@ jobs:
if git ls-remote --exit-code --heads origin bundles >/dev/null 2>&1; then
git fetch origin bundles
git worktree add -B bundles published origin/bundles
echo "==> bundles branch: $(ls published/*.json 2>/dev/null | wc -l) published bundle(s)"
echo "==> bundles branch: $(find published -name 'bundle-*.json' | wc -l) published bundle(s)"
else
# First run. A root commit with an empty tree gives the worktree a
# branch to sit on without inheriting main's history, which has
@@ -140,276 +146,27 @@ jobs:
$SUDO apt-get update -qq
$SUDO apt-get install -y -qq --no-install-recommends jq curl ca-certificates
# ── Resolve the two component releases ───────────────────────────────
# Read ANONYMOUSLY, on purpose. These are exactly the requests the shipped
# installer makes on an operator's machine, which has no Gitea credentials
# (PLAN.md §1: no git and no token on the shard host). Authenticating here
# would hide a repo flipped to private until an operator hit it; this way
# the visibility regression fails CI instead.
- name: Resolve the latest release of each component
id: resolve
run: |
set -euo pipefail
mkdir -p work
for pair in "link:${LINK_REPO}" "overlay:${OVERLAY_REPO}"; do
KEY="${pair%%:*}"; SLUG="${pair#*:}"
curl -sSfL -o "work/${KEY}-release.json" \
"https://${GITEA_HOST}/api/v1/repos/${SLUG}/releases/latest"
TAG="$(jq -r '.tag_name' "work/${KEY}-release.json")"
[ -n "$TAG" ] && [ "$TAG" != "null" ] || { echo "::error::${SLUG} has no published release"; exit 1; }
echo "${KEY}_tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "${KEY}_version=${TAG#v}" >> "$GITHUB_OUTPUT"
echo "==> ${SLUG} latest: ${TAG}"
done
# ── GATE 2 (PLAN.md §7.1): assets exist, checksums match ─────────────
# Every asset the bundle will reference is downloaded and verified against
# the SHA256SUMS the publishing repo shipped beside it. This is not
# ceremony: SHA256SUMS is the trust anchor for these deliberately UNSIGNED
# artifacts (PLAN.md §3), and the installer will verify against the hashes
# THIS job records. A hash copied from a file nobody checked would make the
# whole chain decorative.
# ── Resolve, gate and compose every stream ───────────────────────────
# Release resolution, gate 2 (every asset present and matching its
# SHA256SUMS), gate 1 (the sidecar's PROTOCOL_VERSION at its release tag
# against the payload's declared protocol) and the documents all live in
# the script. Each game composes independently: a Rust failure still lets
# a ServUO bundle publish, and this step goes red afterwards. A game whose
# repos have never released composes nothing, and that is not a failure.
#
# `sha256sum -c` without --ignore-missing fails when SHA256SUMS names a
# file the release does not actually carry. The reverse — an asset with no
# SHA256SUMS entry — is checked separately below, because -c would not
# notice it.
- name: 'Gate 2: download assets and verify checksums'
id: assets
run: |
set -euo pipefail
fail() { echo "::error::$*"; exit 1; }
for KEY in link overlay; do
DIR="work/${KEY}"; mkdir -p "$DIR"
SUMS_URL="$(jq -r '.assets[] | select(.name == "SHA256SUMS") | .browser_download_url' "work/${KEY}-release.json")"
[ -n "$SUMS_URL" ] && [ "$SUMS_URL" != "null" ] \
|| fail "${KEY} release has no SHA256SUMS asset — nothing to verify against"
curl -sSfL -o "${DIR}/SHA256SUMS" "$SUMS_URL"
jq -r '.assets[] | select(.name != "SHA256SUMS") | "\(.name)\t\(.browser_download_url)"' \
"work/${KEY}-release.json" > "${DIR}/asset-list.tsv"
[ -s "${DIR}/asset-list.tsv" ] || fail "${KEY} release carries no assets besides SHA256SUMS"
while IFS="$(printf '\t')" read -r NAME URL; do
[ -n "$NAME" ] || continue
echo " fetching ${KEY}/${NAME}"
curl -sSfL -o "${DIR}/${NAME}" "$URL"
# An asset absent from SHA256SUMS is unverifiable, and `-c` below
# would pass right over it. The `\*?` is not paranoia: sha256sum
# marks binary mode by prefixing the path with `*` instead of the
# two-space text separator, so a naive match on " ${NAME}" would
# miss every entry on a host that defaults to binary mode.
grep -qE "[ \t]\*?${NAME}\$" "${DIR}/SHA256SUMS" \
|| fail "${KEY} asset ${NAME} has no entry in that release's SHA256SUMS"
done < "${DIR}/asset-list.tsv"
( cd "$DIR" && sha256sum -c SHA256SUMS ) \
|| fail "${KEY} assets do not match the SHA256SUMS published with them"
echo "==> ${KEY}: all assets present and verified"
done
# Map link's binaries onto platform keys. The pattern is asserted, not
# assumed: an unrecognized asset name is a hard failure so that adding
# a target to link's release.yml (macOS, a Windows arm64) surfaces here
# as a red run, rather than being silently dropped from every bundle.
#
# linux-aarch64 was recognized here one merge BEFORE link published one
# (PLAN.md §5.2, steps 1 and 3). That order was forced by the two rules
# below being strict in opposite directions: an unknown name fails the
# run, and a missing REQUIRED key fails it too. So the name had to be
# taught before the release that carried it, and the key could only be
# required after — requiring it first would have failed every bundle
# for as long as the gap lasted. link v1.1.1 ships the binary, so the
# key is now required: a dropped target reddens this job instead of
# vanishing from every bundle.
: > work/link-platforms.tsv
while IFS="$(printf '\t')" read -r NAME URL; do
[ -n "$NAME" ] || continue
case "$NAME" in
*-linux-x86_64) PLAT=linux-x86_64 ;;
*-linux-aarch64) PLAT=linux-aarch64 ;;
*-windows-x86_64.exe) PLAT=windows-x86_64 ;;
*) fail "unrecognized link asset '${NAME}' — bundle.yml does not know what platform to file it under. Teach it this name or the bundle would silently omit the asset." ;;
esac
printf '%s\t%s\t%s\t%s\n' "$PLAT" "$NAME" "$URL" \
"$(sha256sum "work/link/${NAME}" | cut -d' ' -f1)" >> work/link-platforms.tsv
done < work/link/asset-list.tsv
for REQUIRED in linux-x86_64 linux-aarch64 windows-x86_64; do
grep -q "^${REQUIRED}$(printf '\t')" work/link-platforms.tsv \
|| fail "link release is missing a ${REQUIRED} binary; the installer ships for all three"
done
# The overlay release carries exactly one artifact: the tarball.
OVERLAY_COUNT="$(wc -l < work/overlay/asset-list.tsv)"
[ "$OVERLAY_COUNT" -eq 1 ] \
|| fail "expected exactly 1 overlay asset besides SHA256SUMS, found ${OVERLAY_COUNT}"
OVERLAY_NAME="$(cut -f1 work/overlay/asset-list.tsv)"
case "$OVERLAY_NAME" in
*.tar.gz) ;;
*) fail "overlay asset '${OVERLAY_NAME}' is not the .tar.gz the installer expects" ;;
esac
{
echo "overlay_name=${OVERLAY_NAME}"
echo "overlay_url=$(cut -f2 work/overlay/asset-list.tsv)"
echo "overlay_sha=$(sha256sum "work/overlay/${OVERLAY_NAME}" | cut -d' ' -f1)"
} >> "$GITHUB_OUTPUT"
# ── GATE 1 (PLAN.md §7.1): the two halves speak the same protocol ────
# This is the check the whole bundle exists for. The sidecar rejects a
# protocol mismatch with 409 rather than mis-parsing, so a mismatched pair
# is not a subtle bug — it is a shard that emits into a void. Catching it
# here costs one HTTP GET; catching it on an operator's box costs them an
# evening.
#
# The two sides are read from genuinely different places because they ARE
# genuinely different:
#
# overlay — manifest.json inside the tarball. The C# plugin announces no
# version on the wire and none is queryable before ServUO
# boots (PLAN.md §2.6), so this hand-maintained declaration is
# the only statement of it that exists.
# sidecar — PROTOCOL_VERSION in sidecar/src/main.rs, read at the RELEASE
# TAG. Not from the binary: `--print-config` would answer, but
# only for releases from v1.1.0 on (it did not exist before
# Phase 0.2), and `--bundle <tag>` has to be able to recompose
# an older bundle. Reading the tag the release was built from
# works uniformly, needs no execution of a downloaded
# artifact, and does not provision a throwaway config and
# print its auth token into a CI log.
- name: 'Gate 1: sidecar and overlay protocol versions agree'
id: protocol
run: |
set -euo pipefail
fail() { echo "::error::$*"; exit 1; }
LINK_TAG="${{ steps.resolve.outputs.link_tag }}"
OVERLAY_TAG="${{ steps.resolve.outputs.overlay_tag }}"
tar -xzf "work/overlay/${{ steps.assets.outputs.overlay_name }}" -C work
MANIFEST="work/${OVERLAY_PREFIX}/manifest.json"
[ -f "$MANIFEST" ] || fail "the overlay tarball has no ${OVERLAY_PREFIX}/manifest.json — the installer resolves it at that exact path"
OVERLAY_PROTOCOL="$(jq -r '.protocol' "$MANIFEST")"
OVERLAY_COMMIT="$(jq -r '.commit' "$MANIFEST")"
MANIFEST_VERSION="$(jq -r '.version' "$MANIFEST")"
# A manifest that disagrees with the tag it shipped under means the
# release workflow stamped one version and tagged another; every
# downstream record of "what is installed" would then be wrong.
[ "$MANIFEST_VERSION" = "${OVERLAY_TAG#v}" ] \
|| fail "overlay manifest says version ${MANIFEST_VERSION} but the release is tagged ${OVERLAY_TAG}"
curl -sSfL -o work/main.rs \
"https://${GITEA_HOST}/${LINK_REPO}/raw/tag/${LINK_TAG}/sidecar/src/main.rs"
LINK_PROTOCOL="$(sed -nE 's/^[[:space:]]*pub const PROTOCOL_VERSION[^=]*=[[:space:]]*([0-9]+).*/\1/p' work/main.rs | head -1)"
# Empty means the constant moved or was renamed. Fail loudly: silently
# treating "could not read" as "matches" is how a mismatched pair ships.
[ -n "$LINK_PROTOCOL" ] \
|| fail "could not read PROTOCOL_VERSION from ${LINK_REPO}@${LINK_TAG}:sidecar/src/main.rs — has the constant moved? Gate 1 cannot be skipped."
echo "==> sidecar ${LINK_TAG} protocol=${LINK_PROTOCOL} | overlay ${OVERLAY_TAG} protocol=${OVERLAY_PROTOCOL}"
[ "$LINK_PROTOCOL" = "$OVERLAY_PROTOCOL" ] || fail \
"PROTOCOL MISMATCH — sidecar ${LINK_TAG} speaks ${LINK_PROTOCOL}, overlay ${OVERLAY_TAG} declares ${OVERLAY_PROTOCOL}. Refusing to publish a bundle that would install a shard whose events the sidecar rejects with 409. Fix: land the matching half and let its release cut, or bump servuo-plugins/overlay.toml."
{
echo "protocol=${LINK_PROTOCOL}"
echo "overlay_commit=${OVERLAY_COMMIT}"
echo "overlay_min_servuo=$(jq -r '.servuo.min_version' "$MANIFEST")"
echo "overlay_patched_against=$(jq -r '.servuo.patches_verified_against' "$MANIFEST")"
} >> "$GITHUB_OUTPUT"
# ── Compose ──────────────────────────────────────────────────────────
# Note the shape difference from PLAN.md §7.1's sketch: `link` carries a
# per-platform asset map rather than one sha256. link publishes a Linux
# binary and a Windows .exe, and the installer runs on both — a single
# hash could only ever have described one of them.
- name: Compose bundle.json
# The outputs are written even when the script fails, so the publish step
# (on `always()`) can still ship the game that did compose.
- name: Compose the bundles
id: compose
run: |
set -euo pipefail
LINK_ASSETS="$(jq -R -s '
split("\n") | map(select(length > 0)) | map(split("\t"))
| map({ (.[0]): { name: .[1], url: .[2], sha256: .[3] } }) | add
' work/link-platforms.tsv)"
jq -n \
--arg link_repo "${LINK_REPO}" \
--arg link_tag "${{ steps.resolve.outputs.link_tag }}" \
--arg link_version "${{ steps.resolve.outputs.link_version }}" \
--argjson link_assets "${LINK_ASSETS}" \
--arg ov_repo "${OVERLAY_REPO}" \
--arg ov_tag "${{ steps.resolve.outputs.overlay_tag }}" \
--arg ov_version "${{ steps.resolve.outputs.overlay_version }}" \
--arg ov_commit "${{ steps.protocol.outputs.overlay_commit }}" \
--arg ov_name "${{ steps.assets.outputs.overlay_name }}" \
--arg ov_url "${{ steps.assets.outputs.overlay_url }}" \
--arg ov_sha "${{ steps.assets.outputs.overlay_sha }}" \
--arg ov_min "${{ steps.protocol.outputs.overlay_min_servuo }}" \
--arg ov_patched "${{ steps.protocol.outputs.overlay_patched_against }}" \
--argjson protocol "${{ steps.protocol.outputs.protocol }}" \
'{
schema: 1,
protocol: $protocol,
link: {
repo: $link_repo,
tag: $link_tag,
version: $link_version,
protocol: $protocol,
assets: $link_assets
},
overlay: {
repo: $ov_repo,
tag: $ov_tag,
version: $ov_version,
commit: $ov_commit,
protocol: $protocol,
servuo: {
min_version: $ov_min,
patches_verified_against: $ov_patched
},
asset: { name: $ov_name, url: $ov_url, sha256: $ov_sha }
}
}' > work/content.json
echo "----- composed content -----"
cat work/content.json
# Idempotence. `bundle` and `generated` are metadata ABOUT this run, so
# comparing them would make every nightly cron look like a change and
# commit a dated duplicate of the same matrix forever. Compare only
# what the installer would actually act on.
CHANGED=true
if [ -f published/current.json ]; then
if jq -S 'del(.bundle, .generated)' published/current.json > work/old-content.json \
&& jq -S '.' work/content.json > work/new-content.json \
&& cmp -s work/old-content.json work/new-content.json; then
CHANGED=false
fi
rc=0
PUBLISHED="$PWD/published" WORK="$PWD/work" GITEA_HOST="$GITEA_HOST" \
bash .gitea/scripts/compose-bundles.sh || rc=$?
if [ -f work/result.env ]; then
grep '^changed=' work/result.env >> "$GITHUB_OUTPUT"
fi
echo "changed=${CHANGED}" >> "$GITHUB_OUTPUT"
if [ "$CHANGED" = false ]; then
echo "==> identical to the published current.json — nothing to publish."
exit 0
fi
# Bundle tags are dates (PLAN.md §7.1). Two bundles on one day — a
# sidecar release in the morning and an overlay release in the
# afternoon is the normal way that happens — get .2, .3, … so a tag
# always names exactly one matrix and `--bundle` stays reproducible.
BASE="$(date -u +%Y.%m.%d)"
TAG="$BASE"; N=1
while [ -f "published/bundle-${TAG}.json" ]; do
N=$((N+1)); TAG="${BASE}.${N}"
done
jq --arg bundle "$TAG" --arg generated "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{ schema: .schema, bundle: $bundle, generated: $generated } + del(.schema)' \
work/content.json > "published/bundle-${TAG}.json"
cp "published/bundle-${TAG}.json" published/current.json
echo "bundle_tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "==> composed bundle ${TAG}"
exit "$rc"
# ── Stale-component check: dispatch, don't wait (PLAN.md §7.3) ───────
# Each component self-releases on merge to its own main, so by the time
@@ -424,10 +181,14 @@ jobs:
# counts would report every README fix as a stuck release and re-dispatch
# a workflow that correctly declines to run, every single night.
#
# This runs even when the bundle is unchanged: an unchanged bundle is the
# exact symptom of a component release that never happened.
# This runs even when the bundle is unchanged, or a game failed: an
# unchanged bundle is the exact symptom of a component release that never
# happened. work/stale.tsv names each component the compose resolved; a
# repo that has never released is absent from it, and has no release to
# be stale against.
- name: Check for components with unreleased work, and dispatch them
id: stale
if: always()
continue-on-error: true
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
@@ -440,11 +201,12 @@ jobs:
# message change what that script does. (Do not write that token
# literally in a comment: the runner parses it, fails, and silently
# skips the whole step.)
mkdir -p work
: > work/stale-warnings.md
[ -f work/stale.tsv ] || exit 0
for pair in "${LINK_REPO}:${{ steps.resolve.outputs.link_tag }}" \
"${OVERLAY_REPO}:${{ steps.resolve.outputs.overlay_tag }}"; do
SLUG="${pair%:*}"; TAG="${pair##*:}"
while IFS="$(printf '\t')" read -r SLUG TAG; do
[ -n "$SLUG" ] || continue
if ! curl -sSfL -o work/compare.json \
"https://${GITEA_HOST}/api/v1/repos/${SLUG}/compare/${TAG}...main"; then
echo "::warning::could not compare ${SLUG} ${TAG}...main; skipping its stale check"
@@ -483,7 +245,7 @@ jobs:
else
echo "==> ${SLUG}: nothing releasable after ${TAG}"
fi
done
done < work/stale.tsv
# ── Publish ──────────────────────────────────────────────────────────
# Preflighted for the same reason the release workflows are: actions/
@@ -491,8 +253,11 @@ jobs:
# config, so a push can succeed on that leftover even with the secrets
# empty. That makes "the push worked" no evidence at all that the repo is
# configured, and the failure surfaces somewhere less obvious later.
#
# `always()`: when one game failed and the other composed, the one that
# composed still ships. The compose step has already made the run red.
- name: Verify publish credentials are configured
if: ${{ steps.compose.outputs.changed == 'true' }}
if: ${{ always() && steps.compose.outputs.changed == 'true' }}
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
@@ -502,19 +267,18 @@ jobs:
[ -n "$(printf '%s' "${REGISTRY_USER:-}" | tr -d '\r\n')" ] || MISSING="${MISSING} REGISTRY_USER"
[ -n "$(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" ] || MISSING="${MISSING} REGISTRY_TOKEN"
if [ -n "$MISSING" ]; then
echo "::error::Missing Actions secret(s):${MISSING}. Set them under Settings → Actions → Secrets on ${REPO}. REGISTRY_TOKEN needs write:repository to push the bundle commit to main."
echo "::error::Missing Actions secret(s):${MISSING}. Set them under Settings → Actions → Secrets on ${REPO}. REGISTRY_TOKEN needs write:repository to push the bundle commit."
exit 1
fi
echo "Publish credentials present."
- name: Commit and push the bundle
if: ${{ steps.compose.outputs.changed == 'true' }}
- name: Commit and push the bundles
if: ${{ always() && steps.compose.outputs.changed == 'true' }}
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
TAG="${{ steps.compose.outputs.bundle_tag }}"
# Secrets can arrive with a trailing newline depending on how they were
# pasted, and a stray CR/LF corrupts the remote URL ("credential url
# cannot be parsed").
@@ -522,9 +286,14 @@ jobs:
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
git remote set-url origin "https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${REPO}.git"
# The subject is the script's ("publish servuo 2026.09.27, rust
# 2026.09.27 [skip ci]"), read from a file rather than interpolated
# from a template value, so nothing in it can change this script.
grep '^commit_subject=' work/result.env | cut -d= -f2- > work/commit-msg.txt
cd published
git add -A
git commit -m "chore(bundle): publish ${TAG} (link ${{ steps.resolve.outputs.link_tag }}, overlay ${{ steps.resolve.outputs.overlay_tag }}, protocol ${{ steps.protocol.outputs.protocol }}) [skip ci]"
git commit -F ../work/commit-msg.txt
# Two runs can compose at once — a component release dispatches this
# while the nightly cron is mid-flight — so losing the race is normal
@@ -538,7 +307,8 @@ jobs:
git rebase origin/bundles
git push origin bundles
fi
echo "==> published bundle-${TAG}.json and current.json on the bundles branch"
echo "==> published on the bundles branch:"
sed 's/^/ /' ../work/published.txt
- name: Job summary
if: always()
@@ -547,16 +317,17 @@ jobs:
{
echo "## Bundle compose"
echo
echo "| Component | Release | Protocol |"
echo "|---|---|---|"
echo "| uo-link sidecar | \`${{ steps.resolve.outputs.link_tag }}\` | ${{ steps.protocol.outputs.protocol }} |"
echo "| servuo-plugins overlay | \`${{ steps.resolve.outputs.overlay_tag }}\` | ${{ steps.protocol.outputs.protocol }} |"
echo
case "${{ steps.compose.outputs.changed }}" in
true) echo "**Published \`${{ steps.compose.outputs.bundle_tag }}\`** → \`bundles/current.json\`" ;;
false) echo "No change — \`bundles/current.json\` already names this combination." ;;
*) echo "Compose did not complete — see the failing step above." ;;
esac
if [ -s work/summary.md ]; then
cat work/summary.md
else
echo "Compose did not complete — see the failing step above."
fi
if [ -s work/published.txt ]; then
echo
echo "Written to the bundles branch:"
echo
sed 's/^/- /' work/published.txt
fi
if [ -s work/stale-warnings.md ]; then
echo
echo "### ⚠ Components with unreleased work"