ci(release): derive the version from commit subjects, and add a manual backdoor
All checks were successful
PR Checks / client-build (pull_request) Successful in 18s
PR Checks / server-tests (pull_request) Successful in 20s
PR Checks / frozen-manifest (pull_request) Successful in 56s

A bundle used to cost a second pull request whose entire content was a number.
The workflow published only when a merge to `main` left `module.json` at a
version with no release yet, so a merge that did not touch that line released
nothing. Measured rather than argued: v0.3.0 (2026-08-12) is the only release
this repo has ever cut, while the nine Teams phases and the cutover landed on
`main` in the week since.

Adopt the engine `link` and `installer` already run - feat!/BREAKING CHANGE ->
major, feat -> minor, fix|perf -> patch, nothing releasable -> no release. The
number that ships is the tag, and CI writes it into the `module.json` inside the
bundle, with an assertion that the rewrite happened: a bundle carrying the wrong
version would install under a number that is not the one it was released as.

`module.json`'s version is kept as a floor rather than deleted - a version above
the newest tag still releases at that version - so the declared model survives as
the special case it always was, and is still how a `coreApi` bump overrules the
subjects. `workflow_dispatch` covers what the rules cannot reach: leave `version`
blank to bump the newest tag by `bump`, or name an exact version. Where the log
and the input disagree the larger bump wins, because a button pressed on a log
full of `feat:` would otherwise publish its `patch` default over a minor's worth
of work.

Two things carried over from `link` at the same time: a tag pushed without a
release behind it is now recovered rather than making that state permanent, and
the changelog moved into the plan step (so the assemble step clears `$OUT`, not
`dist/`, which now holds it).

On this repo's `main` the engine computes v0.4.0. The workflow still never
writes to a branch.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-19 13:01:31 -05:00
parent 637121bce3
commit 8ec21086b5
2 changed files with 247 additions and 75 deletions

View File

