Files
installer/.gitea/workflows/bundle.yml
wtclaude 4f268f7ffb
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m12s
ci(bundle): compose schema 2 for ServUO and Rust beside schema 1
Module-rust phase 18, step 2 of docs/modules/rust/PLAN.md §34.2.7 (D146,
D147). The compose job now writes three streams on the bundles branch:

  current.json         schema 1, ServUO, exactly as before
  v2/servuo/           schema 2, game "servuo"
  v2/rust/             schema 2, game "rust" (Rust-Link + Rust-Plugins)

The gates move into .gitea/scripts/compose-bundles.sh, so they can be run by
hand against the real release API or a mock without a runner. Each game
composes independently: a Rust failure still lets a ServUO bundle publish, and
the run goes red afterwards. A game whose repos have never released composes
nothing and does not fail, which is where Rust is until its cutover.

A ServUO matrix carries the same tag at both schemas, so the first run
publishes v2/servuo/bundle-2026.09.15.json rather than a second name for the
pair. Schema 1 stops being composed on 2027-01-01 and is left frozen.

Tested in debian:12 against the real releases (v2/servuo published as
2026.09.15, schema 1 unchanged, Rust skipped; rerun is a no-op; past the
retirement date schema 1 is frozen) and against a mock Gitea for Rust
(document composed, protocol mismatch refused, missing manifest key refused,
rerun a no-op, ServUO failing independently).

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

338 lines
18 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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-<tag>.json schema 1, ServUO: link + overlay
# v2/servuo/current.json, bundle-<tag>.json schema 2, game "servuo"
# v2/rust/current.json, bundle-<tag>.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-<tag>.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 '<subject>'", 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.<host>.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"