#!/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-.json schema 1, ServUO (until SCHEMA1_RETIRES) # v2/servuo/current.json, bundle-.json schema 2, game "servuo" # v2/rust/current.json, bundle-.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-.json stays pinnable. # # ── One matrix, one tag ────────────────────────────────────────────────────── # A ServUO matrix published at both schemas carries the SAME tag in both, so a # `--bundle ` 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 \t, 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" # 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" 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/-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 /bundle-.json and /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-.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." 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" \ '{ 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 } }' > "$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}." 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}"