From af6d102776194f4ae8112a306e03c8dcbc812e45 Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 27 Aug 2026 10:57:05 +0200 Subject: [PATCH] fix(release): bound the Reddit draft to the post limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Telegram was bounded from the start; Reddit emitted every non-highlighted body in full with no limit at all. This repository's own 132 accumulated notes already render a ~37,000-character draft against Reddit's 40,000 limit, so roughly nine more maximum-length notes would produce a post that cannot be submitted — after the release manager had already consumed the notes that produced it. The draft now trims from the tail of the grouped list, which is ordered breaking then feature, fix and perf, so the least consequential entries go first, and reports what it dropped. A breaking change is never dropped, and highlights that do not fit on their own fail with an actionable error — the same two rules the Telegram renderer already applied. 193 tests passing. Co-Authored-By: Claude Fable 5 --- .changes/README.md | 9 ++- docs/architecture/release-pipeline.md | 6 +- tools/release/release-announcements.mjs | 84 +++++++++++++++----- tools/release/release-announcements.test.mjs | 46 +++++++++++ 4 files changed, 121 insertions(+), 24 deletions(-) diff --git a/.changes/README.md b/.changes/README.md index 0e3e49811..318217075 100644 --- a/.changes/README.md +++ b/.changes/README.md @@ -101,9 +101,12 @@ only read and print. `--format changelog` and `--format blog` write their target file (rerunning `changelog` for the same version replaces that section rather than duplicating it). Only `--consume` deletes anything. -The announcement formats print paste-ready posts to stdout: Telegram plain -text guaranteed to fit the 4096-character limit, Reddit markdown with a -suggested post title on the first line. Use `pnpm --silent run` for these two — +The announcement formats print paste-ready posts to stdout, each guaranteed +to fit its platform's limit: Telegram plain text within 4096 characters, +Reddit markdown within 40,000, with a suggested post title on the first line. +Whatever does not fit collapses into a counter; a breaking change is never +collapsed, and if the highlights alone will not fit, the render fails with an +actionable error rather than shipping a post that cannot be submitted. Use `pnpm --silent run` for these two — plain `pnpm run` prints its lifecycle banner to the same stdout, so a redirected post starts with two lines of build noise. Render and save them **before** diff --git a/docs/architecture/release-pipeline.md b/docs/architecture/release-pipeline.md index 4f6287170..05beb6f2c 100644 --- a/docs/architecture/release-pipeline.md +++ b/docs/architecture/release-pipeline.md @@ -83,7 +83,11 @@ Highlights drive three behaviors: longer post. If the breaking changes alone cannot fit the 4096-character limit, the render fails with an actionable error rather than dropping one. - **Reddit** gives each one an `## Highlights` subsection, with the remaining - changes grouped below. + changes grouped below. Bounded the same way at Reddit's 40,000-character + post limit — this repository's accumulated notes already render ~37,000 — + dropping from the tail of the grouped list, which is ordered breaking → + feature → fix → perf so the least consequential go first. A breaking change + is never dropped there either. - **The blog scaffold** uses the highlight as a ready `###` heading instead of emitting `TODO headline ()`. diff --git a/tools/release/release-announcements.mjs b/tools/release/release-announcements.mjs index 2bffbd216..f0074146a 100644 --- a/tools/release/release-announcements.mjs +++ b/tools/release/release-announcements.mjs @@ -20,6 +20,13 @@ export const WEBSITE_URL = 'https://4gray.github.io/iptvnator'; */ export const TELEGRAM_MESSAGE_LIMIT = 4096; +/** + * Reddit rejects a self-post body over this length. It is roomy, but not + * unreachable: this repository's own accumulated notes already render a + * ~36,500-character draft. + */ +export const REDDIT_POST_LIMIT = 40000; + const TYPE_EMOJI = { breaking: '⚠️', feature: '✨', @@ -162,7 +169,8 @@ export function renderTelegramPost(notes, { version }) { * * @param {object[]} notes * @param {{ version: string }} options - * @returns {string | null} null for an internal-only release + * @returns {string | null} a post within REDDIT_POST_LIMIT, or null for an + * internal-only release */ export function renderRedditPost(notes, { version }) { const { highlights, rest } = splitHighlights(notes); @@ -176,35 +184,71 @@ export function renderRedditPost(notes, { version }) { ? `IPTVnator v${version} — ${highlights.map((note) => note.highlight).join(', ')}` : `IPTVnator v${version} released`; - const blocks = [`Suggested title: ${title}`, '---']; + const buildPost = (visibleRest, droppedCount) => { + const blocks = [`Suggested title: ${title}`, '---']; - if (highlights.length > 0) { - blocks.push('## Highlights'); + if (highlights.length > 0) { + blocks.push('## Highlights'); - for (const note of highlights) { - blocks.push(`### ${note.highlight}`, oneLine(note.body)); + for (const note of highlights) { + blocks.push(`### ${note.highlight}`, oneLine(note.body)); + } + } + + if (visibleRest.length > 0) { + blocks.push( + highlights.length > 0 + ? '## Also in this release' + : "## What's changed" + ); + + for (const group of groupNotes(visibleRest)) { + const entries = group.notes + .map((note) => `- **${note.area}** — ${oneLine(note.body)}`) + .join('\n'); + + blocks.push(`**${group.heading}**\n\n${entries}`); + } + } + + if (droppedCount > 0) { + blocks.push( + `…and ${droppedCount} more ${droppedCount === 1 ? 'change' : 'changes'} — see the full release notes below.` + ); } - } - if (rest.length > 0) { blocks.push( - highlights.length > 0 - ? '## Also in this release' - : "## What's changed" + `[Download](${releaseUrl(version)}) · [Full release notes](${blogUrl(version)}) · [Changelog](${REPO_URL}/blob/master/CHANGELOG.md)` ); - for (const group of groupNotes(rest)) { - const entries = group.notes - .map((note) => `- **${note.area}** — ${oneLine(note.body)}`) - .join('\n'); + return `${blocks.filter(Boolean).join('\n\n')}\n`; + }; - blocks.push(`**${group.heading}**\n\n${entries}`); + // Reddit rejects a body over its post limit, so the draft has to be bounded + // the way the Telegram one is. Entries are dropped from the tail of the + // grouped list, which is ordered breaking → feature → fix → perf, so the + // least consequential go first. + for (let visible = rest.length; visible >= 0; visible -= 1) { + const post = buildPost(rest.slice(0, visible), rest.length - visible); + + if (post.length > REDDIT_POST_LIMIT) { + continue; } + + const droppedBreaking = rest + .slice(visible) + .filter((note) => note.type === 'breaking').length; + + if (droppedBreaking > 0) { + throw new Error( + `${droppedBreaking} breaking change(s) do not fit Reddit's ${REDDIT_POST_LIMIT}-character limit — shorten those notes, or announce this release in several posts` + ); + } + + return post; } - blocks.push( - `[Download](${releaseUrl(version)}) · [Full release notes](${blogUrl(version)}) · [Changelog](${REPO_URL}/blob/master/CHANGELOG.md)` + throw new Error( + `the highlights alone exceed Reddit's ${REDDIT_POST_LIMIT}-character limit — pick fewer or shorten their notes` ); - - return `${blocks.filter(Boolean).join('\n\n')}\n`; } diff --git a/tools/release/release-announcements.test.mjs b/tools/release/release-announcements.test.mjs index 928ac867b..5c89242c3 100644 --- a/tools/release/release-announcements.test.mjs +++ b/tools/release/release-announcements.test.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { + REDDIT_POST_LIMIT, renderRedditPost, renderTelegramPost, splitHighlights, @@ -234,6 +235,51 @@ describe('renderRedditPost', () => { assert.doesNotMatch(post, /Internal churn/); }); + it('stays inside the Reddit post limit and says what it dropped', () => { + const notes = [ + note({ highlight: 'Up Next rail' }), + ...Array.from({ length: 400 }, (_, index) => + note({ + type: 'fix', + body: `Fix ${index}: ${'x'.repeat(360)}`, + sourcePath: `.changes/fix-${index}.md`, + }) + ), + ]; + const post = renderRedditPost(notes, { version: '0.24.0' }); + + assert.ok( + post.length <= REDDIT_POST_LIMIT, + `post is ${post.length} characters` + ); + assert.match(post, /### Up Next rail/); + assert.match(post, /…and \d+ more changes — see the full release notes/); + }); + + it('refuses to drop a breaking change to fit', () => { + const notes = Array.from({ length: 200 }, (_, index) => + note({ + type: 'breaking', + body: `Breaking ${index}: ${'x'.repeat(360)}`, + sourcePath: `.changes/breaking-${index}.md`, + }) + ); + + assert.throws( + () => renderRedditPost(notes, { version: '0.24.0' }), + /breaking change\(s\) do not fit Reddit's 40000-character limit/ + ); + }); + + it('leaves a normal release untouched', () => { + const post = renderRedditPost( + [note({ highlight: 'Up Next rail' }), note({ type: 'fix' })], + { version: '0.24.0' } + ); + + assert.doesNotMatch(post, /…and \d+ more/); + }); + it('returns null for an internal-only release', () => { assert.equal( renderRedditPost([note({ type: 'internal' })], {