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' })], {