# ── 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//`, 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//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. 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