Files
installer/.gitea/workflows/bundle.yml
wtclaude c100d4a2d7
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 5s
ci(bundle): compose and publish the bundle manifest
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 <tag> 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 <noreply@anthropic.com>
2026-08-04 11:10:51 -05:00

519 lines
28 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters

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

# Compose and publish the bundle 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"