Merge pull request 'ci(bundle): compose and publish the bundle manifest' (#3) from ci/bundle-compose into main
Reviewed-on: #3 Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
518
.gitea/workflows/bundle.yml
Normal file
518
.gitea/workflows/bundle.yml
Normal file
@@ -0,0 +1,518 @@
|
|||||||
|
# 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-<tag>.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 <tag>` 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 '<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
|
||||||
|
|
||||||
|
# ── 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.
|
||||||
|
- 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"
|
||||||
@@ -35,7 +35,14 @@ in the docs repo: phases, locked decisions, and the Phase 0 prerequisites in oth
|
|||||||
repos (a `servuo-plugins` release workflow, a non-interactive config read-back in
|
repos (a `servuo-plugins` release workflow, a non-interactive config read-back in
|
||||||
`link`, and the bundle-manifest CI here) that must land before Phase 1 is useful.
|
`link`, and the bundle-manifest CI here) that must land before Phase 1 is useful.
|
||||||
|
|
||||||
This repo currently holds its governance documents and issue/PR templates.
|
All three Phase 0 prerequisites have now landed, so **what the installer will
|
||||||
|
install already exists and is published**, ahead of the binary that installs it:
|
||||||
|
[`bundles/current.json`](bundles/current.json) names the current
|
||||||
|
protocol-checked sidecar + overlay combination, recomposed on every component
|
||||||
|
release and nightly. See [`bundles/README.md`](bundles/README.md).
|
||||||
|
|
||||||
|
Besides that, this repo currently holds its governance documents and issue/PR
|
||||||
|
templates.
|
||||||
|
|
||||||
## Related repos
|
## Related repos
|
||||||
|
|
||||||
|
|||||||
92
bundles/README.md
Normal file
92
bundles/README.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# Bundles
|
||||||
|
|
||||||
|
**These files are generated. Do not edit them by hand.**
|
||||||
|
|
||||||
|
A *bundle* names one exact, protocol-checked combination of the two components the installer
|
||||||
|
deploys — a `uo-link` sidecar release and a `servuo-plugins` overlay release. The installer does not
|
||||||
|
hardcode versions and does not resolve "latest" at run time; it fetches one of these documents and
|
||||||
|
installs what it names. **The bundle is the compat matrix.**
|
||||||
|
|
||||||
|
They are written by [`.gitea/workflows/bundle.yml`](../.gitea/workflows/bundle.yml), which composes
|
||||||
|
one whenever a component publishes a release (dispatched by that release's own workflow) and
|
||||||
|
nightly, so a missed dispatch self-heals. A run that finds nothing changed writes nothing.
|
||||||
|
|
||||||
|
See `docs/installer/PLAN.md` §7 for the design.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
| File | What it is |
|
||||||
|
|---|---|
|
||||||
|
| `current.json` | The bundle the installer uses by default. Always a copy of the newest `bundle-*.json`. |
|
||||||
|
| `bundle-<tag>.json` | Every bundle ever published, kept forever so `--bundle <tag>` stays reproducible. |
|
||||||
|
|
||||||
|
Tags are UTC dates — `2026.08.04`. A second bundle on the same day (a sidecar release in the
|
||||||
|
morning, an overlay release in the afternoon) becomes `2026.08.04.2`, so one tag always names
|
||||||
|
exactly one matrix.
|
||||||
|
|
||||||
|
## How the installer fetches these
|
||||||
|
|
||||||
|
Plain anonymous `GET`s against a public repo. The shard host gets no git and no Gitea credentials
|
||||||
|
(`PLAN.md` §1), so nothing here may require auth:
|
||||||
|
|
||||||
|
```
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
Bundles are committed rather than published as Gitea releases because this repo's *own* releases are
|
||||||
|
the installer binaries, and `/releases/latest` returns whichever release is newest regardless of
|
||||||
|
kind — interleaving the two would make "latest" intermittently resolve to a release containing no
|
||||||
|
installer binary.
|
||||||
|
|
||||||
|
## Schema
|
||||||
|
|
||||||
|
`schema` is the version of *this document's* shape, and is unrelated to `protocol` (the uo-link wire
|
||||||
|
protocol) or to either component's release version. All three move independently.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"schema": 1,
|
||||||
|
"bundle": "2026.08.04", // this bundle's tag; what --bundle takes
|
||||||
|
"generated": "2026-08-04T16:07:13Z",
|
||||||
|
"protocol": 3, // the wire protocol both halves speak (gate 1 proved it)
|
||||||
|
|
||||||
|
"link": {
|
||||||
|
"repo": "RunicGateway/link",
|
||||||
|
"tag": "v1.1.0",
|
||||||
|
"version": "1.1.0",
|
||||||
|
"protocol": 3,
|
||||||
|
"assets": { // per-platform: the installer runs on both
|
||||||
|
"linux-x86_64": { "name": "…", "url": "…", "sha256": "…" },
|
||||||
|
"windows-x86_64": { "name": "…", "url": "…", "sha256": "…" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"overlay": {
|
||||||
|
"repo": "RunicGateway/servuo-plugins",
|
||||||
|
"tag": "v0.1.1",
|
||||||
|
"version": "0.1.1",
|
||||||
|
"commit": "3a52abb…", // recorded into install.json at deploy time
|
||||||
|
"protocol": 3,
|
||||||
|
"servuo": {
|
||||||
|
"min_version": "57.4", // base overlay: only adds files
|
||||||
|
"patches_verified_against": "57.4" // patch tier: skipped with a warning elsewhere
|
||||||
|
},
|
||||||
|
"asset": { "name": "runicgateway-overlay-0.1.1.tar.gz", "url": "…", "sha256": "…" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### The two things worth knowing
|
||||||
|
|
||||||
|
**`sha256` is load-bearing, not decorative.** Every artifact Runic Gateway publishes is deliberately
|
||||||
|
**unsigned** (`PLAN.md` §3) — the checksum is the entire trust anchor. Each hash here was computed by
|
||||||
|
CI from the asset it actually downloaded, *after* verifying it against the `SHA256SUMS` the
|
||||||
|
publishing repo shipped beside it. The installer must verify every download against these values and
|
||||||
|
refuse on a mismatch. A bundle whose hashes are trusted but never checked buys nothing.
|
||||||
|
|
||||||
|
**`link.protocol` and `overlay.protocol` are always equal, and that is the point.** The sidecar
|
||||||
|
rejects a protocol mismatch with `409` rather than mis-parsing, so a mismatched pair is a shard
|
||||||
|
emitting into a void. CI refuses to publish one: it reads `PROTOCOL_VERSION` from the sidecar's
|
||||||
|
source at its release tag and the declared `protocol` from the overlay tarball's `manifest.json`, and
|
||||||
|
fails if they differ. The top-level `protocol` is that agreed value.
|
||||||
40
bundles/bundle-2026.08.04.json
Normal file
40
bundles/bundle-2026.08.04.json
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"schema": 1,
|
||||||
|
"bundle": "2026.08.04",
|
||||||
|
"generated": "2026-08-04T16:07:13Z",
|
||||||
|
"protocol": 3,
|
||||||
|
"link": {
|
||||||
|
"repo": "RunicGateway/link",
|
||||||
|
"tag": "v1.1.0",
|
||||||
|
"version": "1.1.0",
|
||||||
|
"protocol": 3,
|
||||||
|
"assets": {
|
||||||
|
"linux-x86_64": {
|
||||||
|
"name": "uo-link-sidecar-linux-x86_64",
|
||||||
|
"url": "https://gitea.whitlocktech.com/RunicGateway/link/releases/download/v1.1.0/uo-link-sidecar-linux-x86_64",
|
||||||
|
"sha256": "27d491efda3fc6859dd38da9b2aa3b97b5fdf1dc5fc488a8916bb88b03443ad9"
|
||||||
|
},
|
||||||
|
"windows-x86_64": {
|
||||||
|
"name": "uo-link-sidecar-windows-x86_64.exe",
|
||||||
|
"url": "https://gitea.whitlocktech.com/RunicGateway/link/releases/download/v1.1.0/uo-link-sidecar-windows-x86_64.exe",
|
||||||
|
"sha256": "fbefd886af0355bf128f1f4c65657b772a58b128d32438061adb0069978e0b8f"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overlay": {
|
||||||
|
"repo": "RunicGateway/servuo-plugins",
|
||||||
|
"tag": "v0.1.1",
|
||||||
|
"version": "0.1.1",
|
||||||
|
"commit": "3a52abbd77047e7c94883934533edcfef3ede555",
|
||||||
|
"protocol": 3,
|
||||||
|
"servuo": {
|
||||||
|
"min_version": "57.4",
|
||||||
|
"patches_verified_against": "57.4"
|
||||||
|
},
|
||||||
|
"asset": {
|
||||||
|
"name": "runicgateway-overlay-0.1.1.tar.gz",
|
||||||
|
"url": "https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/releases/download/v0.1.1/runicgateway-overlay-0.1.1.tar.gz",
|
||||||
|
"sha256": "75dc6d6ce08322b753a30303b3b2df6f15cf1e84658507d97430af44ec4d34d7"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
40
bundles/current.json
Normal file
40
bundles/current.json
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"schema": 1,
|
||||||
|
"bundle": "2026.08.04",
|
||||||
|
"generated": "2026-08-04T16:07:13Z",
|
||||||
|
"protocol": 3,
|
||||||
|
"link": {
|
||||||
|
"repo": "RunicGateway/link",
|
||||||
|
"tag": "v1.1.0",
|
||||||
|
"version": "1.1.0",
|
||||||
|
"protocol": 3,
|
||||||
|
"assets": {
|
||||||
|
"linux-x86_64": {
|
||||||
|
"name": "uo-link-sidecar-linux-x86_64",
|
||||||
|
"url": "https://gitea.whitlocktech.com/RunicGateway/link/releases/download/v1.1.0/uo-link-sidecar-linux-x86_64",
|
||||||
|
"sha256": "27d491efda3fc6859dd38da9b2aa3b97b5fdf1dc5fc488a8916bb88b03443ad9"
|
||||||
|
},
|
||||||
|
"windows-x86_64": {
|
||||||
|
"name": "uo-link-sidecar-windows-x86_64.exe",
|
||||||
|
"url": "https://gitea.whitlocktech.com/RunicGateway/link/releases/download/v1.1.0/uo-link-sidecar-windows-x86_64.exe",
|
||||||
|
"sha256": "fbefd886af0355bf128f1f4c65657b772a58b128d32438061adb0069978e0b8f"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overlay": {
|
||||||
|
"repo": "RunicGateway/servuo-plugins",
|
||||||
|
"tag": "v0.1.1",
|
||||||
|
"version": "0.1.1",
|
||||||
|
"commit": "3a52abbd77047e7c94883934533edcfef3ede555",
|
||||||
|
"protocol": 3,
|
||||||
|
"servuo": {
|
||||||
|
"min_version": "57.4",
|
||||||
|
"patches_verified_against": "57.4"
|
||||||
|
},
|
||||||
|
"asset": {
|
||||||
|
"name": "runicgateway-overlay-0.1.1.tar.gz",
|
||||||
|
"url": "https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/releases/download/v0.1.1/runicgateway-overlay-0.1.1.tar.gz",
|
||||||
|
"sha256": "75dc6d6ce08322b753a30303b3b2df6f15cf1e84658507d97430af44ec4d34d7"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user