Skip to content
Merged
Show file tree
Hide file tree
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
165 changes: 120 additions & 45 deletions .github/actions/slack-notify/action.yml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -43,76 +83,111 @@ 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) {
line += `<${block.url}|${block.value}>`;
} 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)');
74 changes: 60 additions & 14 deletions .github/workflows/slack-notify.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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:
Expand All @@ -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 }}
Loading