Files
Integration-kit/template/.gitea/workflows/release.yml
wtclaude 3979fa5abf
All checks were successful
PR Checks / prose (pull_request) Successful in 7s
PR Checks / template (pull_request) Successful in 33s
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>
2026-08-19 13:01:51 -05:00

427 lines
21 KiB
YAML

# ── Publish an installable bundle (Gitea Actions) ─────────────────────────
#
# **This file does nothing where it sits.** Gitea only runs workflows found at
# the REPOSITORY root, and inside the kit this one is at `template/.gitea/…`. It
# arms itself the moment your copy of `template/` is a repository of its own —
# which is the point: packaging is the part of a module you cannot guess at, and
# copying a file beats retyping one out of a chapter.
#
# There is a GitHub Actions twin next door in `.github/workflows/release.yml`.
# Keep whichever host you use and delete the other.
#
# ── What a release IS ─────────────────────────────────────────────────────
#
# **An operator never builds anything.** That constraint is the shape of the
# whole module system, so a release is not source: it is the directory core's
# loader expects to find at `modules/<id>/`, already assembled — the prebuilt
# client chunk, any runtime dependency installed, the schema fragment and the
# OpenAPI fragment — packed exactly as it will be unpacked. The website's admin
# install downloads the tarball, verifies it against the `sha256` in the manifest,
# and unpacks it onto the volume. Nothing runs `npm` on the way.
#
# ── The version is DERIVED, and `module.json` is a floor ──────────────────
#
# **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, 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 ───────────────────────────────────────────────────
#
# 1. Change GITEA_HOST and REPO below to yours.
# 2. Settings → Actions → Secrets: add REGISTRY_TOKEN, a Gitea access token
# with `write:repository`, so the job can push the tag and create the release.
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
cancel-in-progress: false
env:
# ── CHANGE THESE ────────────────────────────────────────────────────────
GITEA_HOST: gitea.example.com
REPO: your-org/your-module
jobs:
release:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
- 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")"
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
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
# credential `actions/checkout` left in the git config while the release API
# call 401s, leaving the repo tagged and unreleased.
- name: Verify release credentials are configured
if: ${{ steps.plan.outputs.release == 'true' }}
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
if [ -z "$(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" ]; then
echo "::error::Missing Actions secret REGISTRY_TOKEN (needs write:repository) on ${REPO}."
exit 1
fi
echo "Release credentials present."
- name: Build the client chunk
if: ${{ steps.plan.outputs.release == 'true' }}
run: |
npm ci --prefix client
npm run build --prefix client
# `--omit=dev`, and then PACKED. express and swagger-autogen are build- and
# test-time only — the shipped half is handed express on `ctx` — so this
# installs only what `dependencies` declares. Node resolves those by walking
# up from `modules/<id>/server/`, which is why they ship INSIDE the tarball
# rather than being installed on the operator's box.
#
# With no runtime dependencies at all this produces an empty tree and the
# copy below is a no-op. That is the shape to aim for.
- name: Install the shipped runtime dependencies
if: ${{ steps.plan.outputs.release == 'true' }}
run: npm ci --omit=dev --prefix server
# ── Assemble exactly what an operator's volume gets ──────────────────
#
# Stated as an INCLUDE list, never an exclude list. An exclude list ships
# whatever it forgot: the day someone adds `server/tools/` with a scratch
# credential in it, an exclude list packs it and nobody finds out.
- name: Assemble the bundle
if: ${{ steps.plan.outputs.release == 'true' }}
run: |
set -euo pipefail
ID="${{ steps.plan.outputs.id }}"
VERSION="${{ steps.plan.outputs.version }}"
OUT="dist/${ID}-${VERSION}"
# `$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 — 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/`.
#
# An EXCLUSION list, not an include list, and that is the whole point.
# This was `for d in boot.js core.js index.js db model router`, which
# meant adding `server/utils/` — an ordinary thing to do — silently
# dropped it from every release: the bundle check below only resolves
# the five paths module.json names, so nothing failed here, and the
# module died on an operator's box as a `startup_failed` row instead
# (docs/modules/kit-acceptance.md, F4). Excluding is the safe default
# because the failure mode inverts: forget to exclude something and you
# ship a harmless extra file, rather than omitting a required one.
mkdir -p "$OUT/server"
for e in server/*; do
case "$(basename "$e")" in
test|scripts|swagger|package-lock.json) continue ;;
esac
cp -r "$e" "$OUT/server/"
done
# The client half is the BUILT chunk only. `client/src` is source an
# operator has no use for and core will never read.
mkdir -p "$OUT/client/dist"
cp client/dist/entry.js "$OUT/client/dist/"
# 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. 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, 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" "$VERSION"
tar -C dist -czf "dist/${ID}-${VERSION}.tar.gz" "${ID}-${VERSION}"
rm -rf "$OUT"
SHA="$(sha256sum "dist/${ID}-${VERSION}.tar.gz" | cut -d' ' -f1)"
SIZE="$(stat -c%s "dist/${ID}-${VERSION}.tar.gz")"
# The install manifest — the URL an operator pastes into Admin →
# Modules. A per-asset sha256 fetched over HTTPS, no signatures.
jq -n \
--arg id "$ID" \
--arg name "$(node -p "require('./module.json').name")" \
--arg version "$VERSION" \
--arg coreApi "$(node -p "require('./module.json').coreApi")" \
--arg artifact "${ID}-${VERSION}.tar.gz" \
--arg sha256 "$SHA" \
--argjson size "$SIZE" \
--arg url "https://${GITEA_HOST}/${REPO}/releases/download/v${VERSION}/${ID}-${VERSION}.tar.gz" \
'{schema:1, id:$id, name:$name, version:$version, coreApi:$coreApi,
artifact:$artifact, url:$url, sha256:$sha256, size:$size}' \
> "dist/${ID}-${VERSION}.json"
echo "${SHA} ${ID}-${VERSION}.tar.gz" > dist/SHA256SUMS
cat "dist/${ID}-${VERSION}.json"
# 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' && steps.plan.outputs.reuse_tag != 'true' }}
run: |
set -euo pipefail
TAG="${{ steps.plan.outputs.tag }}"
git config user.name 'Module CI'
git config user.email 'ci@example.com'
git tag -a "$TAG" -m "${{ steps.plan.outputs.id }} ${TAG}"
git push origin "$TAG"
- name: Create the release and upload the bundle
if: ${{ steps.plan.outputs.release == 'true' }}
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
ID="${{ steps.plan.outputs.id }}"
TAG="${{ steps.plan.outputs.tag }}"
VERSION="${{ steps.plan.outputs.version }}"
API="https://${GITEA_HOST}/api/v1/repos/${REPO}"
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
REL_ID="$(curl -sSf -X POST "${API}/releases" \
-H "Authorization: token ${CI_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg tag "$TAG" --arg body "$(cat dist/CHANGELOG.md)" \
'{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')" \
| jq -r '.id')"
echo "Created release ${TAG} (id=${REL_ID})"
for f in "${ID}-${VERSION}.tar.gz" "${ID}-${VERSION}.json" SHA256SUMS; do
curl -sSf -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \
-H "Authorization: token ${CI_TOKEN}" \
-F "attachment=@dist/${f}" >/dev/null
echo " uploaded ${f}"
done