Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 40 additions & 65 deletions docs/refarch/ci-observability-discord.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
> **This doc is meant to be used with Kiro or any coding CLI.** Prompt your AI agent with something like:
>
> ```
> per https://github.com/openabdev/openab/blob/main/docs/refarch/ci-discord-notify.md set up CI notifications to my Discord channel
> per https://github.com/openabdev/openab/blob/main/docs/refarch/ci-observability-discord.md set up CI notifications to my Discord channel
> ```
>
> and it will guide you through the full setup.

Send GitHub Actions CI results (pass/fail) to a Discord channel or thread via webhook, with clickable links, duration, and user mentions.
Send GitHub Actions CI results (pass/fail) to a Discord channel or thread via webhook, with full CI metadata and user mentions.

## Problem

Expand All @@ -22,23 +22,13 @@ When CI runs in GitHub Actions, the only way to know the result is to check the
## What We Want

- CI finishes (pass or fail) → automatically POST result to a specific Discord channel/thread
- Commit message is a **clickable link** pointing to the PR or commit
- Show who committed, how long CI took, and which step failed
- Include both PR URL and Run URL for quick navigation
- Mention a specific user so they get pinged
- Bot-readable content (not hidden in embeds) so mentioned bots can act on it
- Route notifications to the correct thread based on the PR description
- One reusable workflow that any CI job can call

## Challenges

| Challenge | Why it's hard |
|-----------|---------------|
| Notify regardless of outcome | GitHub Actions skips downstream jobs when upstream fails — need `if: always()` |
| Clickable links in Discord | Webhook `content` field does NOT support markdown links — must use embeds |
| Newlines in embed description | `jq --arg` treats `\n` as literal backslash-n — need `printf` for real newlines |
| Route to the right thread | Different PRs need notifications in different threads — need dynamic extraction |
| Don't repeat yourself | Multiple CI workflows need the same notification logic — need reusable workflow |
| Keep secrets safe | Webhook URL contains a token — must never appear in workflow files or logs |

## Two Approaches

### Approach 1: Polling Mode (Cronjob)
Expand Down Expand Up @@ -77,7 +67,7 @@ GitHub Actions ──finish──► HTTP POST ──► Discord thread

**Cons:** Narrow scope — only reports on the workflow that triggered it. Can't see the big picture. Can't auto-fix (notification only). Requires webhook setup.

### When to Use Which
### Comparison

| | Polling (Cronjob) | Notification (Webhook) |
|---|---|---|
Expand All @@ -99,7 +89,7 @@ GitHub Actions ──finish──► HTTP POST ──► Discord thread

## Solution

A **reusable workflow** (`notify-discord.yml`) that any CI workflow calls as its final job. It posts a Discord embed with clickable title, colored sidebar, and user mention — routing to the correct thread based on the PR description.
A **reusable workflow** (`notify-discord.yml`) that any CI workflow calls as its final job. It posts CI results as plain-text content (bot-readable) with user mention — routing to the correct thread based on the PR description.

## Architecture

