diff --git a/docs/refarch/ci-observability-discord.md b/docs/refarch/ci-observability-discord.md index bd1db3a6..34159bf7 100644 --- a/docs/refarch/ci-observability-discord.md +++ b/docs/refarch/ci-observability-discord.md @@ -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 @@ -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) @@ -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) | |---|---|---| @@ -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 @@ -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) | | @@ -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 | | | +----------------------------------------------------------+ ``` @@ -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 @@ -200,9 +191,6 @@ on: commit_author: required: false type: string - commit_sha: - required: false - type: string pr_body: required: false type: string @@ -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 }} @@ -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}" @@ -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 }} @@ -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 |