diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 4192b4f..319088b 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -11,25 +11,52 @@ # 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. # -# ── 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 -# conventional-commit subjects to compute the next version, this repo already has -# one authoritative version — `module.json`'s, which is the version core records -# in `installed_modules` and shows on the admin screen, and which sits beside the -# `coreApi` range a bump usually has to be considered against. Two sources for one -# number is how they drift, so: **a release happens when a merge to `main` leaves -# `module.json` at a version that has no release yet.** Bumping the version is an -# ordinary reviewed PR; publishing is this file's business. +# This file used to release only when a merge to `main` left `module.json` at a +# version with no release yet — the version DECLARED, never computed, on the +# argument that two sources for one number is how they drift. That was true and +# it was still the wrong trade: it makes every bundle cost a second reviewed PR +# whose entire content is a number, and between 2026-08-12 and 2026-08-19 it cost +# this repo *every* bundle — v0.3.0 was the only release while nine phases of +# Teams work landed, because nothing in them touched that line. # -# It follows that this workflow never writes to a branch — it tags and publishes, -# nothing else — so `main` needs no push exception. That is the installer's model, -# 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 -# day someone tightens the rule. +# So the engine `link` and `installer` already run is adopted here (MODULE_SYSTEM +# §2.7.1, decision 19 as amended): +# +# feat!: / BREAKING CHANGE -> major feat: -> minor fix|perf: -> patch +# 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 -# 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): # REGISTRY_TOKEN — Gitea access token with `write:repository`, to push the tag @@ -40,6 +67,16 @@ name: Release on: push: 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: group: release-module-uo @@ -54,6 +91,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 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 with: fetch-depth: 0 @@ -62,32 +101,169 @@ jobs: with: node-version: 20 - - name: Decide whether this commit releases + - name: Plan the release (version + changelog) id: plan env: REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + EVENT: ${{ github.event_name }} + IN_VERSION: ${{ github.event.inputs.version }} + IN_BUMP: ${{ github.event.inputs.bump }} run: | set -euo pipefail - VERSION="$(node -p "require('./module.json').version")" - echo "module.json version: ${VERSION}" + mkdir -p dist + git fetch --tags --force >/dev/null 2>&1 || true - # Does a release already exist for this version? A 404 means no, a 200 - # means yes, and anything else — a network failure, a bad token — is not - # evidence of absence. Guessing "no" would publish over a good release, - # so refuse instead. (The installer learned this one the expensive way.) - 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)" + DECLARED="$(node -p "require('./module.json').version")" + LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)" + CURRENT="${LAST_TAG#v}" + RANGE="${LAST_TAG:+${LAST_TAG}..}HEAD" + echo "module.json declares ${DECLARED}; newest tag is ${LAST_TAG:-}" - case "$HTTP" in - 404) RELEASE=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 ;; - esac + SUBJECTS="$(git log --no-merges --format='%s' $RANGE || true)" + BODIES="$(git log --no-merges --format='%B' $RANGE || true)" - echo "version=${VERSION}" >> "$GITHUB_OUTPUT" - echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT" - echo "release=${RELEASE}" >> "$GITHUB_OUTPUT" + BUMP=none + if echo "$BODIES" | grep -qE 'BREAKING[ -]CHANGE' ; then BUMP=major; fi + if echo "$SUBJECTS" | grep -qE '^[a-z]+(\([^)]+\))?!:' ; then BUMP=major; fi + if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^feat(\([^)]+\))?:' ; then BUMP=minor; fi + if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^(fix|perf)(\([^)]+\))?:' ; then BUMP=patch; fi + + bump() { # -> bumped + IFS=. read -r MA MI PA <<< "$1" + case "$2" in + major) echo "$((MA+1)).0.0" ;; + minor) echo "${MA}.$((MI+1)).0" ;; + patch) echo "${MA}.${MI}.$((PA+1))" ;; + esac + } + + # `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 "tag=v${VERSION}" >> "$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:-}" # Before anything is built or tagged, so a repo without secrets fails # legibly rather than half-publishing: the tag push can succeed on the @@ -131,12 +307,19 @@ jobs: set -euo pipefail VERSION="${{ steps.plan.outputs.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 - # is under — a bundle that ships GPL code without its licence is not - # distributable. - cp module.json swagger-fragment.json LICENSE.md README.md "$OUT/" + # The manifest core reads — with the RELEASED version written into it. + # The committed `module.json` is a floor, not a record of the last + # release (see the header), so copying it verbatim would ship a bundle + # 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. mkdir -p "$OUT/server" @@ -154,16 +337,22 @@ jobs: # 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 # 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 ' 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")); + 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"]) { if (!fs.existsSync(path.join(root, p))) { console.error("bundle is missing " + p); process.exit(1); } } console.log("bundle contents check: ok"); - ' "$OUT" + ' "$OUT" "$VERSION" tar -C dist -czf "dist/module-uo-${VERSION}.tar.gz" "module-uo-${VERSION}" rm -rf "$OUT" @@ -191,36 +380,10 @@ jobs: echo "${SHA} module-uo-${VERSION}.tar.gz" > dist/SHA256SUMS cat "dist/module-uo-${VERSION}.json" - - name: Write the changelog - if: ${{ steps.plan.outputs.release == 'true' }} - 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 - + # Skipped on a recovery run: the tag is already there and is the thing being + # published against. - name: Tag the release - if: ${{ steps.plan.outputs.release == 'true' }} + if: ${{ steps.plan.outputs.release == 'true' && steps.plan.outputs.reuse_tag != 'true' }} env: REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | diff --git a/README.md b/README.md index d4d6407..ba241fc 100644 --- a/README.md +++ b/README.md @@ -134,11 +134,20 @@ website. Module delivery is website-side only. ### Releases -A merge to `main` that leaves `module.json` at a version with no release yet publishes one. The -version is **declared**, not computed from commit subjects: `module.json`'s version is what core -records in `installed_modules` and shows on the admin screen, and it sits beside the `coreApi` range -a bump usually has to be weighed against — two sources for one number is how they drift. Bumping it -is an ordinary reviewed PR. +**Every merge to `main` that carries a releasable commit publishes a bundle.** The next version is +computed from conventional-commit subjects since the newest `v*` tag, as in `link` and `installer`: +`feat!:` or `BREAKING CHANGE` is a major, `feat:` a minor, `fix:` or `perf:` a patch, and a `main` +that gained none of those cuts no release. The number that ships is the **tag**, and CI writes it +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: