Skip to content

discord: retry the webhook instead of dropping the message - #7

Merged
bjornars merged 4 commits into
mainfrom
bsn/discord-notify-composite-action
Aug 28, 2026
Merged

bjornars merged 4 commits into
mainfrom
bsn/discord-notify-composite-action

Conversation

@bjornars

@bjornars bjornars commented Aug 28, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #6

Both notifiers ended in a bare curl, which exits 0 whatever Discord answered, so a 429 printed the refusal into the log and the step went green with the message thrown away. The retry now lives in a composite action, discord-notify, and both workflows route through it.

A composite action rather than a script because it can be a step and the runner fetches it — no checkout. That is what lets novem-code/gaia#3905 delete its own .github/scripts/discord-notify.sh and both of its sparse-checkouts and call this instead. The retry engine is that PR's, behaviour unchanged: retry_after / X-RateLimit-Reset-After / Retry-After in descending authority, 5 attempts, 30s per wait, 60s total, --max-time 15, non-429 4xx not retried.

The interface

- uses: novem-code/github-actions/discord-notify@<sha>
  with:
    webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }}   # required
    content: "text"                                   # or payload:, exactly one
    fail-on-undeliverable: 'true'                     # default 'true'

content takes plain text and the action does the JSON encoding, which deletes the jq -n --arg block from every call site and with it the class of bug where a title containing a quote produced invalid JSON. payload remains for embeds. Both, neither, or invalid JSON fails before any request is made.

fail-on-undeliverable exists instead of the caller writing || true, and deliberately not instead of continue-on-error. continue-on-error: true still renders the step as failed, which reintroduces the exact problem being fixed — a notifier reporting someone else's failure going red reads as "the build broke at the notify step". The switch exits 0 instead. The ::error:: annotation fires either way, so the loss is on the run summary regardless of which you choose.

Where to spend attention

This changes observable behaviour for every repo calling these two workflows. An undeliverable notification now reddens a run that would be green today. I think that is right — these workflows exist only to notify, so nothing else records that the message vanished — but it is the one thing to agree with rather than discover. Note discord.yml still carries a dummy job named "Make sure the action is always successful", which suggests someone once wanted the opposite; left alone.

Merge, do not squash. The workflows pin the action by full SHA at fafee578, and that commit has to stay reachable from main or the pin inside the shipped workflows breaks — along with gaia's pin to the same SHA. That is also why this is two commits rather than one: the pin needs a SHA that already exists. If this branch is rebased the SHA moves and commit 2's pin goes stale, so say so rather than hand-patching it.

The self-reference goes through novem-code/github-actions/discord-notify@<sha> rather than a relative ./discord-notify, because a relative action path inside a reusable workflow resolves against the caller's workspace, which never has this repo checked out.

The same retry policy also exists in Python, in gaia's ops-announce/discord sender (novem-code/gaia#4006) — one retry, a 5s wait capped at 30s, and 5xx not retried; the numbers differ deliberately, because that one blocks a rabbit consumer callback where this one only delays a CI step.

Verification

45 assertions, 0 failures, against a stubbed curl and sleep: all 11 cases from novem-code/gaia#3905 replayed unchanged, plus content encoding round-trip (quotes, newline, backslash), payload passthrough, both-inputs, neither-input, invalid JSON, missing webhook, and the undeliverable switch swallowing a 429 and a 400 while still annotating. shellcheck 0.11.0 exit 0, bash -n clean. No request reached Discord — the harness webhook is example.invalid. The harness is deliberately not committed: it stubs curl on PATH, which is a sharp tool to leave in a repo.

The workflow_call interface of both workflows is byte-identical to main, so existing pinned callers are unaffected.

What the harness could not check: it exercises discord-notify.sh through a stubbed curl, and action.yml was only validated as YAML. A manifest can parse cleanly and still be rejected by the runner's expression evaluator — which is exactly what happened, on the ${{ }} inside an input description — so the manifest is CI-verified, not harness-verified. Read the first green notify_pr run as the real evidence for it.

