From 200b7b147d80ff2af8e989a36775e9e063020334 Mon Sep 17 00:00:00 2001 From: vklimontovich Date: Mon, 11 May 2026 13:55:43 -0400 Subject: [PATCH] feat(slack-notify): add bot-token path with thread + update support Adds chat.postMessage / chat.update support via bot_token + channel inputs. Returns the message ts as an output so callers can thread later messages or edit the parent. Webhook path remains as backward-compatible fallback. Reusable workflow surfaces the new inputs/outputs and now pulls the DEPLOY_BOT_SLACK_TOKEN org secret directly. --- .github/actions/slack-notify/action.yml | 165 +++++++++++++++++------- .github/workflows/slack-notify.yml | 74 +++++++++-- 2 files changed, 180 insertions(+), 59 deletions(-) diff --git a/.github/actions/slack-notify/action.yml b/.github/actions/slack-notify/action.yml index 6645097..2c85136 100644 --- a/.github/actions/slack-notify/action.yml +++ b/.github/actions/slack-notify/action.yml @@ -1,11 +1,43 @@ name: Send Slack Notification -description: Send a formatted notification to Slack with optional bullet blocks +description: Send a formatted notification to Slack via bot token (chat.postMessage / chat.update) or webhook. inputs: + # --------------------------------------------------------------------------- + # Bot token path (preferred). Set bot_token + channel to use chat.postMessage, + # which returns the message ts so callers can thread later messages or edit + # this one. Composite actions can't read org secrets directly, so pass the + # token in from the calling job (typically from secrets.DEPLOY_BOT_SLACK_TOKEN). + # Channel can be a name (`#dev`) or an ID (`C0164J8MU12`). + # --------------------------------------------------------------------------- + bot_token: + description: "Slack bot user OAuth token (xoxb-...). When set together with `channel`, the action posts via chat.postMessage and outputs `ts`." + required: false + default: "" + channel: + description: "Channel name (#dev) or ID (C0164J8MU12). Required when using bot_token. Prefer IDs — they survive channel renames." + required: false + default: "" + thread_ts: + description: "If set, post as a threaded reply to this message ts. Only honored on the bot-token path." + required: false + default: "" + update_ts: + description: "If set, edit the message with this ts (chat.update) instead of posting a new one. Only honored on the bot-token path." + required: false + default: "" + + # --------------------------------------------------------------------------- + # Webhook path (legacy). Kept for backward compatibility. Webhooks don't + # return ts, so threading from later steps is impossible on this path. + # --------------------------------------------------------------------------- slack_webhook_url: - description: "Slack webhook URL (override). Normally leave empty and set SLACK_WEBHOOK_URL env in the job from secrets.CI_SLACK_WEBHOOK; this input only takes effect if that env var is unset (useful for ad-hoc testing)." + description: "Slack webhook URL (legacy). Leave empty when using bot_token. Normally set SLACK_WEBHOOK_URL env from secrets.CI_SLACK_WEBHOOK; this input is for ad-hoc testing." required: false default: "" + + # --------------------------------------------------------------------------- + # Message content (both paths). + # --------------------------------------------------------------------------- color: description: "Attachment color: good, warning, danger, or hex (e.g. #36a64f)." required: false @@ -18,10 +50,18 @@ inputs: required: false default: "" +outputs: + ts: + description: "Timestamp of the posted (or updated) message. Only set on the bot-token path; empty on the webhook path." + value: ${{ steps.send.outputs.ts }} + channel: + description: "Resolved channel ID (Slack returns this even when input was a name). Only set on the bot-token path." + value: ${{ steps.send.outputs.channel }} + runs: using: composite steps: - - name: 🔄 Convert YAML to JSON + - name: 🔄 Convert YAML blocks to JSON id: convert if: ${{ inputs.blocks != '' }} shell: bash @@ -43,33 +83,28 @@ runs: echo "EOF" >> $GITHUB_OUTPUT - name: 💬 Send Slack notification + id: send uses: actions/github-script@v9 env: - # Webhook precedence: SLACK_WEBHOOK_URL env var wins (typically set at - # job level from secrets.CI_SLACK_WEBHOOK — composite actions can't read - # org secrets directly). The `slack_webhook_url` input is a fallback for - # ad-hoc testing or direct invocation. + # Credentials. Bot-token path takes precedence: if BOT_TOKEN + CHANNEL + # are both set, we use chat.postMessage / chat.update. Otherwise we + # fall back to the webhook (SLACK_WEBHOOK_URL env wins, then input). + BOT_TOKEN: ${{ inputs.bot_token }} + CHANNEL: ${{ inputs.channel }} + THREAD_TS: ${{ inputs.thread_ts }} + UPDATE_TS: ${{ inputs.update_ts }} WEBHOOK_INPUT: ${{ inputs.slack_webhook_url }} COLOR: ${{ inputs.color }} HEADER: ${{ inputs.header }} BLOCKS_JSON: ${{ steps.convert.outputs.blocks_json }} with: script: | - const webhook = process.env.SLACK_WEBHOOK_URL || process.env.WEBHOOK_INPUT; - if (!webhook) { - throw new Error( - "slack-notify: no webhook URL. Set SLACK_WEBHOOK_URL env in the " + - "job (from secrets.CI_SLACK_WEBHOOK) or pass `slack_webhook_url` input." - ); - } - const blocksJson = process.env.BLOCKS_JSON; - const blocks = blocksJson ? JSON.parse(blocksJson) : []; + const blockList = blocksJson ? JSON.parse(blocksJson) : []; // Build single text with all blocks as bullet points - const lines = blocks.map(block => { + const lines = blockList.map(block => { let line = `• *${block.title}:* `; - if (block.is_code) { line += `\n\`\`\`${block.value}\`\`\``; } else if (block.url) { @@ -77,42 +112,82 @@ runs: } else { line += block.value; } - return line; }); - const text = lines.join('\n'); - // Build complete payload. `text` (top-level) and `fallback` (attachment) drive - // Slack's notification preview / link-unfurl summary; without them the unfurl - // shows "[no preview available]". Top-level `text` also renders above the - // attachment, so we drop the in-attachment header block to avoid duplication. - const attachment = { - color: process.env.COLOR, - fallback: process.env.HEADER, - }; + // Attachment is shared between both transport paths. Top-level `text` + // and attachment `fallback` drive Slack's notification preview / + // unfurl summary; without them the preview shows "[no preview available]". + const attachment = { color: process.env.COLOR, fallback: process.env.HEADER }; if (text) { - attachment.blocks = [ - { type: "section", text: { type: "mrkdwn", text: text } } - ]; + attachment.blocks = [{ type: "section", text: { type: "mrkdwn", text } }]; + } + + const botToken = process.env.BOT_TOKEN; + const channel = process.env.CHANNEL; + const useBot = botToken && channel; + + if (useBot) { + // ----- Bot path: chat.postMessage / chat.update ----- + const updateTs = process.env.UPDATE_TS; + const threadTs = process.env.THREAD_TS; + const endpoint = updateTs + ? "https://slack.com/api/chat.update" + : "https://slack.com/api/chat.postMessage"; + + const body = { + channel, + text: process.env.HEADER, + attachments: [attachment], + }; + if (updateTs) body.ts = updateTs; + if (threadTs && !updateTs) body.thread_ts = threadTs; + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${botToken}`, + 'Content-Type': 'application/json; charset=utf-8', + }, + body: JSON.stringify(body), + }); + const json = await response.json(); + if (!response.ok || !json.ok) { + throw new Error(`Slack API ${endpoint} failed: ${json.error || response.statusText}`); + } + core.setOutput('ts', json.ts || ''); + core.setOutput('channel', json.channel || ''); + console.log(`✅ Slack notification sent (ts=${json.ts}, channel=${json.channel})`); + return; + } + + // ----- Webhook path (legacy) ----- + const webhook = process.env.SLACK_WEBHOOK_URL || process.env.WEBHOOK_INPUT; + if (!webhook) { + throw new Error( + "slack-notify: no credentials. Either set bot_token + channel (preferred), " + + "or set SLACK_WEBHOOK_URL env / slack_webhook_url input." + ); + } + if (process.env.THREAD_TS || process.env.UPDATE_TS) { + core.warning( + "thread_ts / update_ts ignored on the webhook path — webhooks can't thread or edit. " + + "Switch to bot_token + channel." + ); } - const payload = { - text: process.env.HEADER, - attachments: [attachment], - }; - // Send to Slack + const payload = { text: process.env.HEADER, attachments: [attachment] }; const response = await fetch(webhook, { method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(payload) + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), }); - if (!response.ok) { - const text = await response.text(); - throw new Error(`Slack webhook failed: ${response.statusText} - ${text}`); + const body = await response.text(); + throw new Error(`Slack webhook failed: ${response.statusText} - ${body}`); } - - console.log('✅ Slack notification sent successfully'); + // Webhooks don't return ts/channel. Leave outputs empty. + core.setOutput('ts', ''); + core.setOutput('channel', ''); + console.log('✅ Slack notification sent (webhook)'); diff --git a/.github/workflows/slack-notify.yml b/.github/workflows/slack-notify.yml index ea7364e..bc7dbef 100644 --- a/.github/workflows/slack-notify.yml +++ b/.github/workflows/slack-notify.yml @@ -1,15 +1,20 @@ name: Slack notify (reusable) -# Thin wrapper around .github/actions/slack-notify. The composite action can't -# read org secrets directly, so this reusable workflow pulls -# secrets.CI_SLACK_WEBHOOK and passes it through. Two use cases: +# Thin wrapper around .github/actions/slack-notify. Reusable workflows can read +# org secrets directly, so this wrapper is the natural way to call the action +# without forcing every caller to wire DEPLOY_BOT_SLACK_TOKEN / CI_SLACK_WEBHOOK +# at the job level. # +# Defaults to the bot-token path (chat.postMessage), which returns ts so callers +# can thread or edit later. Webhook path stays as a fallback when no bot token +# is provisioned. +# +# Use cases: # 1. Manual testing of the slack-notify action via the Actions tab # (workflow_dispatch). -# 2. Callers that prefer `secrets: inherit` over setting SLACK_WEBHOOK_URL -# at job level. Note the trade-off: each call spins up its own runner. -# For inline notifications inside an existing job, use the composite -# action directly. +# 2. Callers that prefer `secrets: inherit` over wiring secrets at job level. +# Note the trade-off: each call spins up its own runner. For inline +# notifications inside an existing job, use the composite action directly. on: workflow_call: @@ -25,6 +30,28 @@ on: type: string required: false default: "" + channel: + description: "Channel name or ID. Required for bot-token path." + type: string + required: false + default: "" + thread_ts: + description: "If set, post as a reply to this message ts." + type: string + required: false + default: "" + update_ts: + description: "If set, edit the message with this ts instead of posting a new one." + type: string + required: false + default: "" + outputs: + ts: + description: "Timestamp of the posted (or updated) message. Empty on webhook path." + value: ${{ jobs.send.outputs.ts }} + channel: + description: "Resolved channel ID. Empty on webhook path." + value: ${{ jobs.send.outputs.channel }} workflow_dispatch: inputs: header: @@ -39,19 +66,38 @@ on: type: string required: false default: "" + channel: + type: string + required: false + default: "#dev" + thread_ts: + type: string + required: false + default: "" + update_ts: + type: string + required: false + default: "" jobs: send: runs-on: ubuntu-latest + outputs: + ts: ${{ steps.notify.outputs.ts }} + channel: ${{ steps.notify.outputs.channel }} steps: - # Composite action is pinned to @main — `actions/checkout` in a reusable - # workflow checks out the *caller's* repo, so we can't use a `./` path - # without an extra clone of jitsucom/github-workflows. If this wrapper - # ever ships in tagged releases, bump this @ref alongside the tag. - - uses: jitsucom/github-workflows/.github/actions/slack-notify@main - env: - SLACK_WEBHOOK_URL: ${{ secrets.CI_SLACK_WEBHOOK }} + # Composite action ref note: a reusable workflow can't easily resolve + # "the ref this workflow file was loaded from", so we pin the composite + # action to @main. Bump in tandem when cutting tagged releases. + - name: 💬 Send Slack notification + id: notify + uses: jitsucom/github-workflows/.github/actions/slack-notify@main with: + bot_token: ${{ secrets.DEPLOY_BOT_SLACK_TOKEN }} + channel: ${{ inputs.channel }} + thread_ts: ${{ inputs.thread_ts }} + update_ts: ${{ inputs.update_ts }} + slack_webhook_url: ${{ secrets.CI_SLACK_WEBHOOK }} header: ${{ inputs.header }} color: ${{ inputs.color }} blocks: ${{ inputs.blocks }}