# Compose and publish the bundle manifests. # # This is Phase 0 item 3 of docs/installer/PLAN.md (§7.1–§7.3), extended by # module-rust phase 18 (docs/modules/rust/PLAN.md §34.2.2) to a second game. # # ── What a bundle is ───────────────────────────────────────────────────────── # The bundle IS the compat matrix. The installer does not hardcode component # versions and does not resolve "latest" at run time; it fetches one small JSON # document naming an exact, protocol-checked combination of a sidecar release # and a game-side payload release, and installs that. Because the bundle is # data, a new sidecar release regenerates ~30 lines of JSON and leaves the # installer binary untouched: operators do not re-download the installer to pick # up a sidecar patch, and this repo does not accumulate releases whose code is # byte-identical. # # ── The streams ────────────────────────────────────────────────────────────── # Bundles are COMMITTED to this repo, on their own `bundles` branch: # # current.json, bundle-.json schema 1, ServUO: link + overlay # v2/servuo/current.json, bundle-.json schema 2, game "servuo" # v2/rust/current.json, bundle-.json schema 2, game "rust": Rust-Link + Rust-Plugins # # Schema 2 (D146) names ONE game, with a `game` discriminant and a `payload` # that is an overlay for ServUO and a plugin for Rust. Schema 1 (D147) is still # composed beside it because every installer in the field reads only the root # `current.json` and refuses any other schema; on 2027-01-01 it stops being # composed and is left frozen, never deleted, so old installers still resolve # and every bundle-.json stays pinnable. A ServUO matrix carries the same # tag at both schemas. # # The installer's fetches are plain anonymous raw URLs on a public repo: # # https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles/current.json # https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles/v2/rust/current.json # # A BRANCH, not `main`, because `main` is protected and this job is unattended: # the pre-receive hook declines a push from CI, which is not a thing a nightly # cron can resolve. Publishing to a branch of its own keeps everything the # original choice was for — a reviewable diff, a git history of the compat # matrix, plain raw URLs, no auth on the shard host — and needs no protection # exception. # # The 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. # # `main` is never pushed to by this workflow. (release.yml does not push to it # either — it tags and lets the release API do the rest.) # # ── Where the logic lives ──────────────────────────────────────────────────── # .gitea/scripts/compose-bundles.sh holds the gates and the documents, so it can # be run by hand against the real release API (or a mock) without a runner. This # file owns what needs the runner: checkout, the stale-component dispatch, and # the push. # # ── Triggers (PLAN.md §7.2) ────────────────────────────────────────────────── # workflow_dispatch — POSTed by link's, servuo-plugins', Rust-Link's and # Rust-Plugins' release workflows as their final step, # so a new release recomposes the bundle immediately. # schedule (nightly) — recomputes from whatever the latest releases actually # are, so a missed or failed dispatch self-heals instead # of silently pinning operators to a stale sidecar. # # 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 the four # component repos (to fire their release workflows for the # stale case below). A token without the latter degrades to # a warning, not a failure — the bundle it composes is still # valid. # # The bundle commit carries `[skip ci]`, so it does not re-trigger release.yml. 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. concurrency: group: compose-bundle cancel-in-progress: false env: GITEA_HOST: gitea.whitlocktech.com REPO: RunicGateway/installer jobs: compose: runs-on: ubuntu-latest timeout-minutes: 20 steps: # Full history: the publish step rebases onto the bundles branch if another # run landed while this one was composing, and a depth-1 clone has no base # to rebase onto. - name: Check out the repository uses: actions/checkout@v4 with: fetch-depth: 0 # The published bundles live on their own branch (see the header), so they # are materialized into a worktree rather than being part of the checkout. # The compose script reads and writes `published/`, so the ".2 suffix" # scan and the idempotence check both see what is actually published. - name: Materialize the bundles branch run: | set -euo pipefail git config user.name "installer-ci" git config user.email "ci@whitlocktech.com" # `prune` matters on a re-run in an existing checkout: removing the # directory leaves the worktree registered, and `worktree add` then # refuses the path. CI checks out fresh every time, so this only shows # up when driving the job by hand — which is how it is tested. rm -rf published git worktree prune if git ls-remote --exit-code --heads origin bundles >/dev/null 2>&1; then git fetch origin bundles git worktree add -B bundles published origin/bundles echo "==> bundles branch: $(find published -name 'bundle-*.json' | wc -l) published bundle(s)" else # First run. A root commit with an empty tree gives the worktree a # branch to sit on without inheriting main's history, which has # nothing to do with the compat matrix. EMPTY_TREE="$(git hash-object -t tree /dev/null)" ROOT="$(git commit-tree "$EMPTY_TREE" -m 'chore(bundle): start the bundles branch')" git worktree add -B bundles published "$ROOT" echo "==> bundles branch does not exist yet; it will be created by the first publish" fi - 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, gate and compose every stream ─────────────────────────── # Release resolution, gate 2 (every asset present and matching its # SHA256SUMS), gate 1 (the sidecar's PROTOCOL_VERSION at its release tag # against the payload's declared protocol) and the documents all live in # the script. Each game composes independently: a Rust failure still lets # a ServUO bundle publish, and this step goes red afterwards. A game whose # repos have never released composes nothing, and that is not a failure. # # The outputs are written even when the script fails, so the publish step # (on `always()`) can still ship the game that did compose. - name: Compose the bundles id: compose run: | set -euo pipefail rc=0 PUBLISHED="$PWD/published" WORK="$PWD/work" GITEA_HOST="$GITEA_HOST" \ bash .gitea/scripts/compose-bundles.sh || rc=$? if [ -f work/result.env ]; then grep '^changed=' work/result.env >> "$GITHUB_OUTPUT" fi exit "$rc" # ── Stale-component check: dispatch, don't wait (PLAN.md §7.3) ─────── # Each component self-releases on merge to its own main, so by the time # 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, or a game failed: an # unchanged bundle is the exact symptom of a component release that never # happened. work/stale.tsv names each component the compose resolved; a # repo that has never released is absent from it, and has no release to # be stale against. - name: Check for components with unreleased work, and dispatch them id: stale if: always() continue-on-error: true env: REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} 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 template value into # a shell string there would let any character in a commit-derived # message change what that script does. (Do not write that token # literally in a comment: the runner parses it, fails, and silently # skips the whole step.) mkdir -p work : > work/stale-warnings.md [ -f work/stale.tsv ] || exit 0 while IFS="$(printf '\t')" read -r SLUG TAG; do [ -n "$SLUG" ] || continue if ! curl -sSfL -o work/compare.json \ "https://${GITEA_HOST}/api/v1/repos/${SLUG}/compare/${TAG}...main"; then echo "::warning::could not compare ${SLUG} ${TAG}...main; skipping its stale check" 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 < work/stale.tsv # ── 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. # # `always()`: when one game failed and the other composed, the one that # composed still ships. The compose step has already made the run red. - name: Verify publish credentials are configured if: ${{ always() && 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." exit 1 fi echo "Publish credentials present." - name: Commit and push the bundles if: ${{ always() && steps.compose.outputs.changed == 'true' }} env: REGISTRY_USER: ${{ secrets.REGISTRY_USER }} REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | set -euo pipefail # 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 remote set-url origin "https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${REPO}.git" # The subject is the script's ("publish servuo 2026.09.27, rust # 2026.09.27 [skip ci]"), read from a file rather than interpolated # from a template value, so nothing in it can change this script. grep '^commit_subject=' work/result.env | cut -d= -f2- > work/commit-msg.txt cd published git add -A git commit -F ../work/commit-msg.txt # Two runs can compose at once — a component release dispatches this # while the nightly cron is mid-flight — so losing the race is normal # rather than exceptional. Rebase and retry once instead of failing and # leaving the bundle unpublished until tomorrow. Every file here is a # bundle nobody else edits, and a bundle tag names exactly one matrix, # so a rebase cannot conflict. if ! git push origin bundles; then echo "::warning::push rejected (the bundles branch moved during compose) — rebasing and retrying once" git fetch origin bundles git rebase origin/bundles git push origin bundles fi echo "==> published on the bundles branch:" sed 's/^/ /' ../work/published.txt - name: Job summary if: always() run: | set -euo pipefail { echo "## Bundle compose" echo if [ -s work/summary.md ]; then cat work/summary.md else echo "Compose did not complete — see the failing step above." fi if [ -s work/published.txt ]; then echo echo "Written to the bundles branch:" echo sed 's/^/- /' work/published.txt fi if [ -s work/stale-warnings.md ]; then echo echo "### ⚠ Components with unreleased work" echo cat work/stale-warnings.md fi } >> "$GITHUB_STEP_SUMMARY"