ci: Notify slack on failed release publish (#36114)

This commit is contained in:
Matsu
2026-08-12 12:05:41 +03:00
committed by GitHub
parent 9d8fa66e6a
commit 280bb98c97
5 changed files with 385 additions and 0 deletions
@@ -0,0 +1,103 @@
/**
* Build Block Kit blocks for the nightly Trivy vulnerability digest.
*
* Workflow-specific inputs (passed via notify.mjs flags):
* results — path to trivy-results.json
* imageRef — full image reference (ghcr.io/n8n-io/n8n:nightly)
*
* Repo / run context is read from the GitHub Actions runner env
* (GITHUB_REPOSITORY, GITHUB_SERVER_URL, GITHUB_RUN_ID). Pass `env` to
* override in tests.
*/
import { readFileSync } from 'node:fs';
const SEVERITY_RANK = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 };
const SEVERITY_EMOJI = {
CRITICAL: ':red_circle:',
HIGH: ':large_orange_circle:',
MEDIUM: ':large_yellow_circle:',
LOW: ':large_green_circle:',
};
const MAX_CVE_BLOCKS = 8;
export default function buildTrivyBlocks({ results, imageRef, env = process.env }) {
const repoName = env.GITHUB_REPOSITORY;
const repoUrl = `${env.GITHUB_SERVER_URL}/${repoName}`;
const runUrl = `${repoUrl}/actions/runs/${env.GITHUB_RUN_ID}`;
const report = JSON.parse(readFileSync(results, 'utf8'));
const allVulns = (report.Results ?? [])
.flatMap((r) => r.Vulnerabilities ?? [])
.filter((v) => v && v.VulnerabilityID);
const seen = new Set();
const uniqueVulns = [];
for (const v of allVulns) {
if (seen.has(v.VulnerabilityID)) continue;
seen.add(v.VulnerabilityID);
uniqueVulns.push(v);
}
const counts = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 };
for (const v of uniqueVulns) {
if (v.Severity in counts) counts[v.Severity]++;
}
const cvssOf = (v) => v?.CVSS?.nvd?.V3Score ?? 0;
uniqueVulns.sort((a, b) => {
const sevDiff = (SEVERITY_RANK[a.Severity] ?? 99) - (SEVERITY_RANK[b.Severity] ?? 99);
if (sevDiff !== 0) return sevDiff;
return cvssOf(b) - cvssOf(a);
});
const cveBlock = (v) => ({
type: 'section',
text: {
type: 'mrkdwn',
text: [
`${SEVERITY_EMOJI[v.Severity] ?? ':white_circle:'} *<https://nvd.nist.gov/vuln/detail/${v.VulnerabilityID}|${v.VulnerabilityID}>* (CVSS: \`${v.CVSS?.nvd?.V3Score ?? 'N/A'}\`)`,
`*Package:* \`${v.PkgName}@${v.InstalledVersion}\` → \`${v.FixedVersion ?? 'No fix available'}\``,
].join('\n'),
},
});
return [
{
type: 'header',
text: { type: 'plain_text', text: ':warning: Trivy Scan: Vulnerabilities Detected' },
},
{
type: 'section',
fields: [
{ type: 'mrkdwn', text: `*Repository:*\n<${repoUrl}|${repoName}>` },
{ type: 'mrkdwn', text: `*Image:*\n\`${imageRef}\`` },
{ type: 'mrkdwn', text: `*Critical:*\n:red_circle: ${counts.CRITICAL}` },
{ type: 'mrkdwn', text: `*High:*\n:large_orange_circle: ${counts.HIGH}` },
{ type: 'mrkdwn', text: `*Medium:*\n:large_yellow_circle: ${counts.MEDIUM}` },
{ type: 'mrkdwn', text: `*Low:*\n:large_green_circle: ${counts.LOW}` },
],
},
{
type: 'context',
elements: [
{ type: 'mrkdwn', text: `:shield: ${uniqueVulns.length} unique CVEs affecting packages` },
],
},
{ type: 'divider' },
...uniqueVulns.slice(0, MAX_CVE_BLOCKS).map(cveBlock),
{ type: 'divider' },
{
type: 'actions',
elements: [
{
type: 'button',
text: { type: 'plain_text', text: ':github: View Full Report' },
style: 'primary',
url: runUrl,
},
],
},
];
}
@@ -0,0 +1,96 @@
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import buildTrivyBlocks from './build-trivy-blocks.mjs';
const ENV = {
GITHUB_REPOSITORY: 'n8n-io/n8n',
GITHUB_SERVER_URL: 'https://github.com',
GITHUB_RUN_ID: '1',
};
function build(report) {
const dir = mkdtempSync(join(tmpdir(), 'trivy-blocks-'));
const path = join(dir, 'trivy-results.json');
writeFileSync(path, JSON.stringify(report));
return buildTrivyBlocks({
results: path,
imageRef: 'ghcr.io/n8n-io/n8n:nightly',
env: ENV,
});
}
test('counts vulnerabilities by severity', () => {
const blocks = build({
Results: [{
Vulnerabilities: [
{ VulnerabilityID: 'CVE-1', Severity: 'CRITICAL', PkgName: 'a', InstalledVersion: '1' },
{ VulnerabilityID: 'CVE-2', Severity: 'HIGH', PkgName: 'b', InstalledVersion: '2' },
{ VulnerabilityID: 'CVE-3', Severity: 'HIGH', PkgName: 'c', InstalledVersion: '3' },
{ VulnerabilityID: 'CVE-4', Severity: 'MEDIUM', PkgName: 'd', InstalledVersion: '4' },
{ VulnerabilityID: 'CVE-5', Severity: 'LOW', PkgName: 'e', InstalledVersion: '5' },
],
}],
});
const counts = blocks.find((b) => b.type === 'section' && b.fields);
const fields = Object.fromEntries(
counts.fields.map((f) => f.text.split('\n')).map(([k, v]) => [k, v]),
);
assert.equal(fields['*Critical:*'], ':red_circle: 1');
assert.equal(fields['*High:*'], ':large_orange_circle: 2');
assert.equal(fields['*Medium:*'], ':large_yellow_circle: 1');
assert.equal(fields['*Low:*'], ':large_green_circle: 1');
});
test('dedupes vulnerabilities by CVE id', () => {
const blocks = build({
Results: [{
Vulnerabilities: [
{ VulnerabilityID: 'CVE-1', Severity: 'HIGH', PkgName: 'a', InstalledVersion: '1' },
{ VulnerabilityID: 'CVE-1', Severity: 'HIGH', PkgName: 'a', InstalledVersion: '1' },
{ VulnerabilityID: 'CVE-2', Severity: 'HIGH', PkgName: 'b', InstalledVersion: '2' },
],
}],
});
const ctx = blocks.find((b) => b.type === 'context');
assert.match(ctx.elements[0].text, /2 unique CVEs/);
});
test('sorts by severity then CVSS', () => {
const blocks = build({
Results: [{
Vulnerabilities: [
{ VulnerabilityID: 'CVE-LOW-1', Severity: 'LOW', PkgName: 'a', InstalledVersion: '1' },
{ VulnerabilityID: 'CVE-HIGH-LOWCVSS', Severity: 'HIGH', PkgName: 'b', InstalledVersion: '2', CVSS: { nvd: { V3Score: 5 } } },
{ VulnerabilityID: 'CVE-HIGH-HIGHCVSS', Severity: 'HIGH', PkgName: 'c', InstalledVersion: '3', CVSS: { nvd: { V3Score: 9 } } },
{ VulnerabilityID: 'CVE-CRIT-1', Severity: 'CRITICAL', PkgName: 'd', InstalledVersion: '4' },
],
}],
});
const cveSections = blocks.filter((b) => b.type === 'section' && b.text?.type === 'mrkdwn');
const ids = cveSections.map((b) => b.text.text.match(/CVE-[A-Z0-9-]+/)?.[0]);
assert.deepEqual(ids, ['CVE-CRIT-1', 'CVE-HIGH-HIGHCVSS', 'CVE-HIGH-LOWCVSS', 'CVE-LOW-1']);
});
test('caps CVE detail blocks at 8', () => {
const vulns = Array.from({ length: 20 }, (_, i) => ({
VulnerabilityID: `CVE-${i}`,
Severity: 'HIGH',
PkgName: `p${i}`,
InstalledVersion: '1',
}));
const blocks = build({ Results: [{ Vulnerabilities: vulns }] });
const cveSections = blocks.filter((b) => b.type === 'section' && b.text?.type === 'mrkdwn');
assert.equal(cveSections.length, 8);
});
test('emits view-report button with run url from GH env', () => {
const blocks = build({ Results: [{ Vulnerabilities: [
{ VulnerabilityID: 'CVE-1', Severity: 'HIGH', PkgName: 'a', InstalledVersion: '1' },
] }] });
const actions = blocks.find((b) => b.type === 'actions');
assert.equal(actions.elements[0].url, 'https://github.com/n8n-io/n8n/actions/runs/1');
});
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env node
/**
* Send a Slack chat.postMessage from a GitHub Actions step.
*
* Usage:
* node .github/scripts/slack/notify.mjs \
* --channel '#alerts-build' \
* --text 'CI failed on master'
*
* Rich blocks via a builder module under .github/scripts/slack/:
* node .github/scripts/slack/notify.mjs \
* --channel C0AHNJU9XFA \
* --text 'Trivy: 14 high vulns in nightly' \
* --blocks trivy \
* --results trivy-results.json \
* --image-ref $IMAGE_REF
*
* --blocks <name> resolves to ./build-<name>-blocks.mjs (default export receives the args).
* Builders read repo / run context from $GITHUB_* runner env vars.
*
* Bot token: $SLACK_TOKEN (required).
*
* Exits non-zero on any Slack API error so the step fails loudly.
*/
import { pathToFileURL } from 'node:url';
export async function sendSlackMessage({ token, channel, text, blocks }) {
const payload = { channel, text };
if (blocks) payload.blocks = blocks;
const res = await fetch('https://slack.com/api/chat.postMessage', {
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=utf-8',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(payload),
});
const body = await res.json();
if (!body.ok) {
console.error('Slack chat.postMessage failed:');
console.error(JSON.stringify(body, null, 2));
process.exit(1);
}
console.log(`Slack message posted to ${body.channel} at ts=${body.ts}`);
return body;
}
function parseArgs(argv) {
const out = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (!a.startsWith('--')) continue;
const key = a.slice(2);
const next = argv[i + 1];
const value = next && !next.startsWith('--') ? next : 'true';
out[key] = value;
if (value !== 'true') i++;
}
return out;
}
function kebabToCamel(s) {
return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const channel = args.channel;
const text = args.text;
const blocksName = args.blocks;
if (!channel) throw new Error('--channel is required');
if (!text) throw new Error('--text is required');
const token = process.env.SLACK_TOKEN;
if (!token) throw new Error('SLACK_TOKEN env var is required');
let blocks;
if (blocksName) {
const { default: builder } = await import(`./build-${blocksName}-blocks.mjs`);
const builderArgs = Object.fromEntries(
Object.entries(args)
.filter(([k]) => k !== 'channel' && k !== 'text' && k !== 'blocks')
.map(([k, v]) => [kebabToCamel(k), v]),
);
blocks = builder(builderArgs);
}
await sendSlackMessage({ token, channel, text, blocks });
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
await main();
}
+55
View File
@@ -0,0 +1,55 @@
import { test, mock } from 'node:test';
import assert from 'node:assert/strict';
import { sendSlackMessage } from './notify.mjs';
function mockFetch(responseBody, status = 200) {
return mock.method(globalThis, 'fetch', async () => ({
status,
json: async () => responseBody,
}));
}
test('posts channel + text to chat.postMessage', async (t) => {
const fetchMock = mockFetch({ ok: true, channel: 'C123', ts: '1.0' });
t.after(() => fetchMock.mock.restore());
await sendSlackMessage({ token: 'xoxb-test', channel: '#x', text: 'hi' });
const [url, init] = fetchMock.mock.calls[0].arguments;
assert.equal(url, 'https://slack.com/api/chat.postMessage');
assert.equal(init.method, 'POST');
assert.equal(init.headers.Authorization, 'Bearer xoxb-test');
assert.equal(init.headers['Content-Type'], 'application/json; charset=utf-8');
assert.deepEqual(JSON.parse(init.body), { channel: '#x', text: 'hi' });
});
test('includes blocks when provided', async (t) => {
const fetchMock = mockFetch({ ok: true, channel: 'C123', ts: '1.0' });
t.after(() => fetchMock.mock.restore());
const blocks = [{ type: 'section', text: { type: 'mrkdwn', text: 'x' } }];
await sendSlackMessage({ token: 't', channel: '#x', text: 'hi', blocks });
const [, init] = fetchMock.mock.calls[0].arguments;
assert.deepEqual(JSON.parse(init.body), { channel: '#x', text: 'hi', blocks });
});
test('exits non-zero on Slack ok:false', async (t) => {
const fetchMock = mockFetch({ ok: false, error: 'not_in_channel' });
const exitMock = mock.method(process, 'exit', () => {
throw new Error('exit called');
});
const errMock = mock.method(console, 'error', () => {});
t.after(() => {
fetchMock.mock.restore();
exitMock.mock.restore();
errMock.mock.restore();
});
await assert.rejects(
sendSlackMessage({ token: 't', channel: '#x', text: 'hi' }),
/exit called/,
);
assert.equal(exitMock.mock.calls[0].arguments[0], 1);
});
+34
View File
@@ -216,3 +216,37 @@ jobs:
new_stable_version: ${{ needs.determine-version-info.outputs.new_stable_version }}
release_type: ${{ needs.determine-version-info.outputs.release_type }}
secrets: inherit
notify-on-failure:
name: Notify Slack on failure
needs:
[
determine-version-info,
publish-to-npm,
publish-to-docker-hub,
create-github-release,
move-track-tag,
promote-stable-tag,
generate-and-attach-sbom,
merge-release-tag-to-master,
merge-release-tag-to-rc-branch,
post-release,
]
if: |
always() &&
github.event.pull_request.merged == true &&
(contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: master
sparse-checkout: .github/scripts/slack
sparse-checkout-cone-mode: false
- name: Notify Slack
env:
SLACK_TOKEN: ${{ secrets.RELEASE_HELPER_SLACK_TOKEN }}
run: |
node .github/scripts/slack/notify.mjs \
--channel C036AELNMV0 \
--text '<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|Release publish failed for n8n@${{ needs.determine-version-info.outputs.version }}>. Please go to the actions page and retry failed runs to recover a flake in the release pipeline. <!subteam^S08FYH9C9ME|@release-captain>'