# Automated build + release for the Runic Gateway installer. # # 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) │ # └───────────────────────────────────────────────────────────────────┘ # # This is RunicGateway/link's release.yml with the adapter retargeted at this # repo's crate — exactly the reuse its header anticipated. Differences from link: # # • The crate lives at the REPO ROOT, not in a subdirectory. # • The crate guard (below) — this repo has no Cargo project yet. # • Artifact names follow docs/installer/PLAN.md §3. # # ── Crate guard ────────────────────────────────────────────────────────────── # The repo is in the planning phase. With no Cargo.toml there is nothing to # build, so the plan step forces RELEASE=false and the job exits green having # done nothing. It starts cutting real releases the moment Phase 1 lands the # crate — no edit required here. # # ── Unsigned releases ──────────────────────────────────────────────────────── # Per PLAN.md §3, installer binaries are deliberately UNSIGNED: SHA256SUMS is # the trust anchor. That makes the checksum step below load-bearing rather than # a nicety — do not drop it, and keep SHA256SUMS attached to every release. # # 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/installer): # REGISTRY_USER — Gitea username the token below belongs to # REGISTRY_TOKEN — Gitea access token with `write:repository`, so it can push # the bump commit + tag and create the release. # 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 installer on: push: branches: [main] workflow_dispatch: {} concurrency: group: release-installer cancel-in-progress: false env: GITEA_HOST: gitea.whitlocktech.com REPO: RunicGateway/installer BIN: runicgateway-installer LINUX_TARGET: x86_64-unknown-linux-gnu WINDOWS_TARGET: x86_64-pc-windows-gnu 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 env: REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | set -euo pipefail mkdir -p dist git fetch --tags --force >/dev/null 2>&1 || true # Planning-phase guard: nothing to build without a crate. if [ ! -f Cargo.toml ]; then echo "No Cargo.toml at the repo root yet (planning phase) — nothing to release." echo "This job arms itself when Phase 1 lands the crate. See docs/installer/PLAN.md." echo "release=false" >> "$GITHUB_OUTPUT" exit 0 fi CARGO_VERSION="$(grep -m1 '^version' 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() { # -> 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 # 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 # release, where absent REGISTRY_* secrets took the release API call to # 401 after the tag had already been pushed. Standing down on the tag # alone makes that state permanent: every later run sees the tag, sets # RELEASE=false, and the release never appears. Note this deliberately # OVERRIDES the RELEASE=false decided just above — with the tag in # place there are no releasable commits after it, so the normal path # would stand down, which is exactly why it could never self-heal. 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" 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 "## ${BIN} 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/^/- /' echo echo "### Verifying this download" echo echo "Releases are **unsigned** — \`SHA256SUMS\` is the trust anchor. Verify before running:" echo echo '```bash' echo "sha256sum -c SHA256SUMS --ignore-missing # Linux" echo '```' echo echo '```powershell' echo "Get-FileHash .\\${BIN}-windows-x86_64.exe -Algorithm SHA256 # Windows, compare to SHA256SUMS" echo '```' echo echo "Windows will show a SmartScreen \"unrecognized app\" prompt; this is expected for unsigned binaries." } > 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 (or the pre-crate no-op) stays # green on a repo with no secrets. # # Learned from servuo-plugins' first release: REGISTRY_USER and # REGISTRY_TOKEN were empty, but the tag push SUCCEEDED anyway, because # 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 401'd, leaving the repo tagged # but unreleased. Checking up front makes that 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 bump commit, the tag, and create the release." exit 1 fi echo "Release credentials present." # ── RUST ADAPTER: toolchain + cross-compile deps ───────────────────── - name: Install Rust toolchain, Windows target, and MinGW linker 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 gcc-mingw-w64-x86-64 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}" - 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}\"/" Cargo.toml grep -m1 '^version' 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 --workspace # ── RUST ADAPTER: gates ────────────────────────────────────────────── - name: cargo fmt --check if: ${{ steps.plan.outputs.release == 'true' }} run: cargo fmt --check - name: cargo test if: ${{ steps.plan.outputs.release == 'true' }} run: cargo test --locked # ── RUST ADAPTER: build both targets ───────────────────────────────── - name: cargo build --release (Linux) if: ${{ steps.plan.outputs.release == 'true' }} run: cargo build --release --locked --target "${LINUX_TARGET}" - name: cargo build --release (Windows, cross via MinGW) if: ${{ steps.plan.outputs.release == 'true' }} 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) ──────────────────── # SHA256SUMS is the trust anchor for these unsigned binaries (PLAN.md §3), # so it ships with every release and the docs lead with the verify command. - name: Package artifacts and SHA256SUMS if: ${{ steps.plan.outputs.release == 'true' }} run: | set -euo pipefail cp "target/${LINUX_TARGET}/release/${BIN}" "dist/${BIN}-linux-x86_64" cp "target/${WINDOWS_TARGET}/release/${BIN}.exe" "dist/${BIN}-windows-x86_64.exe" ( cd dist && sha256sum "${BIN}-linux-x86_64" "${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 "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 Cargo.toml 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 # The tag may already exist when finishing a run that died after # tagging (see the plan step). `git tag` on an existing name fails # under `set -e`; pushing an identical existing tag is a harmless # no-op. 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 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}-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