# Compose and publish the bundle manifest. # # This is Phase 0 item 3 of docs/installer/PLAN.md (§7.1–§7.3). # # ── 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 # 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 under bundles/: # # bundles/current.json the bundle the installer uses by default # bundles/bundle-.json every bundle ever published, kept for --bundle # # so the installer's two fetches are plain anonymous raw URLs on a public repo: # # https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/main/bundles/current.json # https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/main/bundles/bundle-2026.08.04.json # # 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. # # The push to `main` needs no new branch-protection exception: release.yml's # version-bump commit already requires REGISTRY_USER to be able to push here. # # ── 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. # 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. # # A run that finds nothing changed writes NOTHING. That is what makes the # nightly cron free: it does not commit a dated no-op every morning. # # ── 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. # # The bundle commit carries `[skip ci]`, so it does not re-trigger release.yml. name: Compose bundle on: workflow_dispatch: {} schedule: # Nightly, off the hour so it does not pile onto every other cron on the box. - cron: '17 4 * * *' # 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. concurrency: group: compose-bundle cancel-in-progress: false 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: runs-on: ubuntu-latest timeout-minutes: 20 steps: # Full history: the push step rebases onto main if release.yml's version # bump landed while this job was composing, and a depth-1 clone has no # base to rebase onto. - name: Check out the bundles directory uses: actions/checkout@v4 with: fetch-depth: 0 - name: Install jq and curl run: | set -euo pipefail command -v jq >/dev/null 2>&1 && command -v curl >/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 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. # # `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 (aarch64, macOS) surfaces here as a # red run, rather than being silently dropped 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 ;; *-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 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 both" 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 ` 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 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 bundles/current.json ]; then if jq -S 'del(.bundle, .generated)' bundles/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 fi echo "changed=${CHANGED}" >> "$GITHUB_OUTPUT" if [ "$CHANGED" = false ]; then echo "==> identical to bundles/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 "bundles/bundle-${TAG}.json" ]; do N=$((N+1)); TAG="${BASE}.${N}" done mkdir -p bundles 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 > "bundles/bundle-${TAG}.json" cp "bundles/bundle-${TAG}.json" bundles/current.json echo "bundle_tag=${TAG}" >> "$GITHUB_OUTPUT" echo "==> composed bundle ${TAG}" # ── 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 # this job looks the release normally already exists. When it does not — # a release workflow that failed, or one still in flight — the fix is to # fire it and move on, NOT to poll: Gitea's dispatch endpoint returns no # run handle, so a waiting job would have to guess which run is its own # while holding a runner idle. # # "Ahead of its release" must mean RELEASABLE commits. The release engines # set RELEASE=false when only docs:/chore: landed, so comparing raw commit # 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. - name: Check for components with unreleased work, and dispatch them id: stale continue-on-error: true env: REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | set -euo pipefail CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" # Warnings go to a FILE, not a step output. The job summary below # reads it with `cat`; interpolating a multi-line `${{ }}` value into # a shell string there would let any character in a commit-derived # message change what that script does. : > work/stale-warnings.md for pair in "${LINK_REPO}:${{ steps.resolve.outputs.link_tag }}" \ "${OVERLAY_REPO}:${{ steps.resolve.outputs.overlay_tag }}"; do SLUG="${pair%:*}"; TAG="${pair##*:}" 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" continue fi # Merge commits are excluded (parents >= 2): their subject is # "Merge pull request ''", which would match feat/fix # through the quoted title and double-count what the real commit # already reports. RELEASABLE="$(jq -r ' [ .commits[]? | select((.parents | length) < 2) | .commit.message | select( (split("\n")[0] | test("^(feat|fix|perf)(\\([^)]+\\))?!?:")) or (split("\n")[0] | test("^[a-z]+(\\([^)]+\\))?!:")) or test("BREAKING[ -]CHANGE") ) | split("\n")[0] ] | length' work/compare.json)" if [ "${RELEASABLE:-0}" -gt 0 ]; then MSG="${SLUG} has ${RELEASABLE} releasable commit(s) after ${TAG} but no newer release. This bundle was composed from ${TAG}; firing that repo's release workflow now and NOT waiting for it. If this repeats nightly, its release workflow is broken — go read its last run." echo "::warning::${MSG}" printf -- '- %s\n' "$MSG" >> work/stale-warnings.md DISPATCH_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/${SLUG}/actions/workflows/release.yml/dispatches" || echo 000)" case "$DISPATCH_HTTP" in 20*) echo " dispatched ${SLUG} release.yml (HTTP ${DISPATCH_HTTP})" ;; *) echo "::warning::dispatching ${SLUG} release.yml returned HTTP ${DISPATCH_HTTP} — REGISTRY_TOKEN may lack write access there. The bundle above is still valid; the new release just will not be picked up until the next run." ;; esac else echo "==> ${SLUG}: nothing releasable after ${TAG}" fi done # ── Publish ────────────────────────────────────────────────────────── # Preflighted for the same reason the release workflows are: actions/ # checkout leaves an http..extraheader credential in the local git # 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. - name: Verify publish credentials are configured if: ${{ steps.compose.outputs.changed == '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 write:repository to push the bundle commit to main." exit 1 fi echo "Publish credentials present." - name: Commit and push the bundle if: ${{ 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"). CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')" CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')" git config user.name "installer-ci" git config user.email "ci@whitlocktech.com" git remote set-url origin "https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${REPO}.git" git add bundles 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]" # The checkout is a detached snapshot of main; push the commit at HEAD # to the branch the installer reads its raw URLs from. release.yml # pushes its version-bump commit to the same branch, so losing the # race is normal rather than exceptional — rebase and retry once # instead of failing and leaving the bundle unpublished until the # next cron. Only bundles/ is touched here, so a rebase over a bump # commit cannot conflict. if ! git push origin "HEAD:main"; then echo "::warning::push rejected (main moved during compose) — rebasing and retrying once" git fetch origin main git rebase origin/main git push origin "HEAD:main" fi echo "==> published bundles/bundle-${TAG}.json and bundles/current.json" - name: Job summary if: always() run: | set -euo pipefail { 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/stale-warnings.md ]; then echo echo "### ⚠ Components with unreleased work" echo cat work/stale-warnings.md fi } >> "$GITHUB_STEP_SUMMARY"