@@ -11,25 +11,52 @@
# admin install downloads the tarball, verifies it against the `sha256` in the # admin install downloads the tarball, verifies it against the `sha256` in the
# manifest, and unpacks it onto the volume. Nothing runs `npm` on the way. # manifest, and unpacks it onto the volume. Nothing runs `npm` on the way.
# #
# ── The version is DECLARED, not derived ──────────────────────────────────── # ── The version is DERIVED, and the declaration is a floor ──────────────────
# #
# Unlike RunicGateway/link and RunicGateway/installer, whose release engines read # This file used to release only when a merge to `main` left `module.json` at a
# conventional-commit subjects to compute the next version, this repo already has # version with no release yet — the version DECLARED, never computed, on the
# one authoritative version — `module.json`'s, which is the version core records # argument that two sources for one number is how they drift. That was true and
# in `installed_modules` and shows on the admin screen, and which sits beside the # it was still the wrong trade: it makes every bundle cost a second reviewed PR
# `coreApi` range a bump usually has to be considered against. Two sources for one # whose entire content is a number, and between 2026-08-12 and 2026-08-19 it cost
# number is how they drift, so: **a release happens when a merge to `main` leaves # this repo *every* bundle — v0.3.0 was the only release while nine phases of
# `module.json` at a version that has no release yet.** Bumping the version is an # Teams work landed, because nothing in them touched that line.
# ordinary reviewed PR; publishing is this file's business.
# #
# It follows that this workflow never writes to a branch — it tags and publishes, # So the engine `link` and `installer` already run is adopted here (MODULE_SYSTEM
# nothing else — so `main` needs no push exception. That is the installer's model, # §2.7.1, decision 19 as amended):
# adopted here for the reason it was adopted there: `main` is protected, and a #
# release engine that has to push to it is a release engine that stops working the # feat!: / BREAKING CHANGE -> major feat: -> minor fix|perf: -> patch
# day someone tightens the rule. # nothing releasable -> no release is cut
# (first ever run, no tag) -> releases what module.json declares
#
# **The declared version is kept as a floor, not deleted.** If `module.json` names
# a version above the newest tag, that version releases — which is the old model
# exactly, surviving as the special case it always was. Raising it by hand is
# still how you say "this one is a minor, whatever the subjects imply", and it is
# still the natural place to move when a `coreApi` bump forces the question. What
# no longer happens is a merge full of `feat:` producing nothing.
#
# The number that ships is therefore the TAG, and CI writes it into the
# `module.json` inside the bundle at assembly time. The committed `module.json` is
# a floor and a starting point, not a record of the last release — `link` reached
# the same arrangement with `Cargo.toml`, for the same reason: a release engine
# that has to commit a bump back to `main` stops working the day someone protects
# the branch, and this one is protected.
#
# ── The backdoor ────────────────────────────────────────────────────────────
#
# `workflow_dispatch` publishes on demand, for the case the rules above cannot
# reach: `module.json` changed in a way worth shipping — a widened `coreApi`, a
# new mount, a capability — with no releasable code behind it. Leave `version`
# blank to bump the newest tag by `bump` (default `patch`), or name an exact
# version to publish that. A dispatch releases even when nothing in the log is
# releasable; that is the entire point of pressing the button.
# #
# Re-running on a version that is already released is a no-op, so a rerun after an # Re-running on a version that is already released is a no-op, so a rerun after an
# unrelated failure is safe. # unrelated failure is safe. A tag that exists with no release behind it is NOT a
# no-op — see the recovery branch in the plan step.
#
# This workflow still never writes to a branch. It tags and publishes, so `main`
# needs no push exception.
# #
# Prerequisites (Settings → Actions → Secrets on RunicGateway/Module-uo): # Prerequisites (Settings → Actions → Secrets on RunicGateway/Module-uo):
# REGISTRY_TOKEN — Gitea access token with `write:repository`, to push the tag # REGISTRY_TOKEN — Gitea access token with `write:repository`, to push the tag
@@ -40,6 +67,16 @@ name: Release
on: on:
push: push:
branches: [main] branches: [main]
workflow_dispatch:
inputs:
version:
description: 'Exact version to publish (e.g. 0.4.1). Blank = bump the newest tag by the level below.'
required: false
default: ''
bump:
description: 'Bump level when version is blank: patch | minor | major'
required: false
default: 'patch'
concurrency: concurrency:
group: release-module-uo group: release-module-uo
@@ -54,6 +91,8 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 30
steps: steps:
# Full history: the plan step reads every tag and every subject since the
# newest one, and a shallow clone has neither.
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -62,32 +101,169 @@ jobs:
with: with:
node-version: 20 node-version: 20
- name: Decide whether this commit releases - name: Plan the release (version + changelog)
id: plan id: plan
env: env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
EVENT: ${{ github.event_name }}
IN_VERSION: ${{ github.event.inputs.version }}
IN_BUMP: ${{ github.event.inputs.bump }}
run: | run: |
set -euo pipefail set -euo pipefail
VERSION="$(node -p "require('./module.json').version")" mkdir -p dist
echo "module.json version: ${VERSION}" git fetch --tags --force >/dev/null 2>&1 || true
# Does a release already exist for this version? A 404 means no, a 200 DECLARED="$(node -p "require('./module.json').version")"
# means yes, and anything else — a network failure, a bad token — is not LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)"
# evidence of absence. Guessing "no" would publish over a good release, CURRENT="${LAST_TAG#v}"
# so refuse instead. (The installer learned this one the expensive way.) RANGE="${LAST_TAG:+${LAST_TAG}..}HEAD"
HTTP="$(curl -s -o /dev/null -w '%{http_code}' \ echo "module.json declares ${DECLARED}; newest tag is ${LAST_TAG:-<none>}"
-H "Authorization: token $(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" \
"https://${GITEA_HOST}/api/v1/repos/${REPO}/releases/tags/v${VERSION}" || echo 000)"
case "$HTTP" in SUBJECTS="$(git log --no-merges --format='%s' $RANGE || true)"
404) RELEASE=true ;; BODIES="$(git log --no-merges --format='%B' $RANGE || true)"
200) RELEASE=false; echo "v${VERSION} is already released — nothing to do." ;;
*) echo "::error::Could not determine whether v${VERSION} is released (HTTP ${HTTP}). Refusing to guess."; exit 1 ;; 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 esac
}
# `sort -V` orders version strings, so the higher of two is its last
# line. Used rather than a hand-rolled field compare because 0.10.0 vs
# 0.9.0 is exactly the comparison a string sort gets wrong.
higher() { printf '%s\n%s\n' "$1" "$2" | sort -V | tail -1; }
rank() { case "$1" in major) echo 3 ;; minor) echo 2 ;; patch) echo 1 ;; *) echo 0 ;; esac; }
bigger_bump() { if [ "$(rank "$1")" -ge "$(rank "$2")" ]; then echo "$1"; else echo "$2"; fi; }
VERSION=""
if [ -n "${IN_VERSION:-}" ]; then
# The backdoor's exact form. Deliberately unvalidated against the log:
# a human typed it, and the already-released check below is the only
# guard that matters.
VERSION="${IN_VERSION}"
echo "dispatch: publishing the requested version ${VERSION}"
else
LEVEL="$BUMP"
# A dispatch with nothing releasable in the log still releases — that
# is what the button is for. Where the log DOES say something, the
# larger of the two wins rather than the input: pressing the button on
# a log full of `feat:` without touching the dropdown would otherwise
# publish its `patch` default over a minor's worth of work, and a
# version that undersells its own contents cannot be taken back.
if [ "${EVENT:-}" = workflow_dispatch ]; then
LEVEL="$(bigger_bump "$LEVEL" "${IN_BUMP:-patch}")"
if [ "$BUMP" = none ]; then
echo "dispatch: nothing releasable in the log, bumping ${LEVEL} anyway"
elif [ "$LEVEL" != "$BUMP" ]; then
echo "dispatch: the log says ${BUMP}, the run asked for ${LEVEL} — taking ${LEVEL}"
fi
fi
if [ -z "$CURRENT" ]; then
VERSION="$DECLARED" # first ever release: ship what is declared
elif [ "$LEVEL" != none ]; then
VERSION="$(bump "$CURRENT" "$LEVEL")"
fi
# The floor. A `module.json` above the newest tag releases at that
# version even when the log says nothing and even when the log says
# patch — which is the pre-2026-08-19 model, kept as a special case.
if [ -n "$CURRENT" ] && [ "$DECLARED" != "$CURRENT" ] \
&& [ "$(higher "$DECLARED" "$CURRENT")" = "$DECLARED" ]; then
if [ -z "$VERSION" ] || [ "$(higher "$DECLARED" "$VERSION")" = "$DECLARED" ]; then
echo "module.json declares ${DECLARED}, above both ${CURRENT} and the derived version — releasing that."
VERSION="$DECLARED"
fi
fi
fi
RELEASE=true
if [ -z "$VERSION" ]; then
RELEASE=false
VERSION="$CURRENT"
echo "Nothing releasable since ${LAST_TAG} (no feat/fix/perf/breaking subject) — standing down."
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 what happened on servuo-plugins' first release,
# where absent 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. Note this deliberately OVERRIDES the RELEASE=false
# above: with the tag in place there is nothing releasable after it, so
# the normal path would stand down, which is why it could never
# self-heal. Anything other than 200/404 — a network failure, a bad
# token — is not evidence of absence, and guessing "no" would publish
# over a good release, so refuse instead.
REUSE_TAG=false
if [ -n "$VERSION" ] && 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)"
case "$REL_HTTP" in
200) echo "v${VERSION} is already released — nothing to do."; RELEASE=false ;;
404) 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 ;;
*) echo "::error::Could not determine whether v${VERSION} is released (HTTP ${REL_HTTP}). Refusing to guess."; exit 1 ;;
esac
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)"
CL_RANGE="${PREV_TAG:+${PREV_TAG}..}v${VERSION}"
SINCE="$PREV_TAG"
else
CL_RANGE="$RANGE"
SINCE="$LAST_TAG"
fi
CL_SUBJECTS="$(git log --no-merges --format='%s' $CL_RANGE || true)"
{
echo "## module-uo v${VERSION}"
echo
echo "Install from the website's Admin → Modules screen by pasting the URL of"
echo "\`module-uo-${VERSION}.json\`, or unpack the tarball onto the modules volume"
echo "as \`modules/uo/\`. Requires a core whose \`MODULE_API_VERSION\` satisfies"
echo "\`$(node -p "require('./module.json').coreApi")\`."
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** — the \`sha256\` in \`module-uo-${VERSION}.json\` is the"
echo "trust anchor, and the website verifies it before unpacking."
echo
echo '```bash'
echo "sha256sum -c SHA256SUMS --ignore-missing"
echo '```'
} > dist/CHANGELOG.md
echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT" echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
echo "release=${RELEASE}" >> "$GITHUB_OUTPUT" echo "release=${RELEASE}" >> "$GITHUB_OUTPUT"
echo "reuse_tag=${REUSE_TAG}" >> "$GITHUB_OUTPUT"
echo "bump=${BUMP}" >> "$GITHUB_OUTPUT"
echo "==> release=${RELEASE} version=${VERSION} bump=${BUMP} declared=${DECLARED} last_tag=${LAST_TAG:-<none>}"
# Before anything is built or tagged, so a repo without secrets fails # Before anything is built or tagged, so a repo without secrets fails
# legibly rather than half-publishing: the tag push can succeed on the # legibly rather than half-publishing: the tag push can succeed on the
@@ -131,12 +307,19 @@ jobs:
set -euo pipefail set -euo pipefail
VERSION="${{ steps.plan.outputs.version }}" VERSION="${{ steps.plan.outputs.version }}"
OUT="dist/module-uo-${VERSION}" OUT="dist/module-uo-${VERSION}"
rm -rf dist && mkdir -p "$OUT" rm -rf "$OUT" && mkdir -p "$OUT"
# The manifest core reads, the two fragments, and the licence the code # The manifest core reads — with the RELEASED version written into it.
# is under — a bundle that ships GPL code without its licence is not # The committed `module.json` is a floor, not a record of the last
# distributable. # release (see the header), so copying it verbatim would ship a bundle
cp module.json swagger-fragment.json LICENSE.md README.md "$OUT/" # whose `installed_modules` row and admin screen disagree with the tag
# it came from. This is the one place the derived number becomes the
# module's own.
jq --arg v "$VERSION" '.version = $v' module.json > "$OUT/module.json"
# The two fragments, and the licence the code is under — a bundle that
# ships GPL code without its licence is not distributable.
cp swagger-fragment.json LICENSE.md README.md "$OUT/"
# The server half, minus what never runs inside core's process. # The server half, minus what never runs inside core's process.
mkdir -p "$OUT/server" mkdir -p "$OUT/server"
@@ -154,16 +337,22 @@ jobs:
# Prove the bundle is loadable before it is published: these are the # Prove the bundle is loadable before it is published: these are the
# paths core's loader resolves out of module.json, and a release whose # paths core's loader resolves out of module.json, and a release whose
# entry point is missing fails on an operator's box with a # entry point is missing fails on an operator's box with a
# `startup_failed` row instead of here. # `startup_failed` row instead of here. The version assertion guards the
# rewrite above — a bundle that still carries the declared version would
# install under a number that is not the one it was released as.
node -e ' node -e '
const fs = require("fs"), path = require("path"); const fs = require("fs"), path = require("path");
const root = process.argv[1]; const [root, want] = process.argv.slice(1);
const m = JSON.parse(fs.readFileSync(path.join(root, "module.json"), "utf8")); const m = JSON.parse(fs.readFileSync(path.join(root, "module.json"), "utf8"));
if (m.version !== want) {
console.error(`bundle declares ${m.version}, but this is release ${want}`);
process.exit(1);
}
for (const p of [m.server, m.schema, m.purge, m.client.entry, "swagger-fragment.json"]) { for (const p of [m.server, m.schema, m.purge, m.client.entry, "swagger-fragment.json"]) {
if (!fs.existsSync(path.join(root, p))) { console.error("bundle is missing " + p); process.exit(1); } if (!fs.existsSync(path.join(root, p))) { console.error("bundle is missing " + p); process.exit(1); }
} }
console.log("bundle contents check: ok"); console.log("bundle contents check: ok");
' "$OUT" ' "$OUT" "$VERSION"
tar -C dist -czf "dist/module-uo-${VERSION}.tar.gz" "module-uo-${VERSION}" tar -C dist -czf "dist/module-uo-${VERSION}.tar.gz" "module-uo-${VERSION}"
rm -rf "$OUT" rm -rf "$OUT"
@@ -191,36 +380,10 @@ jobs:
echo "${SHA} module-uo-${VERSION}.tar.gz" > dist/SHA256SUMS echo "${SHA} module-uo-${VERSION}.tar.gz" > dist/SHA256SUMS
cat "dist/module-uo-${VERSION}.json" cat "dist/module-uo-${VERSION}.json"
- name: Write the changelog # Skipped on a recovery run: the tag is already there and is the thing being
if: ${{ steps.plan.outputs.release == 'true' }} # published against.
run: |
set -euo pipefail
VERSION="${{ steps.plan.outputs.version }}"
LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)"
RANGE="${LAST_TAG:+${LAST_TAG}..}HEAD"
{
echo "## module-uo v${VERSION}"
echo
echo "Install from the website's Admin → Modules screen, or unpack onto the"
echo "modules volume as \`modules/uo/\`. Requires a core whose \`MODULE_API_VERSION\`"
echo "satisfies \`$(node -p "require('./module.json').coreApi")\`."
echo
echo "### Changes"
if [ -n "$LAST_TAG" ]; then echo "Since ${LAST_TAG}:"; fi
git log --no-merges --format='- %s' $RANGE || true
echo
echo "### Verifying this download"
echo
echo "Releases are **unsigned** — the \`sha256\` in \`module-uo-${VERSION}.json\` is the"
echo "trust anchor, and the website verifies it before unpacking."
echo
echo '```bash'
echo "sha256sum -c SHA256SUMS --ignore-missing"
echo '```'
} > dist/CHANGELOG.md
- name: Tag the release - name: Tag the release
if: ${{ steps.plan.outputs.release == 'true' }} if: ${{ steps.plan.outputs.release == 'true' && steps.plan.outputs.reuse_tag != 'true' }}
env: env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: | run: |

