Files
wtclaude f8f7014d53
All checks were successful
PR Checks / prose (pull_request) Successful in -38s
PR Checks / template (pull_request) Successful in 29s
fix(kit): everything the acceptance run found — Phase 5 slice 3
A cold agent was given this repo and the documents it links to, and nothing
else — no core source, no module-uo — and asked to build a module for a second
game. It did, in one pass. The record is docs/modules/kit-acceptance.md; this is
the repair list, plus the two things it recommended that were not defects.

The one it could not find, because it had no core to render against: a module
page built exactly as this kit teaches renders OUTSIDE the site. PublicLayout is
the chrome, not the body. Core grew an opt-in `shell` prop for it
(MODULE_API_VERSION 1.5.0, website#148); the template passes shell="narrow" and
chapter 2 explains why you name a width and never a class.

Fixed:

- **F1, and the worst of them, because it lands in the first twenty minutes.**
  `npm run check:swagger` failed on a PRISTINE template on Windows: the check
  compared the committed fragment byte-for-byte and a default Windows clone is
  CRLF while the generator writes LF. The message blamed "the routes or their
  annotations". Now `template/.gitattributes` pins `eol=lf` and the comparison
  normalises line endings anyway — a check may only fail for the reason it names,
  and this one names a diagnosis.
- **F3** — `.gitea/workflows/release.yml` carries `gitea.example.com` and
  `your-org/your-module` under a literal `# CHANGE THESE`, was not in the rename
  checklist, and `checkRenameSites.js` could not match it, so CI was silent by
  construction. Row added, pattern widened. (The agent reported both workflow
  flavours; only the Gitea one is affected — GitHub supplies its own variables.
  Corrected in the record.) The near-miss is kept in the check's comments and its
  suite: the obvious widening is `example\.com`, which fires on a fixture URL in
  checkImports.test.js. Every alternative has to be a string that cannot occur by
  accident, which is the same rule that made the id `examplegame`.
- **F4** — the release bundle's include list was hardcoded, so adding
  `server/utils/` would have silently dropped it from every release while the
  bundle check stayed green. Inverted to an exclusion list, in both flavours, and
  run by hand because a release workflow never executes in CI.
- **F5** — the annotation-quoting warning was wrong in both directions, and the
  correction is measured rather than reasoned. A backtick is harmless (the
  template's own description has two spans and they survive). A `"` is not, and
  it does not throw: `'A "quoted" status'` is silently TRUNCATED to `A "` while
  swagger-autogen prints Success and the error capture sees nothing. The only
  signal is check:swagger blaming your routes.
- **F6** — `template/.gitignore`, so a copied template that is `git init`ed
  inherits ignore rules instead of nothing.
- **F7** — the UI kit is eight exports across five rows, not seven. The contract
  said seven and this kit had faithfully carried the miscount out of it.

Adopted, not defects:

- Chapter 1 now says to run every check on the untouched copy first. That is what
  found F1; without a baseline the first failure is ambiguous forever.
- The template ships the §2.7 self-check the agent wrote for itself. The rule has
  no CI in general — an outbound socket is not statically detectable — but a
  module can make a decidable claim about its own tree. Ported from its code with
  a header explaining how to NARROW it when a sidecar client arrives, since
  talking to your sidecar is the expected shape and is not what §2.7 forbids.

The pin moves to website edge 4ad8b2b, the 1.5.0 bump, and template/module.json
declares ^1.5.0 — so checkCoreApi's equality assertion still holds and the
template uses a member that exists only at that ref and later.

32 server + 18 client template tests, 21 kit-script tests, all four checks green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 14:40:02 -05:00

253 lines
12 KiB
YAML

# ── Publish an installable bundle (GitHub Actions) ────────────────────────
#
# The GitHub twin of `.gitea/workflows/release.yml`. **Keep whichever host your
# module lives on and delete the other** — nothing breaks if both are present,
# but two release engines racing to tag the same version is a mess nobody needs.
#
# **This file does nothing where it sits.** Workflows run only from the
# REPOSITORY root, and inside the kit this one is at `template/.github/…`. It arms
# itself the moment your copy of `template/` is a repository of its own.
#
# Nothing about a module's release depends on where it is hosted: core installs
# from a **URL**. Point Admin → Modules at the install manifest this job attaches
# to the release and add your host to the website's `MODULE_SOURCE_HOSTS`
# allowlist, and a module released here installs exactly like one released
# anywhere else.
#
# ── What a release IS ─────────────────────────────────────────────────────
#
# **An operator never builds anything.** 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
# 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 ──────────────────────────────────
#
# 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.
#
# 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.
#
# ── Before this can run ───────────────────────────────────────────────────
#
# Nothing to configure. `GITHUB_TOKEN` is provided automatically; the `contents:
# write` permission below is what lets it push a tag and create a release.
name: Release
on:
push:
branches: [main]
permissions:
contents: write
concurrency:
group: release-module
cancel-in-progress: false
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: Decide whether this commit releases
id: plan
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
ID="$(node -p "require('./module.json').id")"
VERSION="$(node -p "require('./module.json').version")"
echo "module.json: ${ID} ${VERSION}"
# `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)"
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
echo "id=${ID}" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
echo "release=${RELEASE}" >> "$GITHUB_OUTPUT"
- 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}"
rm -rf dist && 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 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.
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.
node -e '
const fs = require("fs"), path = require("path");
const root = process.argv[1];
const m = JSON.parse(fs.readFileSync(path.join(root, "module.json"), "utf8"));
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"
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 "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/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"
- 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:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
ID="${{ steps.plan.outputs.id }}"
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"
gh release create "$TAG" \
--title "$TAG" \
--notes-file dist/CHANGELOG.md \
"dist/${ID}-${VERSION}.tar.gz" \
"dist/${ID}-${VERSION}.json" \
dist/SHA256SUMS