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>
282 lines
13 KiB
YAML
282 lines
13 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 DECLARED, not derived ──────────────────────────────────
|
|
#
|
|
# 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.
|
|
#
|
|
# 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.
|
|
#
|
|
# ── 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]
|
|
|
|
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: Decide whether this commit releases
|
|
id: plan
|
|
env:
|
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
|
run: |
|
|
set -euo pipefail
|
|
ID="$(node -p "require('./module.json').id")"
|
|
VERSION="$(node -p "require('./module.json').version")"
|
|
echo "module.json: ${ID} ${VERSION}"
|
|
|
|
# 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)"
|
|
|
|
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"
|
|
|
|
# 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}"
|
|
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. `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.
|
|
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 "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"
|
|
|
|
- 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
|
|
|
|
- name: Tag the release
|
|
if: ${{ steps.plan.outputs.release == '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
|