Skip to content

Commit 3ef386d

Browse files
authored
feat!: introduce the slack_notification GitHub composite ation (#188)
1 parent d3b2278 commit 3ef386d

3 files changed

Lines changed: 333 additions & 0 deletions

File tree

.github/workflows/release.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ jobs:
3030
- { name: sast_scan, path: .github/workflows/sast_scan.yaml }
3131
- { name: npm_test, path: actions/npm_test }
3232
- { name: scan_container_image, path: actions/scan_container_image }
33+
- { name: slack_notification, path: actions/slack_notification }
3334
- { name: update-nr-flows, path: actions/update-nr-flows }
3435
- { name: project-automation, path: .github/workflows/project-automation.yaml }
3536
- { name: recurring_sprint_issue, path: .github/workflows/recurring_sprint_issue.yml }
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Slack notification
2+
3+
Composite action that resolves Slack recipients from GitHub logins and sends a Block Kit
4+
message via `chat.postMessage`. Replaces the hand-rolled `github-script` Slack blocks
5+
duplicated across `tests`, `branch-deploy`, `install-test` and `publish` workflows.
6+
7+
The bot token needs `users:read`, `users.profile:read` and `chat:write`.
8+
Recipient resolution matches a GitHub username against a Slack custom profile field (default `Xf0A2BPU8U77`).
9+
10+
## Inputs
11+
12+
| Input | Required | Default | Description |
13+
|-------|----------|---------|-------------|
14+
| `bot-token` | yes | - | Slack bot token (`secrets.SLACK_GHBOT_TOKEN`). |
15+
| `mode` | yes | - | `channel`, `author`, or `contributors`. See below. |
16+
| `blocks` | yes | - | Block Kit array as a JSON string. Supports `{{ROLE}}`, `{{MENTION}}` and `{{KEY}}` placeholders. |
17+
| `values` | no | `{}` | JSON object of `KEY` → raw string, injected JSON-safely as `{{KEY}}`. Build with `toJSON()`. |
18+
| `channel-id` | no | `C067BD0377F` | Target channel for `mode=channel`. |
19+
| `github-field-id` | no | `Xf0A2BPU8U77` | Slack custom profile field holding the GitHub username. |
20+
| `mention-actor` | no | `false` | `mode=channel`: resolve the actor and expose `{{MENTION}}` as a real ping. |
21+
| `actor` | no | `github.actor` | Actor login. |
22+
| `event-name` | no | `github.event_name` | Drives `contributors` branching. |
23+
| `pr-author` | no | `github.event.pull_request.user.login` | PR author login. |
24+
| `requested-reviewers` | no | `toJSON(...requested_reviewers)` | Reviewers JSON (`contributors`). |
25+
26+
### Modes
27+
28+
- **channel** - post once to `channel-id`. With `mention-actor: true`, `{{MENTION}}` becomes the actor's Slack ping (or plain login if unresolved).
29+
- **author** - DM the PR author. Bots / unresolved authors are skipped silently.
30+
- **contributors** - DM PR author + requested reviewers + pusher; on `workflow_dispatch`, DM the actor. Unresolved users are dropped with a warning.
31+
32+
The send step fails only if **every** send fails; partial failures are warnings.
33+
34+
### Placeholders & escaping
35+
36+
`{{ROLE}}` (per recipient) and `{{MENTION}}` (channel mention) are built-in. Any other
37+
`{{KEY}}` comes from `values`. All substitutions are JSON-escaped, so arbitrary text
38+
(PR titles, commit messages) is safe - **pass such values through `values` using `toJSON()`**,
39+
never inline into the `blocks` string.
40+
41+
## Examples
42+
43+
### Channel post on failure
44+
45+
```yaml
46+
notify-slack:
47+
name: Notify on failure
48+
needs: [build]
49+
if: failure()
50+
runs-on: ubuntu-latest
51+
steps:
52+
- uses: FlowFuse/github-actions-workflows/actions/slack_notification@slack_notification/v1
53+
with:
54+
bot-token: ${{ secrets.SLACK_GHBOT_TOKEN }}
55+
mode: channel
56+
blocks: |
57+
[
58+
{ "type": "header", "text": { "type": "plain_text", "text": ":x: ${{ github.workflow }} workflow failed", "emoji": true } },
59+
{ "type": "divider" },
60+
{ "type": "section", "text": { "type": "mrkdwn", "text": "*Workflow run:*\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View>" } }
61+
]
62+
```
63+
64+
### Author DM on PR failure, channel ping on push
65+
66+
```yaml
67+
- uses: FlowFuse/github-actions-workflows/actions/slack_notification@slack_notification/v1
68+
with:
69+
bot-token: ${{ secrets.SLACK_GHBOT_TOKEN }}
70+
mode: ${{ github.event_name == 'pull_request' && 'author' || 'channel' }}
71+
mention-actor: ${{ github.event_name != 'pull_request' }}
72+
blocks: |
73+
[
74+
{ "type": "header", "text": { "type": "plain_text", "text": ":x: Tests failed", "emoji": true } },
75+
{ "type": "section", "fields": [
76+
{ "type": "mrkdwn", "text": "*Author:* {{MENTION}}" },
77+
{ "type": "mrkdwn", "text": "<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View failed workflow>" }
78+
] }
79+
]
80+
```
81+
82+
> `{{MENTION}}` renders the actor ping on push (channel mode); on PR (author mode) it is empty —
83+
> DM recipients already know they are the author, so branch the text with expressions if needed.
84+
85+
### Contributors DM with arbitrary PR title
86+
87+
```yaml
88+
- uses: FlowFuse/github-actions-workflows/actions/slack_notification@slack_notification/v1
89+
with:
90+
bot-token: ${{ secrets.SLACK_GHBOT_TOKEN }}
91+
mode: contributors
92+
values: |
93+
{ "PR_TITLE": ${{ toJSON(github.event.pull_request.title) }} }
94+
blocks: |
95+
[
96+
{ "type": "header", "text": { "type": "plain_text", "text": "Pull Request ${{ github.event.number }} pre-staging deployment", "emoji": true } },
97+
{ "type": "section", "fields": [
98+
{ "type": "mrkdwn", "text": "*Role:*\n{{ROLE}}" },
99+
{ "type": "mrkdwn", "text": "*Pull Request:*\n{{PR_TITLE}}" }
100+
] }
101+
]
102+
```
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
name: "Slack notification"
2+
description: "Resolve Slack recipients from GitHub logins and send a Block Kit message via chat.postMessage"
3+
4+
inputs:
5+
bot-token:
6+
description: 'Slack bot token (secrets.SLACK_GHBOT_TOKEN). Needs users:read, users.profile:read, chat:write.'
7+
required: true
8+
mode:
9+
description: >-
10+
Who to notify:
11+
"channel" (post to channel-id),
12+
"author" (DM the PR author),
13+
"contributors" (DM PR author + requested reviewers + pusher, or the actor on workflow_dispatch).
14+
required: true
15+
blocks:
16+
description: >-
17+
Block Kit blocks as a JSON array (string). May contain placeholders substituted per send:
18+
{{ROLE}} (recipient role), {{MENTION}} (resolved actor mention, channel mode),
19+
and any {{KEY}} from the `values` input. All substitutions are JSON-escaped.
20+
required: true
21+
values:
22+
description: >-
23+
JSON object of placeholder KEY -> raw string, injected JSON-safely into `blocks` as {{KEY}}.
24+
Build each value with toJSON() so arbitrary text (e.g. PR titles) stays valid JSON.
25+
required: false
26+
default: '{}'
27+
channel-id:
28+
description: 'Target channel ID for mode=channel.'
29+
required: false
30+
default: 'C067BD0377F'
31+
github-field-id:
32+
description: 'Slack custom profile field ID holding the GitHub username.'
33+
required: false
34+
default: 'Xf0A2BPU8U77'
35+
mention-actor:
36+
description: 'mode=channel only: resolve the actor and expose {{MENTION}} as a real Slack ping.'
37+
required: false
38+
default: 'false'
39+
actor:
40+
description: 'GitHub actor (used by mode=contributors/channel).'
41+
required: false
42+
default: ${{ github.actor }}
43+
event-name:
44+
description: 'Triggering event name (drives mode=contributors branching).'
45+
required: false
46+
default: ${{ github.event_name }}
47+
pr-author:
48+
description: 'PR author login (mode=author/contributors).'
49+
required: false
50+
default: ${{ github.event.pull_request.user.login }}
51+
requested-reviewers:
52+
description: 'Requested reviewers as JSON (mode=contributors).'
53+
required: false
54+
default: ${{ toJSON(github.event.pull_request.requested_reviewers) }}
55+
56+
runs:
57+
using: composite
58+
steps:
59+
# Resolve the recipient list. For author/contributors modes, each GitHub login is mapped to a
60+
# Slack user ID by scanning workspace profiles and matching the custom GitHub profile field.
61+
# Unresolved and bot users are dropped with a warning. Outputs `recipients` JSON and, for
62+
# channel mode with mention-actor, `actor_mention`.
63+
- name: Resolve Slack recipients
64+
id: resolve
65+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
66+
env:
67+
SLACK_BOT_TOKEN: ${{ inputs.bot-token }}
68+
SLACK_GITHUB_FIELD_ID: ${{ inputs.github-field-id }}
69+
MODE: ${{ inputs.mode }}
70+
CHANNEL_ID: ${{ inputs.channel-id }}
71+
MENTION_ACTOR: ${{ inputs.mention-actor }}
72+
ACTOR: ${{ inputs.actor }}
73+
EVENT_NAME: ${{ inputs.event-name }}
74+
PR_AUTHOR: ${{ inputs.pr-author }}
75+
REQUESTED_REVIEWERS: ${{ inputs.requested-reviewers }}
76+
with:
77+
script: |
78+
const token = process.env.SLACK_BOT_TOKEN;
79+
const fieldId = process.env.SLACK_GITHUB_FIELD_ID;
80+
const mode = process.env.MODE;
81+
const eventName = process.env.EVENT_NAME;
82+
const actor = process.env.ACTOR;
83+
const mentionActor = process.env.MENTION_ACTOR === 'true';
84+
85+
// Scan the workspace once and resolve a set of GitHub logins -> Slack user IDs.
86+
async function resolveLogins(targets) {
87+
const want = new Set([...targets].map(t => t.toLowerCase()));
88+
const resolved = new Map();
89+
if (want.size === 0) return resolved;
90+
let cursor;
91+
outer: do {
92+
const params = new URLSearchParams({ limit: '200' });
93+
if (cursor) params.set('cursor', cursor);
94+
const res = await fetch(`https://slack.com/api/users.list?${params}`, {
95+
headers: { Authorization: `Bearer ${token}` }
96+
});
97+
const data = await res.json();
98+
if (!data.ok) { core.setFailed(`Slack users.list error: ${data.error}`); return resolved; }
99+
for (const member of data.members) {
100+
if (member.deleted || member.is_bot) continue;
101+
const profileRes = await fetch(`https://slack.com/api/users.profile.get?user=${member.id}`, {
102+
headers: { Authorization: `Bearer ${token}` }
103+
});
104+
const profileData = await profileRes.json();
105+
if (!profileData.ok) continue;
106+
const ghField = profileData.profile?.fields?.[fieldId]?.value;
107+
if (!ghField) continue;
108+
const ghLower = ghField.toLowerCase();
109+
if (want.has(ghLower)) {
110+
resolved.set(ghLower, member.id);
111+
if (resolved.size === want.size) break outer;
112+
}
113+
}
114+
cursor = data.response_metadata?.next_cursor;
115+
} while (cursor);
116+
return resolved;
117+
}
118+
119+
// 1. Desired recipients (logins + roles) per mode. Bots are skipped (no Slack profile).
120+
const wanted = [];
121+
const seen = new Set();
122+
const add = (login, role) => {
123+
if (!login || login.endsWith('[bot]')) return;
124+
const key = login.toLowerCase();
125+
if (seen.has(key)) return;
126+
seen.add(key);
127+
wanted.push({ github: key, role });
128+
};
129+
130+
if (mode === 'author') {
131+
add(process.env.PR_AUTHOR, 'Author');
132+
} else if (mode === 'contributors') {
133+
if (eventName === 'workflow_dispatch') {
134+
add(actor, 'Trigger');
135+
} else {
136+
add(process.env.PR_AUTHOR, 'Author');
137+
for (const r of JSON.parse(process.env.REQUESTED_REVIEWERS || '[]')) add(r.login, 'Reviewer');
138+
add(actor, 'Pusher');
139+
}
140+
} else if (mode !== 'channel') {
141+
core.setFailed(`Unknown mode: ${mode}`);
142+
return;
143+
}
144+
145+
// 2. Resolve recipients + (optionally) the actor for a channel mention, in one scan.
146+
const targets = new Set(wanted.map(w => w.github));
147+
if (mentionActor && actor) targets.add(actor.toLowerCase());
148+
const resolved = await resolveLogins(targets);
149+
150+
// 3. Actor mention for channel posts (real ping if resolved, else plain login).
151+
let actorMention = '';
152+
if (mentionActor) {
153+
const id = actor ? resolved.get(actor.toLowerCase()) : undefined;
154+
if (id) { actorMention = `<@${id}>`; }
155+
else { core.warning(`No Slack user found with GitHub username: ${actor}`); actorMention = actor || ''; }
156+
}
157+
core.setOutput('actor_mention', actorMention);
158+
159+
// 4. Final recipient list.
160+
let recipients;
161+
if (mode === 'channel') {
162+
recipients = [{ slack_id: process.env.CHANNEL_ID, role: 'Channel' }];
163+
} else {
164+
recipients = [];
165+
for (const w of wanted) {
166+
const id = resolved.get(w.github);
167+
if (id) recipients.push({ slack_id: id, github: w.github, role: w.role });
168+
else core.warning(`No Slack user found with GitHub username: ${w.github} (role: ${w.role})`);
169+
}
170+
}
171+
core.info(`Recipients: ${recipients.length}`);
172+
core.setOutput('recipients', JSON.stringify(recipients));
173+
174+
- name: Send Slack notification
175+
if: steps.resolve.outputs.recipients != '' && steps.resolve.outputs.recipients != '[]'
176+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
177+
env:
178+
SLACK_BOT_TOKEN: ${{ inputs.bot-token }}
179+
RECIPIENTS: ${{ steps.resolve.outputs.recipients }}
180+
ACTOR_MENTION: ${{ steps.resolve.outputs.actor_mention }}
181+
BLOCKS: ${{ inputs.blocks }}
182+
VALUES: ${{ inputs.values }}
183+
with:
184+
script: |
185+
const token = process.env.SLACK_BOT_TOKEN;
186+
const recipients = JSON.parse(process.env.RECIPIENTS || '[]');
187+
const template = process.env.BLOCKS;
188+
const actorMention = process.env.ACTOR_MENTION || '';
189+
const values = JSON.parse(process.env.VALUES || '{}');
190+
191+
if (recipients.length === 0) { core.info('No recipients; nothing to send'); return; }
192+
193+
// Insert `raw` in place of {{key}} inside a JSON string literal, JSON-escaped so quotes
194+
// and newlines in the value never break the surrounding JSON.
195+
const sub = (str, key, raw) => {
196+
const escaped = JSON.stringify(String(raw)).slice(1, -1);
197+
return str.split(`{{${key}}}`).join(escaped);
198+
};
199+
200+
let failures = 0;
201+
for (const r of recipients) {
202+
let filled = template;
203+
for (const [k, v] of Object.entries(values)) filled = sub(filled, k, v);
204+
filled = sub(filled, 'ROLE', r.role || '');
205+
filled = sub(filled, 'MENTION', actorMention);
206+
207+
let blocks;
208+
try { blocks = JSON.parse(filled); }
209+
catch (e) { core.setFailed(`Invalid blocks JSON after substitution: ${e.message}`); return; }
210+
211+
const res = await fetch('https://slack.com/api/chat.postMessage', {
212+
method: 'POST',
213+
headers: {
214+
Authorization: `Bearer ${token}`,
215+
'Content-Type': 'application/json; charset=utf-8'
216+
},
217+
body: JSON.stringify({ channel: r.slack_id, blocks })
218+
});
219+
const data = await res.json();
220+
if (!data.ok) {
221+
core.warning(`Slack chat.postMessage failed for ${r.slack_id}: ${data.error}`);
222+
failures++;
223+
} else {
224+
core.info(`Notified ${r.slack_id} (${r.role})`);
225+
}
226+
}
227+
228+
if (failures > 0 && failures === recipients.length) {
229+
core.setFailed(`All ${failures} Slack notifications failed`);
230+
}

0 commit comments

Comments
 (0)