All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m12s
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
338 lines
18 KiB
YAML
338 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
|
||
#
|
||
# 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"
|