feat(ci): packaging, release and the frozen manifest
Phase 2 of docs/modules/rust/PLAN.md. Phase 1 built five guards and ran them by hand; this repo had no workflows at all, so nothing gated the branch that gets released and there was no way to release it. Three pieces: - **release.yml** — the derived-version engine link, installer and Module-uo already run (conventional-commit subjects since the newest tag; module.json's version survives as a floor; workflow_dispatch as the backdoor), assembling the bundle from an include list and publishing the tarball, the install manifest carrying its sha256, and SHA256SUMS. The tag is the number that ships and CI stamps it into the bundle's own module.json. - **pr-checks.yml** — server tests, check:imports, check:bundle, check:swagger, the client build, client tests and check:externals, plus frozen-manifest. - **frozen-manifest** — clones core at the sha pinned in ci/core-ref.json, generates its route table without this module and with it, and takes the difference. It ran locally against that exact ref: six routes, all documented, no core route moved. That is the first proof by a running core that /rust collides with nothing — phase 1 could only check it by reading, because core mounts /status and /version at a tier root where the loader's own collision probe cannot see them. The bundle carries no node_modules, because the shipped half declares no runtime dependencies (org lead, phase 2). checkBundle.js holds both halves of that: the include list still covers everything server/index.js reaches, and no dependency has appeared without the release learning to pack it. Verified by breaking it — dropping "model" from the list names the exact edit and exits 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
229
.gitea/workflows/pr-checks.yml
Normal file
229
.gitea/workflows/pr-checks.yml
Normal file
@@ -0,0 +1,229 @@
|
||||
# Gate every pull request into `main` on a fast, DB-free check suite, so a broken
|
||||
# build or a failing test can't reach the branch that gets released.
|
||||
#
|
||||
# Mirrors RunicGateway/website's pr-checks.yml — this module is two npm packages
|
||||
# shaped like that repo's `server/` and `client/`, and it is loaded into that
|
||||
# repo's process, so it is checked the same way with the same Node version.
|
||||
#
|
||||
# Phase 1 built all of these checks and ran them BY HAND. That is the gap this
|
||||
# file closes: a guard nothing invokes is a guard whose state nobody knows.
|
||||
#
|
||||
# ── What each job is really asking ───────────────────────────────────────────
|
||||
#
|
||||
# The tests are the ordinary half. The `check:*` scripts are the interesting one,
|
||||
# because they are the acceptance criteria of the module contract itself
|
||||
# (docs/website/MODULE_API.md Part 5) rather than of this module's behaviour:
|
||||
#
|
||||
# • `server: check:imports` — no relative path escapes the module root, and no
|
||||
# shipped file resolves a bare specifier. A module that reaches into core's
|
||||
# tree works right up until core moves a file, and the whole boundary is
|
||||
# worth exactly as much as this check is (§5.1).
|
||||
#
|
||||
# • `server: check:bundle` — the release ships everything the entry point can
|
||||
# reach, and still declares no runtime dependency. Every other job here runs
|
||||
# against the whole repo, but a release is a SUBSET of it (release.yml
|
||||
# assembles from the include list in `ci/bundle.json`), and nothing else
|
||||
# compares the two. Module-uo's v1.0.0 is the cautionary tale: `server/commands/`
|
||||
# arrived in a cutover, the include list did not learn about it, and the
|
||||
# module installed and then died at the register stage on the operator's box
|
||||
# with "Cannot find module './commands/guild.command'". Green in CI, broken
|
||||
# there — because the subset only exists in the release.
|
||||
#
|
||||
# • `client: check:externals` — the BUILT chunk has no bare imports left. That
|
||||
# failure is invisible in source: `import { useState } from 'react'` is
|
||||
# correct in every file, and whether it becomes core's React or a bare
|
||||
# specifier no browser can resolve is decided by vite.config.js. It has to be
|
||||
# asked of the artifact, so it runs after the build. (The other half — a
|
||||
# shared dependency being BUNDLED — fails the build itself, from a
|
||||
# resolution-time guard inside vite.config.js.)
|
||||
#
|
||||
# • `server: check:swagger` — `swagger-fragment.json` describes the routes this
|
||||
# module registers, today. Core has no way to generate it: core is a prebuilt
|
||||
# image, this module arrives on a volume afterwards, and it mounts through a
|
||||
# call no static parser can follow. So the fragment core merges into
|
||||
# `/api/docs.json` is whatever this repo committed, and a stale one documents
|
||||
# a URL surface that does not exist (§2.8).
|
||||
#
|
||||
# • `frozen-manifest` — the job with the interesting shape. It clones CORE at
|
||||
# the ref pinned in `ci/core-ref.json`, generates its route manifest twice
|
||||
# (without this module, then with) and takes the difference. That difference
|
||||
# is what this module serves, and it is checked three ways: it must match the
|
||||
# committed `routes.manifest.json`, it must not have REMOVED or changed one of
|
||||
# core's own routes, and every route in it must have an operation in
|
||||
# `swagger-fragment.json` — the per-module form of core's rule that a route
|
||||
# which isn't in the spec doesn't ship (§5.3, §2.8).
|
||||
#
|
||||
# Nothing else can ask those questions. Every other check here runs against
|
||||
# this repo alone, where a mount prefix is a string in `server/index.js` and a
|
||||
# documented path is a string in a JSON file; whether they name the same URL
|
||||
# is a fact about a running core, and this is the only job that has one. It is
|
||||
# also the only thing that can see the blind spot phase 1 had to check by
|
||||
# reading: core answers several public routes mounted at the TIER ROOT rather
|
||||
# than under a prefix (`/status`, `/version`), which the loader's own collision
|
||||
# probe cannot find, so `/rust` being free is now asserted by a core.
|
||||
#
|
||||
# Enforcement (one-time, in the Gitea UI):
|
||||
# Repository Settings → Branches → Branch Protection (rule for `main`)
|
||||
# • Enable Status Check
|
||||
# • Status check patterns: PR Checks / *
|
||||
# Note: Gitea only lists a context in its dropdown after it has reported once,
|
||||
# so let this workflow run on one PR first. The `PR Checks / *` glob matches
|
||||
# without needing the dropdown, and keeps matching as jobs are added.
|
||||
#
|
||||
# Runner: the shared self-hosted `ubuntu-latest` runner. These jobs need only
|
||||
# Node — no Docker socket, no database.
|
||||
#
|
||||
# Scope note: `edge` is gated as well as `main`, though this repo has no `edge`
|
||||
# branch yet. Multi-phase work lands there first everywhere else in this project,
|
||||
# and gating only the `main` hop would run these checks for the first time at the
|
||||
# cutover — the one moment a red build is most expensive to discover. Naming the
|
||||
# branch before it exists costs nothing; an Android workstream that landed nine
|
||||
# PRs on an ungated `edge` is why it is here from the start.
|
||||
|
||||
name: PR Checks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, edge]
|
||||
|
||||
# A newer push to the same PR cancels the in-flight run.
|
||||
concurrency:
|
||||
group: pr-checks-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# npm's own retry, turned up. The shared runner reads ETIMEDOUT from the registry
|
||||
# often enough to matter, and a red X that means "the network hiccuped" costs a
|
||||
# reviewer more than it costs the runner to retry, and teaches everyone to re-run
|
||||
# rather than read a failure.
|
||||
env:
|
||||
NPM_CONFIG_FETCH_RETRIES: 5
|
||||
NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: 20000
|
||||
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: 120000
|
||||
|
||||
jobs:
|
||||
server-tests:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: server/package-lock.json
|
||||
|
||||
# `npm ci` rather than `npm install`: it also proves the lockfile is in
|
||||
# sync with package.json instead of silently updating it.
|
||||
- name: Install server deps
|
||||
run: npm ci --prefix server
|
||||
|
||||
- name: Run server tests
|
||||
run: npm test --prefix server
|
||||
|
||||
- name: Check the module boundary (MODULE_API.md §5.1)
|
||||
run: npm run check:imports --prefix server
|
||||
|
||||
- name: Check the release ships what the module requires
|
||||
run: npm run check:bundle --prefix server
|
||||
|
||||
- name: Check the OpenAPI fragment is current (MODULE_API.md §2.8)
|
||||
run: npm run check:swagger --prefix server
|
||||
|
||||
client-build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: client/package-lock.json
|
||||
|
||||
- name: Install client deps
|
||||
run: npm ci --prefix client
|
||||
|
||||
# The build comes FIRST, and that ordering is load-bearing. Two of the
|
||||
# client tests read `dist/entry.js` — the chunk's externals, and what it
|
||||
# registers when imported against a fake `window.__rg` — and both skip when
|
||||
# there is no build. Run the other way round they skip silently in CI, which
|
||||
# is the worst of both: green, and not asking the question.
|
||||
- name: Build the client chunk
|
||||
run: npm run build --prefix client
|
||||
|
||||
- name: Run client tests
|
||||
run: npm test --prefix client
|
||||
|
||||
- name: Check the built chunk's externals (MODULE_API.md §3.6)
|
||||
run: npm run check:externals --prefix client
|
||||
|
||||
# ── The URLs this module actually serves ──────────────────────────────────
|
||||
#
|
||||
# Everything above proves the module against itself. This proves it against a
|
||||
# real core: the one place where "the prefix I register" and "the path I
|
||||
# document" are the same fact rather than two strings that ought to agree.
|
||||
#
|
||||
# The module is COPIED into the core checkout, never symlinked — core's loader
|
||||
# filters its scan with `entry.isDirectory()`, which reports a link as a link
|
||||
# and skips it silently, so a symlinked module produces a manifest with no
|
||||
# module routes in it and a diff that looks like the module registering nothing.
|
||||
frozen-manifest:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
path: module
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
# Anonymous HTTPS, and a full clone rather than a shallow one: the pin is a
|
||||
# commit sha, and `--depth 1` can only fetch a branch tip.
|
||||
- name: Clone core at the pinned ref (MODULE_API.md §5.3)
|
||||
run: |
|
||||
REPO=$(node -p "require('./module/ci/core-ref.json').repo")
|
||||
REF=$(node -p "require('./module/ci/core-ref.json').ref")
|
||||
echo "core: $REPO @ $REF"
|
||||
git clone --quiet "$REPO" core
|
||||
git -C core checkout --quiet "$REF"
|
||||
|
||||
- name: Install core's server deps
|
||||
run: npm ci --prefix core/server
|
||||
|
||||
# Core alone. `--check` first, so a pin that no longer regenerates its own
|
||||
# committed manifest fails HERE, naming the pin, instead of showing up below
|
||||
# as this module having removed a route it never touched.
|
||||
- name: Generate core's manifest without this module
|
||||
run: |
|
||||
npm run routes:manifest --prefix core/server -- --check
|
||||
cp core/server/routes.manifest.json before.json
|
||||
|
||||
# The chunk has to exist before the loader will accept the module at all —
|
||||
# `client.entry` is validated during the manifest step of the scan, and a
|
||||
# missing one is a load failure, not a warning.
|
||||
- name: Build the client chunk
|
||||
run: |
|
||||
npm ci --prefix module/client
|
||||
npm run build --prefix module/client
|
||||
|
||||
# No `npm ci` on the installed copy, because the shipped half declares no
|
||||
# runtime dependencies and the release packs no `node_modules` (org lead,
|
||||
# phase 2). `check:bundle` in the job above is what keeps that true; if it
|
||||
# ever stops being true, this step and release.yml both grow an install.
|
||||
- name: Install the module into core
|
||||
run: |
|
||||
mkdir -p core/modules/rust
|
||||
tar -C module --exclude=.git --exclude=node_modules -cf - . | tar -C core/modules/rust -xf -
|
||||
|
||||
- name: Generate core's manifest with this module
|
||||
run: |
|
||||
npm run routes:manifest --prefix core/server
|
||||
cp core/server/routes.manifest.json after.json
|
||||
|
||||
- name: Check the frozen manifest and the fragment's coverage
|
||||
working-directory: module
|
||||
run: node server/scripts/frozenManifest.js --before ../before.json --after ../after.json --check
|
||||
442
.gitea/workflows/release.yml
Normal file
442
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,442 @@
|
||||
# Build and publish the installable bundle: `module-rust-<version>.tar.gz` plus
|
||||
# the manifest carrying its sha256 (docs/website/MODULE_SYSTEM.md §2.3, §2.5).
|
||||
#
|
||||
# ── What a release IS here ──────────────────────────────────────────────────
|
||||
#
|
||||
# **An operator never builds anything** (MODULE_SYSTEM.md §1.14 — the constraint
|
||||
# the whole module system is shaped around). So a release is not source: it is the
|
||||
# directory core's loader expects to find at `modules/rust/`, already assembled —
|
||||
# the prebuilt client chunk, the schema fragment and the OpenAPI fragment — packed
|
||||
# as it will be unpacked. Core'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.
|
||||
#
|
||||
# **And nothing is installed into the bundle either**, which is where this repo
|
||||
# differs from Module-uo: the shipped half declares no runtime dependencies, so
|
||||
# there is no `npm ci --omit=dev` and no `server/node_modules` in the tarball (org
|
||||
# lead, phase 2). That is a decision worth being loud about rather than a detail —
|
||||
# `server/scripts/checkBundle.js` fails the PR that adds a dependency without also
|
||||
# teaching this file to install and pack it, because a bundle that declares an
|
||||
# import it does not carry fails the same way a missing directory does.
|
||||
#
|
||||
# ── The version is DERIVED, and the declaration is a floor ──────────────────
|
||||
#
|
||||
# The engine `link`, `installer` and `Module-uo` already run (MODULE_SYSTEM
|
||||
# §2.7.1, decision 19 as amended):
|
||||
#
|
||||
# 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
|
||||
#
|
||||
# Module-uo learned this the expensive way: it released only when a merge left
|
||||
# `module.json` at a version with no release yet — the version DECLARED, never
|
||||
# computed — and between 2026-08-12 and 2026-08-19 that cost it *every* bundle,
|
||||
# because nine phases of work landed without anyone touching that line.
|
||||
#
|
||||
# **The declared version is kept as a floor, not deleted.** If `module.json` names
|
||||
# a version above the newest tag, that version releases. Raising it by hand is how
|
||||
# you say "this one is a minor, whatever the subjects imply", and it is the natural
|
||||
# place to move when a `coreApi` bump forces the question.
|
||||
#
|
||||
# The number that ships is therefore the TAG, and CI writes it into the
|
||||
# `module.json` inside the bundle at assembly time. The committed `module.json` is
|
||||
# a floor and a starting point, not a record of the last release — a release engine
|
||||
# that has to commit a bump back to `main` stops working the day someone protects
|
||||
# the branch, and this one is protected.
|
||||
#
|
||||
# ── The backdoor ────────────────────────────────────────────────────────────
|
||||
#
|
||||
# `workflow_dispatch` publishes on demand, for the case the rules above cannot
|
||||
# reach: `module.json` changed in a way worth shipping — a widened `coreApi`, a
|
||||
# new mount, a capability — with no releasable code behind it. Leave `version`
|
||||
# blank to bump the newest tag by `bump` (default `patch`), or name an exact
|
||||
# version to publish that. A dispatch releases even when nothing in the log is
|
||||
# releasable; that is the entire point of pressing the button.
|
||||
#
|
||||
# Re-running on a version that is already released is a no-op, so a rerun after an
|
||||
# unrelated failure is safe. A tag that exists with no release behind it is NOT a
|
||||
# no-op — see the recovery branch in the plan step. That state is not theoretical:
|
||||
# `servuo-plugins`' first release pushed its tag and then 401'd on the release API
|
||||
# because the secret was absent, and without the recovery branch the repo would
|
||||
# have been stuck there permanently.
|
||||
#
|
||||
# This workflow never writes to a branch. It tags and publishes, so `main` needs
|
||||
# no push exception.
|
||||
#
|
||||
# Prerequisites (Settings → Actions → Secrets on RunicGateway/Module-Rust):
|
||||
# REGISTRY_TOKEN — Gitea access token with `write:repository`, to 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.1.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-rust
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
GITEA_HOST: gitea.whitlocktech.com
|
||||
REPO: RunicGateway/Module-Rust
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
# Full history: the plan step reads every tag and every subject since the
|
||||
# newest one, and a shallow clone has neither.
|
||||
- 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
|
||||
|
||||
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 declares ${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 field compare because 0.10.0 vs
|
||||
# 0.9.0 is exactly the comparison a 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
|
||||
# The backdoor's exact form. Deliberately unvalidated against the log:
|
||||
# a human typed it, and the already-released check below is the only
|
||||
# guard that matters.
|
||||
VERSION="${IN_VERSION}"
|
||||
echo "dispatch: publishing the requested version ${VERSION}"
|
||||
else
|
||||
LEVEL="$BUMP"
|
||||
# A dispatch with nothing releasable in the log still releases — that
|
||||
# is what the button is for. Where the log DOES say something, the
|
||||
# larger of the two wins rather than the input: pressing the button on
|
||||
# a log full of `feat:` without touching the dropdown would otherwise
|
||||
# publish its `patch` default over a minor's worth of work, and a
|
||||
# version that undersells its own contents cannot be taken back.
|
||||
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"
|
||||
elif [ "$LEVEL" != "$BUMP" ]; then
|
||||
echo "dispatch: the log says ${BUMP}, the run asked for ${LEVEL} — taking ${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 even when the log says nothing and even when the log says
|
||||
# patch.
|
||||
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 — which is what happened on servuo-plugins' first release,
|
||||
# where absent secrets took the release API call to 401 after the tag had
|
||||
# already been pushed. Standing down on the tag alone makes that state
|
||||
# permanent. Note this deliberately OVERRIDES the RELEASE=false above:
|
||||
# with the tag in place there is nothing releasable after it, so the
|
||||
# normal path would stand down, which is why it could never self-heal.
|
||||
# Anything other than 200/404 — a network failure, a bad token — is not
|
||||
# evidence of absence, and guessing "no" would publish over a good
|
||||
# release, so refuse instead.
|
||||
REUSE_TAG=false
|
||||
if [ -n "$VERSION" ] && git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
|
||||
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')"
|
||||
REL_HTTP="$(curl -s -o /dev/null -w '%{http_code}' \
|
||||
-H "Authorization: token ${CI_TOKEN}" \
|
||||
"https://${GITEA_HOST}/api/v1/repos/${REPO}/releases/tags/v${VERSION}" || echo 000)"
|
||||
case "$REL_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 ${REL_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 rather than emitting an empty
|
||||
# list: the range that produced it, i.e. 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 "## module-rust v${VERSION}"
|
||||
echo
|
||||
echo "Install from the website's Admin → Modules screen by pasting the URL of"
|
||||
echo "\`module-rust-${VERSION}.json\`, or unpack the tarball onto the modules volume"
|
||||
echo "as \`modules/rust/\`. Requires a core whose \`MODULE_API_VERSION\` satisfies"
|
||||
echo "\`$(node -p "require('./module.json').coreApi")\`."
|
||||
echo
|
||||
echo "A Rust server also needs the other two halves of the bridge:"
|
||||
echo "[Rust-Link](https://${GITEA_HOST}/RunicGateway/Rust-Link) (the sidecar) and"
|
||||
echo "[Rust-Plugins](https://${GITEA_HOST}/RunicGateway/Rust-Plugins) (the Oxide/Carbon plugin)."
|
||||
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 \`module-rust-${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 "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "release=${RELEASE}" >> "$GITHUB_OUTPUT"
|
||||
echo "reuse_tag=${REUSE_TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "bump=${BUMP}" >> "$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 local 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
|
||||
|
||||
# ── Assemble exactly what an operator's volume gets ──────────────────
|
||||
#
|
||||
# Stated as an INCLUDE list, not 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.
|
||||
#
|
||||
# The list itself lives in `ci/bundle.json`, not here, because it has a
|
||||
# second reader: `server/scripts/checkBundle.js` runs in PR checks and asks
|
||||
# whether the list still covers everything `server/index.js` reaches. One
|
||||
# declaration, two readers, so a new directory cannot go missing quietly.
|
||||
- name: Assemble the bundle
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ steps.plan.outputs.version }}"
|
||||
OUT="dist/module-rust-${VERSION}"
|
||||
rm -rf "$OUT" && mkdir -p "$OUT"
|
||||
|
||||
# The manifest core reads — with the RELEASED version written into it.
|
||||
# The committed `module.json` is a floor, not a record of the last
|
||||
# release (see the header), so copying it verbatim would ship a bundle
|
||||
# whose `installed_modules` row and admin screen disagree with the tag it
|
||||
# came from. This is the one place the derived number becomes the
|
||||
# module's own.
|
||||
jq --arg v "$VERSION" '.version = $v' module.json > "$OUT/module.json"
|
||||
|
||||
# The two fragments, and the licence the code is under — a bundle that
|
||||
# ships GPL code without its licence is not distributable.
|
||||
for f in $(jq -r '.root[]' ci/bundle.json); do
|
||||
cp "$f" "$OUT/"
|
||||
done
|
||||
|
||||
# The server half, minus what never runs inside core's process. No
|
||||
# node_modules: the shipped half declares no runtime dependencies, and
|
||||
# check:bundle is what keeps that true.
|
||||
mkdir -p "$OUT/server"
|
||||
for d in $(jq -r '.server[]' ci/bundle.json); do
|
||||
cp -r "server/$d" "$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 it is published: these are the
|
||||
# paths core's loader resolves out of module.json, and a release whose
|
||||
# entry point is missing fails on an operator's box with a
|
||||
# `startup_failed` row instead of here. The version assertion guards the
|
||||
# rewrite above — a bundle that still carried 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.entry, "swagger-fragment.json"]) {
|
||||
if (!fs.existsSync(path.join(root, p))) { console.error("bundle is missing " + p); process.exit(1); }
|
||||
}
|
||||
console.log("bundle contents check: ok");
|
||||
' "$OUT" "$VERSION"
|
||||
|
||||
# ── And that it can actually LOAD ─────────────────────────────────
|
||||
#
|
||||
# The check above stats the paths `module.json` declares, which is a real
|
||||
# question but a shallow one: Module-uo's v1.0.0 passed exactly that and
|
||||
# was still missing `server/commands/`, because a file reached only by a
|
||||
# require inside `register()` is named nowhere in `module.json`. This
|
||||
# resolves every relative require in the assembled tree and asserts the
|
||||
# target is in it — asked of the artifact, so it also catches a copy that
|
||||
# half failed or a list naming a path that has since moved.
|
||||
#
|
||||
# Run from the SOURCE tree (`server/scripts/` never ships) against the
|
||||
# assembled bundle.
|
||||
node server/scripts/checkBundle.js --bundle "$OUT"
|
||||
|
||||
tar -C dist -czf "dist/module-rust-${VERSION}.tar.gz" "module-rust-${VERSION}"
|
||||
rm -rf "$OUT"
|
||||
|
||||
SHA="$(sha256sum "dist/module-rust-${VERSION}.tar.gz" | cut -d' ' -f1)"
|
||||
SIZE="$(stat -c%s "dist/module-rust-${VERSION}.tar.gz")"
|
||||
|
||||
# The install manifest. Same shape as the installer's bundle JSON — a
|
||||
# per-asset sha256 fetched over HTTPS, no signatures — because that is
|
||||
# the model this project already has and a second one would be a second
|
||||
# thing to get right (MODULE_SYSTEM.md §1.11).
|
||||
jq -n \
|
||||
--arg id "$(node -p "require('./module.json').id")" \
|
||||
--arg name "$(node -p "require('./module.json').name")" \
|
||||
--arg version "$VERSION" \
|
||||
--arg coreApi "$(node -p "require('./module.json').coreApi")" \
|
||||
--arg artifact "module-rust-${VERSION}.tar.gz" \
|
||||
--arg sha256 "$SHA" \
|
||||
--argjson size "$SIZE" \
|
||||
--arg url "https://${GITEA_HOST}/${REPO}/releases/download/v${VERSION}/module-rust-${VERSION}.tar.gz" \
|
||||
'{schema:1, id:$id, name:$name, version:$version, coreApi:$coreApi,
|
||||
artifact:$artifact, url:$url, sha256:$sha256, size:$size}' \
|
||||
> "dist/module-rust-${VERSION}.json"
|
||||
|
||||
echo "${SHA} module-rust-${VERSION}.tar.gz" > dist/SHA256SUMS
|
||||
cat "dist/module-rust-${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' }}
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ steps.plan.outputs.tag }}"
|
||||
git config user.name 'Runic Gateway CI'
|
||||
git config user.email 'ci@whitlocktech.net'
|
||||
git tag -a "$TAG" -m "module-rust ${TAG}"
|
||||
git push origin "$TAG"
|
||||
|
||||
- name: Create the Gitea release and upload the bundle
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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 "module-rust-${VERSION}.tar.gz" "module-rust-${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
|
||||
Reference in New Issue
Block a user