Skip to content

Notifications & maintainer automation

Status: Accepted

How the project talks to two audiences without anyone doing it by hand: the public (releases and build activity, in the community server) and the maintainers (the queue of things that need a human decision).

Everything here runs on Discord incoming webhooks — no bot to host — and every integration is dormant until its webhook secret exists, so a fork or a fresh clone never fails for lacking one.


Channel Audience Posts Secret
#releases public A published release, with notes and install line DISCORD_ANNOUNCE_WEBHOOK
#build-log public Every workflow run, on completion (pass/fail) DISCORD_BUILD_WEBHOOK
#awaiting-maintainer-action maintainers (private) Items needing a decision: triage, review, main-branch breakage DISCORD_MAINTAINERS_WEBHOOK

Release pings an opt-in role rather than @everyone: if the org variable DISCORD_RELEASE_ROLE_ID is set, the announcement mentions it; otherwise it posts silently. People who want release notifications self-assign the role.

#releases is a Forum channel, so each release is its own thread — the announcement opens it (role ping and link on that first post), and a long changelog continues as replies inside the same thread rather than flooding the channel. That also makes each release a place to discuss it. #build-log and #awaiting-maintainer-action stay plain text channels; they pass no thread name and post normally.

The channels above are how the project talks outward. The way in is a single address — https://discord.nightworks.io — a redirect NightWorks controls, and the only form any published link takes.

An invite code is not a stable identifier. It can expire, be revoked, or be regenerated, and when it is, every link carrying it dies at once: in READMEs, in forks, in release notes already published, in repository metadata nobody thinks to re-check. The code itself is fine. Publishing it is what makes it fragile.

The redirect moves that fragility somewhere it can be repaired. The invite code then exists in exactly one place — whatever the redirect resolves to — and replacing it is one edit rather than a sweep of every repository. This has already failed once here: five repositories carried a homepageUrl pointing at a superseded invite long after the documented link had moved on, because metadata is not where anyone looks when they update a link.

A link checker cannot catch this. Discord answers any invite path with 200, including codes that never existed, so a dead invite is indistinguishable from a live one over HTTP. The check is therefore on the text rather than the response: the shared hygiene workflow refuses a tracked file containing an invite code.

The webhook URLs are org-level secrets (--visibility all). Two rules keep them safe, both checkable:

  1. No secret reaches fork-PR code. Notifiers trigger on workflow_run or on base-repo events (issues, pull_request_review, push, release) — never on pull_request from a fork, where a contributor’s code could read the secret.
  2. No injection. Every dynamic value (issue title, branch, commit message) is passed through env: into jq --arg, never interpolated into a shell line.

Both live in the single reusable discord-notify.yml, so there is one place to audit, not one per repo.

Two labels encode “a maintainer needs to act”, applied by automation so the queue is a saved search, not a memory game:

Label Applied when Removed when
needs-triage An issue is opened or reopened A maintainer triages it (by hand)
awaiting-maintainer Every check the branch requires has passed on the PR and no approving review has landed The PR is reviewed, or the PR closes

awaiting-maintainer skips PRs opened by a maintainer — a maintainer’s own PR is not awaiting one. As non-maintainers begin contributing, their green PRs surface automatically. The live queue is is:open label:needs-triage,awaiting-maintainer.

“Passes CI” is not a workflow called ci. In lemonfiber that workflow supplies eleven of the nineteen checks main requires; check and deny come from build, the CodeQL analyses and the open-alert gate from codeql, sonar and gate / gate from sonar, and release-workflow from its own file. A flag raised on one workflow’s conclusion is raised while the compile is still running, and the maintainer channel is told a pull request is ready that is not. So the flag is raised on the required set and on nothing narrower, and it waits for every workflow that supplies a member of that set — otherwise whichever finishes last finds nobody asking.

A closed pull request is not awaiting a maintainer, and neither is a merged one. Because the queue is scoped to is:open, a flag left behind does not distort the queue itself — it survives on the closed PR permanently instead, so any label-filtered history reads as though that work shipped unreviewed, and reopening restores a flag that no longer describes the PR’s state. The label therefore clears on closure as well as on review, merged or not.

Assignment reads the maintainers registry — no second source:

  • Issues are assigned, on open, to the maintainer(s) whose scope covers the repo, read from the generated .github/CODEOWNERS (itself generated from maintainers.toml).
  • PRs already request review from the owning maintainer by changed path, via CODEOWNERS — GitHub-native, no workflow.

So maintainership is edited in exactly one place (maintainers.toml), and both review-routing and issue-assignment follow it.

ID Requirement
OPS-R23 A published release MUST announce to the public announcement channel, mentioning the opt-in role when DISCORD_RELEASE_ROLE_ID is set and never @everyone.
OPS-R24 Every workflow run MUST post its completion status (pass/fail) to the public build-log channel.
OPS-R25 A newly opened issue MUST be labelled needs-triage and assigned the covering maintainer from the generated CODEOWNERS.
OPS-R26 A PR on which every status check the base branch requires has concluded successfully, without an approving review, MUST be labelled awaiting-maintainer, unless its author is a maintainer; the label MUST be removed once the PR is reviewed or closed. The flag MUST NOT be raised while a required check is pending or failing, nor before every workflow that supplies one has run.
OPS-R27 Items needing maintainer action MUST post to the private maintainer channel when its webhook is configured.
OPS-R28 Every Discord integration MUST be gated on its webhook secret’s presence, MUST NOT run in fork-PR context with the secret available, and MUST pass all event-derived text through the environment rather than a shell interpolation.
OPS-R53 A notification body over Discord’s embed limit MUST be split at line boundaries rather than truncated; where the channel is a Forum, the overflow MUST post as replies within one thread and the role ping MUST ride only the opening message.
OPS-R56 Every published reference to the community server — documentation, repository metadata, site data, and issue-template contact links — MUST address it as https://discord.nightworks.io, and MUST NOT publish an invite code. CI MUST reject a tracked file containing one.

This page lives in another repository Rendered from lemonfiber/spec at 1d10402, 2026-09-09. Read the source of this page