From 65edccc17c218422ed29f9132e2dd0e9ab2b3a52 Mon Sep 17 00:00:00 2001 From: Andrew Date: Fri, 23 Oct 2026 18:12:08 +0200 Subject: [PATCH] fix(slack-notify): fit messages into Slack's 4,000-character limit chat.update rejects longer text with msg_too_long, so a long release changelog left the release head message stale. Cut at a line break, keep a short closing paragraph (the DB-migration flag) and log the Slack error. --- steps/slack-notify/slackNotify.js | 42 +++++++++++++++++++++++--- steps/slack-notify/slackNotify.test.js | 32 +++++++++++++++++++- 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/steps/slack-notify/slackNotify.js b/steps/slack-notify/slackNotify.js index b618a9a..5c78b5c 100644 --- a/steps/slack-notify/slackNotify.js +++ b/steps/slack-notify/slackNotify.js @@ -22,6 +22,32 @@ const markdownToMrkdwn = (text) => { ); }; +// chat.update rejects `text` over 4,000 characters (`msg_too_long`), and a long +// release changelog gets there. The body is cut at the last line break that fits, +// so no mrkdwn link is split, and the cut is marked. A short closing paragraph +// survives the cut: release flags such as the DB-migration warning live there. +const MAX_TEXT_LENGTH = 4000; +const MAX_KEPT_TAIL_LENGTH = 500; +const TRUNCATION_MARK = '\n… (truncated)'; + +const truncateText = (text, limit = MAX_TEXT_LENGTH) => { + if (text.length <= limit) { + return text; + } + const trimmed = text.trimEnd(); + if (trimmed.length <= limit) { + return trimmed; + } + const tailStart = trimmed.lastIndexOf('\n\n'); + const tail = + tailStart > 0 && trimmed.length - tailStart <= MAX_KEPT_TAIL_LENGTH + ? trimmed.slice(tailStart) + : ''; + const room = limit - TRUNCATION_MARK.length - tail.length; + const lineEnd = trimmed.lastIndexOf('\n', room); + return `${trimmed.slice(0, lineEnd > 0 ? lineEnd : room)}${TRUNCATION_MARK}${tail}`; +}; + // Posts a new message, or edits an existing one when `updateTs` is set // (chat.update) — used to reflect lifecycle status (started → cancelled → // completed) in the release's head message rather than only as thread replies. @@ -79,7 +105,7 @@ const sendMessage = (token, channel, text, threadTs, updateTs) => req.end(); }); -module.exports = { isValidSlackTs, markdownToMrkdwn }; +module.exports = { isValidSlackTs, markdownToMrkdwn, truncateText }; // Standalone runner if (require.main === module) { @@ -103,7 +129,15 @@ if (require.main === module) { console.log('Sending Slack notification.'); - sendMessage(token, channel, markdownToMrkdwn(message), threadTs, updateTs) + const text = markdownToMrkdwn(message); + const fittedText = truncateText(text); + if (fittedText.length < text.trimEnd().length) { + console.log( + `::warning::Slack message truncated from ${text.length} to ${fittedText.length} characters.`, + ); + } + + sendMessage(token, channel, fittedText, threadTs, updateTs) .then((ts) => { if (!isValidSlackTs(ts)) { console.error('Slack returned an invalid ts.'); @@ -117,8 +151,8 @@ if (require.main === module) { console.log(`::set-output name=ts::${ts}`); } }) - .catch(() => { - console.error('Failed to send Slack message.'); + .catch((error) => { + console.error(`Failed to send Slack message: ${error.message}`); process.exit(1); }); } diff --git a/steps/slack-notify/slackNotify.test.js b/steps/slack-notify/slackNotify.test.js index c7b4dec..dea4b7e 100644 --- a/steps/slack-notify/slackNotify.test.js +++ b/steps/slack-notify/slackNotify.test.js @@ -1,6 +1,6 @@ const test = require('node:test'); const assert = require('node:assert/strict'); -const { markdownToMrkdwn } = require('./slackNotify'); +const { markdownToMrkdwn, truncateText } = require('./slackNotify'); test('converts markdown bold to mrkdwn and leaves mrkdwn bold alone', () => { assert.equal( @@ -17,3 +17,33 @@ test('converts links, headers and list items', () => { '*Features*\n• ', ); }); + +test('keeps text within the limit unchanged', () => { + assert.equal(truncateText('a\nb', 3), 'a\nb'); +}); + +test('cuts over-limit text at the last line break that fits and marks it', () => { + const line = '• '; + const text = Array.from({ length: 200 }, () => line).join('\n'); + const truncated = truncateText(text); + + assert.ok(truncated.length <= 4000); + assert.ok(truncated.endsWith('\n… (truncated)')); + const kept = truncated.slice(0, -'\n… (truncated)'.length).split('\n'); + assert.ok(kept.every((keptLine) => keptLine === line)); +}); + +test('keeps a short closing paragraph after the cut', () => { + const list = Array.from({ length: 200 }, () => '• item').join('\n'); + const truncated = truncateText(`${list}\n\n⚠️ *Contains DB migrations*\n`, 100); + + assert.ok(truncated.length <= 100); + assert.ok(truncated.endsWith('\n… (truncated)\n\n⚠️ *Contains DB migrations*')); +}); + +test('hard-cuts a single over-limit line', () => { + const truncated = truncateText('x'.repeat(5000)); + + assert.equal(truncated.length, 4000); + assert.ok(truncated.endsWith('\n… (truncated)')); +});