Files
Rust-Plugins/.gitea/workflows/release.yml
wtclaude 746523387e feat(bridge): a zone's ZoneManager options, its messages, and a ZoneDomes dome (PLAN_REDESIGNS §3)
world.zone gains flags, settings, enterMessage, leaveMessage, delivery,
format and dome (PROTOCOL §19.11). Flags are checked against the live
ZoneManager's own list, read through its API call ZoneFieldListRaw() (D211),
and refused bad-option rather than dropped; settings are an allowlist of
five with bounds. Both are kept on the registry entry and re-applied when
a zone is re-created.

The bridge says a zone's enter and leave messages itself (D193), as chat
or a popup (chat when PopupNotifications is missing), and stays quiet for
5 s after re-creating a zone.

RunicGatewayDomes.cs, a new helper, patches ZoneDomes so its API can be
called without a player, and raises OnRgDomesReady after ZoneDomes' own
start; the bridge puts its domes back only after it, so none is drawn
twice. Domes are removed before their zone at expiry and revert.

hello carries zoneManager { version, flags }, integrations.zoneDomes the
ZoneDomes and helper state, and hello is sent again when ZoneManager,
ZoneDomes or either helper loads or unloads. The release ships the new
helper beside the zone helper.

Not compiled or walked on a rig: the panel's file API was down on both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-29 05:25:43 -05:00

542 lines
29 KiB
YAML

