fix(release): act on the fourth Codex pass

- wrapText left a word longer than the line budget intact, so a valid
  60-character unbroken highlight rendered past the edge of the 1200px card
  and was cropped. Overlong words are now broken at the budget, and a test
  asserts no returned line can exceed it. The previous test asserting the
  old behavior encoded the bug.
- The announcement commands are documented as `pnpm --silent run`:
  reproduced that plain `pnpm run release:notes:telegram > post` writes
  pnpm's lifecycle banner into the file ahead of the post, so the
  advertised paste-ready output was not paste-ready.

186 tests passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-27 10:11:37 +02:00
1 parent 8c3aebecac
commit 62359c80fc
8 files changed
+60 -11

No files matched your search

+6 -3
View File
@@ -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
+2 -1
View File
@@ -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.
+2 -1
View File
@@ -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.
+1 -1
View File
@@ -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<version>/`. 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<version>/`. 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.
+1 -1
View File
@@ -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<version>/`. 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<version>/`. 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.
+5
View File
@@ -30,6 +30,11 @@ chosen deliberately by bumping `package.json`.
| Highlight cards | `release:cards:generate` | `dist/release-highlight-cards/v<version>/` |
| Screenshots | `release:screenshots` | `apps/website/public/blog/<vX-Y>/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
+21 -2
View File
@@ -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) {
+22 -2
View File
@@ -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);