A .patch file does not carry enough for an installer to run the tier safely. The installer additionally needs to know which patches form one all-or-nothing unit (the two vendor-sale patches are useless apart), which companion .cs may only be copied once that unit has landed, whether the change needs a core solution rebuild or just ServUO's dynamic script build, and what capability an operator loses by declining. None of that is derivable from the diffs. patches/tier.json declares it, and release.yml folds it into manifest.json as `patch_tier` — so a new or changed patch regenerates release metadata rather than requiring an installer release, which is the same rule §7.1 already applies to the bundle. The staged copy is removed from patches/ so the tarball carries exactly one statement of the table. The release gate now checks the table in both directions: every .patch described by exactly one feature, every named patch and companion present, every declared target equal to the file the diff actually edits, and every rebuild kind one the installer understands. All four were previously invisible until someone ran the tier on a live shard. Refs: docs/installer/PLAN.md §2.2, §7.0 Co-Authored-By: Claude <noreply@anthropic.com>
536 lines
29 KiB
YAML
536 lines
29 KiB
YAML
# Automated release for the deployable ServUO overlay.
|
|
#
|
|
# Trigger: every push to `main` (i.e. every merged PR).
|
|
#
|
|
# Why this exists: the Runic Gateway installer deploys the plugin from a release
|
|
# tarball, not from git — the shard host gets no git and no Gitea credentials
|
|
# (docs/installer/PLAN.md §1, §5 Phase 0.1). Until this workflow, `link` was the
|
|
# only repo that published releases, so there was nothing for the installer to
|
|
# fetch. This is Phase 0 item 1.
|
|
#
|
|
# 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 │
|
|
# └───────────────────────────────────────────────────────────────────┘
|
|
# ┌── OVERLAY ADAPTER (the only repo-specific part) ──────────────────┐
|
|
# │ consumes: the version │
|
|
# │ produces: runicgateway-overlay-<ver>.tar.gz + SHA256SUMS │
|
|
# └───────────────────────────────────────────────────────────────────┘
|
|
#
|
|
# The engine is `link/.gitea/workflows/release.yml`'s, reused as its own header
|
|
# anticipated — the plan and release steps consume only {version, changelog,
|
|
# artifacts} and know nothing about what is inside the artifacts.
|
|
#
|
|
# ── Three differences from link's copy, all forced by this repo ──────────────
|
|
#
|
|
# 1. NO BUILD. The plugin ships as C# source and ServUO compiles it at boot; it
|
|
# needs ServUO reference assemblies, so there is no way to compile it here.
|
|
# The build gates are replaced by the structural gates below, which is the
|
|
# most this repo can honestly assert about an artifact.
|
|
#
|
|
# 2. NO BUMP COMMIT, and so no push to `main`. link has to write the version
|
|
# into Cargo.toml because the binary embeds it; a tarball embeds nothing but
|
|
# the manifest.json this job generates, so the git tag IS the version. That
|
|
# removes a failure mode outright: this workflow never needs `main` to accept
|
|
# a direct push, so no branch-protection exception is required for it.
|
|
#
|
|
# 3. A MANIFEST. The tarball carries manifest.json — version, commit, declared
|
|
# protocol version, ServUO compatibility, and a SHA256 for every file. The
|
|
# installer needs it because the plugin announces no version on the wire and
|
|
# none is queryable before ServUO boots (PLAN.md §2.6): the manifest is the
|
|
# only thing that lets the bundle CI verify sidecar/overlay protocol
|
|
# agreement BEFORE an operator installs the pair (PLAN.md §7.1, gate 1).
|
|
#
|
|
# Version bump (conventional commits since the last v* tag):
|
|
# feat!: / BREAKING CHANGE -> major feat: -> minor fix|perf: -> patch
|
|
# nothing releasable -> no release is cut (a docs:/chore:-only merge
|
|
# deliberately does NOT cut one — PLAN.md §7.3)
|
|
# (first ever run, no tag) -> releases SEED_VERSION below
|
|
#
|
|
# Prerequisites (Settings → Actions → Secrets on RunicGateway/servuo-plugins):
|
|
# 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 — a nicety, not a requirement:
|
|
# 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.
|
|
#
|
|
# These are checked by an explicit preflight step rather than left to fail
|
|
# wherever they happen to be used first — see the comment on that step for why
|
|
# an absent token does NOT simply fail the tag push.
|
|
#
|
|
# The final step POSTs to the installer repo's bundle workflow, so a new overlay
|
|
# release recomposes the compat matrix immediately instead of waiting for that
|
|
# repo's nightly cron (PLAN.md §7.2). It was deliberately absent until Phase 0
|
|
# item 3 landed something to dispatch — a step that 404s on every release is
|
|
# worse than no step.
|
|
|
|
name: Release overlay
|
|
|
|
on:
|
|
push:
|
|
branches: [main]
|
|
workflow_dispatch: {}
|
|
|
|
concurrency:
|
|
group: release-overlay
|
|
cancel-in-progress: false
|
|
|
|
env:
|
|
GITEA_HOST: gitea.whitlocktech.com
|
|
REPO: RunicGateway/servuo-plugins
|
|
# Artifact naming per PLAN.md §3.
|
|
ARTIFACT: runicgateway-overlay
|
|
# Used only for the very first release, when no v* tag exists yet. Matches the
|
|
# house style set by link (pre-1.0; the release version is independent of the
|
|
# protocol version, which lives in overlay.toml).
|
|
SEED_VERSION: "0.1.0"
|
|
# Notified after a release so the installer's compat matrix picks up this
|
|
# overlay immediately rather than at its next nightly run (PLAN.md §7.2).
|
|
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 the first run here,
|
|
# when the 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
|
|
|
|
# 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 the 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
|
|
|
|
# ── OVERLAY ADAPTER: gates ───────────────────────────────────────────
|
|
# There is no compiler to run, so these assert the things that CAN be
|
|
# checked without a ServUO tree — and each one has actually been a way to
|
|
# ship a broken overlay:
|
|
#
|
|
# • overlay/ mirrors the server root; if Bridge.cfg or the Bridge scripts
|
|
# go missing the deploy silently no-ops (PLAN.md §2.1).
|
|
# • overlay/Scripts/Scripts.csproj is Phase 0 of the plugin itself — it
|
|
# overwrites a stock file to fix ServUO's silent script-build bug. An
|
|
# overlay shipped without it installs code that never compiles, and
|
|
# ServUO reports success anyway.
|
|
# • a malformed .patch is invisible until an operator runs the patch tier
|
|
# on their live shard. `git apply --stat` parses the diff without
|
|
# needing the target files present.
|
|
# • each patch's companion .cs must exist, since it references symbols
|
|
# the patch introduces and is meaningless without it (PLAN.md §2.2).
|
|
# • patches/tier.json must describe every .patch and nothing but. That
|
|
# table is what tells the installer which patches form one unit, which
|
|
# companion follows which, and whether a CORE rebuild is needed — a
|
|
# patch added without it would be shipped and silently never offered.
|
|
- name: Validate the overlay and patch tier
|
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
|
run: |
|
|
set -euo pipefail
|
|
fail() { echo "::error::$*"; exit 1; }
|
|
|
|
[ -f overlay/Config/Bridge.cfg ] || fail "overlay/Config/Bridge.cfg is missing"
|
|
[ -f overlay/Scripts/Scripts.csproj ] || fail "overlay/Scripts/Scripts.csproj is missing (the silent-build-bug fix)"
|
|
[ -d overlay/Scripts/Custom/Bridge ] || fail "overlay/Scripts/Custom/Bridge/ is missing"
|
|
|
|
CS_COUNT="$(find overlay/Scripts/Custom/Bridge -name '*.cs' | wc -l)"
|
|
[ "$CS_COUNT" -gt 0 ] || fail "overlay/Scripts/Custom/Bridge/ contains no .cs files"
|
|
echo "overlay: ${CS_COUNT} bridge script(s)"
|
|
|
|
for p in patches/*.patch; do
|
|
[ -e "$p" ] || fail "patches/ contains no .patch files"
|
|
echo "--- ${p}"
|
|
git apply --stat "$p" || fail "${p} is not a parseable unified diff"
|
|
done
|
|
|
|
# The tier table, checked in BOTH directions. A patch missing from
|
|
# tier.json ships but is never offered to an operator; a tier.json
|
|
# entry naming a file that is not there makes the installer report a
|
|
# feature it cannot apply. Neither surfaces until someone runs the
|
|
# tier on a live shard, so both fail the release here instead.
|
|
[ -f patches/tier.json ] || fail "patches/tier.json is missing (the patch-tier declaration)"
|
|
jq -e . patches/tier.json >/dev/null || fail "patches/tier.json is not valid JSON"
|
|
|
|
DESCRIBED="$(jq -r '.features[].patches[].file' patches/tier.json | LC_ALL=C sort)"
|
|
PRESENT="$(cd patches && ls *.patch | LC_ALL=C sort)"
|
|
if [ "$DESCRIBED" != "$PRESENT" ]; then
|
|
echo "described by tier.json:"; echo "$DESCRIBED" | sed 's/^/ /'
|
|
echo "present in patches/:"; echo "$PRESENT" | sed 's/^/ /'
|
|
fail "patches/tier.json and patches/*.patch disagree — every patch must be described by exactly one feature"
|
|
fi
|
|
|
|
# Each patch's declared target must be the file its diff actually
|
|
# edits. The installer cross-checks the same pair at install time and
|
|
# refuses on a mismatch, so catching it here saves an operator the run.
|
|
while IFS=$'\t' read -r PFILE PTARGET; do
|
|
DIFF_TARGET="$(sed -n 's|^+++ b/||p' "patches/${PFILE}" | head -1 | tr -d '\r')"
|
|
[ "$DIFF_TARGET" = "$PTARGET" ] \
|
|
|| fail "patches/${PFILE} edits ${DIFF_TARGET} but tier.json declares ${PTARGET}"
|
|
done < <(jq -r '.features[].patches[] | [.file, .target] | @tsv' patches/tier.json)
|
|
|
|
# Companions can only be copied after their feature's patches land, so
|
|
# they live here rather than in overlay/ — and a missing one turns a
|
|
# successfully patched shard into one that does not compile.
|
|
for f in $(jq -r '.features[].companions[].file' patches/tier.json); do
|
|
[ -f "patches/${f}" ] || fail "patches/${f} is missing (a feature's companion source)"
|
|
done
|
|
|
|
for r in $(jq -r '.features[].rebuild' patches/tier.json); do
|
|
case "$r" in
|
|
core|scripts) ;;
|
|
*) fail "tier.json declares rebuild=\"${r}\"; only \"core\" or \"scripts\" are understood" ;;
|
|
esac
|
|
done
|
|
echo "patch tier: $(jq -r '.features | length' patches/tier.json) feature(s), $(echo "$PRESENT" | wc -l) patch(es)"
|
|
|
|
[ -f overlay.toml ] || fail "overlay.toml is missing (protocol + ServUO declarations)"
|
|
|
|
# ── OVERLAY ADAPTER: stage, manifest, package ────────────────────────
|
|
# The tarball has a FIXED top-level directory (runicgateway-overlay/), not a
|
|
# versioned one: the installer extracts and then looks for overlay/,
|
|
# patches/ and manifest.json at known paths, and a version-dependent prefix
|
|
# would make it parse the very version it is trying to read.
|
|
#
|
|
# tar flags pin ownership, mtime and member order so the same tree produces
|
|
# a byte-identical tarball — a checksum that changes only when content
|
|
# changes is worth more than one that changes every run.
|
|
- name: Build manifest.json and the release tarball
|
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
|
run: |
|
|
set -euo pipefail
|
|
VERSION="${{ steps.plan.outputs.version }}"
|
|
STAGE="dist/stage/${ARTIFACT}"
|
|
|
|
mkdir -p "${STAGE}"
|
|
cp -r overlay "${STAGE}/overlay"
|
|
cp -r patches "${STAGE}/patches"
|
|
|
|
# tier.json is folded into manifest.json below, so the staged copy is
|
|
# removed: shipping it twice would give the tarball two statements of
|
|
# the same table, one of which nothing reads and both of which are
|
|
# free to drift.
|
|
rm -f "${STAGE}/patches/tier.json"
|
|
|
|
# Declarations from overlay.toml. Read, don't hardcode — the point of
|
|
# that file is that the protocol number lives in one place.
|
|
PROTOCOL="$(grep -m1 -E '^protocol[[:space:]]*=' overlay.toml | sed -E 's/[^0-9]//g')"
|
|
MIN_SERVUO="$(grep -m1 -E '^min_servuo_version[[:space:]]*=' overlay.toml | sed -E 's/.*"([^"]+)".*/\1/')"
|
|
PATCHED_AGAINST="$(grep -m1 -E '^patches_verified_against[[:space:]]*=' overlay.toml | sed -E 's/.*"([^"]+)".*/\1/')"
|
|
[ -n "$PROTOCOL" ] || { echo "::error::could not read protocol from overlay.toml"; exit 1; }
|
|
[ -n "$MIN_SERVUO" ] || { echo "::error::could not read min_servuo_version from overlay.toml"; exit 1; }
|
|
[ -n "$PATCHED_AGAINST" ] || { echo "::error::could not read patches_verified_against from overlay.toml"; exit 1; }
|
|
echo "==> protocol=${PROTOCOL} min_servuo=${MIN_SERVUO} patches_verified_against=${PATCHED_AGAINST}"
|
|
|
|
# The patch tier, folded in verbatim minus its comment block. Paths are
|
|
# rewritten to be relative to the tarball root (`patches/<file>`), which
|
|
# is where the installer will find them after extraction — tier.json
|
|
# names them relative to patches/ because that is where a maintainer
|
|
# editing it is looking.
|
|
TIER="$(jq '
|
|
del(._comment)
|
|
| .features |= map(
|
|
.patches |= map(.file |= "patches/" + .)
|
|
| .companions |= map(.file |= "patches/" + .)
|
|
)' patches/tier.json)"
|
|
|
|
# Per-file SHA256 of everything shipped, as a {path: sha} object. The
|
|
# installer records these in install.json so a later `doctor` can tell
|
|
# "operator edited a deployed file" from "the overlay drifted".
|
|
# The `\*?` is not paranoia: sha256sum marks binary mode by prefixing the
|
|
# path with `*` (`<hash> *path`) instead of the two-space text-mode
|
|
# separator. Coreutils on Linux defaults to text mode, but a build host
|
|
# that doesn't would otherwise put a leading `*` on EVERY key here and
|
|
# silently produce a manifest whose paths match nothing.
|
|
FILES="$(cd "${STAGE}" \
|
|
&& find overlay patches -type f -print0 | LC_ALL=C sort -z | xargs -0 sha256sum \
|
|
| jq -R -s '
|
|
split("\n") | map(select(length > 0))
|
|
| map(capture("^(?<sha>[0-9a-f]+)[ \t]+\\*?(?<path>.+)$"))
|
|
| map({ (.path): .sha }) | add')"
|
|
|
|
jq -n \
|
|
--arg component "servuo-plugins-overlay" \
|
|
--arg version "${VERSION}" \
|
|
--arg commit "${GITHUB_SHA}" \
|
|
--arg repo "${REPO}" \
|
|
--argjson protocol "${PROTOCOL}" \
|
|
--arg min_servuo "${MIN_SERVUO}" \
|
|
--arg patched_against "${PATCHED_AGAINST}" \
|
|
--argjson tier "${TIER}" \
|
|
--argjson files "${FILES}" \
|
|
'{
|
|
component: $component,
|
|
version: $version,
|
|
commit: $commit,
|
|
repo: $repo,
|
|
protocol: $protocol,
|
|
servuo: {
|
|
min_version: $min_servuo,
|
|
patches_verified_against: $patched_against
|
|
},
|
|
patch_tier: $tier,
|
|
files: $files
|
|
}' > "${STAGE}/manifest.json"
|
|
|
|
echo "----- manifest.json (files elided) -----"
|
|
jq 'del(.files) + {file_count: (.files | length)}' "${STAGE}/manifest.json"
|
|
|
|
TARBALL="${ARTIFACT}-${VERSION}.tar.gz"
|
|
tar --sort=name --mtime='UTC 1970-01-01' \
|
|
--owner=0 --group=0 --numeric-owner \
|
|
-czf "dist/${TARBALL}" -C dist/stage "${ARTIFACT}"
|
|
|
|
( cd dist && sha256sum "${TARBALL}" > SHA256SUMS )
|
|
echo "tarball=${TARBALL}" >> "$GITHUB_OUTPUT"
|
|
ls -l dist && echo "----" && cat dist/SHA256SUMS
|
|
id: package
|
|
|
|
# ── 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 "servuo-plugins-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 }}"
|
|
TARBALL="${{ steps.package.outputs.tarball }}"
|
|
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')"
|
|
|
|
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 "${TARBALL}" SHA256SUMS; do
|
|
curl -sSf -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \
|
|
-H "Authorization: token ${CI_TOKEN}" \
|
|
-F "attachment=@dist/${f}" >/dev/null
|
|
echo " uploaded ${f}"
|
|
done
|
|
|
|
# ── Recompose the installer's bundle manifest ────────────────────────
|
|
# The installer does not resolve "latest" at run time — it deploys the
|
|
# exact overlay named by a published bundle (docs/installer/PLAN.md §7.1).
|
|
# An overlay 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 re-reads this tarball's manifest.json and checks its declared
|
|
# `protocol` against the sidecar's PROTOCOL_VERSION before publishing
|
|
# anything (PLAN.md §7.1, gate 1) — which is the check this repo cannot
|
|
# perform for itself, since the C# plugin announces no version on the wire.
|
|
#
|
|
# 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
|