Files
installer/.gitea/scripts/compose-bundles.sh
wtclaude ab2efb62e4
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m27s
feat(rust): RunicNPC as the Rust bundle's third artefact (runicnpc stage 4, D224)
The compose job carries the latest RunicNPC release that answers the API the
bridge's manifest declares (runicnpc_api), verified against its SHA256SUMS
like the other two; none when the bridge needs none, RunicNPC has not
released, or its API is too old, each said in the summary. Walked against a
mock Gitea in all three cases.

The installer reads the optional npc component (older bundles still parse),
fetches and checks RunicNPC's tarball against its manifest, places
RunicNPC.cs before the bridge in each instance's plugins directory, records
it, puts it back on update when edited or deleted, reports it in doctor, and
removes it on uninstall. Its data directory is never touched. Kits joins the
required plugins it reports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-30 04:52:36 -05:00

481 lines
26 KiB
Bash
Executable File

#!/usr/bin/env bash
# Compose every bundle stream from the latest component releases.
#
# Called by .gitea/workflows/bundle.yml, which owns checkout, publishing and the
# stale-component check; this script owns the gates and the documents. It lives
# in a file rather than inline so it can be run by hand against the real release
# API — which is how it is tested — without a runner.
#
# ── The streams (docs/modules/rust/PLAN.md §34.2.2, D146/D147) ───────────────
#
# current.json, bundle-<tag>.json schema 1, ServUO (until SCHEMA1_RETIRES)
# v2/servuo/current.json, bundle-<tag>.json schema 2, game "servuo"
# v2/rust/current.json, bundle-<tag>.json schema 2, game "rust"
#
# A schema-2 document names ONE game. The two games release on their own
# schedules, and a document naming both would hand a ServUO host a new bundle
# every time a Rust plugin shipped.
#
# Schema 1 is still composed because every installer already in the field reads
# only `current.json` and refuses any schema but 1. After SCHEMA1_RETIRES it is
# left FROZEN at its last bundle rather than deleted, so an old installer still
# resolves something and every bundle-<tag>.json stays pinnable.
#
# ── One matrix, one tag ──────────────────────────────────────────────────────
# A ServUO matrix published at both schemas carries the SAME tag in both, so a
# `--bundle <tag>` an operator wrote down means one pair whichever installer
# reads it. The first run after schema 2 lands is the case this is for: the
# ServUO matrix has not changed, so v2/servuo takes the tag schema 1 already
# gave it rather than inventing a second name for the same pair.
#
# ── Failure is per game ──────────────────────────────────────────────────────
# A Rust release with a protocol mismatch must not stop a ServUO bundle from
# publishing, and the reverse. Each game composes in its own subshell; what
# succeeded is written, and the script exits 1 at the end if anything failed, so
# the run is still red.
#
# A game none of whose components has released yet composes nothing, and that is
# NOT a failure — it is the state Rust is in until its first cutover.
#
# ── Interface ────────────────────────────────────────────────────────────────
# PUBLISHED the bundles-branch worktree (read, and written on change)
# WORK scratch directory
# GITEA_HOST default gitea.whitlocktech.com (GITEA_BASE overrides the whole URL)
# TODAY YYYY-MM-DD, default today UTC (tests override it)
# SCHEMA1_RETIRES YYYY-MM-DD, default 2027-01-01 (PLAN.md §34.4)
#
# Results, for the workflow:
# $WORK/result.env changed=true|false, commit_subject=…
# $WORK/summary.md the job summary's body
# $WORK/stale.tsv <repo slug>\t<released tag>, one per resolved component
set -euo pipefail
: "${PUBLISHED:?PUBLISHED must name the bundles-branch worktree}"
: "${WORK:?WORK must name a scratch directory}"
GITEA_HOST="${GITEA_HOST:-gitea.whitlocktech.com}"
# Overridable so the script can be pointed at a mock Gitea (how the Rust path was
# tested before either Rust repository had released).
GITEA_BASE="${GITEA_BASE:-https://${GITEA_HOST}}"
TODAY="${TODAY:-$(date -u +%Y-%m-%d)}"
SCHEMA1_RETIRES="${SCHEMA1_RETIRES:-2027-01-01}"
SERVUO_LINK_REPO="RunicGateway/link"
SERVUO_OVERLAY_REPO="RunicGateway/servuo-plugins"
RUST_LINK_REPO="RunicGateway/Rust-Link"
RUST_PLUGIN_REPO="RunicGateway/Rust-Plugins"
RUNICNPC_REPO="RunicGateway/runicnpc-rust"
# Fixed top-level directories inside each payload tarball. Deliberately NOT
# versioned — a versioned prefix would mean parsing the version out of a path in
# order to read the manifest that declares the version.
SERVUO_OVERLAY_PREFIX="runicgateway-overlay"
RUST_PLUGIN_PREFIX="runicgateway-rust-plugin"
RUNICNPC_PREFIX="runicnpc"
mkdir -p "$WORK" "$PUBLISHED"
: > "$WORK/summary.md"
: > "$WORK/stale.tsv"
: > "$WORK/published.txt"
fail() { echo "::error::$*" >&2; exit 1; }
note() { printf -- '%s\n' "$*" >> "$WORK/summary.md"; }
# ── Release resolution ───────────────────────────────────────────────────────
# Read ANONYMOUSLY, on purpose: these are exactly the requests the shipped
# installer and the egg make, from a host with no Gitea credentials. A repo
# flipped to private fails CI here instead of on an operator's machine.
#
# Returns 0 with $WORK/<key>-release.json written, or 2 when the repo has never
# released (Gitea answers /releases/latest with 404). Anything else is fatal.
resolve_latest() {
local slug="$1" key="$2" code tag
code="$(curl -sS -o "$WORK/${key}-release.json" -w '%{http_code}' \
"${GITEA_BASE}/api/v1/repos/${slug}/releases/latest")" || fail "could not reach ${slug}'s releases"
case "$code" in
200) ;;
404) return 2 ;;
*) fail "${slug} /releases/latest answered HTTP ${code}" ;;
esac
tag="$(jq -r '.tag_name' "$WORK/${key}-release.json")"
[ -n "$tag" ] && [ "$tag" != "null" ] || fail "${slug}'s latest release has no tag"
printf '%s\t%s\n' "$slug" "$tag" >> "$WORK/stale.tsv"
echo "==> ${slug} latest: ${tag}"
}
release_tag() { jq -r '.tag_name' "$WORK/$1-release.json"; }
# ── GATE 2 (installer PLAN.md §7.1): assets exist, checksums match ───────────
# Every asset a bundle will reference is downloaded and verified against the
# SHA256SUMS published beside it. SHA256SUMS is the trust anchor for these
# deliberately UNSIGNED artifacts, and the installer and the egg verify against
# the hashes THIS job records; a hash copied from a file nobody checked would
# make the chain decorative.
#
# `sha256sum -c` catches a SHA256SUMS entry with no asset. The reverse — an asset
# with no entry — is checked by name, because -c would pass right over it. The
# `\*?` matches sha256sum's binary-mode marker.
verify_assets() {
local key="$1" dir="$WORK/$1" sums_url name url
rm -rf "$dir"; 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=$'\t' read -r name url; do
[ -n "$name" ] || continue
echo " fetching ${key}/${name}"
curl -sSfL -o "${dir}/${name}" "$url"
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"
}
sha_of() { sha256sum "$1" | cut -d' ' -f1; }
# One asset as a bundle `{name,url,sha256}` object.
asset_json() {
local key="$1" name="$2" url
url="$(awk -F'\t' -v n="$name" '$1 == n { print $2 }' "$WORK/${key}/asset-list.tsv")"
jq -n --arg name "$name" --arg url "$url" --arg sha "$(sha_of "$WORK/${key}/${name}")" \
'{ name: $name, url: $url, sha256: $sha }'
}
# The single tarball a payload release carries, by name. Exactly one: a second
# .tar.gz would leave the installer guessing which one it deploys.
single_tarball() {
local key="$1" count
count="$(awk -F'\t' '$1 ~ /\.tar\.gz$/' "$WORK/${key}/asset-list.tsv" | wc -l)"
[ "$count" -eq 1 ] || fail "expected exactly 1 .tar.gz in the ${key} release, found ${count}"
awk -F'\t' '$1 ~ /\.tar\.gz$/ { printf "%s", $1 }' "$WORK/${key}/asset-list.tsv"
}
# ── GATE 1 (installer PLAN.md §7.1): both halves speak one protocol ──────────
# The sidecar side is PROTOCOL_VERSION in sidecar/src/main.rs, read at the
# RELEASE TAG — not from the binary, which would mean executing a downloaded
# artifact (and, for older releases, one with no way to answer). Both link and
# Rust-Link keep the constant at that path.
sidecar_protocol() {
local slug="$1" tag="$2" key="$3" p
curl -sSfL -o "$WORK/${key}-main.rs" \
"${GITEA_BASE}/${slug}/raw/tag/${tag}/sidecar/src/main.rs"
p="$(sed -nE 's/^[[:space:]]*pub const PROTOCOL_VERSION[^=]*=[[:space:]]*([0-9]+).*/\1/p' "$WORK/${key}-main.rs" | head -1)"
# Empty means the constant moved. "Could not read" must never read as "matches".
[ -n "$p" ] || fail "could not read PROTOCOL_VERSION from ${slug}@${tag}:sidecar/src/main.rs — has the constant moved? Gate 1 cannot be skipped."
printf '%s' "$p"
}
# Unpack a payload tarball and return the path of its manifest.json, having
# checked the manifest names the version it was released under.
payload_manifest() {
local key="$1" tarball="$2" prefix="$3" tag="$4" m v
rm -rf "$WORK/${key}-x"; mkdir -p "$WORK/${key}-x"
tar -xzf "$WORK/${key}/${tarball}" -C "$WORK/${key}-x"
m="$WORK/${key}-x/${prefix}/manifest.json"
[ -f "$m" ] || fail "the ${key} tarball has no ${prefix}/manifest.json — the installer and the egg resolve it at that exact path"
v="$(jq -r '.version' "$m")"
# A manifest that disagrees with its tag means the release stamped one version
# and tagged another; every record of "what is installed" would then be wrong.
[ "$v" = "${tag#v}" ] || fail "${key} manifest says version ${v} but the release is tagged ${tag}"
printf '%s' "$m"
}
# ── Publishing helpers ───────────────────────────────────────────────────────
# `bundle` and `generated` describe the RUN, not the matrix. Comparing them would
# make every nightly cron look like a change and commit a dated duplicate of the
# same matrix forever, so only what an installer acts on is compared.
same_content() {
local published="$1" content="$2"
[ -f "$published" ] || return 1
cmp -s <(jq -S 'del(.bundle, .generated)' "$published") <(jq -S '.' "$content")
}
# A fresh date tag that names no bundle in any of the given directories. Two
# bundles on one day get .2, .3, … so a tag always names exactly one matrix.
fresh_tag() {
local base tag n=1 d taken
base="$(date -u -d "$TODAY" +%Y.%m.%d)"
tag="$base"
while :; do
taken=false
for d in "$@"; do [ -f "${d}/bundle-${tag}.json" ] && taken=true; done
[ "$taken" = false ] && break
n=$((n + 1)); tag="${base}.${n}"
done
printf '%s' "$tag"
}
# Write <dir>/bundle-<tag>.json and <dir>/current.json from a content document.
# The header keys go first so a person reading the file sees what it is.
publish_doc() {
local dir="$1" tag="$2" content="$3"
mkdir -p "$dir"
jq --arg bundle "$tag" --arg generated "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'(if has("game") then { schema: .schema, game: .game } else { schema: .schema } end)
+ { bundle: $bundle, generated: $generated } + del(.schema, .game)' \
"$content" > "${dir}/bundle-${tag}.json"
cp "${dir}/bundle-${tag}.json" "${dir}/current.json"
local rel="${dir#"$PUBLISHED"}"; rel="${rel#/}"
echo "${rel:+${rel}/}bundle-${tag}.json" >> "$WORK/published.txt"
}
# ── ServUO: schema 1 and v2/servuo ───────────────────────────────────────────
compose_servuo() {
resolve_latest "$SERVUO_LINK_REPO" servuo-link \
|| fail "${SERVUO_LINK_REPO} has no published release"
resolve_latest "$SERVUO_OVERLAY_REPO" servuo-overlay \
|| fail "${SERVUO_OVERLAY_REPO} has no published release"
local link_tag overlay_tag
link_tag="$(release_tag servuo-link)"; overlay_tag="$(release_tag servuo-overlay)"
verify_assets servuo-link
verify_assets servuo-overlay
# link's binaries onto platform keys. An unrecognized name is a hard failure,
# so a new target in link's release.yml reddens this job instead of silently
# vanishing from every bundle; a missing required key fails the same way.
local assets='{}' name url plat
while IFS=$'\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}' — teach compose-bundles.sh this name or the bundle would silently omit it." ;;
esac
assets="$(jq --arg p "$plat" --argjson a "$(asset_json servuo-link "$name")" '. + { ($p): $a }' <<<"$assets")"
done < "$WORK/servuo-link/asset-list.tsv"
for plat in linux-x86_64 linux-aarch64 windows-x86_64; do
jq -e --arg p "$plat" 'has($p)' <<<"$assets" >/dev/null \
|| fail "link release is missing a ${plat} binary; the installer ships for all three"
done
local tarball manifest overlay_protocol link_protocol
[ "$(wc -l < "$WORK/servuo-overlay/asset-list.tsv")" -eq 1 ] \
|| fail "expected exactly 1 overlay asset besides SHA256SUMS"
tarball="$(single_tarball servuo-overlay)"
manifest="$(payload_manifest servuo-overlay "$tarball" "$SERVUO_OVERLAY_PREFIX" "$overlay_tag")"
overlay_protocol="$(jq -r '.protocol' "$manifest")"
link_protocol="$(sidecar_protocol "$SERVUO_LINK_REPO" "$link_tag" servuo-link)"
echo "==> servuo: 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 a bundle whose shard the sidecar rejects with 409. Fix: land the matching half and let its release cut, or bump servuo-plugins/overlay.toml."
local common
common="$(jq -n \
--arg link_repo "$SERVUO_LINK_REPO" --arg link_tag "$link_tag" --argjson assets "$assets" \
--arg ov_repo "$SERVUO_OVERLAY_REPO" --arg ov_tag "$overlay_tag" \
--arg ov_commit "$(jq -r '.commit' "$manifest")" \
--arg ov_min "$(jq -r '.servuo.min_version' "$manifest")" \
--arg ov_patched "$(jq -r '.servuo.patches_verified_against' "$manifest")" \
--argjson ov_asset "$(asset_json servuo-overlay "$tarball")" \
--argjson protocol "$link_protocol" \
'{ protocol: $protocol,
sidecar: { repo: $link_repo, tag: $link_tag, version: ($link_tag | ltrimstr("v")),
protocol: $protocol, assets: $assets },
overlay: { repo: $ov_repo, tag: $ov_tag, version: ($ov_tag | ltrimstr("v")),
commit: $ov_commit, protocol: $protocol,
servuo: { min_version: $ov_min, patches_verified_against: $ov_patched },
asset: $ov_asset } }')"
# Schema 1: byte-for-byte the shape every shipped installer parses.
jq '{ schema: 1, protocol: .protocol, link: .sidecar, overlay: .overlay }' \
<<<"$common" > "$WORK/servuo-s1.json"
# Schema 2: the same pair, in the game-neutral shape (§34.2.2). The payload's
# `compat` is schema 1's `servuo` block.
jq '{ schema: 2, game: "servuo", protocol: .protocol, sidecar: .sidecar,
payload: ({ kind: "overlay" } + (.overlay | del(.servuo)) + { compat: .overlay.servuo }) }' \
<<<"$common" > "$WORK/servuo-s2.json"
local s1_active=false s1_changed=false s2_changed=false tag=""
[[ "$TODAY" < "$SCHEMA1_RETIRES" ]] && s1_active=true
if [ "$s1_active" = true ] && ! same_content "$PUBLISHED/current.json" "$WORK/servuo-s1.json"; then
s1_changed=true
fi
same_content "$PUBLISHED/v2/servuo/current.json" "$WORK/servuo-s2.json" || s2_changed=true
if [ "$s1_changed" = false ] && [ "$s2_changed" = false ]; then
note "- **ServUO**: no change (link \`${link_tag}\`, overlay \`${overlay_tag}\`, protocol ${link_protocol})."
[ "$s1_active" = true ] || note " Schema 1 retired on ${SCHEMA1_RETIRES}; its \`current.json\` stays frozen."
return 0
fi
# One matrix, one tag: when schema 1 already names this pair, v2 borrows its
# tag — unless v2 somehow already holds that tag for different content, since
# a published bundle-<tag>.json is never rewritten.
if [ "$s1_active" = true ] && [ "$s1_changed" = false ] && [ -f "$PUBLISHED/current.json" ]; then
tag="$(jq -r '.bundle' "$PUBLISHED/current.json")"
if [ -f "$PUBLISHED/v2/servuo/bundle-${tag}.json" ]; then tag=""; fi
fi
[ -n "$tag" ] || tag="$(fresh_tag "$PUBLISHED" "$PUBLISHED/v2/servuo")"
[ "$s1_changed" = true ] && publish_doc "$PUBLISHED" "$tag" "$WORK/servuo-s1.json"
[ "$s2_changed" = true ] && publish_doc "$PUBLISHED/v2/servuo" "$tag" "$WORK/servuo-s2.json"
local which=""
[ "$s1_changed" = true ] && which="schema 1"
[ "$s2_changed" = true ] && which="${which:+${which} + }schema 2"
note "- **ServUO**: published \`${tag}\` (${which}) — link \`${link_tag}\`, overlay \`${overlay_tag}\`, protocol ${link_protocol}."
echo "servuo ${tag}" >> "$WORK/published-games.txt"
}
# ── Rust: v2/rust ────────────────────────────────────────────────────────────
compose_rust() {
local rc=0 missing=""
resolve_latest "$RUST_LINK_REPO" rust-link || { rc=$?; [ "$rc" -eq 2 ] && missing="${RUST_LINK_REPO}"; }
rc=0
resolve_latest "$RUST_PLUGIN_REPO" rust-plugin || { rc=$?; [ "$rc" -eq 2 ] && missing="${missing:+${missing} and }${RUST_PLUGIN_REPO}"; }
if [ -n "$missing" ]; then
echo "==> rust: no release yet from ${missing}, so nothing to compose (not a failure)"
note "- **Rust**: skipped — no release yet from ${missing}."
return 0
fi
local link_tag plugin_tag
link_tag="$(release_tag rust-link)"; plugin_tag="$(release_tag rust-plugin)"
verify_assets rust-link
verify_assets rust-plugin
# Rust-Link's release: a binary per platform, the egg's launcher, and the egg
# itself. The egg is verified (gate 2 covers every asset) but is not part of
# the bundle — a panel admin imports it; nothing resolves it from here.
local assets='{}' launcher="" name url plat
while IFS=$'\t' read -r name url; do
[ -n "$name" ] || continue
case "$name" in
*-linux-x86_64) plat=linux-x86_64 ;;
*-windows-x86_64.exe) plat=windows-x86_64 ;;
with-sidecar.sh) launcher="$(asset_json rust-link "$name")"; continue ;;
egg-*.json) continue ;;
*) fail "unrecognized Rust-Link asset '${name}' — teach compose-bundles.sh this name or the bundle would silently omit it." ;;
esac
assets="$(jq --arg p "$plat" --argjson a "$(asset_json rust-link "$name")" '. + { ($p): $a }' <<<"$assets")"
done < "$WORK/rust-link/asset-list.tsv"
# No linux-aarch64: RustDedicated has no arm64 build (D149).
for plat in linux-x86_64 windows-x86_64; do
jq -e --arg p "$plat" 'has($p)' <<<"$assets" >/dev/null \
|| fail "Rust-Link release is missing a ${plat} binary (D149)"
done
[ -n "$launcher" ] || fail "Rust-Link release has no with-sidecar.sh — the egg's startup runs it (§34.2.6)"
local tarball manifest plugin_protocol link_protocol
tarball="$(single_tarball rust-plugin)"
manifest="$(payload_manifest rust-plugin "$tarball" "$RUST_PLUGIN_PREFIX" "$plugin_tag")"
[ -f "$(dirname "$manifest")/RunicGateway.cs" ] \
|| fail "the Rust-Plugins tarball has no ${RUST_PLUGIN_PREFIX}/RunicGateway.cs"
plugin_protocol="$(jq -r '.protocol' "$manifest")"
link_protocol="$(sidecar_protocol "$RUST_LINK_REPO" "$link_tag" rust-link)"
echo "==> rust: sidecar ${link_tag} protocol=${link_protocol} | plugin ${plugin_tag} protocol=${plugin_protocol}"
[ "$link_protocol" = "$plugin_protocol" ] || fail \
"PROTOCOL MISMATCH — Rust-Link ${link_tag} speaks ${link_protocol}, Rust-Plugins ${plugin_tag} declares ${plugin_protocol}. The game link has no 409: a mismatched plugin would mis-parse. Fix: land the matching half and let its release cut, or bump Rust-Plugins/overlay.toml."
# RunicNPC (docs/runicnpc/PLAN.md D224): a third file, optional until its stage 9.
# The bridge's manifest says which RunicNPC API its npc.* commands need
# (`runicnpc_api`, from overlay.toml); the latest RunicNPC release is carried
# only when it answers at least that. A bridge that needs none carries none: a
# RunicNPC it cannot talk to would be an NPC plugin nobody manages.
local npc='null' need npc_tag npc_tarball npc_manifest npc_api npc_carried=''
need="$(jq -r '.runicnpc_api // 0' "$manifest")"
if [ "$need" -gt 0 ]; then
rc=0
resolve_latest "$RUNICNPC_REPO" runicnpc || rc=$?
if [ "$rc" -eq 2 ]; then
note "- **Rust**: the bridge needs RunicNPC API ${need}, and RunicNPC has not released yet — the bundle carries none."
else
npc_tag="$(release_tag runicnpc)"
verify_assets runicnpc
npc_tarball="$(single_tarball runicnpc)"
npc_manifest="$(payload_manifest runicnpc "$npc_tarball" "$RUNICNPC_PREFIX" "$npc_tag")"
[ -f "$(dirname "$npc_manifest")/RunicNPC.cs" ] \
|| fail "the RunicNPC tarball has no ${RUNICNPC_PREFIX}/RunicNPC.cs"
npc_api="$(jq -r '.api' "$npc_manifest")"
echo "==> rust: RunicNPC ${npc_tag} api=${npc_api} | the bridge needs ${need}"
if [ "$npc_api" -lt "$need" ]; then
note "- **Rust**: RunicNPC \`${npc_tag}\` answers API ${npc_api} and the bridge needs ${need} — the bundle carries none until RunicNPC releases one that does."
else
npc="$(jq -n --arg repo "$RUNICNPC_REPO" --arg tag "$npc_tag" --slurpfile m "$npc_manifest" \
--argjson asset "$(asset_json runicnpc "$npc_tarball")" \
'{ repo: $repo, tag: $tag, version: ($tag | ltrimstr("v")), commit: $m[0].commit, api: $m[0].api,
compat: { frameworks: { oxide: { min_version: $m[0].min_oxide_version },
carbon: { min_version: $m[0].min_carbon_version } },
requires_plugins: $m[0].requires_plugins },
asset: $asset }')"
jq -e '([.commit, .compat.frameworks[].min_version] | all(type == "string"))
and (.api | type == "number") and (.compat.requires_plugins | type == "array")' <<<"$npc" >/dev/null \
|| fail "the RunicNPC manifest is missing commit, api, a framework floor, or requires_plugins"
npc_carried="$npc_tag"
fi
fi
fi
jq -n \
--arg link_repo "$RUST_LINK_REPO" --arg link_tag "$link_tag" \
--argjson assets "$assets" --argjson launcher "$launcher" \
--arg p_repo "$RUST_PLUGIN_REPO" --arg p_tag "$plugin_tag" \
--slurpfile m "$manifest" \
--argjson p_asset "$(asset_json rust-plugin "$tarball")" \
--argjson protocol "$link_protocol" \
--argjson npc "$npc" \
'{ schema: 2, game: "rust", protocol: $protocol,
sidecar: { repo: $link_repo, tag: $link_tag, version: ($link_tag | ltrimstr("v")),
protocol: $protocol, assets: $assets, launcher: $launcher },
payload: { kind: "plugin", repo: $p_repo, tag: $p_tag, version: ($p_tag | ltrimstr("v")),
commit: $m[0].commit, protocol: $protocol,
compat: { frameworks: { oxide: { min_version: $m[0].min_oxide_version },
carbon: { min_version: $m[0].min_carbon_version } },
requires_plugins: $m[0].requires_plugins },
asset: $p_asset } }
+ (if $npc == null then {} else { npc: $npc } end)' > "$WORK/rust-s2.json"
# A manifest missing a key would compose a `null` the installer then trusts.
jq -e '([.payload.compat.frameworks[].min_version, .payload.commit] | all(type == "string"))
and (.payload.compat.requires_plugins | type == "array")' "$WORK/rust-s2.json" >/dev/null \
|| fail "the Rust-Plugins manifest is missing commit, a framework floor, or requires_plugins"
if same_content "$PUBLISHED/v2/rust/current.json" "$WORK/rust-s2.json"; then
note "- **Rust**: no change (Rust-Link \`${link_tag}\`, Rust-Plugins \`${plugin_tag}\`, protocol ${link_protocol})."
return 0
fi
local tag
tag="$(fresh_tag "$PUBLISHED/v2/rust")"
publish_doc "$PUBLISHED/v2/rust" "$tag" "$WORK/rust-s2.json"
note "- **Rust**: published \`${tag}\` — Rust-Link \`${link_tag}\`, Rust-Plugins \`${plugin_tag}\`, protocol ${link_protocol}${npc_carried:+, RunicNPC \`${npc_tag}\`}."
echo "rust ${tag}" >> "$WORK/published-games.txt"
}
# ── Run both, independently ──────────────────────────────────────────────────
: > "$WORK/published-games.txt"
FAILED=""
for game in servuo rust; do
echo "────────── ${game} ──────────"
# NOT `if ! ( … )`: a subshell in a condition runs with errexit OFF, so an
# unchecked failed download inside it would sail on. Capture the status instead.
set +e
( set -e; "compose_${game}" )
rc=$?
set -e
if [ "$rc" -ne 0 ]; then
FAILED="${FAILED:+${FAILED}, }${game}"
note "- **${game}**: **FAILED** — see the log. Nothing was published for it."
fi
done
CHANGED=false
[ -s "$WORK/published.txt" ] && CHANGED=true
{
echo "changed=${CHANGED}"
echo "commit_subject=chore(bundle): publish $(paste -sd ',' "$WORK/published-games.txt" | sed 's/,/, /g') [skip ci]"
} > "$WORK/result.env"
echo "────────── result ──────────"
cat "$WORK/summary.md"
[ "$CHANGED" = true ] && { echo "files:"; sed 's/^/ /' "$WORK/published.txt"; }
[ -z "$FAILED" ] || fail "compose failed for: ${FAILED}"