mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-12 02:19:43 +00:00
ci: Notify slack on failed release publish (#36114)
This commit is contained in:
@@ -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');
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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>'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user