docs(book): teach the derived release version, and move the template onto it #8

Merged
whitlocktech merged 1 commits from docs/release-cadence into main 2026-08-19 18:06:40 +00:00
3 changed files with 448 additions and 145 deletions
Showing only changes of commit 3979fa5abf - Show all commits

View File

@@ -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.

View File

@@ -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:-<none>}"
# 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() { # <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
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 }}"

View File

@@ -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:-<none>}"
# `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() { # <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
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 }}"
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" \