Notifications & maintainer automation
Rendered from spec at76d46f3 (2026-10-25) · its source · Edit this page
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.
The three channels
| Channel | Audience | Posts | Secret |
|---|---|---|---|
#releases |
public | A published release, with notes and install line | DISCORD_ANNOUNCE_WEBHOOK |
#build-log |
public | Every run on main, a tag, a schedule or a dispatch, and every pull request run that failed, on completion |
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 public address
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.
What the build log carries
#build-log is the public record of what the branches and releases did, and of
every pull request that went red. A pull request’s green run is not in it: that
answer is already on the pull request, and pull requests are pushed far more
often than main moves, so posting them buries the runs a reader comes to the
channel for. A run cancelled
because a newer push replaced it is not a failure and is not posted either.
Whether a run is posted is read from the workflow_run event itself — its
event and its conclusion — in a job-level if: in the reusable notifier, so a
run that will not be posted is skipped before a runner is assigned rather than
started to say nothing.
Secrets, safety, and forks
The webhook URLs are org-level secrets (--visibility all). Two rules keep them
safe, both checkable:
- No secret reaches fork-PR code. Notifiers trigger on
workflow_runor on base-repo events (issues,pull_request_review,push, release) — never onpull_requestfrom a fork, where a contributor’s code could read the secret. - No injection. Every dynamic value (issue title, branch, commit message)
is passed through
env:intojq --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.
The maintainer queue
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 — and a head a maintainer pushed, which a maintainer is
already acting on. Only someone with write access can push a branch to the
repository, so a completed run on such a branch, started by a person rather than
a bot, is skipped by a job-level if: before a runner is assigned, and so is a
run that did not succeed, which cannot complete the required set. Runs from a
fork and runs a bot started are evaluated in full. 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
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 frommaintainers.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.
Requirements
| 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 | A workflow run for a push to a protected branch, a tag, a release, a schedule or a manual dispatch MUST post its completion status to the public build-log channel, and so MUST a pull request’s run that failed, timed out or could not start. A pull request’s run that succeeded, was cancelled or was skipped MUST NOT be posted, and whether to post MUST be decided without starting a runner. |
| 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 or a maintainer pushed its head; 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. |
Related
- maintainers.md — the registry these read from
- project-workflow.md — the canonical label set and branching model
- releasing.md — where the release announcement is triggered
- 50-governance/cross-repo-ci.md — the reusable-workflow model
- 40-quality/security.md — the no-secret-in-fork-PR rule