# ── 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//`, 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//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/`. mkdir -p "$OUT/server" for d in boot.js core.js index.js db model router; do cp -r "server/$d" "$OUT/server/" done cp server/package.json "$OUT/server/" [ -d server/node_modules ] && cp -r server/node_modules "$OUT/server/" || true # 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