Review the fail-on-undeliverable semantics and the reddening behaviour first; the retry engine itself arrived already tested.

A reusable workflow is job-level, so it cannot be a step: that is why
gaia had to ship the same retry logic as a script and check it out to
run it. A composite action can be a step and the runner fetches it, so
callers need no checkout, no sparse-checkout and no continue-on-error
trap around one.

The retry engine is gaia's, unchanged in behaviour: honour retry_after
and X-RateLimit-Reset-After, bounded 5 attempts / 30s per wait / 60s
total, --max-time so a hung connection cannot stall a job, no retry on a
non-429 4xx, and a non-zero exit when the message never lands.

Two things are new, both because an action has an interface where a
script had argv. `content` takes plain text and does the JSON encoding,
so no caller writes jq to get a title with a quote in it into valid
JSON; `payload` still takes a whole object for embeds. And
fail-on-undeliverable makes gaia's exit-code split declarative instead
of a trailing `|| true` — a notifier that is the deliverable may fail,
one reporting someone else's failure may not, and the ::error::
annotation fires either way.

Inputs cross into the script through env only. A PR title is
attacker-controlled text and must never be interpolated into a shell
body.

Replayed against a stubbed curl and sleep: 45 assertions covering
gaia's original 11 cases plus content encoding, payload validation, the
undeliverable switch and the delivered output. shellcheck 0.11.0 clean.
No request reached Discord; the harness webhook is example.invalid.
Both workflows ended in a bare `curl`, so a rate-limited notification
printed Discord's refusal into the log and the step went green having
sent nothing. This is the same defect as gaia#2409, in the repo gaia
calls for it.

The `workflow_call` interface does not move — same inputs, same
`discord_webhook` secret — so pinned callers are unaffected. What
changes is that an undeliverable message now reddens the run. Sending it
is these workflows' whole purpose; nothing else would record that it
vanished.

The action is referenced by full path and SHA rather than a local
`./discord-notify`. A relative action path inside a reusable workflow
resolves against the CALLER's workspace, which never has this repo
checked out, so the self-reference has to go through the registry. That
is also why the pin is a SHA: a caller who pins this workflow would
otherwise get a floating action underneath it.

Message building keeps its own step and hands the text over through
GITHUB_OUTPUT with a random delimiter, so a title cannot close the
heredoc and inject further outputs. `github.event.action` and
`.pull_request.merged` move into env with the rest — they were
interpolated straight into the shell body. Since `content` now does the
JSON encoding, both call sites lose their jq invocation.
The runner evaluates input descriptions, so the sentence explaining that a
composite action cannot use the secrets context named it in expression
syntax and failed the manifest before the action ran:

  action.yml (Line: 8, Col: 18): Unrecognized named-value: 'secrets'

The advice was right; writing it as a live expression was not. It now names
secrets.DISCORD_WEBHOOK_URL as plain text and says why the wrapper is
missing, so the next person does not restore it.

No local check could have caught this. The harness exercises the script
through a stubbed curl, and the manifest was only checked for YAML validity
— it parses fine and is still rejected by the expression evaluator. CI was
the first thing to actually evaluate it.
Both notifiers pinned fafee57, the commit whose action.yml the runner
rejects, so fixing the manifest alone left them fetching the broken one.
They now pin the fix.

A pin inside this repo has to be repointed by a follow-up commit rather
than an amend: the SHA has to exist before anything can reference it, and
the earlier commits are already pushed.
@bjornars
bjornars marked this pull request as ready for review August 28, 2026 12:01
@bjornars

Copy link
Copy Markdown
Contributor Author

I think we'll just have to try this live

@bjornars
bjornars merged commit fc39126 into main Aug 28, 2026
2 checks passed
@bjornars
bjornars deleted the bsn/discord-notify-composite-action branch August 28, 2026 12:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

discord: a rate-limited notification is dropped and the step still passes

1 participant