All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m17s
Step 2 of installer PLAN.md §5.2. The shard dials the sidecar out on loopback, so wherever ServUO runs this binary has to run too -- and Ampere/Graviton instances and Pi-class boxes are a realistic ServUO home. Until now the release cross-compiled x86_64 Linux and Windows only, so the installer refused every arm64 host by name. Cross-compiling this crate is not Rust-only: sqlx's sqlite feature pulls libsqlite3-sys, which compiles bundled SQLite from C. The build therefore needs a CC/AR pair as well as a linker, the same shape the Windows step already has. libc6-dev-arm64-cross is named explicitly because gcc-aarch64-linux-gnu only recommends it and this install passes --no-install-recommends: with the compiler alone, SQLite's build dies on /usr/include/stdio.h:27: fatal error: bits/libc-header-start.h Both the failure and the fix were reproduced in a rust:1-slim-bookworm container against this crate before the workflow was written. The new binary is added to SHA256SUMS and to the upload list. Every artifact has to appear in both: the installer verifies its download against those sums, and `sha256sum -c` passes silently over a file the sums list does not mention. Merge order matters -- installer#9 teaches the bundle CI this asset name, and must land first. An unrecognized link asset is a hard failure there, by design, so a release published before it would redden the compose job. Co-Authored-By: Claude <noreply@anthropic.com>
342 lines
18 KiB
YAML
342 lines
18 KiB
YAML
# Automated build + release for the uo-link Rust sidecar.
|
|
#
|
|
# Trigger: every push to `main` (i.e. every merged PR).
|
|
#
|
|
# 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 │
|
|
# └───────────────────────────────────────────────────────────────────┘
|
|
# ┌── RUST ADAPTER (the only Rust-specific part) ─────────────────────┐
|
|
# │ consumes: the version │
|
|
# │ produces: the artifacts (linux bin, windows exe, SHA256SUMS) │
|
|
# └───────────────────────────────────────────────────────────────────┘
|
|
#
|
|
# To retarget this engine at a C#/Node/Docker/static project later, only the
|
|
# "Rust adapter" steps change — the plan + release steps consume just
|
|
# {version, changelog, artifacts} and know nothing about Rust.
|
|
#
|
|
# 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 the current Cargo.toml version as-is
|
|
#
|
|
# Prerequisites (Settings → Actions → Secrets on RunicGateway/link):
|
|
# REGISTRY_USER — Gitea username the token below belongs to
|
|
# REGISTRY_TOKEN — Gitea access token. For image builds it needed
|
|
# write:package; THIS workflow additionally needs
|
|
# `write:repository` so it can push the bump commit + tag
|
|
# and create the release. Grant that scope to the token.
|
|
# The final step also dispatches RunicGateway/installer's
|
|
# bundle workflow, so the token ideally has write there too
|
|
# — but that is a nicety, not a requirement: without it the
|
|
# step warns and the installer's nightly cron picks the
|
|
# release up instead.
|
|
# Also: `main` must accept a direct push from that user (disable branch
|
|
# protection for it, or add it as an exception) — the bump commit lands on main.
|
|
#
|
|
# The bump commit carries `[skip ci]`, so it does not re-trigger this workflow.
|
|
|
|
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/link
|
|
WORKDIR: sidecar
|
|
BIN: uo-link-sidecar
|
|
LINUX_TARGET: x86_64-unknown-linux-gnu
|
|
WINDOWS_TARGET: x86_64-pc-windows-gnu
|
|
# Ampere/Graviton instances and Pi-class boxes are a realistic ServUO home,
|
|
# and the shard dials the sidecar out on loopback — so wherever the shard
|
|
# runs, this binary has to run too (installer PLAN.md §5.2).
|
|
ARM64_TARGET: aarch64-unknown-linux-gnu
|
|
# Notified after a release so the installer's compat matrix picks up this
|
|
# version immediately rather than at its next nightly run (PLAN.md §7.2).
|
|
INSTALLER_REPO: RunicGateway/installer
|
|
|
|
jobs:
|
|
release:
|
|
runs-on: ubuntu-latest
|
|
# Don't loop on our own bump commit (belt-and-suspenders with [skip ci]).
|
|
# Quoted because the expression contains a colon (`chore(release):`), which an
|
|
# unquoted YAML scalar would misparse as a mapping value.
|
|
if: "${{ !contains(github.event.head_commit.message, 'chore(release): bump version') }}"
|
|
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
|
|
run: |
|
|
set -euo pipefail
|
|
mkdir -p dist
|
|
git fetch --tags --force >/dev/null 2>&1 || true
|
|
|
|
CARGO_VERSION="$(grep -m1 '^version' "${WORKDIR}/Cargo.toml" | sed -E 's/.*"([^"]+)".*/\1/')"
|
|
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() { # <x.y.z> <major|minor|patch> -> 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="$CARGO_VERSION" # first release: ship what's in Cargo.toml
|
|
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
|
|
|
|
if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
|
|
echo "Tag v${VERSION} already exists — nothing to release."
|
|
RELEASE=false
|
|
fi
|
|
|
|
{
|
|
echo "## ${BIN} v${VERSION}"
|
|
echo
|
|
FEATS="$(echo "$SUBJECTS" | grep -E '^feat' || true)"
|
|
FIXES="$(echo "$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 "$LAST_TAG" ]; then echo "Since ${LAST_TAG}:"; fi
|
|
echo "$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 "==> release=${RELEASE} version=${VERSION} bump=${BUMP} last_tag=${LAST_TAG:-<none>}"
|
|
|
|
# ── RUST ADAPTER: toolchain + cross-compile deps ─────────────────────
|
|
- name: Install Rust toolchain, cross targets, and their linkers
|
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
|
run: |
|
|
set -euo pipefail
|
|
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo"
|
|
$SUDO apt-get update
|
|
# The arm64 cross toolchain is not optional for this crate: sqlx's
|
|
# sqlite feature pulls libsqlite3-sys, which compiles bundled SQLite
|
|
# from C, so a Rust-only cross build fails at the first .c file.
|
|
#
|
|
# libc6-dev-arm64-cross is named explicitly because gcc-aarch64-linux-gnu
|
|
# only *recommends* it, and this install is --no-install-recommends: the
|
|
# compiler arrives without arm64 libc headers and SQLite's build dies on
|
|
# `bits/libc-header-start.h: No such file or directory`. Verified by
|
|
# reproducing both the failure and the fix in a rust:1-slim-bookworm
|
|
# container.
|
|
$SUDO apt-get install -y --no-install-recommends \
|
|
build-essential gcc-mingw-w64-x86-64 \
|
|
gcc-aarch64-linux-gnu libc6-dev-arm64-cross \
|
|
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 "${WINDOWS_TARGET}"
|
|
rustup target add "${ARM64_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 = "..."`).
|
|
sed -i -E "0,/^version = \"[^\"]+\"/s//version = \"${VERSION}\"/" "${WORKDIR}/Cargo.toml"
|
|
grep -m1 '^version' "${WORKDIR}/Cargo.toml"
|
|
# Bumping the manifest version desyncs this crate's own entry in
|
|
# Cargo.lock, which would make the `--locked` fmt/test/build steps below
|
|
# fail ("cannot update the lock file ... --locked was passed"). Sync just
|
|
# the workspace member(s) into the lock — 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)
|
|
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}"
|
|
|
|
# Same shape as the Windows step: a linker for Rust's output and a CC/AR
|
|
# pair for the cc-crate build of bundled SQLite.
|
|
- name: cargo build --release (Linux arm64, cross via aarch64-linux-gnu)
|
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
|
working-directory: sidecar
|
|
env:
|
|
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
|
|
CC_aarch64_unknown_linux_gnu: aarch64-linux-gnu-gcc
|
|
AR_aarch64_unknown_linux_gnu: aarch64-linux-gnu-ar
|
|
run: cargo build --release --locked --target "${ARM64_TARGET}"
|
|
|
|
# ── RUST ADAPTER: package artifacts (+ checksums) ────────────────────
|
|
- name: Package artifacts and SHA256SUMS
|
|
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/${ARM64_TARGET}/release/${BIN}" "dist/${BIN}-linux-aarch64"
|
|
cp "${WORKDIR}/target/${WINDOWS_TARGET}/release/${BIN}.exe" "dist/${BIN}-windows-x86_64.exe"
|
|
# Every artifact must appear here: the installer verifies its download
|
|
# against these sums, and `sha256sum -c` passes silently over a file
|
|
# this list does not mention.
|
|
( cd dist && sha256sum "${BIN}-linux-x86_64" "${BIN}-linux-aarch64" \
|
|
"${BIN}-windows-x86_64.exe" > SHA256SUMS )
|
|
ls -l dist && echo "----" && cat dist/SHA256SUMS
|
|
|
|
# ── RELEASE ENGINE: commit the bump, tag, push ───────────────────────
|
|
- name: Commit version bump and push tag
|
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
|
env:
|
|
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
|
run: |
|
|
set -euo pipefail
|
|
VERSION="${{ steps.plan.outputs.version }}"
|
|
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. Passing them via
|
|
# env (not inline ${{ }}) also keeps a newline from breaking this script.
|
|
CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')"
|
|
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
|
|
git config user.name "uo-link-ci"
|
|
git config user.email "ci@whitlocktech.com"
|
|
git remote set-url origin \
|
|
"https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${REPO}.git"
|
|
|
|
git add "${WORKDIR}/Cargo.toml" "${WORKDIR}/Cargo.lock"
|
|
if ! git diff --cached --quiet; then
|
|
git commit -m "chore(release): bump version to ${TAG} [skip ci]"
|
|
git push origin "HEAD:main"
|
|
else
|
|
echo "Version unchanged (first release) — no bump commit needed."
|
|
fi
|
|
git tag "${TAG}"
|
|
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 push 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 "${BIN}-linux-x86_64" "${BIN}-linux-aarch64" "${BIN}-windows-x86_64.exe" 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 installs the
|
|
# exact combination named by a published bundle (docs/installer/PLAN.md
|
|
# §7.1). So a sidecar release that nobody recomposes around is a release
|
|
# no operator will ever be offered. This step tells the installer repo to
|
|
# rebuild that manifest now, instead of leaving the new version invisible
|
|
# until its nightly cron.
|
|
#
|
|
# 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. The
|
|
# bundle job runs its own gates regardless of who started it.
|
|
#
|
|
# A failure here is a WARNING, never a failure of this job. The release is
|
|
# already published and correct by this point; failing the run would
|
|
# misreport that. The installer's nightly cron recomposes from whatever
|
|
# the latest releases actually are, so a dropped dispatch self-heals — it
|
|
# costs latency, not correctness.
|
|
#
|
|
# `repository_dispatch` is deliberately not used: support for it is
|
|
# uncertain on this Gitea version, while dispatching an existing
|
|
# workflow_dispatch workflow via the API works today.
|
|
- 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
|