Files
link/.gitea/workflows/release.yml
wtclaude 6fb063818a
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m42s
ci(release): show the error body, retry the POST, and sweep for orphan tags
This file is the ancestor of installer's release.yml, and installer#22's
release run found two gaps in it the hard way: the run built every artifact,
pushed its tag, then took a 500 from POST /releases one second later and exited
22, leaving the tag orphaned with no binaries published.

link has not hit that, but it has the same two gaps verbatim.

`curl -sSf` prints no response body on an error status, so the only thing such
a failure leaves in the log is "curl: (22) ... error: 500" and the cause has to
be inferred from timestamps. Every call in the release step now captures the
body and prints it on failure, including the asset uploads.

And nothing retried, so a transient 5xx becomes a permanent orphan. The POST
now retries five times with a 5/10/15/20s backoff. 4xx is deliberately not
retried: a bad token or a malformed body will not improve by being sent again,
and retrying would turn a clear failure into a slow one.

The asset uploads get the same treatment, because a release whose SHA256SUMS
does not cover every binary it advertises is worse than no release -- that file
is the trust anchor for an unsigned download.

The third gap is the one worth reading. The orphan-tag recovery in the plan
step is VERSION-SCOPED: it computes VERSION from the newest tag plus the bump,
then only checks refs/tags/v${VERSION}. That recovers an orphan on the very
next run and is useless afterwards, because once any releasable commit lands
the next run computes a NEW version and never looks at the old tag again.

servuo-plugins v0.1.0 proves it, and the proof is pointed: the commit that
ADDED that recovery 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 orphaned to
this day.

So the plan step now sweeps every v* tag and warns about any without a release.
Deliberately warns rather than recovers: 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. It also never fails the run -- a
sweep that can break a good release is a sweep someone will delete.

Verified by extracting both steps from the YAML and running them: bash -n
clean, the YAML parses, no empty template token in either step, the retry loop
exercised against a stubbed curl across seven cases (first-try success,
500-then-success, two 500s then success, five 500s giving up, 403 and 404
aborting without retrying, and a 000 network failure retried), and the sweep
run against the real repositories -- link clean, servuo-plugins reporting
v0.1.0, installer clean.

Typed ci(...) rather than fix(...) on purpose: the plan step bumps on feat/fix,
and this changes no binary, so a release here would be an empty one. That is
the same rule the fix commit above tripped over.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 12:40:42 -05:00

491 lines
26 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
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
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
# 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
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')"
REL_HTTP="$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: token ${CI_TOKEN}" "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/^/- /'
} > 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 ───────────────────────
# Tag only — `main` is never pushed to.
#
# This step used to commit the version bump back to main first. Two things
# were wrong with that. It has never once executed: an EMPTY template
# expression written literally in a comment (the `$`+`{{ }}` token, which
# is why it is spelled out here) made the runner fail to build the script
# and skip the whole step silently, which is why sidecar/Cargo.toml still
# says 0.1.0 after six releases (the tags exist because the release API
# creates one when it publishes). And had it executed, it would have been
# declined — main is protected, and a release must not depend on a write
# to a protected branch.
#
# So the tag is the version, as it already is in servuo-plugins. The
# workflow still writes the real version into Cargo.toml before building,
# so a released binary self-reports correctly; what it no longer does is
# commit that edit back. The next version is computed from the newest tag,
# never from Cargo.toml, so nothing downstream depends on 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 "uo-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 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
# ── 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