@@ -10,15 +10,16 @@ jobs:
1010 unit-tests :
1111 name : Unit tests
1212 runs-on : ubuntu-latest
13+ strategy :
14+ matrix :
15+ node-version : [24.x]
1316 steps :
1417 - name : Checkout
1518 uses : actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
1619 with :
1720 fetch-depth : 0
18- - name : Use Node.js 24
21+ - name : Use Node.js ${{ matrix.node-version }}
1922 uses : actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
20- with :
21- node-version : 24
2223 - name : Install Dependencies
2324 run : npm ci
2425 - name : Lint
@@ -28,25 +29,154 @@ jobs:
2829
2930 notify-slack :
3031 name : Notify on failure
31- needs : [ unit-tests ]
32+ needs :
33+ - unit-tests
3234 if : failure()
3335 runs-on : ubuntu-latest
34-
3536 steps :
36- - name : Map users
37- id : map-actor-to-slack
38- uses : icalia-actions/map-github-actor@e568d1dd6023e406a1db36db4e1e0b92d9dd7824 # v0.0.2
39- with :
40- actor-map : ${{ vars.SLACK_GITHUB_USERS_MAP }}
41- default-mapping : C067BD0377F
42-
43- - name : Send notification
44- uses : ravsamhq/notify-slack-action@be814b201e233b2dc673608aa46e5447c8ab13f2 # v2.5.0
45- with :
46- status : ' failure'
47- notification_title : ' FlowFuse Tests Pipeline'
48- footer : " <{run_url}|View Run>"
49- mention_users : ${{ steps.map-actor-to-slack.outputs.actor-mapping }}
50- env :
51- SLACK_WEBHOOK_URL : ${{ secrets.GH_WORKFLOWS_WEBHOOK }}
37+ - name : Resolve Slack recipients
38+ id : resolve
39+ uses : actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
40+ env :
41+ SLACK_BOT_TOKEN : ${{ secrets.SLACK_GHBOT_TOKEN }}
42+ SLACK_GITHUB_FIELD_ID : " Xf0A2BPU8U77"
43+ CHANNEL_ID : " C067BD0377F"
44+ PR_AUTHOR : ${{ github.event.pull_request.user.login }}
45+ ACTOR : ${{ github.actor }}
46+ EVENT_NAME : ${{ github.event_name }}
47+ with :
48+ script : |
49+ const token = process.env.SLACK_BOT_TOKEN;
50+ const fieldId = process.env.SLACK_GITHUB_FIELD_ID;
51+
52+ // Resolve a GitHub login -> Slack user ID by scanning workspace profiles and
53+ // matching the custom profile field (GitHub). Returns undefined if not found.
54+ async function resolveSlackId(login) {
55+ const target = login.toLowerCase();
56+ let cursor;
57+ do {
58+ const params = new URLSearchParams({ limit: '200' });
59+ if (cursor) params.set('cursor', cursor);
60+ const res = await fetch(`https://slack.com/api/users.list?${params}`, {
61+ headers: { Authorization: `Bearer ${token}` }
62+ });
63+ const data = await res.json();
64+ if (!data.ok) { core.setFailed(`Slack users.list error: ${data.error}`); return; }
65+ for (const member of data.members) {
66+ if (member.deleted || member.is_bot) continue;
67+ const profileRes = await fetch(`https://slack.com/api/users.profile.get?user=${member.id}`, {
68+ headers: { Authorization: `Bearer ${token}` }
69+ });
70+ const profileData = await profileRes.json();
71+ if (!profileData.ok) continue;
72+ const ghField = profileData.profile?.fields?.[fieldId]?.value;
73+ if (!ghField) continue;
74+ if (ghField.toLowerCase() === target) return member.id;
75+ }
76+ cursor = data.response_metadata?.next_cursor;
77+ } while (cursor);
78+ return undefined;
79+ }
80+
81+ // Push to main - notify the channel, mentioning the actor who pushed.
82+ if (process.env.EVENT_NAME !== 'pull_request') {
83+ const actor = process.env.ACTOR;
84+ const mention = actor ? await resolveSlackId(actor) : undefined;
85+ if (!mention) core.warning(`No Slack user found with GitHub username: ${actor}`);
86+ core.setOutput('recipients', JSON.stringify([{ slack_id: process.env.CHANNEL_ID, role: 'Channel', mention: mention || null }]));
87+ return;
88+ }
89+
90+ // Pull request - notify the author. Bot authors have no Slack profile: skip silently.
91+ const author = process.env.PR_AUTHOR;
92+ if (!author || author.endsWith('[bot]')) {
93+ core.setOutput('recipients', '[]');
94+ return;
95+ }
96+
97+ const slackId = await resolveSlackId(author);
98+ if (!slackId) {
99+ core.warning(`No Slack user found with GitHub username: ${author}`);
100+ core.setOutput('recipients', '[]');
101+ return;
102+ }
103+ core.setOutput('recipients', JSON.stringify([{ slack_id: slackId, github: author.toLowerCase(), role: 'Author' }]));
104+
105+ - name : Send Slack notification
106+ if : steps.resolve.outputs.recipients != '' && steps.resolve.outputs.recipients != '[]'
107+ uses : actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
108+ env :
109+ SLACK_BOT_TOKEN : ${{ secrets.SLACK_GHBOT_TOKEN }}
110+ RECIPIENTS : ${{ steps.resolve.outputs.recipients }}
111+ EVENT_NAME : ${{ github.event_name }}
112+ REF_NAME : ${{ github.ref_name }}
113+ PR_NUMBER : ${{ github.event.pull_request.number }}
114+ COMMIT_SHA : ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
115+ ACTOR : ${{ github.actor }}
116+ SERVER_URL : ${{ github.server_url }}
117+ REPOSITORY : ${{ github.repository }}
118+ RUN_ID : ${{ github.run_id }}
119+ with :
120+ script : |
121+ const token = process.env.SLACK_BOT_TOKEN;
122+ const recipients = JSON.parse(process.env.RECIPIENTS);
123+ const isBranch = process.env.EVENT_NAME !== 'pull_request';
124+ const refName = process.env.REF_NAME;
125+ const prNumber = process.env.PR_NUMBER;
126+ const commitSha = process.env.COMMIT_SHA;
127+ const actor = process.env.ACTOR;
128+ const serverUrl = process.env.SERVER_URL;
129+ const repository = process.env.REPOSITORY;
130+ const runId = process.env.RUN_ID;
131+
132+ const headerText = isBranch
133+ ? `Tests failed against ${refName} branch`
134+ : `Tests failed against #${prNumber} pull request`;
135+ const summaryIcon = isBranch ? 'no_entry' : 'warning';
136+ const summaryText = isBranch
137+ ? ' Deployment to FFC environments will not happen until this issue is resolved.'
138+ : ' Please resolve the problem before merging your changes into the main branch.';
139+ const refLink = isBranch
140+ ? `*Branch:* ${refName}`
141+ : `*Pull request:* <${serverUrl}/${repository}/pull/${prNumber}|${prNumber}>`;
142+
143+ let failures = 0;
144+ for (const r of recipients) {
145+ // Channel posts mention the actor's Slack ID (a real ping) if resolved;
146+ // otherwise (and for DMs) show the plain GitHub login.
147+ const authorText = r.mention ? `<@${r.mention}>` : actor;
148+ const blocks = [
149+ { type: 'header', text: { type: 'plain_text', text: `:x: ${headerText}`, emoji: true } },
150+ { type: 'divider' },
151+ { type: 'rich_text', elements: [ { type: 'rich_text_section', elements: [
152+ { type: 'emoji', name: summaryIcon },
153+ { type: 'text', text: summaryText, style: { bold: true } }
154+ ] } ] },
155+ { type: 'divider' },
156+ { type: 'section', fields: [
157+ { type: 'mrkdwn', text: `*Author:* ${authorText}` },
158+ { type: 'mrkdwn', text: `<${serverUrl}/${repository}/actions/runs/${runId}|View failed workflow>` },
159+ { type: 'mrkdwn', text: `*Last commit:* <${serverUrl}/${repository}/commit/${commitSha}|${commitSha}>` },
160+ { type: 'mrkdwn', text: refLink }
161+ ] }
162+ ];
163+ const res = await fetch('https://slack.com/api/chat.postMessage', {
164+ method: 'POST',
165+ headers: {
166+ Authorization: `Bearer ${token}`,
167+ 'Content-Type': 'application/json; charset=utf-8'
168+ },
169+ body: JSON.stringify({ channel: r.slack_id, blocks })
170+ });
171+ const data = await res.json();
172+ if (!data.ok) {
173+ core.warning(`Slack chat.postMessage failed for ${r.slack_id}: ${data.error}`);
174+ failures++;
175+ } else {
176+ core.info(`Notified ${r.slack_id} (${r.role})`);
177+ }
178+ }
52179
180+ if (failures > 0 && failures === recipients.length) {
181+ core.setFailed(`All ${failures} Slack notifications failed`);
182+ }
0 commit comments