# Automated build + release for the rust-link sidecar, its launcher and the egg. # # Trigger: every push to `main` (i.e. every merged PR — in practice the # edge→main cutover), and by hand. # # Why this exists: the Runic Gateway installer (`--game rust`) and the # Pterodactyl egg both install the sidecar from a release, never from git. Until # module-rust phase 18 (docs/modules/rust/PLAN.md §34.2.1, D145) this repository # had never released. # # Flow — the same two halves as link's and servuo-plugins' release.yml: # # ┌── RELEASE ENGINE (language-agnostic) ─────────────────────────────┐ # │ reads: latest v* git tag + conventional-commit subjects │ # │ produces: next version, changelog, and (at the end) the release │ # └───────────────────────────────────────────────────────────────────┘ # ┌── RUST ADAPTER (the only repo-specific part) ─────────────────────┐ # │ consumes: the version │ # │ produces: rust-link-sidecar-linux-x86_64 (static, musl) │ # │ rust-link-sidecar-windows-x86_64.exe │ # │ with-sidecar.sh, egg-rust-runicgateway.json │ # │ SHA256SUMS │ # └───────────────────────────────────────────────────────────────────┘ # # The engine is servuo-plugins', the most complete copy: tag-only (no bump # commit, so `main` is never pushed to), secrets preflighted before anything is # tagged, an existing tag checked against the release API rather than trusted, # every tag swept for a missing release, and 5xx retried. # # ── The adapter, and how it differs from link's ───────────────────────────── # # * LINUX IS STATIC (musl). The same binary runs on a host under systemd and # inside the egg's game container, whose image is not ours and whose glibc is # not a contract (docs/rust-link/INSTALL_RIG.md). # * NO linux-aarch64. RustDedicated has no arm64 build, so there is no host to # run it on (D149). # * WINDOWS IS A SERVICE. src/windows.rs speaks the SCM handshake; without it the # installer's service would die with error 1053, as link's once did (§34.2.5). # * THE LAUNCHER AND THE EGG ship here. The launcher is fetched by the egg at # install time, so a fix to it reaches a server at its next reinstall with no # re-import (§34.4). The egg is assembled by egg/build.sh from egg/egg.json and # egg/install.sh. # # The version is never the protocol. PROTOCOL_VERSION lives in # sidecar/src/main.rs and moves only when a message shape does. # # Version bump (conventional commits since the last v* tag): # feat!: / BREAKING CHANGE -> major feat: -> minor fix|perf: -> patch # nothing releasable -> no release is cut # (first ever run, no tag) -> releases SEED_VERSION below (§34.4: the first # release takes what the engine derives) # # Prerequisites (Settings → Actions → Secrets on RunicGateway/Rust-Link): # REGISTRY_TOKEN — Gitea access token with `write:repository`, to push the # tag and create the release. The final step also dispatches # RunicGateway/installer's bundle workflow, so the token # ideally has write there too — without it the step warns # and that repo's nightly cron picks the release up instead. # REGISTRY_USER — the Gitea username that token belongs to. name: Release sidecar on: push: branches: [main] workflow_dispatch: {} concurrency: group: release-sidecar cancel-in-progress: false env: GITEA_HOST: gitea.whitlocktech.com REPO: RunicGateway/Rust-Link # The engine's changelog heading. ARTIFACT: rust-link-sidecar WORKDIR: sidecar BIN: rust-link-sidecar LINUX_TARGET: x86_64-unknown-linux-musl WINDOWS_TARGET: x86_64-pc-windows-gnu SEED_VERSION: "0.1.0" INSTALLER_REPO: RunicGateway/installer jobs: release: runs-on: ubuntu-latest steps: - name: Check out full history (need tags + commit log for the bump) uses: actions/checkout@v4 with: fetch-depth: 0 # ── RELEASE ENGINE: decide the next version + changelog ────────────── - name: Plan the release (version + changelog) id: plan env: REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | set -euo pipefail mkdir -p dist git fetch --tags --force >/dev/null 2>&1 || true LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)" if [ -n "$LAST_TAG" ]; then RANGE="${LAST_TAG}..HEAD"; else RANGE="HEAD"; fi SUBJECTS="$(git log --no-merges --format='%s' $RANGE || true)" BODIES="$(git log --no-merges --format='%B' $RANGE || true)" BUMP=none if echo "$BODIES" | grep -qE 'BREAKING[ -]CHANGE' ; then BUMP=major; fi if echo "$SUBJECTS" | grep -qE '^[a-z]+(\([^)]+\))?!:' ; then BUMP=major; fi if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^feat(\([^)]+\))?:' ; then BUMP=minor; fi if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^(fix|perf)(\([^)]+\))?:'; then BUMP=patch; fi bump() { # -> bumped IFS=. read -r MA MI PA <<< "$1" case "$2" in major) echo "$((MA+1)).0.0" ;; minor) echo "${MA}.$((MI+1)).0" ;; patch) echo "${MA}.${MI}.$((PA+1))" ;; esac } RELEASE=true if [ -z "$LAST_TAG" ]; then VERSION="$SEED_VERSION" # first release: seed elif [ "$BUMP" = none ]; then RELEASE=false # no feat/fix/breaking since last tag VERSION="${LAST_TAG#v}" else VERSION="$(bump "${LAST_TAG#v}" "$BUMP")" fi # An existing tag is NOT automatically "nothing to do". A tag with no # release behind it means a previous run tagged and then died before # publishing — which is exactly what happened on servuo-plugins' first # run, when missing REGISTRY_* secrets took the release API call to 401 # after the tag had already been pushed. Standing down on the tag alone # would make that state permanent: every later run would see the tag, # set RELEASE=false, and the release would never appear. So distinguish # the two cases and finish the job the earlier run started. # Note this OVERRIDES the RELEASE=false decided just above. With the tag # already in place there are no releasable commits after it, so the # normal path stands down — which is precisely why the stuck state # could never clear itself. Recovery has to be able to say "yes, # publish" for a version the bump logic considers already done. REUSE_TAG=false if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then REL_HTTP="$(curl -s -o /dev/null -w '%{http_code}' \ -H "Authorization: token $(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" \ "https://${GITEA_HOST}/api/v1/repos/${REPO}/releases/tags/v${VERSION}" || echo 000)" if [ "$REL_HTTP" = "200" ]; then echo "Tag v${VERSION} already has a release — nothing to do." RELEASE=false elif [ "$REL_HTTP" = "404" ]; then echo "::warning::Tag v${VERSION} exists but has no release — a previous run failed after tagging. Reusing the tag and publishing the release it is missing." REUSE_TAG=true RELEASE=true else # Anything else (000 from a network failure, 401/403 from a bad # token) is not evidence of absence. Guessing "no release" here # would re-publish over a good one, so refuse instead. echo "::error::Could not determine whether a release exists for v${VERSION} (HTTP ${REL_HTTP}). Refusing to guess." exit 1 fi fi # ── Orphan sweep ──────────────────────────────────────────────── # # The check above is VERSION-SCOPED: it only ever asks about the one # version this run computed. That is enough to recover an orphan on # the very next run, and useless afterwards — once any releasable # commit lands, the next run computes a NEW version, never looks at # the old tag again, and the orphan becomes permanent and silent. # # servuo-plugins v0.1.0 is the proof: the commit that ADDED the # recovery above was itself a `fix:`, so it bumped to v0.1.1 and the # run that introduced the recovery stepped straight past the tag it # was written to rescue. # # So every v* tag is checked, and anything missing a release is # WARNED about. Deliberately not recovered: publishing an old version # would mean building today's tree and shipping it under a tag whose # tree it is not, which is worse than the inconsistency it fixes. # A human decides whether to recover or drop it. # # Never fails the run. A sweep that can break a good release is a # sweep someone will delete. ORPHANS="" for T in $(git tag -l 'v*' --sort=-v:refname); do T_HTTP="$(curl -s -o /dev/null -w '%{http_code}' \ -H "Authorization: token $(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" \ "https://${GITEA_HOST}/api/v1/repos/${REPO}/releases/tags/${T}" || echo 000)" [ "$T_HTTP" = "404" ] && ORPHANS="${ORPHANS} ${T}" done if [ -n "${ORPHANS}" ]; then echo "::warning::Tags with no release:${ORPHANS} — a run failed after tagging. Publish or delete them; this job will not do either." fi # Changelog range. A recovery run has nothing after the tag, so # summarize what the tag itself contains rather than emitting an empty # list: the range that produced it, i.e. previous-tag..this-tag. if [ "$REUSE_TAG" = true ]; then PREV_TAG="$(git describe --tags --match 'v*' --abbrev=0 "v${VERSION}^" 2>/dev/null || true)" if [ -n "$PREV_TAG" ]; then CL_RANGE="${PREV_TAG}..v${VERSION}"; else CL_RANGE="v${VERSION}"; fi SINCE="$PREV_TAG" else CL_RANGE="$RANGE" SINCE="$LAST_TAG" fi CL_SUBJECTS="$(git log --no-merges --format='%s' $CL_RANGE || true)" { echo "## ${ARTIFACT} v${VERSION}" echo FEATS="$(echo "$CL_SUBJECTS" | grep -E '^feat' || true)" FIXES="$(echo "$CL_SUBJECTS" | grep -E '^(fix|perf)' || true)" [ -n "$FEATS" ] && { echo "### Features"; echo "$FEATS" | sed 's/^/- /'; echo; } [ -n "$FIXES" ] && { echo "### Fixes"; echo "$FIXES" | sed 's/^/- /'; echo; } echo "### All changes" if [ -n "$SINCE" ]; then echo "Since ${SINCE}:"; fi echo "$CL_SUBJECTS" | sed 's/^/- /' } > dist/CHANGELOG.md echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT" echo "release=${RELEASE}" >> "$GITHUB_OUTPUT" echo "bump=${BUMP}" >> "$GITHUB_OUTPUT" echo "reuse_tag=${REUSE_TAG}" >> "$GITHUB_OUTPUT" echo "==> release=${RELEASE} version=${VERSION} bump=${BUMP} reuse_tag=${REUSE_TAG} last_tag=${LAST_TAG:-}" # ── Credential preflight ───────────────────────────────────────────── # Runs BEFORE anything is built or pushed, and only when this run intends # to publish, so a docs:/chore:-only merge stays green on a repo that has # no secrets. # # This exists because of how servuo-plugins' first run failed. REGISTRY_USER and # REGISTRY_TOKEN were empty, but the tag push SUCCEEDED anyway: # actions/checkout leaves an `http..extraheader` credential in the # local git config, so `git remote set-url` to a URL with empty # credentials still authenticated through that leftover header. The # release API call had no such fallback and returned 401 — so the run # tagged the repo and then failed, which is the worst of both outcomes. # Checking the secrets up front turns that into an immediate, legible # failure instead of a half-published release. - name: Verify release credentials are configured if: ${{ steps.plan.outputs.release == '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 the write:repository scope to push the tag and create the release." exit 1 fi echo "Release credentials present." - name: Install jq if: ${{ steps.plan.outputs.release == 'true' }} run: | set -euo pipefail command -v jq >/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 # ── RUST ADAPTER: toolchain + cross-compile deps ───────────────────── # musl-tools gives the cc crate a musl-gcc for the bundled SQLite that # sqlx's sqlite feature compiles from C; mingw does the same for Windows. # A Rust-only cross build fails at the first .c file. - name: Install Rust toolchain, targets, and their C compilers if: ${{ steps.plan.outputs.release == 'true' }} run: | set -euo pipefail SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo" $SUDO apt-get update $SUDO apt-get install -y --no-install-recommends \ build-essential musl-tools gcc-mingw-w64-x86-64 file \ curl ca-certificates git jq if ! command -v cargo >/dev/null 2>&1; then curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ | sh -s -- -y --profile minimal --default-toolchain stable fi echo "${HOME}/.cargo/bin" >> "$GITHUB_PATH" export PATH="${HOME}/.cargo/bin:${PATH}" rustup component add rustfmt rustup target add "${LINUX_TARGET}" rustup target add "${WINDOWS_TARGET}" - name: Set the crate version to match the release if: ${{ steps.plan.outputs.release == 'true' }} run: | set -euo pipefail VERSION="${{ steps.plan.outputs.version }}" # Replace only the [package] version (the first `version = "..."`), so # `--version` on a released binary names its release. Not committed: # the tag is the version. sed -i -E "0,/^version = \"[^\"]+\"/s//version = \"${VERSION}\"/" "${WORKDIR}/Cargo.toml" grep -m1 '^version' "${WORKDIR}/Cargo.toml" # Re-sync this crate's own entry in Cargo.lock, or every `--locked` # step below fails. Dependency pins are untouched. cargo update --manifest-path "${WORKDIR}/Cargo.toml" --workspace # ── RUST ADAPTER: gates ────────────────────────────────────────────── - name: cargo fmt --check if: ${{ steps.plan.outputs.release == 'true' }} working-directory: sidecar run: cargo fmt --check - name: cargo test if: ${{ steps.plan.outputs.release == 'true' }} working-directory: sidecar run: cargo test --locked # ── RUST ADAPTER: build both targets ───────────────────────────────── - name: cargo build --release (Linux, static musl) if: ${{ steps.plan.outputs.release == 'true' }} working-directory: sidecar run: cargo build --release --locked --target "${LINUX_TARGET}" - name: cargo build --release (Windows, cross via MinGW) if: ${{ steps.plan.outputs.release == 'true' }} working-directory: sidecar env: CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER: x86_64-w64-mingw32-gcc CC_x86_64_pc_windows_gnu: x86_64-w64-mingw32-gcc AR_x86_64_pc_windows_gnu: x86_64-w64-mingw32-ar run: cargo build --release --locked --target "${WINDOWS_TARGET}" # ── RUST ADAPTER: package artifacts (+ checksums) ──────────────────── - name: Package artifacts and SHA256SUMS id: package if: ${{ steps.plan.outputs.release == 'true' }} run: | set -euo pipefail cp "${WORKDIR}/target/${LINUX_TARGET}/release/${BIN}" "dist/${BIN}-linux-x86_64" cp "${WORKDIR}/target/${WINDOWS_TARGET}/release/${BIN}.exe" "dist/${BIN}-windows-x86_64.exe" install -m 755 egg/with-sidecar.sh dist/with-sidecar.sh bash egg/build.sh dist/egg-rust-runicgateway.json # A dynamically linked "static" binary fails only inside the game # container, on somebody else's glibc. Refuse it here. if file "dist/${BIN}-linux-x86_64" | grep -q 'dynamically linked'; then echo "::error::the Linux sidecar is dynamically linked; the egg needs it static (musl)"; exit 1 fi ASSETS="${BIN}-linux-x86_64 ${BIN}-windows-x86_64.exe with-sidecar.sh egg-rust-runicgateway.json" # Every artifact must appear here: the installer, the egg and the # bundle CI verify against these sums, and `sha256sum -c` passes # silently over a file this list does not mention. ( cd dist && sha256sum ${ASSETS} > SHA256SUMS ) echo "assets=${ASSETS} SHA256SUMS" >> "$GITHUB_OUTPUT" ls -l dist && echo "----" && cat dist/SHA256SUMS # ── RELEASE ENGINE: tag ────────────────────────────────────────────── # Tag only — no bump commit, so `main` is never pushed to (see header). - name: Push the release tag if: ${{ steps.plan.outputs.release == 'true' }} env: REGISTRY_USER: ${{ secrets.REGISTRY_USER }} REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | set -euo pipefail TAG="${{ steps.plan.outputs.tag }}" # Secrets can arrive with a trailing newline (depending on how they were # pasted); a stray CR/LF corrupts the remote URL ("credential url cannot # be parsed"). Strip line breaks before building the URL. CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')" CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')" git config user.name "rust-link-ci" git config user.email "ci@whitlocktech.com" git remote set-url origin \ "https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${REPO}.git" # The tag may already exist when we are finishing a run that died after # tagging (see the plan step). `git tag` on an existing name fails under # `set -e`, and pushing an identical existing tag is a harmless no-op — # so create it only if it is new, then push either way. A push that # fails here means the remote tag points somewhere else, which SHOULD # stop the run. if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then echo "Tag ${TAG} already exists — reusing it." else git tag "${TAG}" fi git push origin "${TAG}" # ── RELEASE ENGINE: create the Gitea release + upload assets ───────── - name: Create Gitea release and upload assets if: ${{ steps.plan.outputs.release == 'true' }} env: REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | set -euo pipefail TAG="${{ steps.plan.outputs.tag }}" API="https://${GITEA_HOST}/api/v1/repos/${REPO}" BODY="$(cat dist/CHANGELOG.md)" # Same newline hygiene as the tag step: a stray CR/LF in the token would # corrupt the Authorization header. CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')" PAYLOAD="$(jq -n --arg tag "$TAG" --arg body "$BODY" \ '{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')" # installer#22's release run failed exactly here: it landed one second # after the tag push and Gitea answered 500, having not finished # processing the pushed tag. Re-running published the same artifacts # untouched, so it was a race, not a bad request — but the tag sat # orphaned until a human noticed. # # Two things made that worse than it needed to be. # # 1. `curl -sSf` prints NO response body on an error status, so all the # log carried was "curl: (22) ... error: 500" and the cause had to be # inferred from timestamps. Capture the body and print it. # 2. Nothing retried, so a transient 5xx became a permanent orphan. # # 4xx is deliberately NOT retried: a bad token or a malformed body does # not improve by being sent again, and retrying only turns a clear # failure into a slow one. REL_ID="" for attempt in 1 2 3 4 5; do HTTP="$(curl -s -o /tmp/rel.json -w '%{http_code}' -X POST "${API}/releases" \ -H "Authorization: token ${CI_TOKEN}" \ -H "Content-Type: application/json" \ -d "${PAYLOAD}" || echo 000)" if [ "$HTTP" = "201" ] || [ "$HTTP" = "200" ]; then REL_ID="$(jq -r '.id' /tmp/rel.json)" break fi echo "::warning::POST /releases attempt ${attempt} returned HTTP ${HTTP}" echo "--- response body ---" cat /tmp/rel.json || true echo echo "---------------------" case "$HTTP" in 4*) echo "::error::HTTP ${HTTP} is a client error - not retrying."; exit 1 ;; esac if [ "$attempt" = 5 ]; then echo "::error::POST /releases still failing after 5 attempts. Tag ${TAG} is pushed but has no release." echo "::error::Re-run this workflow - the plan step detects the orphan tag and republishes it." exit 1 fi sleep $(( attempt * 5 )) done if [ -z "$REL_ID" ] || [ "$REL_ID" = "null" ]; then echo "::error::Release created but no id came back; refusing to upload assets blind." exit 1 fi echo "Created release ${TAG} (id=${REL_ID})" for f in ${{ steps.package.outputs.assets }}; do # Same treatment. An upload that fails quietly leaves a release whose # SHA256SUMS does not cover every artifact it advertises, which is # worse than no release at all -- that file is the trust anchor. HTTP="$(curl -s -o /tmp/asset.json -w '%{http_code}' -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \ -H "Authorization: token ${CI_TOKEN}" \ -F "attachment=@dist/${f}" || echo 000)" if [ "$HTTP" != "201" ] && [ "$HTTP" != "200" ]; then echo "::error::uploading ${f} returned HTTP ${HTTP}" cat /tmp/asset.json || true exit 1 fi echo " uploaded ${f}" done # ── Recompose the installer's bundle manifest ──────────────────────── # Neither the installer nor the egg resolves "latest" at run time — both # install the exact sidecar named by a published bundle # (docs/modules/rust/PLAN.md §34.2.2). A sidecar release that nobody # recomposes around is therefore a release no operator will ever be # offered. This tells the installer repo to # rebuild that manifest now rather than leaving the new version invisible # until its nightly cron. # # That job reads PROTOCOL_VERSION from sidecar/src/main.rs at this tag # and checks it against the released plugin's declared protocol before # publishing anything (gate 1). # # DISPATCH, DON'T WAIT (PLAN.md §7.3). Gitea's workflow-dispatch endpoint # returns no run handle, so there is nothing to poll: a waiting step would # have to guess which run is its own and hold a runner idle to do it. # # A failure here is a WARNING, never a failure of this job. The release is # already published and correct by this point, and failing the run would # misreport that. The installer's nightly cron recomposes from whatever the # latest releases actually are, so a dropped dispatch costs latency, not # correctness. - name: Ask the installer repo to recompose its bundle if: ${{ steps.plan.outputs.release == 'true' }} env: REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | set -euo pipefail CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')" 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/${INSTALLER_REPO}/actions/workflows/bundle.yml/dispatches" || echo 000)" case "$HTTP" in 20*) echo "Dispatched ${INSTALLER_REPO} bundle.yml (HTTP ${HTTP}) — not waiting for it." ;; 403|404) echo "::warning::Could not dispatch ${INSTALLER_REPO} bundle.yml (HTTP ${HTTP}). REGISTRY_TOKEN likely lacks write:repository on that repo. Release ${{ steps.plan.outputs.tag }} is published and fine; its bundle will be composed by the installer's nightly cron instead." ;; *) echo "::warning::Dispatching ${INSTALLER_REPO} bundle.yml returned HTTP ${HTTP}. Release ${{ steps.plan.outputs.tag }} is published and fine; the nightly cron will recompose the bundle." ;; esac