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 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
ID="$(node -p "require('./module.json').id")"
|
||||
VERSION="$(node -p "require('./module.json').version")"
|
||||
echo "module.json: ${ID} ${VERSION}"
|
||||
mkdir -p dist
|
||||
git fetch --tags --force >/dev/null 2>&1 || true
|
||||
|
||||
# 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.
|
||||
ID="$(node -p "require('./module.json').id")"
|
||||
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:-<none>}"
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
# `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
|
||||
404) RELEASE=true ;;
|
||||
200) RELEASE=false; echo "v${VERSION} is already released — nothing to do." ;;
|
||||
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:-<none>}"
|
||||
|
||||
# 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 }}"
|
||||
|
||||
255
template/.github/workflows/release.yml
vendored
255
template/.github/workflows/release.yml
vendored
@@ -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
|
||||
ID="$(node -p "require('./module.json').id")"
|
||||
VERSION="$(node -p "require('./module.json').version")"
|
||||
echo "module.json: ${ID} ${VERSION}"
|
||||
mkdir -p dist
|
||||
git fetch --tags --force >/dev/null 2>&1 || true
|
||||
|
||||
# `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.
|
||||
ID="$(node -p "require('./module.json').id")"
|
||||
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:-<none>}"
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
# `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
|
||||
404) RELEASE=true ;;
|
||||
200) RELEASE=false; echo "v${VERSION} is already released — nothing to do." ;;
|
||||
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:-<none>}"
|
||||
|
||||
- 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 }}"
|
||||
|
||||
# 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" \
|
||||
|
||||
Reference in New Issue
Block a user