View File

@@ -134,11 +134,20 @@ website. Module delivery is website-side only.
### Releases ### Releases
A merge to `main` that leaves `module.json` at a version with no release yet publishes one. The **Every merge to `main` that carries a releasable commit publishes a bundle.** The next version is
version is **declared**, not computed from commit subjects: `module.json`'s version is what core computed from conventional-commit subjects since the newest `v*` tag, as in `link` and `installer`:
records in `installed_modules` and shows on the admin screen, and it sits beside the `coreApi` range `feat!:` or `BREAKING CHANGE` is a major, `feat:` a minor, `fix:` or `perf:` a patch, and a `main`
a bump usually has to be weighed against — two sources for one number is how they drift. Bumping it that gained none of those cuts no release. The number that ships is the **tag**, and CI writes it
is an ordinary reviewed PR. into the `module.json` inside the bundle.
`module.json`'s version survives as a **floor**: name a version there above the newest tag and that
version is what releases, which is how you overrule the subjects — when a `coreApi` bump forces a
minor, say. What no longer happens is a `main` full of `feat:` producing nothing because a separate
PR to move one number had not been merged yet.
For a change with nothing releasable behind it — a widened `coreApi`, a new mount, a capability —
run the **Release** workflow by hand (Actions → Release → Run workflow). Leave `version` blank to
bump the newest tag by `bump` (default `patch`), or type an exact version to publish that.
Each release carries: Each release carries: