From 3979fa5abfdd98e6c06c7279aae9fa4c5ac0432e Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 19 Aug 2026 13:01:51 -0500 Subject: [PATCH] docs(book): teach the derived release version, and move the template onto it The template shipped the declared-version release engine the reference module has just abandoned: publish when a push to `main` leaves `module.json` at a version with no release yet. Both flavours of the workflow move to the engine `link`, `installer` and now Module-uo run - feat!/BREAKING CHANGE -> major, feat -> minor, fix|perf -> patch - with `module.json` kept as a floor and a `workflow_dispatch` backdoor for a manifest change with no releasable code behind it. The tag is the number that ships, and the job writes it into the `module.json` inside the bundle. The chapter keeps the declared model in view rather than deleting it, because the reason it was abandoned is the part a reader needs: its cost is paid on every release, and the drift it prevents is something review catches anyway. A week of merged work in the reference module produced no bundle at all. Also carried over from the same pass: a tag pushed without a release behind it is recovered instead of standing down forever, and the changelog moved into the plan step (so assemble clears `$OUT`, not `dist/`). Kept: the `# CHANGE THESE` banner, the exclusion list from the acceptance run's F4, and the GitHub twin's `MODULE_SOURCE_HOSTS` note. checkLinks, checkRenameSites and checkChapterPaths pass. Co-Authored-By: Claude --- book/02-website-module.md | 29 ++- template/.gitea/workflows/release.yml | 283 +++++++++++++++++++------ template/.github/workflows/release.yml | 281 +++++++++++++++++------- 3 files changed, 448 insertions(+), 145 deletions(-) diff --git a/book/02-website-module.md b/book/02-website-module.md index cbda1f5..6c93f79 100644 --- a/book/02-website-module.md +++ b/book/02-website-module.md @@ -650,11 +650,30 @@ installed, the schema fragment, the OpenAPI fragment. Core downloads the tarball verifies it against the `sha256` in the install manifest, and unpacks it. Nothing runs `npm` on the way. -**The version is declared in `module.json`, not computed from commit subjects.** -You already have one authoritative version — it is what core records and what the -admin panel shows — and two sources for one number is how they drift. A release -happens when a push to `main` leaves a version that has no release yet, so -bumping is an ordinary reviewed change and publishing is the workflow's business. +**The version is computed from your commit subjects, and `module.json`'s is a +floor.** Every push to `main` carrying a `feat:`, `fix:` or `perf:` publishes a +bundle — `feat!:` and `BREAKING CHANGE` make it a major, `feat:` a minor, the +rest a patch — and a `main` that gained none of those cuts no release. The number +that ships is the **tag**, which the workflow writes into the `module.json` inside +the bundle. + +The alternative is tempting and it is what this project's own reference module +did first: let `module.json`'s version decide, and release whenever a push leaves +it at a version with no release yet. You already have that number, it is what core +records and what the admin panel shows, and two sources for one number is how they +drift. It was abandoned on 2026-08-19 for a reason worth knowing before you copy +either shape — **its cost is paid on every release, and the drift it prevents is +something review catches anyway.** A week of merged work produced no bundle at +all, because none of it happened to touch that line, and shipping it meant first +merging a pull request whose entire content was a number. + +So the declaration is kept, demoted to a floor: name a version in `module.json` +above the newest tag and *that* is what releases. It is still how you say "this +one is a minor" when a `coreApi` bump forces the question. And for a change with +nothing releasable behind it — a widened `coreApi`, a new mount, a new capability +— run the workflow by hand: leave `version` blank to bump the newest tag by +`bump`, or type an exact version. + The workflow tags and publishes and never writes to a branch, so a protected `main` needs no exception. diff --git a/template/.gitea/workflows/release.yml b/template/.gitea/workflows/release.yml index d7dc40b..09274ba 100644 --- a/template/.gitea/workflows/release.yml +++ b/template/.gitea/workflows/release.yml @@ -19,19 +19,45 @@ # 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 `module.json` is a floor ────────────────── # -# Your module already has one authoritative version: `module.json`'s. It is what -# core records in `installed_modules`, what the admin screen shows, and it sits -# beside the `coreApi` range you have to consider a bump against. Two sources for -# one number is how they drift — so **a release happens when a push to `main` -# leaves `module.json` at a version that has no release yet.** Bumping the version -# is an ordinary reviewed pull request; publishing is this file's business. +# **Every push to `main` carrying a releasable commit publishes a bundle.** The +# next version is computed from conventional-commit subjects since the newest +# `v*` tag: # -# It follows that this workflow never writes to a branch. It tags and publishes, -# so a protected `main` needs no push exception — which matters, because a release -# engine that has to push to `main` stops working the day someone tightens the -# rule. Re-running on an already-released version is a no-op. +# 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 obvious alternative is to let `module.json`'s version decide — you already +# have that number, it is what core records in `installed_modules` and what the +# admin screen shows, and two sources for one number is how they drift. This +# repository's reference module shipped that way and moved off it, which is worth +# knowing before you copy either shape: the cost of a declared version is paid on +# **every** release, and the drift it prevents is something review catches anyway. +# A week of merged work there produced no bundle at all, because none of it +# happened to touch that line. +# +# **The declaration is kept as a floor.** Name a version in `module.json` above +# the newest tag and that version is what releases — which is the declared model +# exactly, surviving as the special case it always was, and still the natural way +# to say "this one is a minor" when a `coreApi` bump forces the question. +# +# So the number that ships is the **tag**, and this job writes it into the +# `module.json` inside the bundle. Your committed `module.json` is a floor and a +# starting point, not a record of the last release. +# +# ── The backdoor ────────────────────────────────────────────────────────── +# +# `workflow_dispatch` publishes on demand, for the case the rules cannot reach: a +# `module.json` change worth shipping — a widened `coreApi`, a new mount, a new +# capability — with no releasable code behind it. Leave `version` blank to bump +# the newest tag by `bump`, or name an exact version to publish that. +# +# This workflow never writes to a branch. It tags and publishes, so a protected +# `main` needs no push exception — which matters, because a release engine that +# has to push to `main` stops working the day someone tightens the rule. +# Re-running on an already-released version is a no-op. # # ── Before this can run ─────────────────────────────────────────────────── # @@ -44,6 +70,16 @@ name: Release on: push: branches: [main] + workflow_dispatch: + inputs: + version: + description: 'Exact version to publish (e.g. 0.2.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 @@ -67,34 +103,157 @@ 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 + mkdir -p dist + git fetch --tags --force >/dev/null 2>&1 || true + ID="$(node -p "require('./module.json').id")" - VERSION="$(node -p "require('./module.json').version")" - echo "module.json: ${ID} ${VERSION}" + 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: ${ID}, declaring ${DECLARED}; newest tag is ${LAST_TAG:-}" - # Does a release already exist for this version? 404 means no, 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. - 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)" + SUBJECTS="$(git log --no-merges --format='%s' $RANGE || true)" + BODIES="$(git log --no-merges --format='%B' $RANGE || true)" - 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 + 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 - echo "id=${ID}" >> "$GITHUB_OUTPUT" - echo "version=${VERSION}" >> "$GITHUB_OUTPUT" - echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT" - echo "release=${RELEASE}" >> "$GITHUB_OUTPUT" + 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 compare because 0.10.0 vs 0.9.0 + # is exactly what a plain 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 + VERSION="${IN_VERSION}" + echo "dispatch: publishing the requested version ${VERSION}" + else + LEVEL="$BUMP" + # A dispatch with nothing releasable still releases — that is what the + # button is for. Where the log does say something, the LARGER of the + # two wins: pressing the button on a log full of `feat:` would + # otherwise publish the `patch` default over a minor's worth of work. + 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" + else + echo "dispatch: the log says ${BUMP}, publishing a ${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, whatever the subjects say. + 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, and standing down on the tag alone would make that state + # permanent — every later run sees the tag and stands down, so the + # release never appears. 404 means no release, 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. + REUSE_TAG=false + if [ -n "$VERSION" ] && git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then + 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)" + case "$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 ${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: 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 "## ${ID} v${VERSION}" + echo + echo "Install from the website's Admin → Modules screen by pasting the URL of" + echo "\`${ID}-${VERSION}.json\`, or unpack the tarball onto the modules volume as" + echo "\`modules/${ID}/\`. 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 \`${ID}-${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 "id=${ID}" >> "$GITHUB_OUTPUT" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT" + echo "release=${RELEASE}" >> "$GITHUB_OUTPUT" + echo "reuse_tag=${REUSE_TAG}" >> "$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 @@ -142,12 +301,20 @@ jobs: ID="${{ steps.plan.outputs.id }}" VERSION="${{ steps.plan.outputs.version }}" OUT="dist/${ID}-${VERSION}" - rm -rf dist && mkdir -p "$OUT" + # `$OUT`, not `dist` — the changelog is already sitting in `dist/` from + # the plan step, and the publish step reads it back. + rm -rf "$OUT" && mkdir -p "$OUT" - # The manifest core reads, the OpenAPI fragment, and the licence the - # code is under — a bundle shipping 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. + # Your committed `module.json` is a floor, not a record of the last + # release, so copying it verbatim would ship a bundle whose + # `installed_modules` row and admin screen disagree with the tag it came + # from. This is where the derived number becomes the module's own. + jq --arg v "$VERSION" '.version = $v' module.json > "$OUT/module.json" + + # The OpenAPI fragment, and the licence the code is under — a bundle + # shipping GPL code without its licence is not distributable. + cp swagger-fragment.json LICENSE.md README.md "$OUT/" # The server half, minus everything that never runs inside core's # process: no `test/`, no `scripts/`, no `swagger/`. @@ -177,17 +344,23 @@ jobs: # Prove the bundle is loadable before publishing it: these are the exact # paths core's loader resolves out of module.json. A release whose entry # point is missing otherwise fails on an operator's box, as a - # `startup_failed` row, instead of here. + # `startup_failed` row, instead of here. The version assertion guards the + # rewrite above — a bundle still carrying 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 && m.client.entry, "swagger-fragment.json"]) { if (!p) continue; 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/${ID}-${VERSION}.tar.gz" "${ID}-${VERSION}" rm -rf "$OUT" @@ -213,38 +386,10 @@ jobs: echo "${SHA} ${ID}-${VERSION}.tar.gz" > dist/SHA256SUMS cat "dist/${ID}-${VERSION}.json" - - name: Write the changelog - if: ${{ steps.plan.outputs.release == 'true' }} - run: | - set -euo pipefail - ID="${{ steps.plan.outputs.id }}" - 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 "## ${ID} v${VERSION}" - echo - echo "Install from the website's Admin → Modules screen by pasting the URL of" - echo "\`${ID}-${VERSION}.json\`, or unpack the tarball onto the modules volume as" - echo "\`modules/${ID}/\`. Requires a core whose \`MODULE_API_VERSION\` satisfies" - echo "\`$(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 \`${ID}-${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' }} run: | set -euo pipefail TAG="${{ steps.plan.outputs.tag }}" diff --git a/template/.github/workflows/release.yml b/template/.github/workflows/release.yml index 05d2e24..793c89b 100644 --- a/template/.github/workflows/release.yml +++ b/template/.github/workflows/release.yml @@ -23,13 +23,33 @@ # 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 `module.json` is a floor ────────────────── # -# Your module already has one authoritative version: `module.json`'s. It is what -# core records in `installed_modules` and what the admin screen shows. Two sources -# for one number is how they drift — so **a release happens when a push to `main` -# leaves `module.json` at a version that has no release yet.** Bumping the version -# is an ordinary reviewed pull request; publishing is this file's business. +# **Every push to `main` carrying a releasable commit publishes a bundle.** The +# next version is computed from conventional-commit subjects since the newest +# `v*` tag: +# +# 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 obvious alternative is to let `module.json`'s version decide — you already +# have that number, and two sources for one number is how they drift. This +# repository's reference module shipped that way and moved off it, which is worth +# knowing before you copy either shape: the cost of a declared version is paid on +# **every** release, and the drift it prevents is something review catches anyway. +# A week of merged work there produced no bundle at all, because none of it +# happened to touch that line. +# +# **The declaration is kept as a floor.** Name a version in `module.json` above +# the newest tag and that version is what releases — the declared model surviving +# as the special case it always was, and still the natural way to say "this one +# is a minor" when a `coreApi` bump forces the question. +# +# So the number that ships is the **tag**, and this job writes it into the +# `module.json` inside the bundle. `workflow_dispatch` is the backdoor for a +# `module.json` change worth shipping with no releasable code behind it: leave +# `version` blank to bump the newest tag by `bump`, or name an exact version. # # This workflow never writes to a branch, so a protected `main` needs no push # exception. Re-running on an already-released version is a no-op. @@ -44,6 +64,16 @@ name: Release on: push: branches: [main] + workflow_dispatch: + inputs: + version: + description: 'Exact version to publish (e.g. 0.2.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' permissions: contents: write @@ -65,36 +95,162 @@ jobs: with: node-version: 20 - - name: Decide whether this commit releases + - name: Plan the release (version + changelog) id: plan env: GH_TOKEN: ${{ github.token }} + EVENT: ${{ github.event_name }} + IN_VERSION: ${{ github.event.inputs.version }} + IN_BUMP: ${{ github.event.inputs.bump }} run: | set -euo pipefail + mkdir -p dist + git fetch --tags --force >/dev/null 2>&1 || true + ID="$(node -p "require('./module.json').id")" - VERSION="$(node -p "require('./module.json').version")" - echo "module.json: ${ID} ${VERSION}" + 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: ${ID}, declaring ${DECLARED}; newest tag is ${LAST_TAG:-}" - # `gh release view` exits non-zero when the release does not exist — but - # it also exits non-zero when the API is unreachable, and those two are - # not the same answer. Ask for the status code instead: 404 means no, - # 200 means yes, anything else is not evidence of absence, and guessing - # "no" would publish over a good release. - HTTP="$(curl -s -o /dev/null -w '%{http_code}' \ - -H "Authorization: Bearer ${GH_TOKEN}" \ - -H "Accept: application/vnd.github+json" \ - "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases/tags/v${VERSION}" || echo 000)" + SUBJECTS="$(git log --no-merges --format='%s' $RANGE || true)" + BODIES="$(git log --no-merges --format='%B' $RANGE || true)" - 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 + 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 - echo "id=${ID}" >> "$GITHUB_OUTPUT" - echo "version=${VERSION}" >> "$GITHUB_OUTPUT" - echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT" - echo "release=${RELEASE}" >> "$GITHUB_OUTPUT" + 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 compare because 0.10.0 vs 0.9.0 + # is exactly what a plain 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 + VERSION="${IN_VERSION}" + echo "dispatch: publishing the requested version ${VERSION}" + else + LEVEL="$BUMP" + # A dispatch with nothing releasable still releases — that is what the + # button is for. Where the log does say something, the LARGER of the + # two wins: pressing the button on a log full of `feat:` would + # otherwise publish the `patch` default over a minor's worth of work. + 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" + else + echo "dispatch: the log says ${BUMP}, publishing a ${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, whatever the subjects say. + 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 + + # A tag with no release behind it is NOT "nothing to do": it means a + # previous run tagged and then died before publishing, and standing down + # on the tag alone makes that state permanent. `gh release view` exits + # non-zero when the release does not exist — but it also exits non-zero + # when the API is unreachable, and those two are not the same answer. + # Ask for the status code instead: 404 means no, 200 means yes, anything + # else is not evidence of absence, and guessing "no" would publish over + # a good release. + REUSE_TAG=false + if [ -n "$VERSION" ] && git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then + HTTP="$(curl -s -o /dev/null -w '%{http_code}' \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases/tags/v${VERSION}" || echo 000)" + case "$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 ${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: 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 "## ${ID} v${VERSION}" + echo + echo "Install from the website's Admin → Modules screen by pasting the URL of" + echo "\`${ID}-${VERSION}.json\`, or unpack the tarball onto the modules volume as" + echo "\`modules/${ID}/\`. Requires a core whose \`MODULE_API_VERSION\` satisfies" + echo "\`$(node -p "require('./module.json').coreApi")\`." + echo + echo "The website only installs from hosts on its \`MODULE_SOURCE_HOSTS\` allowlist —" + echo "an operator installing this needs \`github.com\` on theirs." + 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 \`${ID}-${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 "id=${ID}" >> "$GITHUB_OUTPUT" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT" + echo "release=${RELEASE}" >> "$GITHUB_OUTPUT" + echo "reuse_tag=${REUSE_TAG}" >> "$GITHUB_OUTPUT" + echo "==> release=${RELEASE} version=${VERSION} bump=${BUMP} declared=${DECLARED} last_tag=${LAST_TAG:-}" - name: Build the client chunk if: ${{ steps.plan.outputs.release == 'true' }} @@ -126,12 +282,20 @@ jobs: ID="${{ steps.plan.outputs.id }}" VERSION="${{ steps.plan.outputs.version }}" OUT="dist/${ID}-${VERSION}" - rm -rf dist && mkdir -p "$OUT" + # `$OUT`, not `dist` — the changelog is already sitting in `dist/` from + # the plan step, and the publish step reads it back. + rm -rf "$OUT" && mkdir -p "$OUT" - # The manifest core reads, the OpenAPI fragment, and the licence the - # code is under — a bundle shipping 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. + # Your committed `module.json` is a floor, not a record of the last + # release, so copying it verbatim would ship a bundle whose + # `installed_modules` row and admin screen disagree with the tag it came + # from. This is where the derived number becomes the module's own. + jq --arg v "$VERSION" '.version = $v' module.json > "$OUT/module.json" + + # The OpenAPI fragment, and the licence the code is under — a bundle + # shipping GPL code without its licence is not distributable. + cp swagger-fragment.json LICENSE.md README.md "$OUT/" # The server half, minus everything that never runs inside core's # process: no `test/`, no `scripts/`, no `swagger/`. @@ -163,14 +327,18 @@ jobs: # `startup_failed` row, instead of here. 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 && m.client.entry, "swagger-fragment.json"]) { if (!p) continue; 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/${ID}-${VERSION}.tar.gz" "${ID}-${VERSION}" rm -rf "$OUT" @@ -196,39 +364,6 @@ jobs: echo "${SHA} ${ID}-${VERSION}.tar.gz" > dist/SHA256SUMS cat "dist/${ID}-${VERSION}.json" - - name: Write the changelog - if: ${{ steps.plan.outputs.release == 'true' }} - run: | - set -euo pipefail - ID="${{ steps.plan.outputs.id }}" - 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 "## ${ID} v${VERSION}" - echo - echo "Install from the website's Admin → Modules screen by pasting the URL of" - echo "\`${ID}-${VERSION}.json\`, or unpack the tarball onto the modules volume as" - echo "\`modules/${ID}/\`. Requires a core whose \`MODULE_API_VERSION\` satisfies" - echo "\`$(node -p "require('./module.json').coreApi")\`." - echo - echo "The website only installs from hosts on its \`MODULE_SOURCE_HOSTS\` allowlist —" - echo "an operator installing this needs \`github.com\` on theirs." - 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 \`${ID}-${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 and publish if: ${{ steps.plan.outputs.release == 'true' }} env: @@ -239,10 +374,14 @@ jobs: TAG="${{ steps.plan.outputs.tag }}" VERSION="${{ steps.plan.outputs.version }}" - git config user.name 'github-actions[bot]' - git config user.email 'github-actions[bot]@users.noreply.github.com' - git tag -a "$TAG" -m "${ID} ${TAG}" - git push origin "$TAG" + # Skipped on a recovery run — the tag is already there, and is the + # thing being published against. + if [ "${{ steps.plan.outputs.reuse_tag }}" != "true" ]; then + git config user.name 'github-actions[bot]' + git config user.email 'github-actions[bot]@users.noreply.github.com' + git tag -a "$TAG" -m "${ID} ${TAG}" + git push origin "$TAG" + fi gh release create "$TAG" \ --title "$TAG" \ -- 2.49.1