From c100d4a2d70b6906707b684a9456778926bbde95 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 4 Aug 2026 11:10:51 -0500 Subject: [PATCH] ci(bundle): compose and publish the bundle manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0 item 3 of docs/installer/PLAN.md (§7.1-§7.3). The installer resolves what to install *from* the bundle, so this has to exist before Phase 1 code is useful. Both components it composes now have releases, which is what unblocked it. Adds .gitea/workflows/bundle.yml — resolve both components' latest releases, run the two compose-time gates, and publish bundles/current.json — plus the first real bundle (2026.08.04: link v1.1.0 + overlay v0.1.1, protocol 3). Bundles are COMMITTED under bundles/, not published as releases. This repo's own releases are the installer binaries, and /releases/latest returns whichever release is newest regardless of kind, so interleaving bundle releases would make "latest" intermittently resolve to a release carrying no installer binary. The push to main needs no new branch-protection exception: release.yml's version-bump commit already requires it. Gate 1 (protocol agreement) reads the sidecar's PROTOCOL_VERSION from sidecar/src/main.rs at the release tag, not from the binary. --print-config would answer, but only for releases from v1.1.0 on, and --bundle has to be able to recompose an older bundle. It also avoids executing a downloaded artifact and provisioning a throwaway config whose auth token would land in a CI log. The overlay half comes from manifest.json inside the tarball, which is the only statement of that version that exists. Gate 2 (assets) downloads every asset and verifies it against the SHA256SUMS its publishing repo shipped, then records the hash it computed itself. These artifacts are deliberately unsigned, so a hash copied from a file nobody checked would make the whole chain decorative. An asset with no SHA256SUMS entry is caught separately, since `sha256sum -c` passes right over it. Release reads are ANONYMOUS on purpose: they are exactly the requests the shipped installer makes on a host with no Gitea credentials, so a repo flipped to private fails here rather than on an operator's machine. Stale components (§7.3) are dispatched, never awaited — Gitea's dispatch endpoint returns no run handle. "Ahead of its release" counts only releasable commits and excludes merge commits, whose subject quotes the feat/fix title and would otherwise re-dispatch a workflow that correctly declines to run, every night. A run that finds nothing changed writes nothing, comparing everything except `bundle` and `generated` — that is what makes the nightly cron free rather than a dated duplicate every morning. Verified by running the workflow's exact compose steps in a Linux container against the live releases: both gates pass, the composed bundle is the file committed here, a re-run reports changed=false, and the stale-detection filter scores 1 releasable commit for link v1.0.0..main (excluding the merge that quotes it) and 0 for a docs-only range. Co-Authored-By: Claude --- .gitea/workflows/bundle.yml | 518 +++++++++++++++++++++++++++++++++ README.md | 9 +- bundles/README.md | 92 ++++++ bundles/bundle-2026.08.04.json | 40 +++ bundles/current.json | 40 +++ 5 files changed, 698 insertions(+), 1 deletion(-) create mode 100644 .gitea/workflows/bundle.yml create mode 100644 bundles/README.md create mode 100644 bundles/bundle-2026.08.04.json create mode 100644 bundles/current.json diff --git a/.gitea/workflows/bundle.yml b/.gitea/workflows/bundle.yml new file mode 100644 index 0000000..ddfa4c4 --- /dev/null +++ b/.gitea/workflows/bundle.yml @@ -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-.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 ` 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 ''", 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..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" diff --git a/README.md b/README.md index 0693aa8..b7a7aa3 100644 --- a/README.md +++ b/README.md @@ -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 `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 diff --git a/bundles/README.md b/bundles/README.md new file mode 100644 index 0000000..315c949 --- /dev/null +++ b/bundles/README.md @@ -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-.json` | Every bundle ever published, kept forever so `--bundle ` 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. diff --git a/bundles/bundle-2026.08.04.json b/bundles/bundle-2026.08.04.json new file mode 100644 index 0000000..7d99bb5 --- /dev/null +++ b/bundles/bundle-2026.08.04.json @@ -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" + } + } +} diff --git a/bundles/current.json b/bundles/current.json new file mode 100644 index 0000000..7d99bb5 --- /dev/null +++ b/bundles/current.json @@ -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" + } + } +} -- 2.49.1