Unset and empty were already safe. A MALFORMED id was not: any truthy
string went straight into `<@&…>`, so a stray space in the secret produced
a literal `<@& >` and a pasted mention produced `<@&<@&123>>` — visible
junk at the top of the most public message the project sends.
`normalizeRoleId` is now the single decision point, applied inside
`buildPayload` rather than at the call site so every caller gets the same
guarantee. It trims, unwraps a pasted `<@&123>`, and requires a 17-20
digit snowflake. Anything else is rejected rather than guessed at: a ping
that silently does not happen is a far smaller problem than a broken one
that does.
`allowed_mentions.roles` is derived from the same normalised value, so
the two can never disagree — a content string naming a role the payload
does not permit would be a mention that renders but never notifies.
The CLI warns when a non-empty id was supplied and rejected. Dropping it
silently would leave someone wondering why the ping never fired.
One webhook meant the choice was all-or-nothing: either every release
announced itself to everyone, or nothing was automated. Two channels,
deliberately asymmetric:
staff posts AUTOMATICALLY on every release
public ONLY from a deliberate manual dispatch
The staff copy carries a link back to this workflow with the inputs
spelled out. That is what makes it a review step rather than a duplicate
feed: the post being read IS the post that will go out, and the button to
send it is attached to it. Otherwise "now go publish it" is a separate
instruction living in someone's head.
The public copy does not get that field — an announcement should not tell
its readers how to re-send it. Returned as `undefined` rather than an
empty array, since an empty field block renders as dead space.
Guarded twice. The workflow only passes `--channel public` on a manual
dispatch, AND the script refuses public when `GITHUB_EVENT_NAME=release`.
One of those is enough; both are cheap, and the failure mode is
announcing to everyone with nobody having read it.
Only the public post pings the role. A ping in the staff channel on every
release is noise in a room that exists to watch releases.
`DISCORD_WEBHOOK_STAFF` / `DISCORD_WEBHOOK_PUBLIC`, with the existing
`DISCORD_WEBHOOK_URL` kept as a staff fallback so the current
single-webhook setup keeps working untouched.
v2.12.0 went out as "Captain Hook" with a placeholder avatar — a webhook
posts under whatever name and image it was created with, and Discord's
default is a generic pair.
`username` and `avatar_url` override that per message, so the identity
lives in the payload rather than in a webhook's settings. It then stays
right if the webhook is ever rotated or recreated, and it is the same in
every channel the automation posts to.
Avatar is the 512px manifest icon, fetched from raw.githubusercontent by
Discord server-side. It has to be raster — an SVG will not render, which
rules out the repo's favicon.svg, and that failure mode is silent. Pinned
to `main` rather than a tag so replacing the logo needs no code change.
`DISCORD_USERNAME` / `DISCORD_AVATAR_URL` override both. An empty value
falls through to the defaults rather than blanking the identity and
handing the post back to the webhook's name.
The dry run now prints both, since identity is invisible in the embed
body and is exactly what was wrong the first time.
Ten commits landed after `next.md` was written and none of them were in it.
Anything left out of that file simply does not ship in the notes.
No new highlights. The cap is 4-5 and it was already at 5 — per the file's
own rule, if a sixth feels essential something else was not a highlight.
Per-frame lock and bypass is a shortcut to something the seeds dialog
already did, so it goes in the tail rather than displacing anything.
Tail gains six entries in two new groups: the frame chips and collapsible
grid on the Loop and Seed List, the broken-reference fix, the what's-new
page, and the update check that was burning GitHub's hourly allowance.
Count corrected 20 → 26, which the update dialog computes independently
from the same bullets.
Also fixes a real bug in the announcement transform, caught by previewing
it against this file: `splitBody` stripped `<details>` BEFORE counting the
tail. The "Also in this release" bullets live inside that block, so it
counted nothing real — while the authoring guidance in the trailing HTML
comment contributed bullets of its own. A 26-change release announced
itself as 3. Comments are stripped, details are not.
README gets the Nodes 2.0 line. Renderer support is a compatibility fact
people check before installing, it was the single largest piece of work in
this release, and the README did not mention it anywhere. The "switching
between them with a workflow already open" clause is deliberate: that is
what repeatedly broke and is now specifically handled.
Announcements were written by hand, which is why they went out "only once
a few versions" -- the cost was never posting, it was composing a second
piece of prose about a release that had already been written up once.
Nothing new has to be written. `docs/release-notes/next.md` is already
curated by hand per release and already IS the GitHub release body, so
the announcement is a transform of it rather than a fresh document.
`scripts/lib/discord-notes.mjs` handles the gaps between GitHub markdown
and Discord, which are specific rather than general:
- masked links render ONLY inside an embed, so the notes go in an embed
and the message content stays a bare ping
- there are no tables; rows become bullets
- <details> shows as raw tags
- headings render, but an embed `##` outsizes the embed title above it,
so headings become bold
- the description caps at 4096, and truncation cuts at a bullet
boundary -- a hard slice can land inside a link and render as broken
markup, which is worse than saying less
It cuts at the same `<!-- /modal -->` marker the in-app update dialog
uses, for the same reason: an announcement is an interruption. The tail
is counted rather than dropped, so a three-bullet post over a forty-change
release does not read as a quiet week.
`allowed_mentions` is always explicit. Release notes are not written with
`@everyone` in mind and a webhook will happily resolve one that slipped
in; only a configured role id can ping.
Two entry points. `release: published` posts automatically. A manual
`workflow_dispatch` takes `--since <tag>` and rolls several releases into
one post -- the catch-up case that actually happens -- and defaults to a
dry run so a manual trigger cannot surprise anyone. Dry run is also the
CLI default: `pnpm announce --version v2.11.0` prints and posts nothing.
Kept as a separate workflow from release.yml on purpose: a Discord
outage, a rotated webhook or a rate limit must never fail a release that
has already shipped.
Also widens the vitest include to `scripts/**/*.test.mjs`. A broken
announcement transform is otherwise only noticed after it has been
posted.