# Automated release for the Rust bridge plugin.
#
# Trigger: every push to `main` (i.e. every merged PR — in practice the
# edge→main cutover), and by hand.
#
# Why this exists: the Runic Gateway installer (`--game rust`) and the
# Pterodactyl egg both deploy the plugin from a release tarball, never from git
# — a game host gets no git and no Gitea credentials. Until module-rust phase 18
# (docs/modules/rust/PLAN.md §34.2.1, D145) this repository had never released,
# so there was nothing for either to fetch.
#
# Flow — the same two halves as servuo-plugins' release.yml, whose release
# engine is reused here unchanged:
#
# ┌── RELEASE ENGINE (language-agnostic) ─────────────────────────────┐
# │ reads: latest v* git tag + conventional-commit subjects │
# │ produces: next version, changelog, and (at the end) the release │
# └───────────────────────────────────────────────────────────────────┘
# ┌── PLUGIN ADAPTER (the only repo-specific part) ───────────────────┐
# │ consumes: the version │
# │ produces: runicgateway-rust-plugin-<ver>.tar.gz + SHA256SUMS │
# └───────────────────────────────────────────────────────────────────┘
#
# ── What differs from servuo-plugins' copy ───────────────────────────────────
#
# 1. NO BUILD, for the same reason: the plugin ships as C# source and Oxide or
# Carbon compiles it against assemblies that exist only on a Rust server.
# The gates are the static checks PR Checks already runs, re-run on the
# exact commit being released.
#
# 2. A FRAMEWORK-NEUTRAL LAYOUT. The tarball holds one directory,
# `runicgateway-rust-plugin/`, with `RunicGateway.cs`, its helpers
# (`RunicGatewayZones.cs`, `RunicGatewayDomes.cs`) and `manifest.json` at its top. Every `.cs` in it is listed in the
# manifest's `files`, and the installer and the egg place every one of them. It does NOT mirror `oxide/plugins/`: the same file goes to
# `oxide/plugins/` or `carbon/plugins/`, and the installer and the egg each
# decide which (§34.2.1). The fixed, unversioned prefix is so a reader never
# has to parse the version out of a path to find the manifest that states it.
#
# 3. THE VERSION IS STAMPED into the shipped copy's `[Info(…)]` attribute, so
# `oxide.plugins` / `c.plugins` on a server names the release it runs. The
# committed file keeps its placeholder; the manifest records the commit.
# Only the bridge is stamped. A helper (docs/modules/rust/PLAN_FIXES.md D168,
# D182 — today `RunicGatewayZones.cs` for ZoneManager and `RunicGatewayDomes.cs`
# for ZoneDomes, PLAN_REDESIGNS D194) keeps its own
# version: it does one job, changes rarely, and the bridge reads its version at
# hello to know which job it does.
#
# The version is never the protocol. The protocol lives in overlay.toml and
# moves only when a message shape does; the version moves on every releasable
# commit.
#
# Version bump (conventional commits since the last v* tag):
# feat!: / BREAKING CHANGE -> major feat: -> minor fix|perf: -> patch
# nothing releasable -> no release is cut
# (first ever run, no tag) -> releases SEED_VERSION below (§34.4: the first
# release takes what the engine derives)
#
# Prerequisites (Settings → Actions → Secrets on RunicGateway/Rust-Plugins):
# REGISTRY_TOKEN — Gitea access token with `write:repository`, to push the
# tag and create the release. The final step also dispatches
# RunicGateway/installer's bundle workflow, so the token
# ideally has write there too — without it the step warns
# and that repo's nightly cron picks the release up instead.
# REGISTRY_USER — the Gitea username that token belongs to.
name: Release plugin
on:
push:
branches: [main]
workflow_dispatch: {}
concurrency:
group: release-plugin
cancel-in-progress: false
env:
GITEA_HOST: gitea.whitlocktech.com
REPO: RunicGateway/Rust-Plugins
ARTIFACT: runicgateway-rust-plugin
PLUGIN: overlay/oxide/plugins/RunicGateway.cs
# The helpers shipped beside the bridge (D168, D182). Optional at runtime —
# the bridge loads without them — and installed by default.
HELPERS: overlay/oxide/plugins/RunicGatewayZones.cs overlay/oxide/plugins/RunicGatewayDomes.cs
SEED_VERSION: "0.1.0"
INSTALLER_REPO: RunicGateway/installer
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Check out full history (need tags + commit log for the bump)
uses: actions/checkout@v4
with:
fetch-depth: 0
# ── RELEASE ENGINE: decide the next version + changelog ──────────────
- name: Plan the release (version + changelog)
id: plan
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
mkdir -p dist
git fetch --tags --force >/dev/null 2>&1 || true
LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)"
if [ -n "$LAST_TAG" ]; then RANGE="${LAST_TAG}..HEAD"; else RANGE="HEAD"; fi
SUBJECTS="$(git log --no-merges --format='%s' $RANGE || true)"
BODIES="$(git log --no-merges --format='%B' $RANGE || true)"
BUMP=none
if echo "$BODIES" | grep -qE 'BREAKING[ -]CHANGE' ; then BUMP=major; fi
if echo "$SUBJECTS" | grep -qE '^[a-z]+(\([^)]+\))?!:' ; then BUMP=major; fi
if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^feat(\([^)]+\))?:' ; then BUMP=minor; fi
if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^(fix|perf)(\([^)]+\))?:'; then BUMP=patch; fi
bump() { # <x.y.z> <major|minor|patch> -> bumped
IFS=. read -r MA MI PA <<< "$1"
case "$2" in
major) echo "$((MA+1)).0.0" ;;
minor) echo "${MA}.$((MI+1)).0" ;;
patch) echo "${MA}.${MI}.$((PA+1))" ;;
esac
}
RELEASE=true
if [ -z "$LAST_TAG" ]; then
VERSION="$SEED_VERSION" # first release: seed
elif [ "$BUMP" = none ]; then
RELEASE=false # no feat/fix/breaking since last tag
VERSION="${LAST_TAG#v}"
else
VERSION="$(bump "${LAST_TAG#v}" "$BUMP")"
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
# run, when missing REGISTRY_* secrets took the release API call to 401
# after the tag had already been pushed. Standing down on the tag alone
# would make that state permanent: every later run would see the tag,
# set RELEASE=false, and the release would never appear. So distinguish
# the two cases and finish the job the earlier run started.
# Note this OVERRIDES the RELEASE=false decided just above. With the tag
# already in place there are no releasable commits after it, so the
# normal path stands down — which is precisely why the stuck state
# could never clear itself. Recovery has to be able to say "yes,
# publish" for a version the bump logic considers already done.
REUSE_TAG=false
if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
REL_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/v${VERSION}" || echo 000)"
if [ "$REL_HTTP" = "200" ]; then
echo "Tag v${VERSION} already has a release — nothing to do."
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" here
# 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
# ── 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: the commit that ADDED the
# recovery above was itself a `fix:`, so it bumped to v0.1.1 and the
# run that introduced the recovery stepped straight past the tag it
# was written to rescue.
#
# 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.
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 "## ${ARTIFACT} v${VERSION}"
echo
FEATS="$(echo "$CL_SUBJECTS" | grep -E '^feat' || true)"
FIXES="$(echo "$CL_SUBJECTS" | grep -E '^(fix|perf)' || true)"
[ -n "$FEATS" ] && { echo "### Features"; echo "$FEATS" | sed 's/^/- /'; echo; }
[ -n "$FIXES" ] && { echo "### Fixes"; echo "$FIXES" | sed 's/^/- /'; echo; }
echo "### All changes"
if [ -n "$SINCE" ]; then echo "Since ${SINCE}:"; fi
echo "$CL_SUBJECTS" | sed 's/^/- /'
} > dist/CHANGELOG.md
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
echo "release=${RELEASE}" >> "$GITHUB_OUTPUT"
echo "bump=${BUMP}" >> "$GITHUB_OUTPUT"
echo "reuse_tag=${REUSE_TAG}" >> "$GITHUB_OUTPUT"
echo "==> release=${RELEASE} version=${VERSION} bump=${BUMP} reuse_tag=${REUSE_TAG} last_tag=${LAST_TAG:-<none>}"
# ── Credential preflight ─────────────────────────────────────────────
# Runs BEFORE anything is built or pushed, and only when this run intends
# to publish, so a docs:/chore:-only merge stays green on a repo that has
# no secrets.
#
# This exists because of how servuo-plugins' first run failed. REGISTRY_USER and
# REGISTRY_TOKEN were empty, but the tag push SUCCEEDED anyway:
# 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 — so the run
# tagged the repo and then failed, which is the worst of both outcomes.
# Checking the secrets up front turns that into an immediate, legible
# failure instead of a half-published release.
- name: Verify release credentials are configured
if: ${{ steps.plan.outputs.release == 'true' }}
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
MISSING=""
[ -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 the write:repository scope to push the tag and create the release."
exit 1
fi
echo "Release credentials present."
- name: Install jq
if: ${{ steps.plan.outputs.release == 'true' }}
run: |
set -euo pipefail
command -v jq >/dev/null 2>&1 && exit 0
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo"
$SUDO apt-get update -qq
$SUDO apt-get install -y -qq --no-install-recommends jq
# ── PLUGIN ADAPTER: gates ────────────────────────────────────────────
# No compiler exists for this plugin outside a Rust server, so the gates
# are the ones PR Checks runs, re-run on the exact commit being released:
# every hook listed in ExpectedHooks, every hook void, and ProtocolVersion
# equal to overlay.toml. A cutover merge commit is a tree no PR check ran
# on as such, so it is worth asking again.
- uses: actions/setup-node@v4
if: ${{ steps.plan.outputs.release == 'true' }}
with:
node-version: 20
- name: Validate the plugin
if: ${{ steps.plan.outputs.release == 'true' }}
run: |
set -euo pipefail
[ -f "$PLUGIN" ] || { echo "::error::${PLUGIN} is missing"; exit 1; }
for helper in $HELPERS; do
[ -f "$helper" ] || { echo "::error::${helper} is missing"; exit 1; }
done
[ -f overlay.toml ] || { echo "::error::overlay.toml is missing (protocol + framework declarations)"; exit 1; }
node scripts/checkPlugin.js
# The stamp below rewrites exactly one [Info(...)] line; zero or two
# would ship a plugin whose reported version is a lie.
COUNT="$(grep -cE '^[[:space:]]*\[Info\("Runic Gateway", "RunicGateway", "[^"]*"\)\]' "$PLUGIN" || true)"
[ "$COUNT" = 1 ] || { echo "::error::expected exactly one [Info(\"Runic Gateway\", \"RunicGateway\", ...)] attribute in ${PLUGIN}, found ${COUNT}"; exit 1; }
# ── PLUGIN ADAPTER: stage, manifest, package ─────────────────────────
# tar flags pin ownership, mtime and member order so the same tree produces
# a byte-identical tarball — a checksum that changes only when content
# changes is worth more than one that changes every run.
- name: Build manifest.json and the release tarball
id: package
if: ${{ steps.plan.outputs.release == 'true' }}
run: |
set -euo pipefail
VERSION="${{ steps.plan.outputs.version }}"
STAGE="dist/stage/${ARTIFACT}"
mkdir -p "${STAGE}"
sed -E 's/^([[:space:]]*\[Info\("Runic Gateway", "RunicGateway", ")[^"]*("\)\])/\1'"${VERSION}"'\2/' \
"$PLUGIN" > "${STAGE}/RunicGateway.cs"
grep -qF "[Info(\"Runic Gateway\", \"RunicGateway\", \"${VERSION}\")]" "${STAGE}/RunicGateway.cs" \
|| { echo "::error::stamping the version into [Info(...)] did not take"; exit 1; }
# The helpers go in as committed, each keeping its own [Info] version.
for helper in $HELPERS; do
cp "$helper" "${STAGE}/$(basename "$helper")"
done
# `files` names every .cs staged, with its sha256 — the installer places
# exactly this set, and checks each file against it.
PAIRS=()
for f in "${STAGE}"/*.cs; do
PAIRS+=("$(basename "$f")" "$(sha256sum "$f" | cut -d' ' -f1)")
done
FILES="$(jq -n '[$ARGS.positional | _nwise(2) | {(.[0]): .[1]}] | add' --args "${PAIRS[@]}")"
# Declarations from overlay.toml. Read, don't hardcode — the point of
# that file is that the protocol number lives in one place.
toml_str() { grep -m1 -E "^$1[[:space:]]*=" overlay.toml | sed -E 's/.*"([^"]+)".*/\1/'; }
PROTOCOL="$(grep -m1 -E '^protocol[[:space:]]*=' overlay.toml | sed -E 's/[^0-9]//g')"
MIN_OXIDE="$(toml_str min_oxide_version)"
MIN_CARBON="$(toml_str min_carbon_version)"
# requires_plugins = ["Kits", "ZoneManager"] is already a JSON array.
# One line, quoted strings only; anything else fails the check below.
REQUIRES="$(grep -m1 -E '^requires_plugins[[:space:]]*=' overlay.toml | sed -E 's/^[^=]*=[[:space:]]*//')"
[ -n "$PROTOCOL" ] || { echo "::error::could not read protocol from overlay.toml"; exit 1; }
[ -n "$MIN_OXIDE" ] || { echo "::error::could not read min_oxide_version from overlay.toml"; exit 1; }
[ -n "$MIN_CARBON" ] || { echo "::error::could not read min_carbon_version from overlay.toml"; exit 1; }
jq -e 'type == "array" and all(type == "string")' <<<"$REQUIRES" >/dev/null \
|| { echo "::error::requires_plugins in overlay.toml is not a one-line array of strings"; exit 1; }
echo "==> protocol=${PROTOCOL} oxide>=${MIN_OXIDE} carbon>=${MIN_CARBON} requires=${REQUIRES}"
jq -n \
--arg component "rust-plugin" \
--arg version "${VERSION}" \
--arg commit "${GITHUB_SHA}" \
--arg repo "${REPO}" \
--argjson protocol "${PROTOCOL}" \
--arg min_oxide "${MIN_OXIDE}" \
--arg min_carbon "${MIN_CARBON}" \
--argjson requires "${REQUIRES}" \
--argjson files "${FILES}" \
'{
component: $component,
version: $version,
commit: $commit,
repo: $repo,
protocol: $protocol,
min_oxide_version: $min_oxide,
min_carbon_version: $min_carbon,
requires_plugins: $requires,
files: $files
}' > "${STAGE}/manifest.json"
echo "----- manifest.json -----"
cat "${STAGE}/manifest.json"
TARBALL="${ARTIFACT}-${VERSION}.tar.gz"
tar --sort=name --mtime='UTC 1970-01-01' \
--owner=0 --group=0 --numeric-owner \
-czf "dist/${TARBALL}" -C dist/stage "${ARTIFACT}"
( cd dist && sha256sum "${TARBALL}" > SHA256SUMS )
echo "tarball=${TARBALL}" >> "$GITHUB_OUTPUT"
ls -l dist && echo "----" && cat dist/SHA256SUMS
# ── RELEASE ENGINE: tag ──────────────────────────────────────────────
# Tag only — no bump commit, so `main` is never pushed to (see header).
- name: Push the release tag
if: ${{ steps.plan.outputs.release == 'true' }}
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
TAG="${{ steps.plan.outputs.tag }}"
# Secrets can arrive with a trailing newline (depending on how they were
# pasted); a stray CR/LF corrupts the remote URL ("credential url cannot
# be parsed"). Strip line breaks before building the URL.
CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')"
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
git config user.name "rust-plugins-ci"
git config user.email "ci@whitlocktech.com"
git remote set-url origin \
"https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${REPO}.git"
# The tag may already exist when we are finishing a run that died after
# tagging (see the plan step). `git tag` on an existing name fails under
# `set -e`, and pushing an identical existing tag is a harmless no-op —
# so create it only if it is new, then push either way. 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}"
fi
git push origin "${TAG}"
# ── RELEASE ENGINE: create the Gitea release + upload assets ─────────
- name: Create Gitea release and upload assets
if: ${{ steps.plan.outputs.release == 'true' }}
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
TAG="${{ steps.plan.outputs.tag }}"
TARBALL="${{ steps.package.outputs.tarball }}"
API="https://${GITEA_HOST}/api/v1/repos/${REPO}"
BODY="$(cat dist/CHANGELOG.md)"
# Same newline hygiene as the tag step: a stray CR/LF in the token would
# corrupt the Authorization header.
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
PAYLOAD="$(jq -n --arg tag "$TAG" --arg body "$BODY" \
'{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')"
# installer#22's release run failed exactly here: it landed one second
# after the tag push and Gitea answered 500, having not finished
# processing the pushed tag. Re-running published the same artifacts
# untouched, so it was a race, not a bad request — but the tag sat
# orphaned until a human noticed.
#
# Two things made that worse than it needed to be.
#
# 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.
#
# 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 "${TARBALL}" SHA256SUMS; do
# Same treatment. An upload that fails quietly leaves a release whose
# SHA256SUMS does not cover every artifact 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}" || 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
# ── Recompose the installer's bundle manifest ────────────────────────
# Neither the installer nor the egg resolves "latest" at run time — both
# deploy the exact plugin named by a published bundle
# (docs/modules/rust/PLAN.md §34.2.2). A plugin release that nobody
# recomposes around is therefore a release no operator will ever be
# offered. This tells the installer repo to
# rebuild that manifest now rather than leaving the new version invisible
# until its nightly cron.
#
# That job re-reads this tarball's manifest.json and checks its declared
# `protocol` against Rust-Link's PROTOCOL_VERSION before publishing
# anything (gate 1) — the check this repo cannot perform for itself, and
# the one that matters most here: the game link has no 409, so a
# mismatched plugin would mis-parse rather than be refused.
#
# 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.
#
# A failure here is a WARNING, never a failure of this job. The release is
# already published and correct by this point, and failing the run would
# misreport that. The installer's nightly cron recomposes from whatever the
# latest releases actually are, so a dropped dispatch costs latency, not
# correctness.
- 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