diff --git a/.changes/README.md b/.changes/README.md index 02ca9addb..6e7dfd52f 100644 --- a/.changes/README.md +++ b/.changes/README.md @@ -79,8 +79,8 @@ pnpm run release:notes:validate pnpm run release:notes:github pnpm run release:notes:changelog pnpm run release:notes:blog -pnpm run release:notes:telegram -pnpm run release:notes:reddit +pnpm --silent run release:notes:telegram +pnpm --silent run release:notes:reddit node tools/release/build-release-notes.mjs --consume ``` @@ -102,7 +102,10 @@ 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. Render and save them **before** +suggested post title on the first line. 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** `--consume` — the changelog keeps the entries, but the `highlight:` metadata lives only in the note files. Publishing is manual; nothing posts anywhere. An internal-only release has nothing to announce: both formats then print an diff --git a/.claude/skills/release-cut/SKILL.md b/.claude/skills/release-cut/SKILL.md index 3ac74118d..0f1e76d9c 100644 --- a/.claude/skills/release-cut/SKILL.md +++ b/.claude/skills/release-cut/SKILL.md @@ -33,7 +33,8 @@ pnpm run i18n:check `pnpm nx run electron-backend:build-e2e`, then `pnpm run release:screenshots`. 5. Render the announcement drafts, saving output outside the repository: - `pnpm run release:notes:telegram` and `pnpm run release:notes:reddit`. + `pnpm --silent run release:notes:telegram` and the `:reddit` counterpart + (`--silent`, or pnpm's lifecycle banner lands in the saved post). 6. Render highlight cards after the screenshots: `pnpm run release:cards:generate`. Review them; copy `hero.jpg` into the blog post's asset directory if it should ship as the hero image. diff --git a/.codex/skills/release-cut/SKILL.md b/.codex/skills/release-cut/SKILL.md index 3ac74118d..0f1e76d9c 100644 --- a/.codex/skills/release-cut/SKILL.md +++ b/.codex/skills/release-cut/SKILL.md @@ -33,7 +33,8 @@ pnpm run i18n:check `pnpm nx run electron-backend:build-e2e`, then `pnpm run release:screenshots`. 5. Render the announcement drafts, saving output outside the repository: - `pnpm run release:notes:telegram` and `pnpm run release:notes:reddit`. + `pnpm --silent run release:notes:telegram` and the `:reddit` counterpart + (`--silent`, or pnpm's lifecycle banner lands in the saved post). 6. Render highlight cards after the screenshots: `pnpm run release:cards:generate`. Review them; copy `hero.jpg` into the blog post's asset directory if it should ship as the hero image. diff --git a/AGENTS.md b/AGENTS.md index c1f5ed52e..52bc15ee5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,7 @@ This file provides guidance to coding agents working in this repository. - CI enforces this: the "Release note gate" job in `.github/workflows/ci.yml` fails PRs that change runtime code without an added `.changes/*.md` or the label (policy in `tools/release/check-release-note-gate.mjs`; tests/e2e/website/mock-server/docs paths are auto-exempt). - The `release-notes` skill covers writing notes; the `release-cut` skill covers the full release sequence. Canonical contract — surfaces, ordering constraints, the required draft asset set: `docs/architecture/release-pipeline.md`. - Validate before finishing: `pnpm run release:notes:validate`. -- Announcement drafts and highlight cards are built from the same notes: `pnpm run release:notes:telegram` and `pnpm run release:notes:reddit` print paste-ready posts to stdout (Telegram is guaranteed to fit its 4096-character limit), and `pnpm run release:cards:generate` renders branded 1200×630 highlight cards plus a release hero into `dist/release-highlight-cards/v/`. All three read `highlight:` metadata that exists only in the note files, so they must run before `build-release-notes.mjs --consume`; the cards additionally need `release:screenshots` to have run. Nothing is posted or copied into the website tree automatically. +- Announcement drafts and highlight cards are built from the same notes: `pnpm --silent run release:notes:telegram` and `pnpm --silent run release:notes:reddit` print paste-ready posts to stdout (Telegram is guaranteed to fit its 4096-character limit; `--silent` keeps pnpm's lifecycle banner out of a redirected post), and `pnpm run release:cards:generate` renders branded 1200×630 highlight cards plus a release hero into `dist/release-highlight-cards/v/`. All three read `highlight:` metadata that exists only in the note files, so they must run before `build-release-notes.mjs --consume`; the cards additionally need `release:screenshots` to have run. Nothing is posted or copied into the website tree automatically. - Pushes to `master` and `v*` can publish Docker images. A `v*` tag build creates a draft GitHub release. - `pnpm run release:verify:draft` waits for that tag build (polling until the run is indexed, then `gh run watch`) and verifies the draft's status, authored body, and complete required asset set. It is read-only and deliberately fails on an already-published release, because it is the gate that runs before publication. - Publishing the GitHub release verifies its Snap assets and automatically uploads them to `edge`; installed-Snap smoke and candidate/stable promotion remain manual. diff --git a/CLAUDE.md b/CLAUDE.md index 579acf0b1..63eeff64d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - CI enforces this: the "Release note gate" job in `.github/workflows/ci.yml` fails PRs that change runtime code without an added `.changes/*.md` or the label (policy in `tools/release/check-release-note-gate.mjs`; tests/e2e/website/mock-server/docs paths are auto-exempt). - The `release-notes` skill covers writing notes; the `release-cut` skill covers the full release sequence. Canonical contract — surfaces, ordering constraints, the required draft asset set: `docs/architecture/release-pipeline.md`. - Validate before finishing: `pnpm run release:notes:validate`. -- Announcement drafts and highlight cards are built from the same notes: `pnpm run release:notes:telegram` and `pnpm run release:notes:reddit` print paste-ready posts to stdout (Telegram is guaranteed to fit its 4096-character limit), and `pnpm run release:cards:generate` renders branded 1200×630 highlight cards plus a release hero into `dist/release-highlight-cards/v/`. All three read `highlight:` metadata that exists only in the note files, so they must run before `build-release-notes.mjs --consume`; the cards additionally need `release:screenshots` to have run. Nothing is posted or copied into the website tree automatically. +- Announcement drafts and highlight cards are built from the same notes: `pnpm --silent run release:notes:telegram` and `pnpm --silent run release:notes:reddit` print paste-ready posts to stdout (Telegram is guaranteed to fit its 4096-character limit; `--silent` keeps pnpm's lifecycle banner out of a redirected post), and `pnpm run release:cards:generate` renders branded 1200×630 highlight cards plus a release hero into `dist/release-highlight-cards/v/`. All three read `highlight:` metadata that exists only in the note files, so they must run before `build-release-notes.mjs --consume`; the cards additionally need `release:screenshots` to have run. Nothing is posted or copied into the website tree automatically. - Pushes to `master` and `v*` can publish Docker images. A `v*` tag build creates a draft GitHub release. - `pnpm run release:verify:draft` waits for that tag build (polling until the run is indexed, then `gh run watch`) and verifies the draft's status, authored body, and complete required asset set. It is read-only and deliberately fails on an already-published release, because it is the gate that runs before publication. - Publishing the GitHub release verifies its Snap assets and automatically uploads them to `edge`; installed-Snap smoke and candidate/stable promotion remain manual. diff --git a/docs/architecture/release-pipeline.md b/docs/architecture/release-pipeline.md index 88ef4fd59..6f8b9a795 100644 --- a/docs/architecture/release-pipeline.md +++ b/docs/architecture/release-pipeline.md @@ -30,6 +30,11 @@ chosen deliberately by bumping `package.json`. | Highlight cards | `release:cards:generate` | `dist/release-highlight-cards/v/` | | Screenshots | `release:screenshots` | `apps/website/public/blog//screenshots/` | +Run the two stdout commands as `pnpm --silent run …` whenever the output is +redirected to a file or a clipboard. Without it pnpm prints its lifecycle +banner (`> iptvnator@0.23.0 release:notes:telegram …`) to the same stdout, and +the saved post starts with two lines of build noise. + ### The ordering constraint that matters `build-release-notes.mjs --consume` is the destructive boundary: it deletes the diff --git a/tools/release/highlight-cards.mjs b/tools/release/highlight-cards.mjs index fde1c13f1..e422d0aaf 100644 --- a/tools/release/highlight-cards.mjs +++ b/tools/release/highlight-cards.mjs @@ -61,8 +61,13 @@ export function escapeXml(text) { /** * Greedy word wrap by character budget. SVG has no automatic text layout and - * font metrics vary by host, so the budget is conservative; a single word - * longer than the budget gets its own line rather than being cut. + * font metrics vary by host, so the budget is conservative. + * + * Every returned line is at most `maxChars` long, including when a single + * word exceeds the budget: such a word is broken at the budget rather than + * left whole. Leaving it whole is what a naive wrap does, and a 60-character + * unbroken headline — well inside the validated limit — then rendered past + * the edge of the 1200px card and was cropped. * * @param {string} text * @param {number} maxChars @@ -75,6 +80,20 @@ export function wrapText(text, maxChars, maxLines) { let current = ''; for (const word of words) { + if (word.length > maxChars) { + if (current) { + lines.push(current); + } + + for (let start = 0; start < word.length; start += maxChars) { + lines.push(word.slice(start, start + maxChars)); + } + + // Keep the final chunk open so a following short word can join it. + current = lines.pop() ?? ''; + continue; + } + const candidate = current ? `${current} ${word}` : word; if (candidate.length <= maxChars || !current) { diff --git a/tools/release/highlight-cards.test.mjs b/tools/release/highlight-cards.test.mjs index 90e906601..a10c0dfb8 100644 --- a/tools/release/highlight-cards.test.mjs +++ b/tools/release/highlight-cards.test.mjs @@ -78,13 +78,33 @@ describe('wrapText', () => { ]); }); - it('gives an overlong single word its own line instead of cutting it', () => { + it('breaks a word longer than the budget instead of overflowing the card', () => { assert.deepEqual(wrapText('supercalifragilistic ok', 10, 3), [ - 'supercalifragilistic', + 'supercalif', + 'ragilistic', 'ok', ]); }); + it('never returns a line wider than the budget', () => { + // A 60-character unbroken highlight is valid input; on a 34-character + // feature-card budget it must still fit inside the canvas. + for (const [text, maxChars, maxLines] of [ + ['W'.repeat(60), 34, 2], + ['W'.repeat(60), 30, 2], + ['W'.repeat(60), 60, 1], + ['a'.repeat(200), 34, 2], + ['short words only here', 34, 2], + ]) { + for (const line of wrapText(text, maxChars, maxLines)) { + assert.ok( + line.length <= maxChars, + `"${line}" is ${line.length} > ${maxChars}` + ); + } + } + }); + it('ellipsizes the last kept line on overflow', () => { const lines = wrapText('aaa bbb ccc ddd eee', 3, 2);