All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m27s
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
339 lines
18 KiB
YAML
339 lines
18 KiB
YAML
# 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,
|
||
# and RunicNPC when the bridge needs it (D224)
|
||
#
|
||
# 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,
|
||
# Rust-Plugins' and runicnpc-rust's 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"
|