# Automated release for the deployable ServUO overlay. # # Trigger: every push to `main` (i.e. every merged PR). # # Why this exists: the Runic Gateway installer deploys the plugin from a release # tarball, not from git — the shard host gets no git and no Gitea credentials # (docs/installer/PLAN.md §1, §5 Phase 0.1). Until this workflow, `link` was the # only repo that published releases, so there was nothing for the installer to # fetch. This is Phase 0 item 1. # # Flow (two conceptual halves, kept separate on purpose): # # ┌── RELEASE ENGINE (language-agnostic) ─────────────────────────────┐ # │ reads: latest v* git tag + conventional-commit subjects │ # │ produces: next version, changelog, and (at the end) the release │ # └───────────────────────────────────────────────────────────────────┘ # ┌── OVERLAY ADAPTER (the only repo-specific part) ──────────────────┐ # │ consumes: the version │ # │ produces: runicgateway-overlay-.tar.gz + SHA256SUMS │ # └───────────────────────────────────────────────────────────────────┘ # # The engine is `link/.gitea/workflows/release.yml`'s, reused as its own header # anticipated — the plan and release steps consume only {version, changelog, # artifacts} and know nothing about what is inside the artifacts. # # ── Three differences from link's copy, all forced by this repo ────────────── # # 1. NO BUILD. The plugin ships as C# source and ServUO compiles it at boot; it # needs ServUO reference assemblies, so there is no way to compile it here. # The build gates are replaced by the structural gates below, which is the # most this repo can honestly assert about an artifact. # # 2. NO BUMP COMMIT, and so no push to `main`. link has to write the version # into Cargo.toml because the binary embeds it; a tarball embeds nothing but # the manifest.json this job generates, so the git tag IS the version. That # removes a failure mode outright: this workflow never needs `main` to accept # a direct push, so no branch-protection exception is required for it. # # 3. A MANIFEST. The tarball carries manifest.json — version, commit, declared # protocol version, ServUO compatibility, and a SHA256 for every file. The # installer needs it because the plugin announces no version on the wire and # none is queryable before ServUO boots (PLAN.md §2.6): the manifest is the # only thing that lets the bundle CI verify sidecar/overlay protocol # agreement BEFORE an operator installs the pair (PLAN.md §7.1, gate 1). # # Version bump (conventional commits since the last v* tag): # feat!: / BREAKING CHANGE -> major feat: -> minor fix|perf: -> patch # nothing releasable -> no release is cut (a docs:/chore:-only merge # deliberately does NOT cut one — PLAN.md §7.3) # (first ever run, no tag) -> releases SEED_VERSION below # # Prerequisites (Settings → Actions → Secrets on RunicGateway/servuo-plugins): # 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 — a nicety, not a requirement: # 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. # # These are checked by an explicit preflight step rather than left to fail # wherever they happen to be used first — see the comment on that step for why # an absent token does NOT simply fail the tag push. # # The final step POSTs to the installer repo's bundle workflow, so a new overlay # release recomposes the compat matrix immediately instead of waiting for that # repo's nightly cron (PLAN.md §7.2). It was deliberately absent until Phase 0 # item 3 landed something to dispatch — a step that 404s on every release is # worse than no step. name: Release overlay on: push: branches: [main] workflow_dispatch: {} concurrency: group: release-overlay cancel-in-progress: false env: GITEA_HOST: gitea.whitlocktech.com REPO: RunicGateway/servuo-plugins # Artifact naming per PLAN.md §3. ARTIFACT: runicgateway-overlay # Used only for the very first release, when no v* tag exists yet. Matches the # house style set by link (pre-1.0; the release version is independent of the # protocol version, which lives in overlay.toml). SEED_VERSION: "0.1.0" # Notified after a release so the installer's compat matrix picks up this # overlay immediately rather than at its next nightly run (PLAN.md §7.2). 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 the first run here, # when the 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 # 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 the 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 # ── OVERLAY ADAPTER: gates ─────────────────────────────────────────── # There is no compiler to run, so these assert the things that CAN be # checked without a ServUO tree — and each one has actually been a way to # ship a broken overlay: # # • overlay/ mirrors the server root; if Bridge.cfg or the Bridge scripts # go missing the deploy silently no-ops (PLAN.md §2.1). # • overlay/Scripts/Scripts.csproj is Phase 0 of the plugin itself — it # overwrites a stock file to fix ServUO's silent script-build bug. An # overlay shipped without it installs code that never compiles, and # ServUO reports success anyway. # • a malformed .patch is invisible until an operator runs the patch tier # on their live shard. `git apply --stat` parses the diff without # needing the target files present. # • each patch's companion .cs must exist, since it references symbols # the patch introduces and is meaningless without it (PLAN.md §2.2). - name: Validate the overlay and patch tier if: ${{ steps.plan.outputs.release == 'true' }} run: | set -euo pipefail fail() { echo "::error::$*"; exit 1; } [ -f overlay/Config/Bridge.cfg ] || fail "overlay/Config/Bridge.cfg is missing" [ -f overlay/Scripts/Scripts.csproj ] || fail "overlay/Scripts/Scripts.csproj is missing (the silent-build-bug fix)" [ -d overlay/Scripts/Custom/Bridge ] || fail "overlay/Scripts/Custom/Bridge/ is missing" CS_COUNT="$(find overlay/Scripts/Custom/Bridge -name '*.cs' | wc -l)" [ "$CS_COUNT" -gt 0 ] || fail "overlay/Scripts/Custom/Bridge/ contains no .cs files" echo "overlay: ${CS_COUNT} bridge script(s)" for p in patches/*.patch; do [ -e "$p" ] || fail "patches/ contains no .patch files" echo "--- ${p}" git apply --stat "$p" || fail "${p} is not a parseable unified diff" done # Companion files that can only be copied after their patch lands. for f in patches/BridgeVendorSale.cs patches/BridgeModerationAudit.cs; do [ -f "$f" ] || fail "${f} is missing (a patch's companion source)" done [ -f overlay.toml ] || fail "overlay.toml is missing (protocol + ServUO declarations)" # ── OVERLAY ADAPTER: stage, manifest, package ──────────────────────── # The tarball has a FIXED top-level directory (runicgateway-overlay/), not a # versioned one: the installer extracts and then looks for overlay/, # patches/ and manifest.json at known paths, and a version-dependent prefix # would make it parse the very version it is trying to read. # # tar flags pin ownership, mtime and member order so the same tree produces # a byte-identical tarball — a checksum that changes only when content # changes is worth more than one that changes every run. - name: Build manifest.json and the release tarball if: ${{ steps.plan.outputs.release == 'true' }} run: | set -euo pipefail VERSION="${{ steps.plan.outputs.version }}" STAGE="dist/stage/${ARTIFACT}" mkdir -p "${STAGE}" cp -r overlay "${STAGE}/overlay" cp -r patches "${STAGE}/patches" # Declarations from overlay.toml. Read, don't hardcode — the point of # that file is that the protocol number lives in one place. PROTOCOL="$(grep -m1 -E '^protocol[[:space:]]*=' overlay.toml | sed -E 's/[^0-9]//g')" MIN_SERVUO="$(grep -m1 -E '^min_servuo_version[[:space:]]*=' overlay.toml | sed -E 's/.*"([^"]+)".*/\1/')" PATCHED_AGAINST="$(grep -m1 -E '^patches_verified_against[[:space:]]*=' overlay.toml | sed -E 's/.*"([^"]+)".*/\1/')" [ -n "$PROTOCOL" ] || { echo "::error::could not read protocol from overlay.toml"; exit 1; } [ -n "$MIN_SERVUO" ] || { echo "::error::could not read min_servuo_version from overlay.toml"; exit 1; } [ -n "$PATCHED_AGAINST" ] || { echo "::error::could not read patches_verified_against from overlay.toml"; exit 1; } echo "==> protocol=${PROTOCOL} min_servuo=${MIN_SERVUO} patches_verified_against=${PATCHED_AGAINST}" # Per-file SHA256 of everything shipped, as a {path: sha} object. The # installer records these in install.json so a later `doctor` can tell # "operator edited a deployed file" from "the overlay drifted". # The `\*?` is not paranoia: sha256sum marks binary mode by prefixing the # path with `*` (` *path`) instead of the two-space text-mode # separator. Coreutils on Linux defaults to text mode, but a build host # that doesn't would otherwise put a leading `*` on EVERY key here and # silently produce a manifest whose paths match nothing. FILES="$(cd "${STAGE}" \ && find overlay patches -type f -print0 | LC_ALL=C sort -z | xargs -0 sha256sum \ | jq -R -s ' split("\n") | map(select(length > 0)) | map(capture("^(?[0-9a-f]+)[ \t]+\\*?(?.+)$")) | map({ (.path): .sha }) | add')" jq -n \ --arg component "servuo-plugins-overlay" \ --arg version "${VERSION}" \ --arg commit "${GITHUB_SHA}" \ --arg repo "${REPO}" \ --argjson protocol "${PROTOCOL}" \ --arg min_servuo "${MIN_SERVUO}" \ --arg patched_against "${PATCHED_AGAINST}" \ --argjson files "${FILES}" \ '{ component: $component, version: $version, commit: $commit, repo: $repo, protocol: $protocol, servuo: { min_version: $min_servuo, patches_verified_against: $patched_against }, files: $files }' > "${STAGE}/manifest.json" echo "----- manifest.json (files elided) -----" jq 'del(.files) + {file_count: (.files | length)}' "${STAGE}/manifest.json" TARBALL="${ARTIFACT}-${VERSION}.tar.gz" tar --sort=name --mtime='UTC 1970-01-01' \ --owner=0 --group=0 --numeric-owner \ -czf "dist/${TARBALL}" -C dist/stage "${ARTIFACT}" ( cd dist && sha256sum "${TARBALL}" > SHA256SUMS ) echo "tarball=${TARBALL}" >> "$GITHUB_OUTPUT" ls -l dist && echo "----" && cat dist/SHA256SUMS id: package # ── 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 "servuo-plugins-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 }}" TARBALL="${{ steps.package.outputs.tarball }}" 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')" REL_ID="$(curl -sSf -X POST "${API}/releases" \ -H "Authorization: token ${CI_TOKEN}" \ -H "Content-Type: application/json" \ -d "$(jq -n --arg tag "$TAG" --arg body "$BODY" \ '{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')" \ | jq -r '.id')" echo "Created release ${TAG} (id=${REL_ID})" for f in "${TARBALL}" SHA256SUMS; do curl -sSf -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \ -H "Authorization: token ${CI_TOKEN}" \ -F "attachment=@dist/${f}" >/dev/null echo " uploaded ${f}" done # ── Recompose the installer's bundle manifest ──────────────────────── # The installer does not resolve "latest" at run time — it deploys the # exact overlay named by a published bundle (docs/installer/PLAN.md §7.1). # An overlay 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 re-reads this tarball's manifest.json and checks its declared # `protocol` against the sidecar's PROTOCOL_VERSION before publishing # anything (PLAN.md §7.1, gate 1) — which is the check this repo cannot # perform for itself, since the C# plugin announces no version on the wire. # # 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