All checks were successful
PR Checks / rust-gates (pull_request) Successful in 3m53s
Module-rust phase 18, step 4 of docs/modules/rust/PLAN.md §34.2.7.
The Windows service (D149, §34.2.5): src/windows.rs, ported from link's fix
for error 1053. The same exe tries the SCM handshake and falls through to a
console run on 1063; it reports Running only once the listener and store are
up, and logs to a daily file beside its config. One binary serves every
RunicGatewayRust-<id> instance, because the SCM ignores the dispatcher's name
for an own-process service.
An empty environment variable now counts as unset. A Pterodactyl egg exports
every variable it declares, so a blank RUSTLINK_WEB_TOKEN arrived as "" and
overrode the saved token, and a new one was generated and persisted on every
boot. That breaks D152, which this change makes true.
The egg (R20, R22, D151, D152, §34.2.6), in egg/:
- install.sh is egg 18's script with two changes. A wipe guard moves
rust-link/ to /tmp around `rm -rf ${REMOVE_FILES}`. The bridge block then
fetches a schema-2 Rust bundle (pinnable by RUNICGATEWAY_BUNDLE), checks
every asset's sha256 and the plugin's protocol before placing anything, and
places the plugin by FRAMEWORK. Vanilla installs nothing and does not fail.
- with-sidecar.sh is the launcher. It unsets blank variables, builds the web
bind from RUSTLINK_WEB_PORT, and runs --print-config so that a newly
generated token is printed once. It prints the URL and server id for the
admin page, then execs the game. It no longer uses `set -e`: nothing the
bridge gets wrong may keep the game from booting.
- The startup's launcher prefix is conditional, so a server with no bridge
boots exactly as egg 18 does.
- build.sh assembles egg-rust-runicgateway.json. PR Checks runs it.
The release (D145, §34.2.1) reuses servuo-plugins' engine. It publishes the
static musl Linux binary, the Windows exe, the launcher, the egg and
SHA256SUMS, and dispatches the installer's bundle.yml. PR Checks gains a
clippy run for the Windows target.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
518 lines
27 KiB
YAML
518 lines
27 KiB
YAML
# 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() { # <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="$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:-<none>}"
|
|
|
|
# ── 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.<host>.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
|