Expand All @@ -111,7 +101,7 @@ A **reusable workflow** (`notify-discord.yml`) that any CI workflow calls as its
| | [check] ──► cargo fmt / clippy / test | |
| | │ | |
| | │ outputs: status, duration, commit_msg, | |
| | │ commit_author, commit_sha | |
| | │ commit_author, failed_step | |
| | ▼ | |
| | [notify] (if: always()) | |
| | │ calls ──► notify-discord.yml (reusable) | |
Expand All @@ -130,14 +120,14 @@ A **reusable workflow** (`notify-discord.yml`) that any CI workflow calls as its
| |
| #channel or thread |
| ┌─────────────────────────────────────────────────┐ |
| │ ✅ feat: add new provider ← clickable │ |
| │ ────────────────────────────────────────────── │ |
| │ ✅ CI success — repo@main │ |
| │ 👤 author │ |
| │ ❌ CI failure — repo@main | PR #42 │ |
| │ 👤 author — commit message │ |
| │ ⏱️ 3m42s │ |
| │ View Run ← clickable │ |
| │ 💥 Failed at: Tests │ |
| │ https://github.com/.../pull/42 │ |
| │ https://github.com/.../actions/runs/123 │ |
| │ @user-mention │ |
| └─────────────────────────────────────────────────┘ |
| @user-mention |
| |
+----------------------------------------------------------+
```
Expand All @@ -148,10 +138,11 @@ A **reusable workflow** (`notify-discord.yml`) that any CI workflow calls as its
|----------|-----------|
| Reusable workflow (`workflow_call`) | Any CI workflow can call it; single source of truth |
| `if: always()` on notify job | Fires on success, failure, and cancellation |
| Discord embed (not plain content) | Supports clickable title, colored sidebar, markdown in description |
| Plain-text content (not embed) | Bots can read `message.content`; embeds are invisible to bots |
| `printf` + `jq --rawfile` | Only reliable way to get real newlines into JSON payload |
| Thread ID from PR body | Dynamic routing — each PR notifies its own thread |
| Fallback to repo variable | Push-to-main events still get notified somewhere |
| `printf` for newlines | `jq --arg` preserves real `\n` from printf output |
| Both PR URL and Run URL | PR for context, Run for debugging logs |

## Setup

Expand Down Expand Up @@ -200,9 +191,6 @@ on:
commit_author:
required: false
type: string
commit_sha:
required: false
type: string
pr_body:
required: false
type: string
Expand All @@ -224,7 +212,6 @@ jobs:
DURATION: ${{ inputs.duration }}
COMMIT_MSG: ${{ inputs.commit_msg }}
COMMIT_AUTHOR: ${{ inputs.commit_author }}
COMMIT_SHA: ${{ inputs.commit_sha }}
PR_BODY: ${{ inputs.pr_body }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
REPO: ${{ github.repository }}
Expand All @@ -240,41 +227,29 @@ jobs:
[ -z "$THREAD_ID" ] && THREAD_ID="$DEFAULT_THREAD_ID"

if [ "$STATUS" = "success" ]; then
COLOR=3066993; EMOJI="✅"
else
COLOR=15158332; EMOJI="❌"
fi

# Embed title = commit msg (clickable link to PR or commit)
TITLE="${COMMIT_MSG:-CI ${STATUS}}"
if [ -n "$PR" ]; then
TITLE_URL="${SERVER_URL}/${REPO}/pull/${PR}"
elif [ -n "$COMMIT_SHA" ]; then
TITLE_URL="${SERVER_URL}/${REPO}/commit/${COMMIT_SHA}"
EMOJI="✅"
else
TITLE_URL="${RUN_URL}"
EMOJI="❌"
fi

# Build description using printf for real newlines
DESC="${EMOJI} **CI ${STATUS}** — \`${REPO}@${REF}\`"
[ -n "$PR" ] && DESC="${DESC} | PR #${PR}"
[ -n "$COMMIT_AUTHOR" ] && DESC=$(printf "%s\n👤 %s" "$DESC" "$COMMIT_AUTHOR")
[ -n "$DURATION" ] && DESC=$(printf "%s\n⏱️ %s" "$DESC" "$DURATION")
[ "$STATUS" != "success" ] && [ -n "$FAILED_STEP" ] && \
DESC=$(printf "%s\n💥 Failed at: **%s**" "$DESC" "$FAILED_STEP")
DESC=$(printf "%s\n[View Run](%s)" "$DESC" "$RUN_URL")

# Build JSON payload
CONTENT=""
[ -n "$MENTION_USER_ID" ] && CONTENT="<@${MENTION_USER_ID}>"

PAYLOAD=$(jq -n \
--arg content "$CONTENT" \
--arg title "$TITLE" \
--arg url "$TITLE_URL" \
--arg desc "$DESC" \
--argjson color "$COLOR" \
'{content: $content, embeds: [{title: $title, url: $url, description: $desc, color: $color}]}')
# Build message into a temp file for proper newlines
{
printf '%s **CI %s** — `%s@%s`' "$EMOJI" "$STATUS" "$REPO" "$REF"
[ -n "$PR" ] && printf ' | PR #%s' "$PR"
echo ""
[ -n "$COMMIT_AUTHOR" ] && printf '👤 %s' "$COMMIT_AUTHOR"
[ -n "$COMMIT_MSG" ] && printf ' — `%s`' "$COMMIT_MSG"
[ -n "$COMMIT_AUTHOR" ] && echo ""
[ -n "$DURATION" ] && echo "⏱️ ${DURATION}"
[ "$STATUS" != "success" ] && [ -n "$FAILED_STEP" ] && echo "💥 Failed at: **${FAILED_STEP}**"
[ -n "$PR" ] && echo "${SERVER_URL}/${REPO}/pull/${PR}"
echo "$RUN_URL"
[ -n "$MENTION_USER_ID" ] && echo "<@${MENTION_USER_ID}>"
} > /tmp/msg.txt

# Use jq --rawfile to preserve real newlines in JSON
PAYLOAD=$(jq -n --rawfile msg /tmp/msg.txt \
'{content: $msg, allowed_mentions: {parse: ["users"]}}')

URL="${WEBHOOK_URL}"
[ -n "$THREAD_ID" ] && URL="${URL}?thread_id=${THREAD_ID}"
Expand Down Expand Up @@ -332,7 +307,6 @@ jobs:
duration: ${{ needs.check.outputs.duration }}
commit_msg: ${{ needs.check.outputs.commit_msg }}
commit_author: ${{ needs.check.outputs.commit_author }}
commit_sha: ${{ github.event.pull_request.head.sha || github.sha }}
pr_body: ${{ github.event.pull_request.body }}
secrets:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
Expand All @@ -352,9 +326,10 @@ The workflow extracts the first match and posts to that thread. If absent, it fa

| Issue | Solution |
|-------|----------|
| `content` field doesn't support markdown links | Use `embeds` with `title`/`url` for clickable links |
| `\n` in `jq --arg` becomes literal `\\n` | Use `printf` to produce real newlines before passing to jq |
| Embed content invisible to bots | Use plain-text `content` field — bots only see `message.content` |
| `\n` in `jq --arg` becomes literal `\\n` | Write to temp file, use `jq --rawfile` to preserve real newlines |
| Duplicate YAML keys silently break workflows | Validate with `actionlint` or check Actions run errors |
| Webhook URL contains a token | Always store as a **secret**, never in workflow files or docs |
| `if: always()` required on notify job | Otherwise it's skipped when upstream jobs fail |
| Mention requires numeric Discord user ID | Use `<@USER_ID>` format in `content` (not in embed) |
| Mention requires numeric Discord user ID | Use `<@USER_ID>` format in `content` |
| Webhook mentions don't trigger bots | Webhook messages don't fire bot `MESSAGE_CREATE` — mention real users instead |
Loading