All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m28s
The first commit on this branch said the plan step "CAN recover an orphan, but
only on a run that reaches it". Checking link and servuo-plugins for the same
gaps showed that understated it.
The recovery is VERSION-SCOPED. It computes VERSION from the newest tag plus the
conventional-commit bump, then only checks refs/tags/v${VERSION}. So it recovers
an orphan on the very next run and is useless afterwards: once any releasable
commit lands, the next run computes a NEW version and never looks at the old tag
again. The orphan becomes permanent and silent.
servuo-plugins proved it, and the proof is pointed. Its v0.1.0 had been orphaned
since 2026-08-04 -- tag present, no release, no assets -- while v0.1.1, v0.2.0
and v1.0.0 all published normally. The commit that ADDED the recovery to that
repo was itself typed "fix(release): preflight credentials and recover the
orphaned v0.1.0 tag", so it bumped to v0.1.1, and the run that introduced the
recovery stepped straight past the tag it was written to rescue.
The retry added in the previous commit makes an orphan much less likely, but it
does not make one impossible -- a cancelled job or a dying runner produces the
same state with no 500 anywhere -- and until now nothing would ever have
mentioned it again.
So the plan step now sweeps every v* tag and warns about any without a release.
It WARNS rather than recovers, on the org lead's decision. 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; and a routine push
silently republishing ancient history is not a thing this pipeline should be
able to do. Recovery stays limited to the version the run computed.
It also never fails the run. A sweep that can break a good release is a sweep
someone will delete.
Verified by running the loop against the real repositories rather than a stub,
since the only thing worth proving is that it tells a clean repo from a dirty
one:
link (9 tags): clean
servuo-plugins (4 tags): :⚠️:Tags with no release: v0.1.0
installer (2 tags): clean
and again after servuo-plugins#15 deleted that tag, where all three report
clean. Every run block bash -n clean, the YAML parses, and no empty template
token.
Companion PRs: link#33 and servuo-plugins#15.
Co-Authored-By: Claude <noreply@anthropic.com>
502 lines
27 KiB
YAML
502 lines
27 KiB
YAML
# 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 ──────────────────────────────────────────────────────────────
|
|
# With no Cargo.toml at the repo root there is nothing to build, so the plan step
|
|
# forces RELEASE=false and the job exits green having done nothing.
|
|
#
|
|
# That guard is what makes the `edge` branch work. Phase 1 (installer core) and
|
|
# Phase 2 (sidecar + service) land on `edge`, so `main` stays crate-free and this
|
|
# workflow keeps standing down — an installer binary that syncs the overlay but
|
|
# cannot install the sidecar is not something to publish to operators. The first
|
|
# release is cut by the `edge → main` cutover, with 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 release tag and create the release.
|
|
#
|
|
# `main` needs NO push exception: this workflow tags and publishes, and never
|
|
# writes to a branch. Keeping it that way is deliberate — a first release that
|
|
# depends on a write to a protected branch fails at the worst possible moment.
|
|
|
|
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
|
|
# The installer has to run wherever the sidecar it installs can run, and link
|
|
# publishes an arm64 Linux binary from v1.2.0 (PLAN.md §5.2, step 4 of 4).
|
|
ARM64_TARGET: aarch64-unknown-linux-gnu
|
|
|
|
jobs:
|
|
release:
|
|
runs-on: ubuntu-latest
|
|
# Vestigial since this workflow stopped writing a bump commit, and kept as
|
|
# belt-and-braces in case one ever returns.
|
|
# 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() { # <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
|
|
|
|
# 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
|
|
|
|
# ── 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, and the proof is pointed: the
|
|
# commit that ADDED the recovery above was itself typed
|
|
# `fix(release): ... recover the orphaned v0.1.0 tag`, so it bumped to
|
|
# v0.1.1 — and the run that introduced the recovery stepped straight
|
|
# past the tag it was written to rescue. That tag is still orphaned.
|
|
#
|
|
# 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 "## ${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:-<none>}"
|
|
|
|
# ── 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.<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 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, 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
|
|
# libc6-dev-arm64-cross is named explicitly on purpose: gcc-aarch64-linux-gnu only
|
|
# *recommends* it, and this install runs --no-install-recommends. Without it the Rust
|
|
# half of the arm64 build succeeds and then `ring` (under ureq's rustls) dies compiling
|
|
# C, on a missing bits/libc-header-start.h.
|
|
$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}\"/" 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}"
|
|
|
|
# The installer has to run wherever the sidecar it installs can run, and link publishes an
|
|
# arm64 Linux binary (PLAN.md §5.2). Without this step the target is installed and the
|
|
# artifact is packaged, but nothing ever builds it — which is exactly how the first release
|
|
# attempt failed, at `cp: cannot stat target/aarch64-unknown-linux-gnu/release/...`.
|
|
- name: cargo build --release (Linux arm64, cross)
|
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
|
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) ────────────────────
|
|
# 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/${ARM64_TARGET}/release/${BIN}" "dist/${BIN}-linux-aarch64"
|
|
cp "target/${WINDOWS_TARGET}/release/${BIN}.exe" "dist/${BIN}-windows-x86_64.exe"
|
|
# Every artifact must be listed: `sha256sum -c` passes silently over a
|
|
# file the sums do not mention, and an operator verifying a download
|
|
# would get a pass on a binary nobody vouched for.
|
|
( 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 ───────────────────────
|
|
# Tag only — `main` is never pushed to.
|
|
#
|
|
# This step used to commit the version bump back to main first, and it has
|
|
# never executed in any repo that carries it: an EMPTY template expression
|
|
# written literally in the comment below (the `$`+`{{ }}` token, spelled
|
|
# out here for that reason) makes the runner fail to build the script and
|
|
# skip the step WITHOUT failing the job. link/release.yml carried the same
|
|
# bug for six releases, which is why its Cargo.toml still says 0.1.0 while
|
|
# its tags reach v1.1.1 — the release API creates the tag when it
|
|
# publishes, so the pipeline worked by accident.
|
|
#
|
|
# It also would have been declined if it had run: `main` is protected, and
|
|
# the bundle job proved that on 2026-08-05 (`pre-receive hook declined`).
|
|
# A first release must not depend on a write to a protected branch.
|
|
#
|
|
# So the tag is the version, as in servuo-plugins. The version is still
|
|
# written into Cargo.toml before building, so a released binary
|
|
# self-reports correctly; it is simply not committed back. The next
|
|
# version is computed from the newest tag, never from the file.
|
|
- 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. They are passed
|
|
# via env rather than interpolated into this script, so a newline cannot
|
|
# break it — do NOT write a template token literally in a comment here,
|
|
# or the runner will skip this step without failing the job.
|
|
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"
|
|
# 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')"
|
|
|
|
PAYLOAD="$(jq -n --arg tag "$TAG" --arg body "$BODY" \
|
|
'{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')"
|
|
|
|
# This POST is the step that orphaned tag v0.1.1 (run 75): it landed one
|
|
# second after the tag push and Gitea answered 500, having not finished
|
|
# processing the pushed tag. Re-running the workflow published the same
|
|
# four assets untouched, so the failure was a race, not a bad request.
|
|
#
|
|
# Two things went wrong there, and both are fixed here.
|
|
#
|
|
# 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 tag.
|
|
# The plan step CAN recover one, but only on a run that reaches it --
|
|
# and a later push with no releasable commits stands down before it
|
|
# gets there, so in practice the tag sits until a human notices.
|
|
#
|
|
# 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 "${BIN}-linux-x86_64" "${BIN}-linux-aarch64" "${BIN}-windows-x86_64.exe" SHA256SUMS; do
|
|
# Same treatment. An upload that fails quietly leaves a release whose
|
|
# SHA256SUMS does not cover every binary 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
|