Compare commits
7 Commits
622abe9ed5
...
edge
| Author | SHA1 | Date | |
|---|---|---|---|
| e9ca759227 | |||
| a8fa524263 | |||
| 39736f8448 | |||
| 3979fa5abf | |||
| 67aff992a4 | |||
| d497a3b09a | |||
| 744e5b7944 |
@@ -43,9 +43,11 @@ in the contract ([`MODULE_API.md`][api] §2.7, `MODULE_API_VERSION` 1.4.0), not
|
||||
style preference, and chapter 3 is mostly about why. The short version: the
|
||||
website is the internet-facing process and your game is not; the sidecar persists
|
||||
before it forwards, so a website that is down or mid-deploy loses nothing; and a
|
||||
game must never block on a web request. A game that already exposes a
|
||||
remote-control surface — Rust's RCON over WebSocket, say — needs a *thin* sidecar,
|
||||
not none.
|
||||
game must never block on a web request. A game that genuinely delivers events on a
|
||||
surface of its own needs a *thin* sidecar, not none — but check that it delivers
|
||||
events rather than answering questions, because a channel built for an operator
|
||||
typing commands can only be polled, and polling turns "someone left at 14:02" into
|
||||
"the count was different at 14:03".
|
||||
|
||||
## Start here
|
||||
|
||||
|
||||
@@ -147,8 +147,9 @@ statement of your dependencies, and it makes a test double for it — see
|
||||
|
||||
## What you register
|
||||
|
||||
Eight calls, all synchronous, all documented in [§2.4][api]. What is worth knowing
|
||||
is not their signatures but the model behind them.
|
||||
Twelve calls — ten registrations and the two lifecycle hooks — all synchronous,
|
||||
all listed in [§2.4][api]. What is worth knowing is not their signatures but the
|
||||
model behind them.
|
||||
|
||||
**Every call stages; nothing is committed until your whole module is known good.**
|
||||
The shape of a claim is checked at the call, so a malformed one throws with your
|
||||
@@ -279,6 +280,119 @@ Every hook is awaited and none may throw past core: a subscriber's failure costs
|
||||
neither another subscriber nor the save itself. A hiccup in your sidecar breaking
|
||||
somebody's blog post edit would be a worse bug than a stale mirror.
|
||||
|
||||
### Telling core something happened
|
||||
|
||||
Three registrations and one call, and together they are the seam where a module
|
||||
is most tempted to reach past the boundary. The rule that keeps them safe is one
|
||||
sentence: **you declare what CAN happen; core decides who is told.**
|
||||
|
||||
A **trigger** is not a notification stream, and the two are easy to confuse
|
||||
because both are catalogs of things that happen in your game. A stream is a
|
||||
subscribe toggle, and you publish to it yourself. A trigger is a **payload
|
||||
contract**: it names the variables an event carries and how wide an audience it
|
||||
may ever be given, an operator writes rules against it, and *core* does the
|
||||
sending. Their ids share one namespace, so declaring both for the same id is
|
||||
legal — that is one event with a toggle and a contract — while taking an id
|
||||
another module owns is not.
|
||||
|
||||
Two fields on a trigger are worth more than their size.
|
||||
|
||||
**`ceiling` is required and has no default, and the values are ordered by
|
||||
containment rather than by size.** It is the widest audience a rule on this
|
||||
trigger may ever be given. There is no safe value to guess: `owner` silently
|
||||
breaks a broadcast, `authenticated` silently widens something meant for staff.
|
||||
And the ladder reading of the seven values is the trap — a `staff` ceiling does
|
||||
**not** permit `owner`, because "one person" for a cheat-detection event is *the
|
||||
player it was detected on*. Fewer people is not less exposure.
|
||||
|
||||
**`subjectKey` must name one of your declared variables**, because it is what the
|
||||
cooldown is keyed on — "once per house", not "once per user". Core checks it at
|
||||
registration and refuses the module, so this is one you meet at your first boot
|
||||
rather than in production. The check is there because the failure it prevents is
|
||||
the silent kind: a subjectKey naming nothing keys every subject on `undefined`,
|
||||
which looks exactly like the feature working right up until two houses share it.
|
||||
|
||||
Every variable needs an `example`, and it is not decoration: it is what lets an
|
||||
operator preview and test-send a body without waiting for a real game event,
|
||||
which is the reason template systems ship untested. The type set is closed and
|
||||
has no `object` or `array` — a message that has to walk a structure has outgrown
|
||||
interpolation.
|
||||
|
||||
An **audience** is a named set of *people* you can resolve over your own data,
|
||||
for an operator to point a rule at. "This clan's members" is one. "Everyone who
|
||||
opened the last mail" is not, and nothing here builds it.
|
||||
|
||||
**Your resolver returns user ids and nothing else.** It is not handed a template,
|
||||
a channel or an address, and it cannot enumerate them; core maps ids to addresses
|
||||
on its own side, after preferences, suppression and the verification gate. That
|
||||
narrowness is deliberate — a module still cannot send mail, and this is the
|
||||
obvious place a back door would go. Two consequences follow from it:
|
||||
|
||||
- **A resolver that fails resolves to NOBODY**, never to everybody and never to
|
||||
its last good answer. Core enforces that, and your resolver should choose it
|
||||
too, so the log can say which clan.
|
||||
- **Its params are CONSTANT.** An operator fills them in when they save the rule.
|
||||
There is no way to say "the clan this event was about" — if a rule needs that,
|
||||
the *event* carries its own recipients instead. This is the constraint most
|
||||
worth knowing before you design around it rather than after.
|
||||
|
||||
The third registration ships the **content**: the bodies your messages use and
|
||||
the rules that decide when one is sent. Both arrive **switched off**, and
|
||||
`enabled` is not a parameter. An operator turns a module's mail on; installing a
|
||||
module never does.
|
||||
|
||||
The two halves have different lifetimes, and the asymmetry is the contract.
|
||||
**Templates re-ensure on every boot** under a seed version, so a better default
|
||||
reaches deployments that never edited it while one an operator *has* edited is
|
||||
left alone. **Rule groups are offered once, per named group key**, because
|
||||
re-offering would resurrect a rule somebody deleted and reset one they enabled.
|
||||
The consequence is easy to trip over: a rule appended to an existing group
|
||||
reaches **fresh installs only**. That is the guarantee rather than a limitation
|
||||
to route around, and a rule that must reach existing deployments takes a new
|
||||
group key. You name the groups, so the choice is yours to make knowingly.
|
||||
|
||||
Core's generic bodies are a first-class answer rather than a fallback. Point a
|
||||
channel at `notify.event` or `inapp.event` and author nothing; ship a body of your
|
||||
own when the message has something to say that a structural projection of the
|
||||
payload cannot.
|
||||
|
||||
**One thing in a seeded body is not checked when you register it.** The call
|
||||
asserts that `blocks` is a non-empty array and stops there; the body itself is
|
||||
validated by the block registry, which runs in the editor and in the renderer. So
|
||||
a malformed block registers cleanly, seeds cleanly, and first shows itself when an
|
||||
operator opens the body or a rule fires. Build one, open it in Admin → Engagement →
|
||||
Templates once, and you have checked the half that boot cannot.
|
||||
|
||||
Finally the call. `ctx.events.emit(triggerId, envelope)` fires one of your own
|
||||
triggers — core binds the owner from the calling module and never reads it from
|
||||
the arguments, so there is no shape of this call that fires somebody else's
|
||||
event. It returns nothing and, in production, never throws: there is nothing a
|
||||
module could correctly do about a delivery failure from inside a game-event
|
||||
handler, so there is nothing to await. **Outside production it does throw**, at
|
||||
your call site — a payload that does not match the contract you declared is a bug
|
||||
rather than a condition, and the throw is how you meet it in your own tests
|
||||
instead of in an operator's log six weeks later.
|
||||
|
||||
Beside it is the one call that skips the rules entirely.
|
||||
`ctx.inbox.push(userId, item)` writes a single item into a single person's on-site
|
||||
inbox. Reach for it when there is nothing for an operator to decide — a job that
|
||||
person started has finished — and for anything else use a trigger, so the message
|
||||
can be turned off, re-targeted, or sent by mail as well without a code change. The
|
||||
posture is `emit`'s: the owner is bound from the calling module, it returns
|
||||
nothing, and it will not tell you that the user has that channel switched off,
|
||||
because a module that could see that could enumerate people's preferences one
|
||||
write at a time.
|
||||
|
||||
**Emit on the transition, not on the poll.** The template's `refresh()` runs every
|
||||
thirty seconds and emits only when the world's online state actually changed.
|
||||
Core's cooldown and hourly cap would both hold if it did not — but leaning on
|
||||
them means emitting "the world is still up" and calling it news, and the operator
|
||||
who tightens the cooldown to stop it has hidden your bug rather than fixed it.
|
||||
Two smaller traps sit inside the same function and are worth reading in
|
||||
`template/server/boot.js`: the previous state has to be read *before* the write,
|
||||
or every poll looks like no change at all, and the very first boot has no previous
|
||||
state, which is not a change either.
|
||||
|
||||
### Becoming the source of Teams
|
||||
|
||||
`api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders })` — and
|
||||
@@ -650,11 +764,30 @@ installed, the schema fragment, the OpenAPI fragment. Core downloads the tarball
|
||||
verifies it against the `sha256` in the install manifest, and unpacks it. Nothing
|
||||
runs `npm` on the way.
|
||||
|
||||
**The version is declared in `module.json`, not computed from commit subjects.**
|
||||
You already have one authoritative version — it is what core records and what the
|
||||
admin panel shows — and two sources for one number is how they drift. A release
|
||||
happens when a push to `main` leaves a version that has no release yet, so
|
||||
bumping is an ordinary reviewed change and publishing is the workflow's business.
|
||||
**The version is computed from your commit subjects, and `module.json`'s is a
|
||||
floor.** Every push to `main` carrying a `feat:`, `fix:` or `perf:` publishes a
|
||||
bundle — `feat!:` and `BREAKING CHANGE` make it a major, `feat:` a minor, the
|
||||
rest a patch — and a `main` that gained none of those cuts no release. The number
|
||||
that ships is the **tag**, which the workflow writes into the `module.json` inside
|
||||
the bundle.
|
||||
|
||||
The alternative is tempting and it is what this project's own reference module
|
||||
did first: let `module.json`'s version decide, and release whenever a push leaves
|
||||
it at a version with no release yet. You already have that number, it is what core
|
||||
records and what the admin panel shows, and two sources for one number is how they
|
||||
drift. It was abandoned on 2026-08-19 for a reason worth knowing before you copy
|
||||
either shape — **its cost is paid on every release, and the drift it prevents is
|
||||
something review catches anyway.** A week of merged work produced no bundle at
|
||||
all, because none of it happened to touch that line, and shipping it meant first
|
||||
merging a pull request whose entire content was a number.
|
||||
|
||||
So the declaration is kept, demoted to a floor: name a version in `module.json`
|
||||
above the newest tag and *that* is what releases. It is still how you say "this
|
||||
one is a minor" when a `coreApi` bump forces the question. And for a change with
|
||||
nothing releasable behind it — a widened `coreApi`, a new mount, a new capability
|
||||
— run the workflow by hand: leave `version` blank to bump the newest tag by
|
||||
`bump`, or type an exact version.
|
||||
|
||||
The workflow tags and publishes and never writes to a branch, so a protected
|
||||
`main` needs no exception.
|
||||
|
||||
|
||||
@@ -132,28 +132,46 @@ forwarded, a lossy live feed, an authenticated read API with a version on it.
|
||||
|
||||
## "But my game already speaks a remote-control protocol"
|
||||
|
||||
Then your sidecar is **thin**, not absent.
|
||||
Then your sidecar is **thin**, not absent — and be sure the surface you are
|
||||
thinking of actually carries what your module needs, because that is where this
|
||||
question usually goes wrong.
|
||||
|
||||
Rust — the survival game — is the worked example here, in
|
||||
[`rust-dryrun.md`][dryrun]: a module designed on paper for a game chosen for how
|
||||
little it shares with Ultima Online. Rust ships RCON over WebSocket, so a
|
||||
`rust-link` has no protocol to invent and no game-side plugin to write at all. It
|
||||
keeps:
|
||||
A remote-control channel is built for an operator typing commands: it tells you
|
||||
what you asked about, when you ask. What a website needs is what *happened* —
|
||||
every kill, every join, every departure, delivered whether or not anyone was
|
||||
listening at that moment. Those are different products, and a channel that
|
||||
answers the first can only approximate the second by polling it, which turns
|
||||
"someone left the clan at 14:02" into "the count was different at 14:03".
|
||||
|
||||
- the RCON connection, its credentials and its reconnect loop, **out of an Express
|
||||
process** — where the failure mode is a wedged request handler;
|
||||
- a store, so the site is not blank whenever the game is restarting, which for that
|
||||
genre is a daily scheduled event;
|
||||
- an HTTP + WS API with a version on it, so the module talks to one shape of thing
|
||||
regardless of what the game speaks.
|
||||
So the honest test is not *does my game expose a protocol* but **does it deliver
|
||||
events**. If it does, your sidecar keeps that connection, its credentials and its
|
||||
reconnect loop out of an Express process, keeps a store so the site is not blank
|
||||
whenever the game restarts, and presents your module one versioned HTTP + WS
|
||||
shape. That is what thin means: less code, the same architecture.
|
||||
|
||||
It drops the bespoke wire protocol and the plugin. That is what "thin" means: less
|
||||
code, not a different architecture.
|
||||
**Rust is the worked example, and it is not that case.** The dry run in
|
||||
[`rust-dryrun.md`][dryrun] designs a module for it precisely because it shares so
|
||||
little with Ultima Online — and its answer is an **Oxide plugin**: C# loaded by
|
||||
the mod framework a modded Rust server already runs, hooking the game's events and
|
||||
dialling out to a sidecar, exactly as the ServUO overlay does. The Rust server is a
|
||||
*binary*, where ServUO is source a shard owner compiles, so the way in is a
|
||||
published hook API rather than a file you edit. **The three-part shape survives
|
||||
that unchanged**, which is the more useful finding: the plugin-dials-out
|
||||
arrangement is not a property of having source access.
|
||||
|
||||
That document originally concluded the opposite — "no sidecar, the module dials
|
||||
RCON directly" — and it carries a dated correction saying so, rather than having
|
||||
been quietly rewritten. The value of a dry run is the record of what it found,
|
||||
including where it was overruled.
|
||||
It also pairs **one sidecar to one game server**, on that server's own host,
|
||||
rather than one sidecar fronting a community's several — because those servers sit
|
||||
on separate machines, and a shared sidecar would be reached across a network by
|
||||
plugins that are supposed to talk to it over loopback. Worth knowing before you
|
||||
design yours: if your game runs as a fleet, the question "how many sidecars" is
|
||||
answered by where the loopback boundary is, not by how many processes you would
|
||||
rather run. Your module holding several clients is the cheaper end of that trade,
|
||||
and core never learns there is more than one.
|
||||
|
||||
That document reached the game over RCON until 2026-08-19, and before that
|
||||
concluded there should be **no sidecar at all**. It carries both corrections,
|
||||
dated, rather than having been quietly rewritten — the value of a dry run is the
|
||||
record of what it found, including where it was overruled.
|
||||
|
||||
## Building yours
|
||||
|
||||
|
||||
@@ -4,10 +4,17 @@ The chapter with the least code and the highest stakes. Everything else in this
|
||||
book fails by showing an operator a broken web page; this part fails by taking the
|
||||
game down while people are playing it.
|
||||
|
||||
If your game already speaks a remote-control protocol, you may not need any of
|
||||
this — see the end of [chapter 3](03-sidecar.md). If it does not, something has to
|
||||
run inside the game and feed your sidecar, and the rules below are what keep that
|
||||
something from being the reason the server froze.
|
||||
If your game already **delivers events** on a surface of its own, you may not need
|
||||
any of this — see the end of [chapter 3](03-sidecar.md), and read the test there
|
||||
before deciding, because a channel that answers questions is not the same thing.
|
||||
Otherwise something has to run inside the game and feed your sidecar, and the
|
||||
rules below are what keep that something from being the reason the server froze.
|
||||
|
||||
**That something does not have to be source you compile.** The worked example
|
||||
below is an overlay built into a server whose code you have; the dry run for Rust
|
||||
is an **Oxide plugin**, C# loaded by a closed server's own mod framework and
|
||||
hooking published events. Every rule in this chapter applies identically to both —
|
||||
they are properties of being inside a game loop, not of how you got there.
|
||||
|
||||
The worked example is `servuo-plugins`, the Ultima Online shard plugin, whose link
|
||||
layer is one file: `overlay/Scripts/Custom/Bridge/BridgeLink.cs`. It is C# against
|
||||
|
||||
@@ -1,34 +1,47 @@
|
||||
{
|
||||
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
|
||||
"branch": "main",
|
||||
"ref": "963d734dcc09580a7d8bb676370b4faf9b8727b2",
|
||||
"ref": "66bb3b9a3fad01112c06f32d931c9bae56d22de6",
|
||||
"why": [
|
||||
"The core this kit is written against, pinned to a commit rather than a branch.",
|
||||
"This one is the Teams cutover, the commit MODULE_API_VERSION 1.6.0 reached",
|
||||
"`main` on, and 1.6.0 is what template/module.json declares. It moved here from",
|
||||
"the 1.5.0 bump because Teams expanded the contract the book teaches: the",
|
||||
"template now registers a Team provider and declares slots for core to fill,",
|
||||
"and both are members that exist only at this ref and later.",
|
||||
"This one is the engagement cutover, the commit MODULE_API_VERSION 1.9.0 reached",
|
||||
"`main` on, and 1.9.0 is what template/module.json declares. It moved here from",
|
||||
"1.6.0 (the Teams cutover) because engagement expanded the contract the book",
|
||||
"teaches by three registrations and two calls: a module now declares what its",
|
||||
"game can announce and never who is told.",
|
||||
"",
|
||||
"Moving this pin is the moment someone re-reads the chapters: CI asserts the",
|
||||
"version template/module.json declares still equals this core's",
|
||||
"MODULE_API_VERSION, so a contract bump turns this repo red on purpose",
|
||||
"(MODULE_SYSTEM.md 2.11.1 d2, 2.10).",
|
||||
"(MODULE_SYSTEM.md 2.11.1 d2, 2.10). Note what that means in the other",
|
||||
"direction, because it is easy to misread as a safety net: the check clones",
|
||||
"THIS ref, so a core that has moved past it does not turn the repo red on its",
|
||||
"own. Nothing goes red until someone moves the pin. Between cutovers the kit is",
|
||||
"not wrong, it is DATED - and this file is where the date is written down.",
|
||||
"",
|
||||
"That mechanism earned its keep this time. Writing the chapters against 1.6.0",
|
||||
"found that core's inverted-slot fills named three of module-uo's slots",
|
||||
"literally, so the direction worked for that one module and silently did",
|
||||
"nothing for any other game - an empty page with nothing logged. That is the",
|
||||
"exact class of thing a book written for an audience outside this org is meant",
|
||||
"to catch, and it was fixed in core before this pin moved.",
|
||||
"The mechanism earned its keep again here. Writing chapter 2's engagement",
|
||||
"section against 1.9.0 found that the seeded body a module ships is the one",
|
||||
"thing registerEngagementSeeds does not validate - it checks that `blocks` is a",
|
||||
"non-empty array and stops - so the template's own example body had a heading",
|
||||
"level of 2 where the block registry takes 'h2', and no block ids at all. It",
|
||||
"would have registered, seeded, and failed the first time an operator opened it.",
|
||||
"Caught by running the template's register() through core's real registry at",
|
||||
"this ref, which is what a re-read is for; both the fix and the gap are now in",
|
||||
"the chapter and beside the code.",
|
||||
"",
|
||||
"That gap is also why this file's own instruction is not enough on its own. The",
|
||||
"template job builds and tests the template against fakes and checks this",
|
||||
"number; it does not load the module into core. A declaration a fake accepts",
|
||||
"and core refuses would ship green, so a pin move is a run against a real core,",
|
||||
"not just an edit here.",
|
||||
"",
|
||||
"The branch said `edge` until 2026-08-12, when the module system cut over and",
|
||||
"that branch was deleted (MODULE_SYSTEM.md 2.9). Teams cut a second `edge` and",
|
||||
"this pin skipped it entirely: the kit is written against what shipped, never",
|
||||
"against what is in flight. Nothing in CI reads the branch field - it clones",
|
||||
"the repo and checks out the sha - which is why a wrong label here would sit",
|
||||
"unnoticed. It is for the person deciding whether a newer core is worth",
|
||||
"re-reading the book for.",
|
||||
"that branch was deleted (MODULE_SYSTEM.md 2.9). Two later workstreams cut an",
|
||||
"`edge` of their own and this pin skipped both: the kit is written against what",
|
||||
"shipped, never against what is in flight. Nothing in CI reads the branch field",
|
||||
"- it clones the repo and checks out the sha - which is why a wrong label here",
|
||||
"would sit unnoticed. It is for the person deciding whether a newer core is",
|
||||
"worth re-reading the book for.",
|
||||
"",
|
||||
"Same convention as Module-uo's ci/core-ref.json, deliberately - one file, one",
|
||||
"sha, reviewable in a diff."
|
||||
|
||||
@@ -24,8 +24,14 @@ const { stripFences } = require('./lib/markdown')
|
||||
const ROOT = path.resolve(__dirname, '..')
|
||||
const QUIET = process.argv.includes('--quiet')
|
||||
|
||||
// Directories that hold no prose we own.
|
||||
const SKIP_DIRS = new Set(['.git', 'node_modules', 'dist'])
|
||||
// Directories that hold no prose we own. `.core` and `core` are core's own
|
||||
// checkout: .gitignore reserves both because moving `ci/core-ref.json` means
|
||||
// cloning core in here first, and without this that clone hands the reader nine
|
||||
// broken links in somebody else's README the moment they follow the pin-bump
|
||||
// instructions. CI never saw it — the clone happens in the `template` job and
|
||||
// this check runs in `prose` — which is exactly the kind of failure that only
|
||||
// ever meets a person.
|
||||
const SKIP_DIRS = new Set(['.git', 'node_modules', 'dist', '.core', 'core'])
|
||||
|
||||
/** Every markdown file in the repo, repo-relative, sorted. */
|
||||
function markdownFiles(dir = ROOT, out = []) {
|
||||
|
||||
@@ -19,19 +19,45 @@
|
||||
# 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 ──────────────────────────────────
|
||||
# ── The version is DERIVED, and `module.json` is a floor ──────────────────
|
||||
#
|
||||
# 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.
|
||||
# **Every push to `main` carrying a releasable commit publishes a bundle.** The
|
||||
# next version is computed from conventional-commit subjects since the newest
|
||||
# `v*` tag:
|
||||
#
|
||||
# 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.
|
||||
# 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
|
||||
#
|
||||
# The obvious alternative is to let `module.json`'s version decide — you already
|
||||
# have that number, it is what core records in `installed_modules` and what the
|
||||
# admin screen shows, and two sources for one number is how they drift. This
|
||||
# repository's reference module shipped that way and moved off it, which is worth
|
||||
# knowing before you copy either shape: the cost of a declared version is paid on
|
||||
# **every** release, and the drift it prevents is something review catches anyway.
|
||||
# A week of merged work there produced no bundle at all, because none of it
|
||||
# happened to touch that line.
|
||||
#
|
||||
# **The declaration is kept as a floor.** Name a version in `module.json` above
|
||||
# the newest tag and that version is what releases — which is the declared model
|
||||
# exactly, surviving as the special case it always was, and still the natural way
|
||||
# to say "this one is a minor" when a `coreApi` bump forces the question.
|
||||
#
|
||||
# So the number that ships is the **tag**, and this job writes it into the
|
||||
# `module.json` inside the bundle. Your committed `module.json` is a floor and a
|
||||
# starting point, not a record of the last release.
|
||||
#
|
||||
# ── The backdoor ──────────────────────────────────────────────────────────
|
||||
#
|
||||
# `workflow_dispatch` publishes on demand, for the case the rules cannot reach: a
|
||||
# `module.json` change worth shipping — a widened `coreApi`, a new mount, a new
|
||||
# capability — with no releasable code behind it. Leave `version` blank to bump
|
||||
# the newest tag by `bump`, or name an exact version to publish 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 ───────────────────────────────────────────────────
|
||||
#
|
||||
@@ -44,6 +70,16 @@ name: Release
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Exact version to publish (e.g. 0.2.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
|
||||
@@ -67,34 +103,157 @@ jobs:
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Decide whether this commit releases
|
||||
- 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
|
||||
ID="$(node -p "require('./module.json').id")"
|
||||
VERSION="$(node -p "require('./module.json').version")"
|
||||
echo "module.json: ${ID} ${VERSION}"
|
||||
mkdir -p dist
|
||||
git fetch --tags --force >/dev/null 2>&1 || true
|
||||
|
||||
# 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.
|
||||
ID="$(node -p "require('./module.json').id")"
|
||||
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: ${ID}, declaring ${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 compare because 0.10.0 vs 0.9.0
|
||||
# is exactly what a plain 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
|
||||
VERSION="${IN_VERSION}"
|
||||
echo "dispatch: publishing the requested version ${VERSION}"
|
||||
else
|
||||
LEVEL="$BUMP"
|
||||
# A dispatch with nothing releasable still releases — that is what the
|
||||
# button is for. Where the log does say something, the LARGER of the
|
||||
# two wins: pressing the button on a log full of `feat:` would
|
||||
# otherwise publish the `patch` default over a minor's worth of work.
|
||||
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"
|
||||
else
|
||||
echo "dispatch: the log says ${BUMP}, publishing a ${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, whatever the subjects say.
|
||||
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, and standing down on the tag alone would make that state
|
||||
# permanent — every later run sees the tag and stands down, so the
|
||||
# release never appears. 404 means no release, 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.
|
||||
REUSE_TAG=false
|
||||
if [ -n "$VERSION" ] && git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
|
||||
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." ;;
|
||||
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 ${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: 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 "## ${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
|
||||
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 \`${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
|
||||
|
||||
echo "id=${ID}" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "release=${RELEASE}" >> "$GITHUB_OUTPUT"
|
||||
echo "reuse_tag=${REUSE_TAG}" >> "$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
|
||||
@@ -142,12 +301,20 @@ jobs:
|
||||
ID="${{ steps.plan.outputs.id }}"
|
||||
VERSION="${{ steps.plan.outputs.version }}"
|
||||
OUT="dist/${ID}-${VERSION}"
|
||||
rm -rf dist && mkdir -p "$OUT"
|
||||
# `$OUT`, not `dist` — the changelog is already sitting in `dist/` from
|
||||
# the plan step, and the publish step reads it back.
|
||||
rm -rf "$OUT" && 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 manifest core reads — with the RELEASED version written into it.
|
||||
# Your committed `module.json` is a floor, not a record of the last
|
||||
# release, so copying it verbatim would ship a bundle whose
|
||||
# `installed_modules` row and admin screen disagree with the tag it came
|
||||
# from. This is where the derived number becomes the module's own.
|
||||
jq --arg v "$VERSION" '.version = $v' module.json > "$OUT/module.json"
|
||||
|
||||
# The OpenAPI fragment, and the licence the code is under — a bundle
|
||||
# shipping GPL code without its licence is not distributable.
|
||||
cp 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/`.
|
||||
@@ -177,17 +344,23 @@ jobs:
|
||||
# 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.
|
||||
# `startup_failed` row, instead of here. The version assertion guards the
|
||||
# rewrite above — a bundle still carrying 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 = process.argv[1];
|
||||
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 && 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"
|
||||
' "$OUT" "$VERSION"
|
||||
|
||||
tar -C dist -czf "dist/${ID}-${VERSION}.tar.gz" "${ID}-${VERSION}"
|
||||
rm -rf "$OUT"
|
||||
@@ -213,38 +386,10 @@ jobs:
|
||||
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
|
||||
|
||||
# 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' }}
|
||||
if: ${{ steps.plan.outputs.release == 'true' && steps.plan.outputs.reuse_tag != 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ steps.plan.outputs.tag }}"
|
||||
|
||||
255
template/.github/workflows/release.yml
vendored
255
template/.github/workflows/release.yml
vendored
@@ -23,13 +23,33 @@
|
||||
# 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 ──────────────────────────────────
|
||||
# ── The version is DERIVED, and `module.json` is a floor ──────────────────
|
||||
#
|
||||
# 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.
|
||||
# **Every push to `main` carrying a releasable commit publishes a bundle.** The
|
||||
# next version is computed from conventional-commit subjects since the newest
|
||||
# `v*` tag:
|
||||
#
|
||||
# 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
|
||||
#
|
||||
# The obvious alternative is to let `module.json`'s version decide — you already
|
||||
# have that number, and two sources for one number is how they drift. This
|
||||
# repository's reference module shipped that way and moved off it, which is worth
|
||||
# knowing before you copy either shape: the cost of a declared version is paid on
|
||||
# **every** release, and the drift it prevents is something review catches anyway.
|
||||
# A week of merged work there produced no bundle at all, because none of it
|
||||
# happened to touch that line.
|
||||
#
|
||||
# **The declaration is kept as a floor.** Name a version in `module.json` above
|
||||
# the newest tag and that version is what releases — the declared model surviving
|
||||
# as the special case it always was, and still the natural way to say "this one
|
||||
# is a minor" when a `coreApi` bump forces the question.
|
||||
#
|
||||
# So the number that ships is the **tag**, and this job writes it into the
|
||||
# `module.json` inside the bundle. `workflow_dispatch` is the backdoor for a
|
||||
# `module.json` change worth shipping with no releasable code behind it: leave
|
||||
# `version` blank to bump the newest tag by `bump`, or name an exact version.
|
||||
#
|
||||
# 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.
|
||||
@@ -44,6 +64,16 @@ name: Release
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Exact version to publish (e.g. 0.2.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'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -65,36 +95,162 @@ jobs:
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Decide whether this commit releases
|
||||
- name: Plan the release (version + changelog)
|
||||
id: plan
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
EVENT: ${{ github.event_name }}
|
||||
IN_VERSION: ${{ github.event.inputs.version }}
|
||||
IN_BUMP: ${{ github.event.inputs.bump }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ID="$(node -p "require('./module.json').id")"
|
||||
VERSION="$(node -p "require('./module.json').version")"
|
||||
echo "module.json: ${ID} ${VERSION}"
|
||||
mkdir -p dist
|
||||
git fetch --tags --force >/dev/null 2>&1 || true
|
||||
|
||||
# `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.
|
||||
ID="$(node -p "require('./module.json').id")"
|
||||
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: ${ID}, declaring ${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 compare because 0.10.0 vs 0.9.0
|
||||
# is exactly what a plain 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
|
||||
VERSION="${IN_VERSION}"
|
||||
echo "dispatch: publishing the requested version ${VERSION}"
|
||||
else
|
||||
LEVEL="$BUMP"
|
||||
# A dispatch with nothing releasable still releases — that is what the
|
||||
# button is for. Where the log does say something, the LARGER of the
|
||||
# two wins: pressing the button on a log full of `feat:` would
|
||||
# otherwise publish the `patch` default over a minor's worth of work.
|
||||
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"
|
||||
else
|
||||
echo "dispatch: the log says ${BUMP}, publishing a ${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, whatever the subjects say.
|
||||
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
|
||||
|
||||
# A tag with no release behind it is NOT "nothing to do": it means a
|
||||
# previous run tagged and then died before publishing, and standing down
|
||||
# on the tag alone makes that state permanent. `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.
|
||||
REUSE_TAG=false
|
||||
if [ -n "$VERSION" ] && git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
|
||||
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." ;;
|
||||
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 ${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: 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 "## ${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
|
||||
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 \`${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
|
||||
|
||||
echo "id=${ID}" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "release=${RELEASE}" >> "$GITHUB_OUTPUT"
|
||||
echo "reuse_tag=${REUSE_TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "==> release=${RELEASE} version=${VERSION} bump=${BUMP} declared=${DECLARED} last_tag=${LAST_TAG:-<none>}"
|
||||
|
||||
- name: Build the client chunk
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
@@ -126,12 +282,20 @@ jobs:
|
||||
ID="${{ steps.plan.outputs.id }}"
|
||||
VERSION="${{ steps.plan.outputs.version }}"
|
||||
OUT="dist/${ID}-${VERSION}"
|
||||
rm -rf dist && mkdir -p "$OUT"
|
||||
# `$OUT`, not `dist` — the changelog is already sitting in `dist/` from
|
||||
# the plan step, and the publish step reads it back.
|
||||
rm -rf "$OUT" && 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 manifest core reads — with the RELEASED version written into it.
|
||||
# Your committed `module.json` is a floor, not a record of the last
|
||||
# release, so copying it verbatim would ship a bundle whose
|
||||
# `installed_modules` row and admin screen disagree with the tag it came
|
||||
# from. This is where the derived number becomes the module's own.
|
||||
jq --arg v "$VERSION" '.version = $v' module.json > "$OUT/module.json"
|
||||
|
||||
# The OpenAPI fragment, and the licence the code is under — a bundle
|
||||
# shipping GPL code without its licence is not distributable.
|
||||
cp 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/`.
|
||||
@@ -163,14 +327,18 @@ jobs:
|
||||
# `startup_failed` row, instead of here.
|
||||
node -e '
|
||||
const fs = require("fs"), path = require("path");
|
||||
const root = process.argv[1];
|
||||
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 && 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"
|
||||
' "$OUT" "$VERSION"
|
||||
|
||||
tar -C dist -czf "dist/${ID}-${VERSION}.tar.gz" "${ID}-${VERSION}"
|
||||
rm -rf "$OUT"
|
||||
@@ -196,39 +364,6 @@ jobs:
|
||||
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:
|
||||
@@ -239,10 +374,14 @@ jobs:
|
||||
TAG="${{ steps.plan.outputs.tag }}"
|
||||
VERSION="${{ steps.plan.outputs.version }}"
|
||||
|
||||
# Skipped on a recovery run — the tag is already there, and is the
|
||||
# thing being published against.
|
||||
if [ "${{ steps.plan.outputs.reuse_tag }}" != "true" ]; then
|
||||
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"
|
||||
fi
|
||||
|
||||
gh release create "$TAG" \
|
||||
--title "$TAG" \
|
||||
|
||||
@@ -112,6 +112,7 @@ backticking table names**.
|
||||
| `.gitea/workflows/release.yml` | `GITEA_HOST` and `REPO`, under the `# CHANGE THESE` banner — the only two, and they are wrong until you do. (The `.github/` flavour needs nothing: GitHub supplies `GITHUB_REPOSITORY` and friends.) |
|
||||
| `server/package.json` | package `name` and `description` |
|
||||
| `server/core.js` | the message every accessor throws |
|
||||
| `server/index.js` | the trigger, audience, template and rule-group ids — all four are namespaced with your module id, and core refuses them otherwise |
|
||||
| `server/boot.js` | the placeholder world name |
|
||||
| `server/db/schema.sql` | every table name — the prefix must be your id |
|
||||
| `server/db/purge.sql` | the same table names |
|
||||
@@ -123,6 +124,7 @@ backticking table names**.
|
||||
| `server/swagger/doc.js` | the tag, and the `Examplegame…` schema prefix |
|
||||
| `server/scripts/swaggerFragment.js` | the generated fragment's `info.title` |
|
||||
| `server/test/_fakes.js` | `ctx.moduleId` |
|
||||
| `server/test/entry.test.js` | the trigger id the world-status test asserts |
|
||||
| `server/test/worldStatus.test.js` | the fixture's world name |
|
||||
| `server/test/clanProvider.test.js` | the fixture's world name |
|
||||
| `server/package-lock.json` | **regenerated** — `npm install --prefix server` |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "examplegame",
|
||||
"name": "Example Game",
|
||||
"version": "0.1.0",
|
||||
"coreApi": "^1.6.0",
|
||||
"coreApi": "^1.9.0",
|
||||
"server": "server/index.js",
|
||||
"client": { "entry": "client/dist/entry.js" },
|
||||
"schema": "server/db/schema.sql",
|
||||
|
||||
@@ -52,7 +52,45 @@ async function refresh() {
|
||||
try {
|
||||
// A real module calls its sidecar's REST API here. Two hardcoded values
|
||||
// stand in, so that the page renders and the seam is visible.
|
||||
await worldStatusDb.setStatus({ online: true, players: 0, worldName: 'Example World' })
|
||||
const next = { online: true, players: 0, worldName: 'Example World' }
|
||||
|
||||
// ── Emitting a declared event ──────────────────────────────────────────
|
||||
//
|
||||
// **Emit on the TRANSITION, not on the poll.** This function runs every
|
||||
// thirty seconds; a rule on an event fired every thirty seconds is a rule
|
||||
// that mails somebody every thirty seconds. Core has a cooldown and an
|
||||
// hourly cap and they would both hold, but leaning on them means the module
|
||||
// is emitting "the world is still up" and calling it news. Read the previous
|
||||
// state, compare, and emit only when the answer changed.
|
||||
//
|
||||
// The read is BEFORE the write for the same reason, and getting that
|
||||
// backwards is the easy version of this bug: after `setStatus` the previous
|
||||
// value is gone and every poll looks like no change at all — an emitter that
|
||||
// never fires and never errors.
|
||||
const previous = await worldStatusDb.getStatus()
|
||||
await worldStatusDb.setStatus(next)
|
||||
|
||||
// `previous === null` is the first boot on a fresh install, not a change.
|
||||
// Treating it as one would announce the world coming online to everyone the
|
||||
// first time an operator started the site.
|
||||
if (previous && Boolean(previous.online) !== next.online) {
|
||||
// Fire-and-forget: no await, no return value, nothing to handle. Core
|
||||
// validates the payload against what `index.js` declared, and what a
|
||||
// mismatch does depends on where you are running. **In production it is
|
||||
// dropped and logged** against this module, because a notification must
|
||||
// never be able to break the thing it is about. **Anywhere else it throws**,
|
||||
// at this line, so the stack points at your own call instead of at a
|
||||
// warning nobody reads. Neither is a condition to catch: a payload that
|
||||
// does not match the contract you declared is a bug to fix.
|
||||
core.emit('examplegame.world.status_changed', {
|
||||
data: {
|
||||
worldName: next.worldName,
|
||||
status: next.online ? 'online' : 'offline',
|
||||
players: next.players,
|
||||
url: '/world',
|
||||
},
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('could not refresh world status', { error: err.message })
|
||||
}
|
||||
|
||||
@@ -91,6 +91,21 @@ module.exports = {
|
||||
// stack. Routers are built inside `register()`, so `ctx` is set by then.
|
||||
get middleware() { return need().middleware },
|
||||
|
||||
// Firing a declared event (MODULE_API.md §2.3). Wrapped as a call rather than
|
||||
// exposed as `get events()`, so that `require('../core').emit` taken at file
|
||||
// scope still resolves `ctx` at call time like everything else here.
|
||||
//
|
||||
// **It returns nothing, and in production it never throws at the caller.** The
|
||||
// emit is the end of this module's involvement: core validates the payload
|
||||
// against the declared contract, decides which rules match, resolves who they
|
||||
// reach and sends. A module cannot address a person, choose a channel or write
|
||||
// a subject line, and this seam is deliberately too narrow to try (§2.7).
|
||||
//
|
||||
// Outside production a bad payload throws here rather than being logged, which
|
||||
// is the point: you meet the mismatch in your own tests instead of in an
|
||||
// operator's log six weeks later.
|
||||
emit: (triggerId, envelope) => need().events.emit(triggerId, envelope),
|
||||
|
||||
// Deployment facts. `moduleRoot` is the absolute path to `modules/<id>/` — the
|
||||
// only correct way to find a file you shipped, because the working directory is
|
||||
// core's and the module's location is the loader's business.
|
||||
|
||||
@@ -103,6 +103,165 @@ module.exports = function register(ctx, api) {
|
||||
// optional ones is an edit to the provider and not to this file.
|
||||
api.registerTeamProvider(clanProvider)
|
||||
|
||||
// ── Engagement: declaring what your game can announce ────────────────────
|
||||
//
|
||||
// The three calls below are one seam, and it is the one where a module is most
|
||||
// tempted to reach past the boundary. **You declare what CAN happen; core
|
||||
// decides who is told.** A module never names a person, a channel or an
|
||||
// address, and never sends anything (MODULE_API 1.7.0 and 1.9.0; §2.7).
|
||||
//
|
||||
// A TRIGGER is not a notification stream, and the two are easy to confuse
|
||||
// because both are catalogs of things that happen. A stream is a subscribe
|
||||
// toggle you publish to yourself. A trigger is a PAYLOAD CONTRACT an operator
|
||||
// writes rules against — it says what variables the event carries and how wide
|
||||
// an audience it may ever be given, and core does the sending. Their ids share
|
||||
// one namespace, so declaring both for one id is legal and is one event with a
|
||||
// toggle and a contract; taking an id another module owns is not.
|
||||
api.registerEventTriggers([
|
||||
{
|
||||
id: 'examplegame.world.status_changed',
|
||||
label: 'World came up or went down',
|
||||
description: 'The game server changed between online and offline.',
|
||||
kind: 'event',
|
||||
// The cooldown subject: "once per world", not "once per user". It must
|
||||
// NAME one of the variables below — core refuses the registration
|
||||
// otherwise, with this trigger's id in the message, and the module does not
|
||||
// load. That check exists because the failure it prevents is silent: a
|
||||
// subjectKey naming nothing keys every subject on `undefined`, which looks
|
||||
// exactly like the feature working right up until two worlds share it.
|
||||
subjectKey: 'worldName',
|
||||
audience: 'authenticated', // what a rule is CREATED with
|
||||
// ...and the widest it may EVER be given. Required, with no default,
|
||||
// because there is no safe value to guess: `owner` would silently break a
|
||||
// broadcast and `authenticated` would silently widen a staff-only event.
|
||||
// The values are ordered by CONTAINMENT, not by size — see chapter 2.
|
||||
ceiling: 'authenticated',
|
||||
version: 1,
|
||||
variables: [
|
||||
// Every variable needs an `example`, and it is not decoration: it is what
|
||||
// lets an operator preview and test-send a template without waiting for a
|
||||
// real game event, which is the reason template systems ship untested.
|
||||
{ name: 'worldName', type: 'string', required: true, example: 'Example World' },
|
||||
{ name: 'status', type: 'string', required: true, example: 'online' },
|
||||
{ name: 'players', type: 'int', required: false, example: 42 },
|
||||
// A `url` is validated SITE-RELATIVE, because it ends up in an href in a
|
||||
// mail somebody opens days later. Never a full URL of your own.
|
||||
{ name: 'url', type: 'url', required: false, example: '/world' },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
// An AUDIENCE is a named set of PEOPLE this module can resolve over its own
|
||||
// data, for an operator to point a rule at. "This clan's members" is one;
|
||||
// "everyone who opened the last mail" is not, and nothing here builds it.
|
||||
//
|
||||
// **The resolver returns user ids and nothing else.** It is not handed a
|
||||
// template, a channel or an address and it cannot enumerate them — core maps
|
||||
// ids to addresses on its own side, after preferences, suppression and the
|
||||
// verification gate. That is what stops this becoming the back door §2.7 spends
|
||||
// a section closing.
|
||||
//
|
||||
// Audiences are their OWN id space, unlike triggers and streams: an audience
|
||||
// names a set of people and a trigger names an event, so the two may share a
|
||||
// name without colliding.
|
||||
api.registerAudiences([
|
||||
{
|
||||
id: 'examplegame.clan.members',
|
||||
label: 'Members of a clan',
|
||||
// `int` or `string` only, and CONSTANT — an operator fills these in when
|
||||
// they save the rule. There is no way to say "the clan this event was
|
||||
// about"; if a rule needs that, the EVENT carries its own recipients
|
||||
// instead. Finding that out late is a phase's worth of rework.
|
||||
params: [{ id: 'clanId', type: 'string', required: true }],
|
||||
ceiling: 'members',
|
||||
resolve: async ({ clanId }) => clanProvider.listClanMemberUserIds({ clanId }),
|
||||
},
|
||||
])
|
||||
|
||||
// Finally the CONTENT: the bodies your messages use, and the rules that decide
|
||||
// when one is sent. Both arrive **switched off** — `enabled` is not a parameter
|
||||
// and there is no call that sets it. An operator turns a module's mail on;
|
||||
// installing a module never does.
|
||||
//
|
||||
// The two halves have different lifetimes, and the asymmetry is the contract:
|
||||
//
|
||||
// • **Templates are re-ensured on every boot**, under `seedVersion`, so
|
||||
// improving a default body reaches deployments that never edited it — and
|
||||
// one an operator HAS edited is marked customized and left alone. Bump
|
||||
// `seedVersion` when the body changes; never for a comment.
|
||||
// • **Rule groups are offered ONCE, per named group key.** Re-offering would
|
||||
// resurrect a rule an operator deleted and reset one they enabled. So a rule
|
||||
// appended to an existing group reaches FRESH INSTALLS ONLY. That is the
|
||||
// guarantee rather than a limitation to work around: a rule that has to
|
||||
// reach existing deployments takes a NEW group key, and you choose that
|
||||
// knowingly because you name the groups.
|
||||
//
|
||||
// Core's generic bodies are a first-class answer, not a fallback: point a
|
||||
// channel at `notify.event` / `inapp.event` / `notify.digest` and author
|
||||
// nothing. Ship a body of your own when the message has something to say that a
|
||||
// structural projection of the payload cannot. Below, the mail does — a world
|
||||
// coming back deserves a sentence — and the in-app item does not, so it uses
|
||||
// core's.
|
||||
//
|
||||
// Note the two casings, which are not a slip: a TEMPLATE is an object this call
|
||||
// shapes (`triggerId`), and a RULE is a row (`trigger_id`). Copy them as they
|
||||
// are.
|
||||
api.registerEngagementSeeds({
|
||||
templates: [
|
||||
{
|
||||
// MUST be namespaced `<moduleId>.` — the key column is unique across the
|
||||
// whole table, and an unprefixed `notify.event` from a module would
|
||||
// collide with core's own body and win.
|
||||
key: 'examplegame.world-status-changed',
|
||||
name: 'World — status changed',
|
||||
channel: 'email',
|
||||
subject: '{{worldName}} is {{status}}',
|
||||
triggerId: 'examplegame.world.status_changed',
|
||||
triggerVersion: 1,
|
||||
seedVersion: 1,
|
||||
// The same block objects the template editor writes, so an operator can
|
||||
// open this in the admin panel and keep editing from here.
|
||||
//
|
||||
// **This is the one thing in this file core does not check for you.**
|
||||
// `registerEngagementSeeds` asserts that `blocks` is a non-empty array and
|
||||
// stops; the BODY is validated by the block registry, which runs in the
|
||||
// editor and in the renderer. So a malformed block registers, seeds, and
|
||||
// first shows itself when an operator opens the body or a rule fires.
|
||||
// Two that are easy to get wrong: every block carries its own `id`, and
|
||||
// `email.heading`'s `level` is 'h1' | 'h2' | 'h3' — not a number.
|
||||
blocks: [
|
||||
{ id: 'h', type: 'email.heading', props: { level: 'h2', text: '{{worldName}} is {{status}}' } },
|
||||
{ id: 'intro', type: 'email.text', props: { text: 'There are {{players}} players online right now.' } },
|
||||
{ id: 'cta', type: 'email.button', props: { label: 'Open the world page', url: '{{url}}' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
ruleGroups: [
|
||||
{
|
||||
key: 'world-v1',
|
||||
note: 'the world status rule, seeded once',
|
||||
rules: [
|
||||
{
|
||||
trigger_id: 'examplegame.world.status_changed',
|
||||
name: 'World status changes',
|
||||
audience: 'authenticated',
|
||||
channels: ['email', 'inapp'],
|
||||
template_keys: {
|
||||
email: 'examplegame.world-status-changed',
|
||||
inapp: 'inapp.event',
|
||||
},
|
||||
// Two ceilings on volume, and they answer different questions. The
|
||||
// cooldown is per SUBJECT — one mail per world per hour, however many
|
||||
// times it flaps. The hourly cap is per RULE, and is the thing that
|
||||
// keeps a misconfiguration from becoming a mail storm.
|
||||
cooldown_seconds: 3600,
|
||||
max_sends_per_hour: 200,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// The lifecycle hooks (§2.5). `onBoot` runs after core's schema, after this
|
||||
// module's schema fragment, and BEFORE the HTTP listener binds — so a module
|
||||
// that must not serve traffic until it has warmed a cache gets that for free.
|
||||
|
||||
@@ -82,4 +82,25 @@ async function replaceClan({ externalId, name, abbr, memberCount, members }) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { listClans, findClan, listMembers, replaceClan, CLANS, MEMBERS }
|
||||
/**
|
||||
* The site accounts behind one clan's roster — the whole of an audience resolver.
|
||||
*
|
||||
* `user_id` is NULL for most characters, and the filter is the point: an audience
|
||||
* resolves to PEOPLE WITH ACCOUNTS, and a character nobody has linked is not one.
|
||||
* Returning its NULL would hand core a hole in an array it is about to mail.
|
||||
*
|
||||
* DISTINCT because one person may hold several characters in the same clan, and
|
||||
* the resolver's contract is a set of users rather than a list of characters.
|
||||
* Without it a three-character player is told three times.
|
||||
*/
|
||||
async function listMemberUserIds(clanId) {
|
||||
const rows = await core.query(
|
||||
`SELECT DISTINCT user_id AS userId
|
||||
FROM ${MEMBERS}
|
||||
WHERE clan_id = ? AND user_id IS NOT NULL`,
|
||||
[clanId],
|
||||
)
|
||||
return rows.map((r) => r.userId)
|
||||
}
|
||||
|
||||
module.exports = { listClans, findClan, listMembers, listMemberUserIds, replaceClan, CLANS, MEMBERS }
|
||||
|
||||
@@ -292,6 +292,36 @@ async function projectRoster(externalId, members, viewer) {
|
||||
// a page that does not exist, which is worse.
|
||||
const pageUrlTemplate = '/examplegame/clans/{externalId}'
|
||||
|
||||
// ── The audience resolver ─────────────────────────────────────────────────
|
||||
//
|
||||
// Registered in `index.js` as `examplegame.clan.members` and called by core when
|
||||
// a rule pointed at that audience fires. It lives beside the provider because it
|
||||
// answers a question about the same rows, and it is NOT part of the provider —
|
||||
// core calls it through the audience registry, not through the five members
|
||||
// above.
|
||||
//
|
||||
// **Three rules, and every one of them protects somebody's mailbox rather than
|
||||
// this module's correctness.**
|
||||
//
|
||||
// 1. Return user ids and nothing else. You are not handed a template, a channel
|
||||
// or an address, and you may not enumerate them; core maps ids to addresses
|
||||
// on its own side, after preferences, suppression and the verification gate.
|
||||
// 2. Never widen on failure. A resolver that cannot answer returns the EMPTY set
|
||||
// — never "everyone", never the last good answer. Core treats a throw the
|
||||
// same way, but doing it here is what lets the log say which clan.
|
||||
// 3. It is a SET of people, not a list of characters. The `DISTINCT` is in the
|
||||
// query for that reason (see `clanProvider.db.js`).
|
||||
async function listClanMemberUserIds({ clanId }) {
|
||||
try {
|
||||
return await db.listMemberUserIds(clanId)
|
||||
} catch (err) {
|
||||
log.warn('could not resolve clan members; resolving to nobody', {
|
||||
clanId, message: err.message,
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getTeams,
|
||||
getTeamMembers,
|
||||
@@ -300,4 +330,5 @@ module.exports = {
|
||||
rosterVisibleTo,
|
||||
pageUrlTemplate,
|
||||
gameIsReachable,
|
||||
listClanMemberUserIds,
|
||||
}
|
||||
|
||||
@@ -49,6 +49,11 @@ function fakeCtx(overrides = {}) {
|
||||
return log
|
||||
},
|
||||
auth: { getUserFromRequest: spy(null) },
|
||||
// The engagement seam (§2.3). One method, recording, because that is the
|
||||
// whole of what a module may do with it: fire a declared event and stop.
|
||||
// Core's own emit is fire-and-forget and returns nothing, so this does too —
|
||||
// a fake that returned a receipt would invite a module to wait on one.
|
||||
events: { emit: spy(undefined) },
|
||||
middleware: {
|
||||
requireAuth: (req, res, next) => next(),
|
||||
requireRole: () => (req, res, next) => next(),
|
||||
@@ -85,7 +90,10 @@ function fakeCtx(overrides = {}) {
|
||||
* an operator's install.
|
||||
*/
|
||||
function fakeApi() {
|
||||
const record = { routes: null, extensions: [], streams: null, legs: [], hooks: {}, teamProvider: null }
|
||||
const record = {
|
||||
routes: null, extensions: [], streams: null, legs: [], hooks: {}, teamProvider: null,
|
||||
triggers: null, audiences: null, engagementSeeds: null,
|
||||
}
|
||||
const called = new Set()
|
||||
const once = (name) => {
|
||||
if (called.has(name)) throw new Error(`${name}() called twice`)
|
||||
@@ -103,6 +111,9 @@ function fakeApi() {
|
||||
// module registering a provider collides with the first. A fake cannot see
|
||||
// the second module, and asserting the half it can see is still worth doing.
|
||||
registerTeamProvider(provider) { once('registerTeamProvider'); record.teamProvider = provider },
|
||||
registerEventTriggers(triggers) { once('registerEventTriggers'); record.triggers = triggers },
|
||||
registerAudiences(audiences) { once('registerAudiences'); record.audiences = audiences },
|
||||
registerEngagementSeeds(seeds) { once('registerEngagementSeeds'); record.engagementSeeds = seeds },
|
||||
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
|
||||
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
|
||||
}
|
||||
|
||||
@@ -130,3 +130,163 @@ test('the manifest declares what the loader requires', () => {
|
||||
// serves, so an entry in the module root would publish the whole module.
|
||||
if (manifest.client) assert.ok(manifest.client.entry.includes('/'), 'client.entry must be in a subdirectory')
|
||||
})
|
||||
|
||||
// ── The engagement seam ───────────────────────────────────────────────────
|
||||
//
|
||||
// Core validates most of what is declared here at registration, and a module
|
||||
// that gets it wrong does not load. These tests are mostly NOT that validator
|
||||
// restated: they are the rules a module can satisfy at boot and still have got
|
||||
// WRONG in a way whose only symptom is mail somebody received. Where one does
|
||||
// overlap core — the namespacing and subjectKey assertions below — it is because
|
||||
// `npm test` is a cheaper place to meet the failure than a first boot, and the
|
||||
// message here names the field.
|
||||
|
||||
test('every declared trigger is namespaced, ceilinged, and carries examples', () => {
|
||||
const { api } = register()
|
||||
const triggers = api.record.triggers
|
||||
assert.ok(Array.isArray(triggers) && triggers.length, 'no triggers were declared')
|
||||
|
||||
for (const t of triggers) {
|
||||
// Trigger ids and notification-stream ids are ONE namespace, so an id must
|
||||
// carry this module's own prefix or it is a claim on somebody else's.
|
||||
assert.ok(t.id.startsWith(`${manifest.id}.`), `${t.id} is not namespaced`)
|
||||
|
||||
// `ceiling` is required and has no default: there is no safe value to guess.
|
||||
assert.ok(t.ceiling, `${t.id} declares no ceiling`)
|
||||
|
||||
// Core refuses this one too; failing it here just costs less. What the rule
|
||||
// protects is the cooldown key — "once per world", not "once per user" — and
|
||||
// a subjectKey naming nothing would key every subject on `undefined`.
|
||||
const names = t.variables.map((v) => v.name)
|
||||
assert.ok(names.includes(t.subjectKey), `${t.id}: subjectKey "${t.subjectKey}" is not a variable`)
|
||||
|
||||
for (const v of t.variables) {
|
||||
// Not decoration: the example is what makes a template previewable and
|
||||
// test-sendable without waiting for a real game event.
|
||||
assert.ok('example' in v, `${t.id}.${v.name} has no example`)
|
||||
// The type set is closed. A payload that needs a structure has outgrown
|
||||
// interpolation, and a template cannot walk one.
|
||||
assert.ok(
|
||||
['string', 'int', 'float', 'boolean', 'datetime', 'url'].includes(v.type),
|
||||
`${t.id}.${v.name} has type "${v.type}"`,
|
||||
)
|
||||
// A url is site-relative, because it ends up in an href in a mail somebody
|
||||
// opens days later.
|
||||
if (v.type === 'url') assert.match(v.example, /^\/[^/]/, `${t.id}.${v.name} must be site-relative`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('an audience resolves to nobody rather than to everybody when it fails', async () => {
|
||||
const ctx = fakeCtx({ db: { query: () => Promise.reject(new Error('database is down')), pool: {} } })
|
||||
const { api } = register(ctx)
|
||||
const audience = api.record.audiences[0]
|
||||
|
||||
// The one behaviour worth a test of its own. Core treats a throw the same way,
|
||||
// so this is not core's guard restated — it is the module choosing the same
|
||||
// answer deliberately, and the reason is that the alternatives are both worse:
|
||||
// "everyone" mails the wrong people and a stale answer mails yesterday's.
|
||||
assert.deepStrictEqual(await audience.resolve({ clanId: 'clan-1' }), [])
|
||||
})
|
||||
|
||||
test('a seeded rule names only this module’s triggers, and its own or core’s templates', () => {
|
||||
const { api } = register()
|
||||
const seeds = api.record.engagementSeeds
|
||||
const ownKeys = new Set(seeds.templates.map((t) => t.key))
|
||||
const coreKeys = new Set(['notify.event', 'inapp.event', 'notify.digest'])
|
||||
|
||||
for (const t of seeds.templates) {
|
||||
// The key column is UNIQUE across the whole table, so an unprefixed
|
||||
// `notify.event` from a module would collide with core's body and win.
|
||||
assert.ok(t.key.startsWith(`${manifest.id}.`), `template ${t.key} is not namespaced`)
|
||||
// `protected` means "the system breaks without this body" — true of a
|
||||
// password reset and of nothing a module ships. Core refuses a module
|
||||
// template that sets it, because it would take an operator's delete button
|
||||
// away.
|
||||
assert.ok(!('protected' in t), `template ${t.key} may not mark itself protected`)
|
||||
}
|
||||
|
||||
for (const group of seeds.ruleGroups) {
|
||||
for (const rule of group.rules) {
|
||||
assert.ok(
|
||||
api.record.triggers.some((t) => t.id === rule.trigger_id),
|
||||
`${rule.name} names a trigger this module does not declare`,
|
||||
)
|
||||
for (const key of Object.values(rule.template_keys)) {
|
||||
assert.ok(ownKeys.has(key) || coreKeys.has(key), `${rule.name} names an unknown template ${key}`)
|
||||
}
|
||||
// `enabled` is not a parameter, and a value passed for it is ignored
|
||||
// rather than refused. Passing one anyway states an intention the platform
|
||||
// will not honour, so the honest thing is not to write it.
|
||||
assert.ok(!('enabled' in rule), `${rule.name} may not seed itself enabled`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('every seeded body is a shape the block registry will accept', () => {
|
||||
const { api } = register()
|
||||
|
||||
// The gap this exists for: `registerEngagementSeeds` checks that `blocks` is a
|
||||
// non-empty array and stops. The BODY is validated by core's block registry,
|
||||
// which runs in the template editor and in the renderer — so a malformed block
|
||||
// registers, seeds, and first shows itself when an operator opens the body or a
|
||||
// rule fires. Core is not here to ask, so assert the two rules that are easy to
|
||||
// get wrong and impossible to notice.
|
||||
const HEADING_LEVELS = ['h1', 'h2', 'h3']
|
||||
|
||||
for (const t of api.record.engagementSeeds.templates) {
|
||||
const ids = new Set()
|
||||
for (const block of t.blocks) {
|
||||
// Every block carries its own id, unique within the body: it is how the
|
||||
// editor addresses one block, and how `inapp.event`'s renderer maps blocks
|
||||
// onto the inbox row's columns by role.
|
||||
assert.ok(block.id && typeof block.id === 'string', `${t.key}: a block has no id`)
|
||||
assert.ok(!ids.has(block.id), `${t.key}: two blocks share the id "${block.id}"`)
|
||||
ids.add(block.id)
|
||||
assert.ok(block.type.startsWith('email.'), `${t.key}: ${block.type} is not an email block`)
|
||||
|
||||
// A heading's `level` is a SIZE token, not a number. `{ level: 2 }` reads
|
||||
// perfectly and is refused, and it renders at the default size in any
|
||||
// preview that skips validation — which is the whole trap.
|
||||
if (block.type === 'email.heading') {
|
||||
assert.ok(
|
||||
HEADING_LEVELS.includes(block.props.level),
|
||||
`${t.key}: heading level "${block.props.level}" must be one of ${HEADING_LEVELS.join(', ')}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('the world event fires on the transition and not on the poll', async () => {
|
||||
const boot = require('../boot')
|
||||
|
||||
// Online already, and reporting online again. The refresh writes, and nothing
|
||||
// is announced: this runs every thirty seconds, and a rule on an event fired
|
||||
// every thirty seconds mails somebody every thirty seconds. Core's cooldown
|
||||
// would hold — but leaning on it means emitting "still up" and calling it news.
|
||||
const steady = fakeCtx({ db: { query: () => Promise.resolve([{ online: 1 }]), pool: {} } })
|
||||
require('../core')._reset()
|
||||
require('../core').init(steady)
|
||||
await boot.refresh()
|
||||
assert.deepStrictEqual(steady.events.emit.calls, [])
|
||||
|
||||
// Offline before, online now. One emit, with the declared payload.
|
||||
const flipped = fakeCtx({ db: { query: () => Promise.resolve([{ online: 0 }]), pool: {} } })
|
||||
require('../core')._reset()
|
||||
require('../core').init(flipped)
|
||||
await boot.refresh()
|
||||
assert.strictEqual(flipped.events.emit.calls.length, 1)
|
||||
const [triggerId, envelope] = flipped.events.emit.calls[0]
|
||||
assert.strictEqual(triggerId, 'examplegame.world.status_changed')
|
||||
assert.strictEqual(envelope.data.status, 'online')
|
||||
|
||||
// A fresh install, where there is no previous row at all. Not a change — and
|
||||
// announcing it would tell everyone the world came online the first time an
|
||||
// operator started the site.
|
||||
const fresh = fakeCtx({ db: { query: () => Promise.resolve([]), pool: {} } })
|
||||
require('../core')._reset()
|
||||
require('../core').init(fresh)
|
||||
await boot.refresh()
|
||||
assert.deepStrictEqual(fresh.events.emit.calls, [])
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user