From 29ca94aa4371e9a71e24ccac4c529bd62f6fabd5 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 29 Aug 2026 10:16:07 +0200 Subject: [PATCH] feat(release): announcement formats, highlight cards, and draft verification (#1480) --- .changes/README.md | 59 +- .claude/skills/release-cut/SKILL.md | 22 +- .claude/skills/release-notes/SKILL.md | 5 + .codex/skills/release-cut/SKILL.md | 22 +- .codex/skills/release-notes/SKILL.md | 5 + AGENTS.md | 5 +- CLAUDE.md | 5 +- docs/architecture/release-pipeline.md | 218 ++++++ package.json | 5 + tools/release/build-release-notes.mjs | 40 +- tools/release/build-release-notes.test.mjs | 87 ++- tools/release/generate-highlight-cards.mjs | 383 ++++++++++ tools/release/highlight-cards.mjs | 562 +++++++++++++++ tools/release/highlight-cards.test.mjs | 690 +++++++++++++++++++ tools/release/project.json | 9 +- tools/release/release-announcements.mjs | 290 ++++++++ tools/release/release-announcements.test.mjs | 329 +++++++++ tools/release/release-notes-render.mjs | 37 +- tools/release/release-notes.mjs | 93 ++- tools/release/release-notes.test.mjs | 127 ++++ tools/release/verify-draft-release.mjs | 489 +++++++++++++ tools/release/verify-draft-release.test.mjs | 432 ++++++++++++ 22 files changed, 3850 insertions(+), 64 deletions(-) create mode 100644 docs/architecture/release-pipeline.md create mode 100644 tools/release/generate-highlight-cards.mjs create mode 100644 tools/release/highlight-cards.mjs create mode 100644 tools/release/highlight-cards.test.mjs create mode 100644 tools/release/release-announcements.mjs create mode 100644 tools/release/release-announcements.test.mjs create mode 100644 tools/release/verify-draft-release.mjs create mode 100644 tools/release/verify-draft-release.test.mjs diff --git a/.changes/README.md b/.changes/README.md index b8be5c01e..318217075 100644 --- a/.changes/README.md +++ b/.changes/README.md @@ -3,8 +3,8 @@ Every PR with a user-visible change drops one file here describing that change in plain language. At release time `tools/release/build-release-notes.mjs` turns the accumulated files into the -GitHub release body, the `CHANGELOG.md` section, and a blog-post scaffold for -the website — then deletes them. +GitHub release body, the `CHANGELOG.md` section, a blog-post scaffold for +the website, and Telegram/Reddit announcement drafts — then deletes them. The point is to write the note **while the context is still fresh**, instead of reconstructing three months of work from commit titles at release time. @@ -19,6 +19,7 @@ type: feature area: playback issues: [1187] screenshot: up-next-rail +highlight: Up Next rail --- Series now show an "Up Next" rail beside the player on wide windows: the rest @@ -31,6 +32,7 @@ of the current season, watch progress, and click-to-play inline. | `area` | yes | lowercase slug, same as the conventional-commit scope | | `issues` | no | issue numbers this closes — `[1187]` or `1187` | | `screenshot` | no | slug from `tools/release/screenshots.manifest.json` | +| `highlight` | no | short headline (max 60 chars) marking a release highlight | There is **no version field**. The release version is chosen deliberately at release time, not derived from these files. @@ -49,6 +51,15 @@ The body is capped at 400 characters — depth belongs in the blog post. - ❌ "Fix off-by-one in `resolveEnrichmentSeasonNumber`" - ✅ "Series whose title carries a season marker no longer show the wrong season" +`highlight` marks the change as one of the release's headline features and +gives it a short, poster-worthy name. The 60-character cap keeps it roughly to +one line on the hero card; card text wraps by estimated width, so a headline of +unusually wide glyphs may still wrap or ellipsize rather than overflow. Highlights lead the Telegram/Reddit +announcements (everything else collapses into a "+N more" counter) and become +ready-made section headings in the blog scaffold. Set it on the two or three +changes worth announcing — a release where everything is a highlight has none. +Not allowed on `type: internal`. + `type: internal` records invisible maintenance. Internal notes stay collapsed in `CHANGELOG.md`, are omitted from the blog scaffold, and are removed from the authored public GitHub body by @@ -69,6 +80,8 @@ pnpm run release:notes:validate pnpm run release:notes:github pnpm run release:notes:changelog pnpm run release:notes:blog +pnpm --silent run release:notes:telegram +pnpm --silent run release:notes:reddit node tools/release/build-release-notes.mjs --consume ``` @@ -83,13 +96,32 @@ A bare `--` separator is accepted and ignored, so the npm habit of `pnpm run release:notes:github -- --version 0.24.0` works too: pnpm forwards that separator to the script rather than consuming it the way npm does. -`--validate` and `--format github` 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. +`--validate`, `--format github`, `--format telegram`, and `--format reddit` +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, 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** +`--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 +explanation on stderr, leave stdout empty, and exit 0 — the same shape +`extract-changelog-section.mjs --public` uses for its empty public body. The release sequence is: bump the version → `release:notes:changelog` → -`release:notes:blog` → `--consume` → commit → tag → push. The tag build then +`release:notes:blog` → `release:screenshots` → `release:notes:telegram` / +`release:notes:reddit` → `release:cards:generate` → `--consume` → commit → tag +→ push → `release:verify:draft`. Everything reading `highlight:` comes before +`--consume`, because that step deletes the only copy of it. Full contract: +`docs/architecture/release-pipeline.md`. The tag build then extracts the new `CHANGELOG.md` section into the GitHub release body (`tools/release/extract-changelog-section.mjs`) and **fails the release** if the section is missing — a tag cut without the changelog step cannot silently @@ -116,6 +148,19 @@ Adding a shot for a new feature = one entry in `tools/release/screenshots.manifest.json` (plus, if navigation is new, one named action in `tools/release/capture-navigation.ts`). +## Highlight cards + +`pnpm run release:cards:generate` renders one branded 1200×630 card per +`highlight:` note (headline, body, and a framed screenshot strip when the note +names one) plus a release hero card — for Telegram/Reddit previews and the +blog `hero.jpg`. It reads screenshots from the published blog directory, so it +runs after `release:screenshots` and, like the announcement formats, before +`--consume`. Output goes to `dist/release-highlight-cards/v/`, and a +rerun replaces the cards it previously wrote there; copying a card into the +website tree is a deliberate manual act. `release:cards:dry-run` lists what +would be rendered. A release without highlights still gets its hero card, and +an internal-only release writes nothing — neither is an error. + ```bash pnpm nx run electron-backend:build-e2e # once, before capturing pnpm run release:screenshots # all shots, both themes diff --git a/.claude/skills/release-cut/SKILL.md b/.claude/skills/release-cut/SKILL.md index 2ff98f9b0..0f1e76d9c 100644 --- a/.claude/skills/release-cut/SKILL.md +++ b/.claude/skills/release-cut/SKILL.md @@ -5,6 +5,8 @@ description: Use when preparing, cutting, tagging, publishing, or verifying an I # Release Cut +Full contract, asset table and rationale: `docs/architecture/release-pipeline.md`. + The tag workflow authors the public GitHub body with `node tools/release/extract-changelog-section.mjs --public "${VERSION}"`. Keep the full changelog, including internal notes, committed before tagging. @@ -30,9 +32,18 @@ pnpm run i18n:check 4. Capture required manifest screenshots only against mock servers: `pnpm nx run electron-backend:build-e2e`, then `pnpm run release:screenshots`. -5. Consume notes only after reviewing all generated output: +5. Render the announcement drafts, saving output outside the repository: + `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. +7. Consume notes only after reviewing all generated output: `node tools/release/build-release-notes.mjs --consume`. +Steps 5 and 6 must precede `--consume`: `highlight:` exists only in the note +files it deletes. Publishing announcements is manual, after the release. + The consume command is the destructive boundary: it deletes the direct note files. Stage only release-owned files, including exact website post/assets and `git add -A -- .changes`, then commit and create the exact tag. @@ -54,10 +65,11 @@ git push upstream v0.25.1 ``` Master and `v*` pushes can publish Docker images. The tag build creates a draft -GitHub release. Verify authored text plus generated commits and all required -macOS, Windows, DEB, RPM, Pacman (`.pacman`/`.pkg.tar.*`), AppImage, Snap, -Flatpak, updater metadata, blockmaps, and -`linux-frame-copy-runtime-sources.tar.xz`. +GitHub release. Run `pnpm run release:verify:draft` — it waits for the tag +build, then checks draft status, warns on an empty authored body, and verifies +the complete 27-asset set documented in `docs/architecture/release-pipeline.md`. +It is read-only, and fails on an already-published release. Still review the +authored text and generated commits by eye. After verification, manually publish the GitHub release. That publication automatically verifies its Snap assets and uploads them to `edge`. diff --git a/.claude/skills/release-notes/SKILL.md b/.claude/skills/release-notes/SKILL.md index 308456234..ad87b9119 100644 --- a/.claude/skills/release-notes/SKILL.md +++ b/.claude/skills/release-notes/SKILL.md @@ -17,6 +17,7 @@ type: fix area: stalker issues: [1234] screenshot: optional-manifest-slug +highlight: Optional short headline --- Stalker series now resume the correct episode. @@ -25,6 +26,10 @@ Stalker series now resume the correct episode. `type` is `breaking`, `feature`, `fix`, `perf`, or `internal`. Omit optional fields instead of inventing values. Never add a version or PR number. +`highlight` (max 60 characters, never on `internal`) names a headline feature: +it leads the Telegram/Reddit announcement drafts and becomes the blog section +heading. Reserve it for the two or three changes worth announcing. + `internal` records invisible maintenance. It stays collapsed in `CHANGELOG.md` but is omitted from the blog and the authored public GitHub body. GitHub's generated commit list may still mention the underlying commits. diff --git a/.codex/skills/release-cut/SKILL.md b/.codex/skills/release-cut/SKILL.md index 2ff98f9b0..0f1e76d9c 100644 --- a/.codex/skills/release-cut/SKILL.md +++ b/.codex/skills/release-cut/SKILL.md @@ -5,6 +5,8 @@ description: Use when preparing, cutting, tagging, publishing, or verifying an I # Release Cut +Full contract, asset table and rationale: `docs/architecture/release-pipeline.md`. + The tag workflow authors the public GitHub body with `node tools/release/extract-changelog-section.mjs --public "${VERSION}"`. Keep the full changelog, including internal notes, committed before tagging. @@ -30,9 +32,18 @@ pnpm run i18n:check 4. Capture required manifest screenshots only against mock servers: `pnpm nx run electron-backend:build-e2e`, then `pnpm run release:screenshots`. -5. Consume notes only after reviewing all generated output: +5. Render the announcement drafts, saving output outside the repository: + `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. +7. Consume notes only after reviewing all generated output: `node tools/release/build-release-notes.mjs --consume`. +Steps 5 and 6 must precede `--consume`: `highlight:` exists only in the note +files it deletes. Publishing announcements is manual, after the release. + The consume command is the destructive boundary: it deletes the direct note files. Stage only release-owned files, including exact website post/assets and `git add -A -- .changes`, then commit and create the exact tag. @@ -54,10 +65,11 @@ git push upstream v0.25.1 ``` Master and `v*` pushes can publish Docker images. The tag build creates a draft -GitHub release. Verify authored text plus generated commits and all required -macOS, Windows, DEB, RPM, Pacman (`.pacman`/`.pkg.tar.*`), AppImage, Snap, -Flatpak, updater metadata, blockmaps, and -`linux-frame-copy-runtime-sources.tar.xz`. +GitHub release. Run `pnpm run release:verify:draft` — it waits for the tag +build, then checks draft status, warns on an empty authored body, and verifies +the complete 27-asset set documented in `docs/architecture/release-pipeline.md`. +It is read-only, and fails on an already-published release. Still review the +authored text and generated commits by eye. After verification, manually publish the GitHub release. That publication automatically verifies its Snap assets and uploads them to `edge`. diff --git a/.codex/skills/release-notes/SKILL.md b/.codex/skills/release-notes/SKILL.md index 308456234..ad87b9119 100644 --- a/.codex/skills/release-notes/SKILL.md +++ b/.codex/skills/release-notes/SKILL.md @@ -17,6 +17,7 @@ type: fix area: stalker issues: [1234] screenshot: optional-manifest-slug +highlight: Optional short headline --- Stalker series now resume the correct episode. @@ -25,6 +26,10 @@ Stalker series now resume the correct episode. `type` is `breaking`, `feature`, `fix`, `perf`, or `internal`. Omit optional fields instead of inventing values. Never add a version or PR number. +`highlight` (max 60 characters, never on `internal`) names a headline feature: +it leads the Telegram/Reddit announcement drafts and becomes the blog section +heading. Reserve it for the two or three changes worth announcing. + `internal` records invisible maintenance. It stays collapsed in `CHANGELOG.md` but is omitted from the blog and the authored public GitHub body. GitHub's generated commit list may still mention the underlying commits. diff --git a/AGENTS.md b/AGENTS.md index 964f5274f..380832d3c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,11 +66,14 @@ This file provides guidance to coding agents working in this repository. - Name it `-.md`; `area` matches the conventional-commit scope. There is no version field — the release version is chosen at release time. - Write the body for a user, not a reviewer: "the player now remembers volume between episodes", not "hoist volume state into the session". Max 400 characters; depth belongs in the release blog post. - `type: internal` records invisible maintenance. Internal notes stay collapsed in `CHANGELOG.md`, are omitted from the blog scaffold, and are removed from the authored public GitHub body by `extract-changelog-section.mjs --public`; GitHub's generated commit list remains separate, so an internal-only release can have an empty authored body. +- `highlight: ` (max 60 characters, rejected on `type: internal`) marks a note as one of the release's two or three headline changes. Highlights lead the Telegram/Reddit announcement drafts, become ready-made blog section headings, and are the input the highlight-card generator renders from. A release where everything is a highlight has none. - Skip the note for test-only changes, docs, CI/workflow plumbing, and pure refactors with no behavior change. When skipping on a PR that touches `apps/**` or `libs/**`, apply the `no-release-note` label. - 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. +- 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 --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. - Release-post screenshots come only from the release capture script running against the mock servers. Never add a screenshot taken from a real playlist or account to `apps/website/public/blog/**` — real streams, logos, and metadata are copyrighted, and credentials must never reach a published image. - Final task summaries should state whether a release note was added or why it was skipped. diff --git a/CLAUDE.md b/CLAUDE.md index cc40fc23f..e4701a13a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,11 +32,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - Name it `-.md`; `area` matches the conventional-commit scope. There is no version field — the release version is chosen at release time. - Write the body for a user, not a reviewer: "the player now remembers volume between episodes", not "hoist volume state into the session". Max 400 characters; depth belongs in the release blog post. - `type: internal` records invisible maintenance. Internal notes stay collapsed in `CHANGELOG.md`, are omitted from the blog scaffold, and are removed from the authored public GitHub body by `extract-changelog-section.mjs --public`; GitHub's generated commit list remains separate, so an internal-only release can have an empty authored body. +- `highlight: ` (max 60 characters, rejected on `type: internal`) marks a note as one of the release's two or three headline changes. Highlights lead the Telegram/Reddit announcement drafts, become ready-made blog section headings, and are the input the highlight-card generator renders from. A release where everything is a highlight has none. - Skip the note for test-only changes, docs, CI/workflow plumbing, and pure refactors with no behavior change. When skipping on a PR that touches `apps/**` or `libs/**`, apply the `no-release-note` label. - 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. +- 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 --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. - Release-post screenshots come only from the release capture script running against the mock servers. Never add a screenshot taken from a real playlist or account to `apps/website/public/blog/**` — real streams, logos, and metadata are copyrighted, and credentials must never reach a published image. - Final task summaries should state whether a release note was added or why it was skipped. diff --git a/docs/architecture/release-pipeline.md b/docs/architecture/release-pipeline.md new file mode 100644 index 000000000..ddf11e0fe --- /dev/null +++ b/docs/architecture/release-pipeline.md @@ -0,0 +1,218 @@ +# Release Pipeline + +How a release is assembled, from the note an author writes during an ordinary +PR to the draft GitHub release a human publishes. + +The agent-facing entry points are the `release-notes` skill (writing notes) and +the `release-cut` skill (running a release). This document is the contract they +reference: the asset set, the ordering constraints, and the reasons behind +them. The skills stay short on purpose; the detail lives here. + +## Two phases + +**During ordinary PRs** every user-visible change adds one +`.changes/-.md` note, written while the context is fresh. CI's +"Release note gate" enforces it. Format and field table: `.changes/README.md`. + +**At release time** `tools/release/build-release-notes.mjs` fans those notes +out into every surface, then deletes them. Nothing derives the version — it is +chosen deliberately by bumping `package.json`. + +## Surfaces built from one set of notes + +| Surface | Command | Writes | +| --- | --- | --- | +| `CHANGELOG.md` section | `release:notes:changelog` | the file | +| Website blog scaffold | `release:notes:blog` | `apps/website/src/content/blog/-release-notes.mdx` | +| GitHub release body | (tag build) | via `extract-changelog-section.mjs --public` | +| Telegram announcement | `release:notes:telegram` | stdout | +| Reddit announcement | `release:notes:reddit` | stdout | +| 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 +note files. The `CHANGELOG.md` keeps every entry's text, but **`highlight:` +lives only in the note files** and is not recoverable afterwards. Every surface +that reads it — both announcements and the cards — must therefore run before +`--consume`. The cards additionally need `release:screenshots` to have already +published its frames, since they composite them. + +## `highlight:` — what leads a release + +An optional note field naming one of the release's two or three headline +changes. It is rejected on `type: internal`, and capped at **60 characters** to +keep it headline-sized — roughly what the hero card fits on one line. + +The cap is an authoring guideline, not a rendering guarantee: character count +is not width. Card text wraps by *estimated rendered width* +(`estimateTextWidth`), because 34 `W` at font-size 52 measures ~1948px where +1072px are available — a character-capped line still ran off the canvas. + +The SVG names `DM Sans`, but nothing guarantees it is installed: every host +resolves the fallback chain differently, and the same line measures 0.389 em +per `r` here against about 0.49 em elsewhere. No estimate can be both tight +and correct across environments, so the factors sit well above the widest +observation — locally the model over-estimates every sample by at least 1.25×. + +That estimate is deliberately **inverted**: narrow characters are enumerated +and everything else is assumed wide. Enumerating the wide ones instead cannot +converge — successive review passes each found another under-estimated glyph +(`W`, then CJK and emoji, then the `ae` ligature) — and a glyph the list misses +crops the card while every unit test still passes. With the wide default the +estimate can only run high, and running high costs an early line break nobody +sees. `tools/release/highlight-cards.test.mjs` renders each sample through sharp and asserts +the estimate never falls below the measured ink width, which is the guard +against that whole class of bug. + +Text that cannot fit even after wrapping is ellipsized, and each emitted line +carries an SVG `textLength` clamp when the estimate still says it would +overflow. + +Highlights drive three behaviors: + +- **Telegram** leads with them and folds everything else into a "…plus N more" + counter. A `type: breaking` note is never folded, highlighted or not: + announcing a breaking change as "fixes and improvements" is worse than a + 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. Its suggested title names as many highlights as + Reddit's separate 300-character title cap allows and counts the rest — five + highlights at the validated 60-character maximum already overshoot it while + the body stays nowhere near its own limit. The body is 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 ()`. + +Prose fields keep `#`. `parseFrontmatterLine` strips trailing `# comment` text +only from closed-vocabulary fields (`type`, `area`, `issues`, `screenshot`), +whose values can never contain one — `highlight: Sources #N chip` is a headline. + +### Internal-only releases + +A release whose notes are all `type: internal` is a legal shape: the authored +GitHub body is empty and GitHub's generated commit list carries the detail. +Both announcement formats then print an explanation on stderr, leave stdout +empty, and exit 0 — the same shape `extract-changelog-section.mjs --public` +already uses for its empty public body. + +## Highlight cards + +`tools/release/highlight-cards.mjs` plans and lays out; +`tools/release/generate-highlight-cards.mjs` renders through sharp. Output is 1200×630 (Open Graph), matching the website +palette in `apps/website/tailwind.config.mjs`. + +- One card per highlight, plus a release hero card written as both `hero.png` + and the `hero.jpg` the blog scaffold's frontmatter references. +- A highlight naming a `screenshot:` gets a framed screenshot strip along the + bottom; one without gets a typographic layout instead. The frame is opaque + and painted after the text, so the body's line budget is **derived from the + space left above it**, never assumed — a fixed count sliced the last line in + half whenever the headline wrapped to two lines. +- Card filenames come from the note filename, never the screenshot slug: two + highlights may legitimately share one manifest shot, and naming cards after + it made the second overwrite the first. + +Screenshots come only from the capture script running against the mock servers. +Never publish one taken from a real playlist or account — streams, logos and +metadata are copyrighted, and credentials must never reach a published image. + +Output lands in `dist/release-highlight-cards/v/`, outside version +control — keyed by the exact version, because 0.24.0 and 0.24.1 share a blog +post but not a card set. A run first removes the cards a previous run left in +that directory (only files matching what this tool writes), so a renamed or +dropped highlight cannot leave a stale image waiting to be published. Copying a +card into the website tree is a deliberate manual act. + +A release with no `highlight:` notes is not an error: the hero card is still +rendered and the run exits 0. An internal-only release has nothing public to +put on a card and exits 0, first clearing any cards an earlier run of the same +version left behind. An **empty** `.changes/` directory is a different thing +and does fail: it almost always means this step ran after `--consume`, and +reporting that as "internal-only" would hide the one ordering mistake the +pipeline is built to prevent. + +## Draft verification + +The `v*` tag build creates a **draft** GitHub release. +`pnpm run release:verify:draft` (`tools/release/verify-draft-release.mjs`) is +the gate that runs before a human publishes it. It is strictly read-only: it +never publishes, edits or deletes. + +1. **Find the run.** `gh run list` reports what is indexed *right now* — its + `--limit` caps how many runs come back, it does not wait for one to appear, + and a tag pushed seconds ago routinely is not indexed yet. The verifier + polls (10 attempts, 6 s apart) before concluding the tag was never pushed. +2. **Wait for it.** An in-progress run is streamed through + `gh run watch --exit-status`. A completed run with a non-success conclusion + fails immediately. A missing `gh` binary and an interrupted watch are + reported as themselves, not as a build failure — `spawnSync` surfaces both + as `status: null`. +3. **Check the release.** Draft status, the authored body, and the complete + asset set below. + +The authored-body check compares the release body against the **local +`CHANGELOG.md` section**, not against emptiness. The tag workflow appends +GitHub's generated notes to the authored text (`FULL_BODY` in +`.github/workflows/build-and-make.yaml`), so the body is never empty and an emptiness test could +never fail. An internal-only release, whose public section is legitimately +empty, is reported as such rather than warned about. + +An already-published release still gets its asset report — auditing one after +the fact is useful — but **never a success exit**. Reporting a pass for a +pre-publication gate after publication would claim a boundary already crossed. + +### Required asset set + +27 assets, verified against a real complete matrix build. When the build matrix +in `.github/workflows/build-and-make.yaml` gains or loses a target, update +`requiredAssetRules()` in the same PR. + +| Platform | Assets | +| --- | --- | +| macOS | `-mac-{x64,arm64}.{dmg,zip}` + a `.blockmap` for each (8) | +| Windows | `-windows-x64-setup.exe` + `.blockmap` (2) | +| DEB | `-linux-{amd64,arm64,armv7l}.deb` (3) | +| AppImage | `-linux-{x86_64,arm64,armv7l}.AppImage` (3) | +| Snap | `-linux-{amd64,armhf}.snap` (2) | +| RPM | `-linux-x86_64.rpm` (1) | +| Flatpak | `-linux-x86_64.flatpak` (1) | +| Pacman | `-linux-x64.pacman` **or** `-linux-x86_64.pkg.tar.*` (1) | +| Updater metadata | `latest.yml`, `latest-mac.yml`, `latest-linux.yml`, `latest-linux-arm.yml`, `latest-linux-arm64.yml` (5) | +| Source compliance | `linux-frame-copy-runtime-sources.tar.xz` (1) | + +Electron Builder has shipped both pacman artifact shapes, so either satisfies +that rule. Rules compare plain strings rather than a regex built from the +version — `requiredAssetRules()` is exported, and escaping an interpolated +value correctly would be a standing trap. + +An asset no rule claims is reported as a `NOTE:` and does **not** fail the run: +a new build target should surface for a human to notice, not block a release +until the rules catch up. + +## After verification + +Publishing the GitHub release is manual. That publication automatically +verifies its Snap assets and uploads them to `edge`; installed-Snap smoke and +candidate/stable promotion remain manual (see +`tools/packaging/validate-snap-release-boundary.mjs`). Keep the blog post a +draft during artifact verification, then publish it in a follow-up commit and +verify the website deployment. + +## Validation + +```bash +pnpm run release:notes:validate # every note parses and satisfies the schema +pnpm nx run release-tools:test # the tooling's own unit tests +pnpm nx run release-tools:lint +``` diff --git a/package.json b/package.json index 28acfda84..2e00e0bde 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,11 @@ "release:notes:github": "node tools/release/build-release-notes.mjs --format github", "release:notes:changelog": "node tools/release/build-release-notes.mjs --format changelog", "release:notes:blog": "node tools/release/build-release-notes.mjs --format blog", + "release:notes:telegram": "node tools/release/build-release-notes.mjs --format telegram", + "release:notes:reddit": "node tools/release/build-release-notes.mjs --format reddit", + "release:verify:draft": "node tools/release/verify-draft-release.mjs", + "release:cards:dry-run": "node tools/release/generate-highlight-cards.mjs --dry-run", + "release:cards:generate": "node tools/release/generate-highlight-cards.mjs --generate", "release:screenshots": "tsx tools/release/capture-release-screenshots.ts", "lint": "nx run-many --target=lint --all", "build": "nx build electron-backend" diff --git a/tools/release/build-release-notes.mjs b/tools/release/build-release-notes.mjs index 39a921d97..648a29bdb 100644 --- a/tools/release/build-release-notes.mjs +++ b/tools/release/build-release-notes.mjs @@ -6,6 +6,8 @@ * node tools/release/build-release-notes.mjs --version 0.24.0 --format github * node tools/release/build-release-notes.mjs --version 0.24.0 --format changelog * node tools/release/build-release-notes.mjs --version 0.24.0 --format blog + * node tools/release/build-release-notes.mjs --version 0.24.0 --format telegram + * node tools/release/build-release-notes.mjs --version 0.24.0 --format reddit * node tools/release/build-release-notes.mjs --version 0.24.0 --consume * * Every mode except `--consume` is a safe dry run: nothing is deleted unless @@ -18,6 +20,10 @@ import path from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; +import { + renderRedditPost, + renderTelegramPost, +} from './release-announcements.mjs'; import { loadNotes } from './release-notes.mjs'; import { manifestSlugs } from './screenshot-guards.mjs'; import { @@ -38,7 +44,7 @@ const BLOG_DIR = path.join(workspaceRoot, 'apps/website/src/content/blog'); /** Generated sections are inserted directly below this marker. */ const CHANGELOG_MARKER = ''; -const FORMATS = new Set(['github', 'changelog', 'blog']); +const FORMATS = new Set(['github', 'changelog', 'blog', 'telegram', 'reddit']); function parseArgs(argv) { const options = { @@ -235,6 +241,27 @@ function writeBlogScaffold(content, version, force) { return target; } +/** + * An internal-only release has nothing to announce publicly. That is a legal + * release shape, so it prints an explanation on stderr and leaves stdout + * empty — matching `extract-changelog-section.mjs --public`, which allows an + * empty public body for exactly the same case — rather than failing. + * + * @param {string | null} post + * @param {string} label + */ +function writeAnnouncement(post, label) { + if (post === null) { + console.error( + `Internal-only release: no public ${label} announcement to render.` + ); + + return; + } + + process.stdout.write(post.endsWith('\n') ? post : `${post}\n`); +} + function main() { const options = parseArgs(process.argv.slice(2)); const notesDir = path.resolve(workspaceRoot, options.dir); @@ -294,6 +321,17 @@ function main() { process.stdout.write(`${renderGithubBody(notes, { links })}\n`); } + // Announcements are dry runs to stdout, like `github`. They must be + // rendered before `--consume`: the CHANGELOG keeps the entries, but + // the `highlight:` metadata lives only in the note files. + if (options.format === 'telegram') { + writeAnnouncement(renderTelegramPost(notes, { version }), 'Telegram'); + } + + if (options.format === 'reddit') { + writeAnnouncement(renderRedditPost(notes, { version }), 'Reddit'); + } + if (options.format === 'changelog') { const section = renderChangelogSection(notes, { version, diff --git a/tools/release/build-release-notes.test.mjs b/tools/release/build-release-notes.test.mjs index e7d77f9dd..8c513c7f1 100644 --- a/tools/release/build-release-notes.test.mjs +++ b/tools/release/build-release-notes.test.mjs @@ -6,7 +6,7 @@ */ import assert from 'node:assert/strict'; -import { execFileSync } from 'node:child_process'; +import { spawnSync } from 'node:child_process'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -36,24 +36,24 @@ function makeNotesDir() { } /** + * spawnSync rather than execFileSync: a successful run also writes to stderr + * (the resolved-version notice, the internal-only announcement explanation), + * and execFileSync only hands back stdout. + * * @returns {{ status: number, stdout: string, stderr: string }} */ function runCli(args) { - try { - const stdout = execFileSync(process.execPath, [CLI, ...args], { - cwd: workspaceRoot, - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - }); + const result = spawnSync(process.execPath, [CLI, ...args], { + cwd: workspaceRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); - return { status: 0, stdout, stderr: '' }; - } catch (error) { - return { - status: error.status ?? 1, - stdout: error.stdout ?? '', - stderr: error.stderr ?? '', - }; - } + return { + status: result.status ?? 1, + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + }; } after(() => { @@ -103,6 +103,63 @@ describe('build-release-notes CLI arguments', () => { assert.match(result.stdout, /\*\*playback\*\* — An example note\./); }); + it('renders the telegram announcement to stdout', () => { + const result = runCli([ + '--format', + 'telegram', + '--version', + '0.24.0', + '--dir', + makeNotesDir(), + ]); + + assert.equal(result.status, 0); + assert.match(result.stdout, /🎉 IPTVnator v0\.24\.0 is out!/); + assert.match(result.stdout, /✨ An example note\./); + assert.match(result.stdout, /releases\/tag\/v0\.24\.0/); + }); + + it('renders the reddit announcement to stdout', () => { + const result = runCli([ + '--format', + 'reddit', + '--version', + '0.24.0', + '--dir', + makeNotesDir(), + ]); + + assert.equal(result.status, 0); + assert.match(result.stdout, /^Suggested title: IPTVnator v0\.24\.0/); + assert.match(result.stdout, /- \*\*playback\*\* — An example note\./); + }); + + it('reports an internal-only release instead of failing on announcements', () => { + const directory = mkdtempSync(path.join(tmpdir(), 'release-cli-')); + + tempDirs.push(directory); + writeFileSync( + path.join(directory, 'deps-bump.md'), + '---\ntype: internal\narea: deps\n---\n\nBumped a parser.\n', + 'utf8' + ); + + for (const format of ['telegram', 'reddit']) { + const result = runCli([ + '--format', + format, + '--version', + '0.24.0', + '--dir', + directory, + ]); + + assert.equal(result.status, 0, format); + assert.equal(result.stdout, '', format); + assert.match(result.stderr, /Internal-only release/, format); + } + }); + it('still rejects a genuinely unknown argument', () => { const result = runCli(['--validate', '--nope']); diff --git a/tools/release/generate-highlight-cards.mjs b/tools/release/generate-highlight-cards.mjs new file mode 100644 index 000000000..637e44a7e --- /dev/null +++ b/tools/release/generate-highlight-cards.mjs @@ -0,0 +1,383 @@ +#!/usr/bin/env node +/** + * Renders release highlight cards from `highlight:` notes: one 1200×630 + * card per highlight (branded background, headline, body, framed screenshot + * strip when the note names one) plus a release hero card. + * + * node tools/release/generate-highlight-cards.mjs --dry-run + * node tools/release/generate-highlight-cards.mjs --generate + * node tools/release/generate-highlight-cards.mjs --generate --theme light + * + * Must run BEFORE `--consume` (the highlight metadata lives only in the note + * files) and after `release:screenshots` (screenshot strips are read from the + * published blog directory). Output goes to dist/, outside version control — + * committing a card into the website tree is a deliberate manual act. + */ + +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, +} from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; +import sharp from 'sharp'; + +import { + buildFeatureCardSvg, + buildHeroCardSvg, + buildShotMaskSvg, + CARD_HEIGHT, + isOwnedCardFile, + SHOT_LEFT, + SHOT_TOP, + SHOT_WIDTH, + planHighlightCards, +} from './highlight-cards.mjs'; +import { loadNotes } from './release-notes.mjs'; +import { releaseSlug } from './release-notes-render.mjs'; + +const workspaceRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../..' +); +const CLI_USAGE = + 'Usage: generate-highlight-cards.mjs (--dry-run | --generate) [--theme dark|light] [--version ] [--dir ] [--out ]'; + +export function parseCardArguments(args) { + const options = { + mode: null, + theme: 'dark', + version: null, + dir: '.changes', + out: null, + }; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + const takeValue = () => { + const value = args[index + 1]; + + if (!value || value.startsWith('--')) { + return null; + } + + index += 1; + + return value; + }; + + if (arg === '--dry-run' || arg === '--generate') { + if (options.mode) { + return null; + } + + options.mode = arg.slice(2); + } else if (arg === '--theme') { + const value = takeValue(); + + if (value !== 'dark' && value !== 'light') { + return null; + } + + options.theme = value; + } else if (arg === '--version') { + const value = takeValue(); + + if (!value || !/^\d+\.\d+\.\d+$/.test(value)) { + return null; + } + + options.version = value; + } else if (arg === '--dir' || arg === '--out') { + const value = takeValue(); + + if (!value) { + return null; + } + + options[arg.slice(2)] = value; + } else if (arg !== '--') { + return null; + } + } + + return options.mode ? options : null; +} + +/** + * Composites the bottom screenshot strip: resize to the strip width, crop to + * the visible height, round the corners via a dest-in mask. + * + * @param {string} screenshotPath + * @returns {Promise<{ input: Buffer, left: number, top: number }>} + */ +async function prepareShotOverlay(screenshotPath) { + const stripHeight = CARD_HEIGHT - SHOT_TOP; + const resized = sharp(screenshotPath).resize({ width: SHOT_WIDTH }); + const metadata = await resized.png().toBuffer({ resolveWithObject: true }); + const visibleHeight = Math.min(stripHeight, metadata.info.height); + const cropped = await sharp(metadata.data) + .extract({ + left: 0, + top: 0, + width: SHOT_WIDTH, + height: visibleHeight, + }) + .composite([ + { + input: Buffer.from(buildShotMaskSvg(SHOT_WIDTH, visibleHeight)), + blend: 'dest-in', + }, + ]) + .png() + .toBuffer(); + + return { input: cropped, left: SHOT_LEFT, top: SHOT_TOP }; +} + +/** + * Repo-relative when the path is inside the workspace, absolute otherwise: + * `--out /tmp/cards` printed as a stack of `../../..` segments is worse than + * no shortening at all. + * + * @param {string} target + * @returns {string} + */ +function displayPath(target) { + const relative = path.relative(workspaceRoot, target); + + return relative.startsWith('..') ? target : relative; +} + +/** + * Removes the cards a previous run of THIS generator left in `outputDir`, so a + * renamed, dropped, or newly-internal highlight cannot leave a stale image + * sitting there waiting to be published. Only files matching what this tool + * writes are touched; anything else in the directory is left alone. + * + * @param {string} outputDir + * @returns {string[]} removed filenames + */ +export function removeStaleCards(outputDir) { + if (!existsSync(outputDir)) { + return []; + } + + const stale = readdirSync(outputDir).filter(isOwnedCardFile); + + for (const entry of stale) { + rmSync(path.join(outputDir, entry)); + } + + return stale; +} + +/** + * @param {{ feature: object[], hero: object }} plan + * @param {string} version + * @param {string} outputDir + * @returns {Promise} written file paths + */ +export async function renderCards(plan, version, outputDir) { + mkdirSync(outputDir, { recursive: true }); + removeStaleCards(outputDir); + + const written = []; + + for (const job of plan.feature) { + const base = sharp(Buffer.from(buildFeatureCardSvg(job, version))); + const composites = job.screenshotPath + ? [await prepareShotOverlay(job.screenshotPath)] + : []; + const target = path.join(outputDir, job.fileName); + + await base.composite(composites).png().toFile(target); + written.push(target); + } + + const heroSvg = Buffer.from(buildHeroCardSvg(plan.hero)); + const heroPng = path.join(outputDir, plan.hero.fileName); + const heroJpg = path.join(outputDir, 'hero.jpg'); + + await sharp(heroSvg).png().toFile(heroPng); + // The blog scaffold's frontmatter references `hero.jpg`. + await sharp(heroSvg).flatten().jpeg({ quality: 92 }).toFile(heroJpg); + written.push(heroPng, heroJpg); + + return written; +} + +async function main() { + const options = parseCardArguments(process.argv.slice(2)); + + if (options === null) { + console.error(CLI_USAGE); + process.exit(2); + } + + if (options.version === null) { + options.version = JSON.parse( + readFileSync(path.join(workspaceRoot, 'package.json'), 'utf8') + ).version; + console.error(`Using version ${options.version} from package.json.`); + } + + const notesDir = path.resolve(workspaceRoot, options.dir); + const { notes, errors } = loadNotes(notesDir); + + if (errors.length > 0) { + console.error('Invalid release notes:\n'); + for (const error of errors) { + console.error(` ${error}`); + } + process.exit(1); + } + + // An empty directory is not an internal-only release: it is almost always + // this step running AFTER `--consume` deleted the notes, which is the one + // ordering mistake the whole pipeline warns about. Reporting it as + // "internal-only" would hide the mistake and quietly ship no cards at all. + if (notes.length === 0) { + console.error( + [ + `No notes found in ${displayPath(notesDir)}/.`, + 'Cards are built from `highlight:`, which exists only in the note', + 'files — if `build-release-notes.mjs --consume` already ran, that', + 'metadata is gone. Render cards before consuming.', + ].join(' ') + ); + process.exit(1); + } + + const slug = releaseSlug(options.version); + const screenshotsDir = path.join( + workspaceRoot, + 'apps/website/public/blog', + slug, + 'screenshots' + ); + const plan = planHighlightCards(notes, { + version: options.version, + releaseSlug: slug, + screenshotsDir, + theme: options.theme, + }); + // Keyed by the exact version, not the minor slug: 0.24.0 and 0.24.1 share + // a blog post but not a card set, and mixing them in one directory invites + // publishing the previous patch's card. + const outputDir = path.resolve( + workspaceRoot, + options.out ?? + path.join('dist/release-highlight-cards', `v${options.version}`) + ); + + // Neither shape below is an error: an internal-only release is legal, and + // a release nobody marked a highlight on still deserves its hero card. + // Failing here would break the documented release sequence. + if (plan.publicNoteCount === 0) { + console.error( + 'Internal-only release: no public change to put on a card.' + ); + + // A release that turned internal-only after cards were already made + // for this exact version must not leave them behind. `--dry-run` + // deletes nothing. + if (options.mode === 'generate') { + const removed = removeStaleCards(outputDir); + + if (removed.length > 0) { + console.log( + `Removed ${removed.length} card(s) left by an earlier run of v${options.version}.` + ); + } + } + + return; + } + + if (plan.feature.length === 0) { + console.error( + 'No `highlight:` notes found — rendering the hero card only. Mark the headline changes in .changes/ to get feature cards.' + ); + } + + const missingShots = plan.feature.filter( + (job) => job.screenshotPath && !existsSync(job.screenshotPath) + ); + + if (missingShots.length > 0) { + console.error('Missing screenshot(s) for highlight card(s):\n'); + for (const job of missingShots) { + console.error( + ` ${displayPath(job.screenshotPath)}` + ); + } + console.error('\nRun `pnpm run release:screenshots` first.'); + process.exit(1); + } + + console.log(`${plan.feature.length} highlight card(s) + hero for v${options.version}:`); + for (const job of plan.feature) { + const shot = job.screenshotPath + ? displayPath(job.screenshotPath) + : 'no screenshot (typographic card)'; + + console.log(` ${job.fileName} — "${job.headline}" (${shot})`); + } + + if (options.mode === 'dry-run') { + console.log(`\nWould write to ${displayPath(outputDir)}/.`); + + return; + } + + const stale = existsSync(outputDir) + ? readdirSync(outputDir).filter(isOwnedCardFile) + : []; + + if (stale.length > 0) { + console.log( + `Replacing ${stale.length} card(s) from a previous run in the same directory.` + ); + } + + const written = await renderCards(plan, options.version, outputDir); + + for (const file of written) { + console.log(`Wrote ${displayPath(file)}`); + } + + console.log( + `\nReview the images, then copy what you publish (e.g. hero.jpg → apps/website/public/blog/${slug}/hero.jpg).` + ); +} + +// realpath on both sides: Node resolves symlinks for `import.meta.url` but +// not for argv[1], so reaching this script through a symlinked path would +// otherwise exit 0 having rendered nothing. +const isDirectRun = (() => { + if (!process.argv[1]) { + return false; + } + + try { + return ( + realpathSync(process.argv[1]) === + realpathSync(fileURLToPath(import.meta.url)) + ); + } catch { + return false; + } +})(); + +if (isDirectRun) { + main().catch((error) => { + console.error(error.message); + process.exit(1); + }); +} diff --git a/tools/release/highlight-cards.mjs b/tools/release/highlight-cards.mjs new file mode 100644 index 000000000..e5bda076a --- /dev/null +++ b/tools/release/highlight-cards.mjs @@ -0,0 +1,562 @@ +/** + * Pure layout layer for release highlight cards: plans which cards a release + * gets from its `highlight:` notes and builds the SVG for each. Rendering to + * PNG (sharp) lives in generate-highlight-cards.mjs; everything here is + * deterministic string work, so it is unit-testable without an image library. + * + * Card size is the 1200×630 Open Graph format — right for Telegram/Reddit + * link previews and reusable as a blog hero. + */ + +import path from 'node:path'; + +import { groupNotes } from './release-notes.mjs'; + +export const CARD_WIDTH = 1200; +export const CARD_HEIGHT = 630; + +/** Screenshot strip: fills the card bottom under the text block. */ +export const SHOT_WIDTH = 880; +export const SHOT_TOP = 330; +export const SHOT_LEFT = (CARD_WIDTH - SHOT_WIDTH) / 2; +export const SHOT_RADIUS = 14; + +/** Left margin, and the width text may occupy before the right margin. */ +const TEXT_LEFT = 64; +export const TEXT_MAX_WIDTH = CARD_WIDTH - TEXT_LEFT * 2; +/** Hero bullet lines start further right, after the accent dot. */ +const HERO_BULLET_LEFT = 100; +export const HERO_BULLET_MAX_WIDTH = CARD_WIDTH - HERO_BULLET_LEFT - TEXT_LEFT; + +/** Feature-card text block, laid out to always clear the screenshot frame. */ +const HEADLINE_TOP = 168; +const HEADLINE_LINE_HEIGHT = 58; +const BODY_LINE_HEIGHT = 32; +const BODY_GAP = 8; +/** Lowest permitted body baseline: the frame starts at SHOT_TOP - 2. */ +export const TEXT_BOTTOM = SHOT_TOP - 24; + +const BRAND = { + backgroundTop: '#0a0a08', + backgroundBottom: '#141412', + text: '#f0f0eb', + muted: '#8a8a80', + accent: '#20a8a8', + accentBright: '#38c4c4', + warm: '#d4a853', + frame: '#2e2e28', +}; + +const FONT_STACK = "'DM Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif"; + +/** Human count labels for the hero footer, singular and plural. */ +const COUNT_LABELS = { + breaking: ['breaking change', 'breaking changes'], + feature: ['feature', 'features'], + fix: ['fix', 'fixes'], + perf: ['performance win', 'performance wins'], +}; + +export function escapeXml(text) { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Per-character advance width as a fraction of the font size. + * + * Counting characters is not a width budget: 34 `W` at font-size 52 measures + * ~1948px where only ~1072px are available, so a character-capped line still + * overflowed the canvas. + * + * The model is deliberately inverted — narrow characters are enumerated and + * **everything else is assumed wide**. Enumerating the wide ones instead is a + * game that cannot be won: successive passes each found another + * under-estimated glyph (`W`, then CJK and emoji, then the `ae` ligature), + * and any glyph the list misses crops the card silently. In this shape the + * estimate can only ever run high, and running high costs an early line + * break nobody sees. + */ +const WIDEST_FACTOR = 1.25; + +/** + * ASCII advance factors. The SVG names `DM Sans` but nothing guarantees it is + * installed, so every host resolves the fallback chain differently and the + * same line renders at different widths: `r` measures 0.389 em here and about + * 0.49 em in the environment that reported this. These factors sit above the + * widest of those observations with margin, because the failure that matters + * is one-directional — an over-estimate wraps early, an under-estimate crops. + */ +const ASCII_FACTORS = new Map([ + [' ', 0.4], + ...[...".,;:'\"`!|()[]{}/\\-ilIjtfr"].map((character) => [character, 0.55]), + ...[...'MWmw@%&#'].map((character) => [character, WIDEST_FACTOR]), +]); + +/** + * @param {string} character a single code point + * @returns {number} advance width as a fraction of the font size + */ +function advanceFactor(character) { + const known = ASCII_FACTORS.get(character); + + if (known !== undefined) { + return known; + } + + if (character >= 'a' && character <= 'z') { + return 0.75; + } + + if (character >= 'A' && character <= 'Z') { + return 0.92; + } + + if (character >= '0' && character <= '9') { + return 0.75; + } + + // Everything else: accented Latin, ligatures, Cyrillic, Greek, CJK, kana, + // hangul, emoji, and whatever else a headline turns out to carry. + return WIDEST_FACTOR; +} + +/** + * @param {string} text + * @param {number} fontSize + * @returns {number} estimated rendered width in pixels + */ +export function estimateTextWidth(text, fontSize) { + let units = 0; + + // Iterating the string yields code points, so an emoji counts once. + for (const character of text) { + units += advanceFactor(character); + } + + return units * fontSize; +} + +/** Splits one overlong word into chunks that each fit `maxWidth`. */ +function breakWord(word, maxWidth, fontSize) { + const chunks = []; + let chunk = ''; + + for (const character of word) { + if ( + chunk && + estimateTextWidth(chunk + character, fontSize) > maxWidth + ) { + chunks.push(chunk); + chunk = character; + continue; + } + + chunk += character; + } + + if (chunk) { + chunks.push(chunk); + } + + return chunks; +} + +/** + * Greedy word wrap by estimated rendered width. SVG has no automatic text + * layout, so the wrap has to decide the breaks itself; every returned line is + * estimated to fit `maxWidth`, including when a single word does not — such a + * word is broken rather than left to run off the canvas. + * + * @param {string} text + * @param {{ maxWidth: number, fontSize: number, maxLines: number }} options + * @returns {string[]} at most maxLines lines, the last ellipsized on overflow + */ +export function wrapText(text, { maxWidth, fontSize, maxLines }) { + const words = text.replace(/\s+/g, ' ').trim().split(' ').filter(Boolean); + const lines = []; + let current = ''; + + for (const word of words) { + if (estimateTextWidth(word, fontSize) > maxWidth) { + if (current) { + lines.push(current); + } + + lines.push(...breakWord(word, maxWidth, fontSize)); + // Keep the final chunk open so a following short word can join it. + current = lines.pop() ?? ''; + continue; + } + + const candidate = current ? `${current} ${word}` : word; + + if (!current || estimateTextWidth(candidate, fontSize) <= maxWidth) { + current = candidate; + continue; + } + + lines.push(current); + current = word; + } + + if (current) { + lines.push(current); + } + + if (lines.length > maxLines) { + const kept = lines.slice(0, maxLines); + let last = kept[maxLines - 1]; + + while ( + last.length > 1 && + estimateTextWidth(`${last}…`, fontSize) > maxWidth + ) { + last = last.slice(0, -1); + } + + kept[maxLines - 1] = `${last.trimEnd()}…`; + + return kept; + } + + return lines; +} + +/** + * One card per `highlight:` note, plus one release hero card. The screenshot + * (when the note names one) is read from the published blog directory the + * capture run writes to, in the requested theme. + * + * @param {object[]} notes parsed `.changes` notes + * @param {{ version: string, releaseSlug: string, screenshotsDir: string, theme: string }} options + * @returns {{ feature: object[], publicNoteCount: number, hero: object }} + */ +export function planHighlightCards(notes, options) { + const { version, releaseSlug, screenshotsDir, theme } = options; + const ordered = groupNotes(notes) + .filter((group) => group.type !== 'internal') + .flatMap((group) => group.notes); + const highlights = ordered.filter((note) => note.highlight); + + const takenSlugs = new Set(); + + const feature = highlights.map((note) => { + // Named after the note file, never the screenshot slug: filenames are + // unique within `.changes/`, while two highlights may legitimately + // point at the same manifest shot — naming cards after it would let + // one silently overwrite the other. + const base = cardSlug(path.basename(note.sourcePath, '.md')); + // Normalizing can collapse two distinct names onto one, so uniqueness + // is re-established here rather than assumed. + let slug = base; + + for (let suffix = 2; takenSlugs.has(slug); suffix += 1) { + slug = `${base}-${suffix}`; + } + + takenSlugs.add(slug); + + return { + slug, + fileName: `card-${slug}.png`, + headline: note.highlight, + body: note.body, + screenshotPath: note.screenshot + ? path.join(screenshotsDir, `${note.screenshot}-${theme}.png`) + : null, + }; + }); + + const counts = groupNotes(ordered) + .map((group) => { + const [singular, plural] = COUNT_LABELS[group.type]; + + return `${group.notes.length} ${group.notes.length === 1 ? singular : plural}`; + }) + .join(' · '); + + return { + feature, + // Public notes, not total: an internal-only release has nothing to put + // on a card, which is a legal release shape rather than an error. + publicNoteCount: ordered.length, + hero: { + fileName: 'hero.png', + version, + releaseSlug, + headlines: highlights.map((note) => note.highlight), + counts, + }, + }; +} + +/** Files this generator owns in an output directory. */ +export function isOwnedCardFile(fileName) { + return ( + /^card-[a-z0-9-]+\.png$/.test(fileName) || + fileName === 'hero.png' || + fileName === 'hero.jpg' + ); +} + +/** + * Every emitted filename must satisfy isOwnedCardFile(), or a later run cannot + * reclaim the card it wrote. Note filenames are conventionally lowercase slugs + * but nothing enforces it, so normalize rather than trust: `player_new-ui.md` + * would otherwise produce a card no cleanup pass can ever remove. + * + * @param {string} noteBaseName note filename without its `.md` extension + * @returns {string} + */ +export function cardSlug(noteBaseName) { + const normalized = noteBaseName + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + + return normalized || 'note'; +} + +function backgroundDefs() { + return [ + '', + ``, + ``, + ``, + '', + ``, + ``, + ``, + '', + '', + ].join(''); +} + +function backgroundRects() { + return [ + ``, + ``, + ].join(''); +} + +function brandHeader(version) { + const chipX = 262; + + return [ + `IPTVnator`, + ``, + `v${escapeXml(version)}`, + ].join(''); +} + +/** + * `maxWidth` is a hard backstop, not the wrap budget: the wrap already fits + * every line by estimate, and this clamps anything the estimate got wrong so + * a mis-measured glyph compresses instead of running off the canvas. + */ +function textLines(lines, { x, y, size, weight, fill, lineHeight, maxWidth }) { + return lines + .map((line, index) => { + const clamp = + maxWidth && estimateTextWidth(line, size) > maxWidth + ? ` textLength="${maxWidth}" lengthAdjust="spacingAndGlyphs"` + : ''; + + return `${escapeXml(line)}`; + }) + .join(''); +} + +/** + * Feature card: brand header, headline, muted body one-liner, and either a + * framed screenshot strip along the bottom or (without a screenshot) an + * accent rule under a larger, vertically centered headline. + * + * @param {object} job entry from planHighlightCards().feature + * @param {string} version + * @returns {string} SVG document; the screenshot itself is composited by the + * renderer inside the frame this SVG draws + */ +export function buildFeatureCardSvg(job, version) { + const parts = [ + ``, + backgroundDefs(), + backgroundRects(), + brandHeader(version), + ]; + + if (job.screenshotPath) { + const headline = wrapText(job.headline, { + maxWidth: TEXT_MAX_WIDTH, + fontSize: 52, + maxLines: 2, + }); + // The body's line budget is derived from the space actually left above + // the screenshot frame, never assumed: the frame is opaque and painted + // after the text, so a fixed line count silently sliced the last line + // in half whenever the headline wrapped to two lines. + const bodyTop = + HEADLINE_TOP + headline.length * HEADLINE_LINE_HEIGHT + BODY_GAP; + const bodyLines = Math.max( + 1, + Math.min( + 3, + Math.floor((TEXT_BOTTOM - bodyTop) / BODY_LINE_HEIGHT) + 1 + ) + ); + const body = wrapText(job.body, { + maxWidth: TEXT_MAX_WIDTH, + fontSize: 23, + maxLines: bodyLines, + }); + + parts.push( + textLines(headline, { + x: TEXT_LEFT, + y: HEADLINE_TOP, + size: 52, + weight: 800, + fill: BRAND.text, + lineHeight: HEADLINE_LINE_HEIGHT, + maxWidth: TEXT_MAX_WIDTH, + }) + ); + parts.push( + textLines(body, { + x: TEXT_LEFT, + y: bodyTop, + size: 23, + weight: 400, + fill: BRAND.muted, + lineHeight: BODY_LINE_HEIGHT, + maxWidth: TEXT_MAX_WIDTH, + }) + ); + // Frame stroke sits behind the composited screenshot; the strip is + // bottom-cropped by the canvas, so only the top corners round. + parts.push( + `` + ); + } else { + const headline = wrapText(job.headline, { + maxWidth: TEXT_MAX_WIDTH, + fontSize: 62, + maxLines: 2, + }); + const body = wrapText(job.body, { + maxWidth: TEXT_MAX_WIDTH, + fontSize: 26, + maxLines: 3, + }); + const headlineY = 250; + + parts.push( + textLines(headline, { + x: TEXT_LEFT, + y: headlineY, + size: 62, + weight: 800, + fill: BRAND.text, + lineHeight: 74, + maxWidth: TEXT_MAX_WIDTH, + }) + ); + parts.push( + `` + ); + parts.push( + textLines(body, { + x: TEXT_LEFT, + y: headlineY + headline.length * 74 + 8, + size: 26, + weight: 400, + fill: BRAND.muted, + lineHeight: 38, + maxWidth: TEXT_MAX_WIDTH, + }) + ); + } + + parts.push(''); + + return parts.join(''); +} + +/** + * Hero card: big version, the highlight names as an accent-bulleted list, + * and the per-type note counts along the bottom. + * + * @param {object} hero planHighlightCards().hero + * @returns {string} + */ +export function buildHeroCardSvg(hero) { + const listed = hero.headlines.slice(0, 4); + const omitted = hero.headlines.length - listed.length; + const parts = [ + ``, + backgroundDefs(), + backgroundRects(), + `IPTVnator`, + `v${escapeXml(hero.version)}`, + ``, + ]; + + listed.forEach((headline, index) => { + const y = 330 + index * 56; + + parts.push( + `` + ); + // wrapText yields no lines for whitespace-only input; validation + // rejects that upstream, but a card run must not die half-written. + const [line = ''] = wrapText(headline, { + maxWidth: HERO_BULLET_MAX_WIDTH, + fontSize: 30, + maxLines: 1, + }); + + parts.push( + textLines([line], { + x: HERO_BULLET_LEFT, + y, + size: 30, + weight: 600, + fill: BRAND.text, + lineHeight: 0, + maxWidth: HERO_BULLET_MAX_WIDTH, + }) + ); + }); + + if (omitted > 0) { + parts.push( + `…and ${omitted} more` + ); + } + + if (hero.counts) { + parts.push( + `${escapeXml(hero.counts)}` + ); + } + + parts.push(''); + + return parts.join(''); +} + +/** + * Rounded-corner alpha mask for the screenshot strip (dest-in composite). + * + * @param {number} width + * @param {number} height + * @returns {string} + */ +export function buildShotMaskSvg(width, height) { + return ``; +} diff --git a/tools/release/highlight-cards.test.mjs b/tools/release/highlight-cards.test.mjs new file mode 100644 index 000000000..cbfe7fbe4 --- /dev/null +++ b/tools/release/highlight-cards.test.mjs @@ -0,0 +1,690 @@ +import assert from 'node:assert/strict'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { after, describe, it } from 'node:test'; +import sharp from 'sharp'; + +import { + buildFeatureCardSvg, + buildHeroCardSvg, + CARD_HEIGHT, + CARD_WIDTH, + escapeXml, + estimateTextWidth, + HERO_BULLET_MAX_WIDTH, + isOwnedCardFile, + planHighlightCards, + SHOT_TOP, + TEXT_BOTTOM, + TEXT_MAX_WIDTH, + wrapText, +} from './highlight-cards.mjs'; +import { + parseCardArguments, + removeStaleCards, + renderCards, +} from './generate-highlight-cards.mjs'; + +const tempDirs = []; + +function makeTempDir() { + const directory = mkdtempSync(path.join(tmpdir(), 'highlight-cards-')); + + tempDirs.push(directory); + + return directory; +} + +function note(overrides = {}) { + return { + type: 'feature', + area: 'playback', + issues: [], + screenshot: null, + highlight: null, + unknownKeys: [], + body: 'Series now show an Up Next rail beside the player.', + sourcePath: '.changes/playback-up-next.md', + ...overrides, + }; +} + +const planOptions = { + version: '0.24.0', + releaseSlug: 'v0-24', + screenshotsDir: '/blog/v0-24/screenshots', + theme: 'dark', +}; + +after(() => { + for (const directory of tempDirs) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe('wrapText', () => { + const budget = (maxWidth, fontSize, maxLines) => ({ + maxWidth, + fontSize, + maxLines, + }); + + it('wraps on word boundaries within the width budget', () => { + // Asserted as a property, not an exact split: the break points move + // whenever the advance factors are tuned, and pinning them would make + // every future calibration look like a regression. + const text = 'one two three four'; + const lines = wrapText(text, budget(60, 10, 4)); + + assert.ok(lines.length > 1, 'expected the text to wrap at all'); + assert.equal(lines.join(' '), text, 'words were lost or reordered'); + for (const line of lines) { + assert.ok(estimateTextWidth(line, 10) <= 60, line); + } + }); + + it('breaks a word wider than the budget instead of overflowing the card', () => { + const lines = wrapText('supercalifragilistic ok', budget(60, 10, 4)); + + assert.ok(lines.length > 1); + assert.equal(lines.join('').replace(/ /g, ''), 'supercalifragilisticok'); + }); + + it('keeps every line inside the budget, wide glyphs included', () => { + // Counting characters is not a width budget: 34 `W` at font-size 52 + // measures ~1948px where only 1072px are available. + for (const [text, maxWidth, fontSize, maxLines] of [ + ['W'.repeat(60), TEXT_MAX_WIDTH, 52, 2], + ['W'.repeat(60), TEXT_MAX_WIDTH, 62, 2], + ['W'.repeat(60), HERO_BULLET_MAX_WIDTH, 30, 1], + ['M'.repeat(200), TEXT_MAX_WIDTH, 23, 3], + ['ЖЮ'.repeat(40), TEXT_MAX_WIDTH, 52, 2], + ['Advanced subtitles with external files', TEXT_MAX_WIDTH, 52, 2], + ]) { + for (const line of wrapText( + text, + budget(maxWidth, fontSize, maxLines) + )) { + const width = estimateTextWidth(line, fontSize); + + assert.ok( + width <= maxWidth, + `"${line}" estimates ${Math.round(width)}px > ${maxWidth}px` + ); + } + } + }); + + it('treats uncalibrated scripts as full-width rather than narrow', () => { + // CJK, kana, hangul and emoji are ~1 em; falling through to the + // lowercase-Latin factor let a 60-glyph headline paint off-canvas. + for (const glyph of ['界', 'ひ', '한', '🎉', 'Ω'.repeat(1)]) { + const wide = estimateTextWidth(glyph.repeat(28), 52); + + assert.ok( + wide > TEXT_MAX_WIDTH, + `28 × "${glyph}" estimates only ${Math.round(wide)}px` + ); + } + }); + + it('keeps a full-width headline inside the canvas', () => { + for (const line of wrapText('界'.repeat(60), budget(TEXT_MAX_WIDTH, 52, 2))) { + assert.ok(estimateTextWidth(line, 52) <= TEXT_MAX_WIDTH, line); + } + }); + + it('estimates a wide glyph run near its measured rendered width', () => { + // Calibration anchor: 34 `W` at font-size 52 renders ~1948px. + const estimate = estimateTextWidth('W'.repeat(34), 52); + + assert.ok(estimate > 1800, `estimate ${estimate} is too low`); + }); + + it('ellipsizes the last kept line on overflow', () => { + const lines = wrapText('aaa bbb ccc ddd eee', budget(30, 10, 2)); + + assert.equal(lines.length, 2); + assert.match(lines[1], /…$/); + assert.ok(estimateTextWidth(lines[1], 10) <= 30); + }); +}); + +describe('escapeXml', () => { + it('escapes markup and quote characters', () => { + assert.equal( + escapeXml(` & "b" 'c'`), + '<a> & "b" 'c'' + ); + }); +}); + +describe('planHighlightCards', () => { + it('plans one card per highlight in group order plus a hero', () => { + const plan = planHighlightCards( + [ + note({ type: 'fix', body: 'Not highlighted.' }), + note({ + highlight: 'Up Next rail', + screenshot: 'up-next-rail', + }), + note({ + type: 'breaking', + highlight: 'New settings', + sourcePath: '.changes/settings-rework.md', + }), + ], + planOptions + ); + + assert.deepEqual( + plan.feature.map((job) => job.fileName), + ['card-settings-rework.png', 'card-playback-up-next.png'] + ); + assert.equal( + plan.feature[1].screenshotPath, + path.join('/blog/v0-24/screenshots', 'up-next-rail-dark.png') + ); + assert.equal(plan.feature[0].screenshotPath, null); + assert.deepEqual(plan.hero.headlines, [ + 'New settings', + 'Up Next rail', + ]); + assert.equal(plan.hero.counts, '1 breaking change · 1 feature · 1 fix'); + }); + + it('keeps card filenames unique when two highlights share a screenshot', () => { + const plan = planHighlightCards( + [ + note({ + highlight: 'Rail on wide windows', + screenshot: 'up-next-rail', + sourcePath: '.changes/playback-up-next.md', + }), + note({ + highlight: 'Rail progress bars', + screenshot: 'up-next-rail', + sourcePath: '.changes/playback-rail-progress.md', + }), + ], + planOptions + ); + + assert.deepEqual( + plan.feature.map((job) => job.fileName), + ['card-playback-up-next.png', 'card-playback-rail-progress.png'] + ); + // Both still read the same shared screenshot. + assert.equal( + plan.feature[0].screenshotPath, + plan.feature[1].screenshotPath + ); + }); + + it('reports the public note count so an internal-only release is detectable', () => { + assert.equal( + planHighlightCards( + [note({ type: 'internal', body: 'Churn.' })], + planOptions + ).publicNoteCount, + 0 + ); + assert.equal( + planHighlightCards( + [note(), note({ type: 'internal', body: 'Churn.' })], + planOptions + ).publicNoteCount, + 1 + ); + }); + + it('emits only filenames the ownership predicate can reclaim', () => { + // Nothing enforces the lowercase-slug filename convention, and a card + // the cleanup pass cannot recognize is a card that lives forever. + const plan = planHighlightCards( + [ + note({ + highlight: 'New UI', + sourcePath: '.changes/Player_New-UI.md', + }), + note({ + highlight: 'Other', + sourcePath: '.changes/player.new.ui.md', + }), + ], + planOptions + ); + + assert.deepEqual( + plan.feature.map((job) => job.fileName), + ['card-player-new-ui.png', 'card-player-new-ui-2.png'] + ); + for (const job of plan.feature) { + assert.equal(isOwnedCardFile(job.fileName), true, job.fileName); + } + }); + + it('claims only the files it writes', () => { + for (const owned of ['card-playback-up-next.png', 'hero.png', 'hero.jpg']) { + assert.equal(isOwnedCardFile(owned), true, owned); + } + + for (const foreign of [ + 'notes.txt', + 'card-Upper.png', + 'screenshot-dashboard-dark.png', + 'hero.webp', + ]) { + assert.equal(isOwnedCardFile(foreign), false, foreign); + } + }); + + it('never plans a card for internal notes', () => { + const plan = planHighlightCards( + [note({ type: 'internal', highlight: 'Nope' })], + planOptions + ); + + assert.deepEqual(plan.feature, []); + assert.deepEqual(plan.hero.headlines, []); + }); + + it('respects the requested screenshot theme', () => { + const plan = planHighlightCards( + [note({ highlight: 'Up Next rail', screenshot: 'up-next-rail' })], + { ...planOptions, theme: 'light' } + ); + + assert.match(plan.feature[0].screenshotPath, /-light\.png$/); + }); +}); + +describe('card SVGs', () => { + it('escapes user text and carries the version chip', () => { + const svg = buildFeatureCardSvg( + { + slug: 'x', + fileName: 'card-x.png', + headline: 'Support & "vod"', + body: "The app's import.", + screenshotPath: null, + }, + '0.24.0' + ); + + assert.match(svg, /Support <live> & "vod"/); + assert.match(svg, /v0\.24\.0/); + assert.doesNotMatch(svg, //); + }); + + it('draws the screenshot frame only when a screenshot exists', () => { + const withShot = buildFeatureCardSvg( + { + headline: 'H', + body: 'B', + screenshotPath: '/shots/x-dark.png', + }, + '0.24.0' + ); + const without = buildFeatureCardSvg( + { headline: 'H', body: 'B', screenshotPath: null }, + '0.24.0' + ); + + // The frame rect is the only element using the surface frame color. + assert.match(withShot, /fill="#2e2e28"/); + assert.doesNotMatch(without, /fill="#2e2e28"/); + }); + + it('keeps every body line clear of the opaque screenshot frame', () => { + // A two-line headline plus a long body used to push the last body + // line under the frame, which is painted after the text. + const svg = buildFeatureCardSvg( + { + headline: 'Advanced subtitles with external files and styling', + body: 'External subtitle files load from disk, timing shifts in half-second steps, and you can set caption size and colour — the settings stick between episodes.', + screenshotPath: '/shots/x-dark.png', + }, + '0.24.0' + ); + const baselines = [...svg.matchAll(/]*\sy="(\d+)"/g)].map( + (match) => Number(match[1]) + ); + + assert.ok(baselines.length > 0); + for (const baseline of baselines) { + assert.ok( + baseline <= TEXT_BOTTOM, + `baseline ${baseline} overlaps the frame at ${SHOT_TOP - 2}` + ); + } + }); + + it('survives a whitespace-only headline instead of dying half-written', () => { + assert.doesNotThrow(() => + buildHeroCardSvg({ + fileName: 'hero.png', + version: '0.24.0', + releaseSlug: 'v0-24', + headlines: [' '], + counts: '1 feature', + }) + ); + }); + + it('lists at most four highlights on the hero and counts the rest', () => { + const svg = buildHeroCardSvg({ + fileName: 'hero.png', + version: '0.24.0', + releaseSlug: 'v0-24', + headlines: ['One', 'Two', 'Three', 'Four', 'Five'], + counts: '5 features', + }); + + assert.match(svg, />FourFive { + it('requires a mode and defaults the theme', () => { + assert.equal(parseCardArguments([]), null); + assert.deepEqual(parseCardArguments(['--dry-run']), { + mode: 'dry-run', + theme: 'dark', + version: null, + dir: '.changes', + out: null, + }); + }); + + it('parses the full flag set', () => { + assert.deepEqual( + parseCardArguments([ + '--generate', + '--theme', + 'light', + '--version', + '0.24.0', + '--dir', + 'notes', + '--out', + 'cards', + ]), + { + mode: 'generate', + theme: 'light', + version: '0.24.0', + dir: 'notes', + out: 'cards', + } + ); + }); + + it('rejects bad usage', () => { + for (const args of [ + ['--dry-run', '--generate'], + ['--generate', '--theme', 'sepia'], + ['--generate', '--version', '0.24'], + ['--generate', '--nope'], + ['--generate', '--out'], + ]) { + assert.equal(parseCardArguments(args), null, args.join(' ')); + } + }); +}); + +describe('renderCards', () => { + it('renders 1200×630 PNGs plus the hero pair from a real screenshot', async () => { + const shotsDir = makeTempDir(); + const outputDir = path.join(makeTempDir(), 'cards'); + const screenshotPath = path.join(shotsDir, 'up-next-rail-dark.png'); + + await sharp({ + create: { + width: 1280, + height: 720, + channels: 3, + background: { r: 20, g: 20, b: 24 }, + }, + }) + .png() + .toFile(screenshotPath); + + const plan = planHighlightCards( + [ + note({ highlight: 'Up Next rail', screenshot: 'up-next-rail' }), + note({ + type: 'perf', + highlight: 'Faster imports', + body: 'Playlists import faster.', + sourcePath: '.changes/m3u-faster-imports.md', + }), + ], + { ...planOptions, screenshotsDir: shotsDir } + ); + const written = await renderCards(plan, '0.24.0', outputDir); + + assert.deepEqual( + written.map((file) => path.basename(file)), + [ + 'card-playback-up-next.png', + 'card-m3u-faster-imports.png', + 'hero.png', + 'hero.jpg', + ] + ); + + for (const file of written) { + assert.ok(existsSync(file), file); + const metadata = await sharp(file).metadata(); + + assert.equal(metadata.width, CARD_WIDTH, file); + assert.equal(metadata.height, CARD_HEIGHT, file); + } + }); + + it('removeStaleCards tolerates a missing directory and reports what it took', () => { + const outputDir = path.join(makeTempDir(), 'never-created'); + + assert.deepEqual(removeStaleCards(outputDir), []); + + mkdirSync(outputDir, { recursive: true }); + writeFileSync(path.join(outputDir, 'card-gone.png'), 'x'); + writeFileSync(path.join(outputDir, 'hero.jpg'), 'x'); + writeFileSync(path.join(outputDir, 'keep.txt'), 'x'); + + assert.deepEqual(removeStaleCards(outputDir).sort(), [ + 'card-gone.png', + 'hero.jpg', + ]); + assert.deepEqual(readdirSync(outputDir), ['keep.txt']); + }); + + it('removes its own stale cards but leaves other files alone', async () => { + const outputDir = path.join(makeTempDir(), 'cards'); + + mkdirSync(outputDir, { recursive: true }); + writeFileSync(path.join(outputDir, 'card-removed-feature.png'), 'old'); + writeFileSync(path.join(outputDir, 'notes.txt'), 'keep me'); + + const plan = planHighlightCards( + [note({ highlight: 'Faster imports' })], + planOptions + ); + + await renderCards(plan, '0.24.0', outputDir); + + const remaining = readdirSync(outputDir).sort(); + + assert.deepEqual(remaining, [ + 'card-playback-up-next.png', + 'hero.jpg', + 'hero.png', + 'notes.txt', + ]); + }); +}); + +describe('generate-highlight-cards CLI', () => { + const CLI = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + 'generate-highlight-cards.mjs' + ); + + function runCli(args) { + const result = spawnSync(process.execPath, [CLI, ...args], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + + return { + status: result.status ?? 1, + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + }; + } + + it('fails on an empty notes directory instead of calling it internal-only', () => { + // The realistic cause is running this step after `--consume`, which + // deleted the notes; silently reporting "internal-only" would hide it. + const emptyDir = makeTempDir(); + const result = runCli([ + '--generate', + '--version', + '0.24.0', + '--dir', + emptyDir, + '--out', + path.join(makeTempDir(), 'cards'), + ]); + + assert.equal(result.status, 1); + assert.match(result.stderr, /No notes found/); + assert.match(result.stderr, /Render cards before consuming/); + assert.doesNotMatch(result.stderr, /Internal-only/); + }); + + it('clears stale cards when a release becomes internal-only', () => { + const notesDir = makeTempDir(); + const outputDir = path.join(makeTempDir(), 'cards'); + + writeFileSync( + path.join(notesDir, 'deps-bump.md'), + '---\ntype: internal\narea: deps\n---\n\nBumped a parser.\n', + 'utf8' + ); + mkdirSync(outputDir, { recursive: true }); + writeFileSync(path.join(outputDir, 'card-old-feature.png'), 'stale'); + writeFileSync(path.join(outputDir, 'keep.txt'), 'mine'); + + const result = runCli([ + '--generate', + '--version', + '0.24.0', + '--dir', + notesDir, + '--out', + outputDir, + ]); + + assert.equal(result.status, 0); + assert.match(result.stderr, /Internal-only release/); + assert.deepEqual(readdirSync(outputDir), ['keep.txt']); + }); + + it('leaves stale cards untouched in dry-run mode', () => { + const notesDir = makeTempDir(); + const outputDir = path.join(makeTempDir(), 'cards'); + + writeFileSync( + path.join(notesDir, 'deps-bump.md'), + '---\ntype: internal\narea: deps\n---\n\nBumped a parser.\n', + 'utf8' + ); + mkdirSync(outputDir, { recursive: true }); + writeFileSync(path.join(outputDir, 'card-old-feature.png'), 'stale'); + + const result = runCli([ + '--dry-run', + '--version', + '0.24.0', + '--dir', + notesDir, + '--out', + outputDir, + ]); + + assert.equal(result.status, 0); + assert.deepEqual(readdirSync(outputDir), ['card-old-feature.png']); + }); +}); + +describe('estimateTextWidth against real rendering', () => { + /** Ink width of one rendered line, via sharp's trim. */ + async function measureRenderedWidth(text, fontSize) { + const svg = [ + '', + ``, + escapeXml(text), + '', + ].join(''); + const { info } = await sharp(Buffer.from(svg)) + .trim({ threshold: 1 }) + .toBuffer({ resolveWithObject: true }); + + return info.width; + } + + it('never under-estimates a rendered line', async () => { + // The guard against the whole class of bug that produced this model: + // an under-estimate skips both the wrap and the textLength clamp, and + // the card is cropped with every unit test still passing. + const samples = [ + ['Advanced subtitles with external files', 52], + ['Live TV recordings in the download manager', 52], + // A long run of a "narrow" glyph: the case that proved the + // narrow factors were themselves under-estimates. + ['r'.repeat(54), 52], + ['i'.repeat(54), 52], + ['....------', 52], + ['0123456789', 52], + ['W'.repeat(34), 52], + ['M'.repeat(34), 62], + ['æ'.repeat(34), 52], + ['œ'.repeat(30), 52], + ['界'.repeat(28), 52], + ['한'.repeat(28), 30], + ['Дашборд с рекомендациями TMDB', 52], + ['#@%&'.repeat(10), 52], + ['Ünïcödé áccênts thrøughöut', 52], + ]; + + for (const [text, fontSize] of samples) { + const measured = await measureRenderedWidth(text, fontSize); + const estimate = estimateTextWidth(text, fontSize); + + // Fonts resolve differently per host — the SVG names DM Sans and + // nothing guarantees it is installed — so this asserts the model + // stays conservative in whatever environment runs it. A failure + // here means the factors in `advanceFactor` are too low for this + // host's fallback and must be raised, not that the test is wrong. + assert.ok( + estimate >= measured, + `"${text.slice(0, 30)}" at ${fontSize}px: estimate ${Math.round(estimate)}px < measured ${measured}px — raise the advanceFactor values` + ); + } + }); +}); diff --git a/tools/release/project.json b/tools/release/project.json index e9003ea6a..8c3518d36 100644 --- a/tools/release/project.json +++ b/tools/release/project.json @@ -10,6 +10,10 @@ "inputs": [ "{workspaceRoot}/tools/release/release-notes.mjs", "{workspaceRoot}/tools/release/release-notes-render.mjs", + "{workspaceRoot}/tools/release/release-announcements.mjs", + "{workspaceRoot}/tools/release/verify-draft-release.mjs", + "{workspaceRoot}/tools/release/highlight-cards.mjs", + "{workspaceRoot}/tools/release/generate-highlight-cards.mjs", "{workspaceRoot}/tools/release/extract-changelog-section.mjs", "{workspaceRoot}/tools/release/check-release-note-gate.mjs", "{workspaceRoot}/tools/release/build-release-notes.mjs", @@ -19,10 +23,13 @@ "{workspaceRoot}/tools/release/release-notes.test.mjs", "{workspaceRoot}/tools/release/release-note-gate.test.mjs", "{workspaceRoot}/tools/release/build-release-notes.test.mjs", + "{workspaceRoot}/tools/release/release-announcements.test.mjs", + "{workspaceRoot}/tools/release/verify-draft-release.test.mjs", + "{workspaceRoot}/tools/release/highlight-cards.test.mjs", "{workspaceRoot}/tools/release/screenshot-guards.test.mjs" ], "options": { - "command": "node --test tools/release/release-notes.test.mjs tools/release/release-note-gate.test.mjs tools/release/build-release-notes.test.mjs tools/release/screenshot-guards.test.mjs", + "command": "node --test tools/release/release-notes.test.mjs tools/release/release-note-gate.test.mjs tools/release/build-release-notes.test.mjs tools/release/release-announcements.test.mjs tools/release/verify-draft-release.test.mjs tools/release/highlight-cards.test.mjs tools/release/screenshot-guards.test.mjs", "cwd": "{workspaceRoot}" } }, diff --git a/tools/release/release-announcements.mjs b/tools/release/release-announcements.mjs new file mode 100644 index 000000000..21a1cdd11 --- /dev/null +++ b/tools/release/release-announcements.mjs @@ -0,0 +1,290 @@ +/** + * Announcement renderers: compact release posts for Telegram and Reddit. + * + * Both formats are built around `highlight:` notes — the two or three changes + * worth leading with — and compress everything else into a counter (Telegram) + * or a collapsed list (Reddit). `internal` notes never appear. + * + * Output goes to stdout so it can be piped or pasted; publishing is a human + * act, these scripts never talk to any social platform. + */ + +import { groupNotes, REPO_URL } from './release-notes.mjs'; +import { releaseSlug } from './release-notes-render.mjs'; + +export const WEBSITE_URL = 'https://4gray.github.io/iptvnator'; + +/** + * Telegram truncates nothing — it rejects messages over 4096 characters, so + * the renderer must guarantee the limit instead of hoping. + */ +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; + +/** + * Reddit caps a post title separately from its body, and far tighter: five + * highlights at the validated 60-character maximum already overshoot it while + * the body stays nowhere near its own limit. + */ +export const REDDIT_TITLE_LIMIT = 300; + +/** + * Names as many highlights as the title limit allows, counting the rest. + * + * @param {string} version + * @param {object[]} highlights + * @returns {string} + */ +export function buildRedditTitle(version, highlights) { + const prefix = `IPTVnator v${version}`; + + if (highlights.length === 0) { + return `${prefix} released`; + } + + const names = highlights.map((note) => note.highlight); + + for (let visible = names.length; visible > 0; visible -= 1) { + const hidden = names.length - visible; + const title = `${prefix} — ${names.slice(0, visible).join(', ')}${ + hidden > 0 ? `, and ${hidden} more` : '' + }`; + + if (title.length <= REDDIT_TITLE_LIMIT) { + return title; + } + } + + // Unreachable while the highlight cap stays well under the title limit, + // but a title is never worth failing a release over. + return `${prefix} released`; +} + +const TYPE_EMOJI = { + breaking: '⚠️', + feature: '✨', + fix: '🔧', + perf: '⚡', +}; + +/** Collapses a note body to a single line. */ +function oneLine(body) { + return body.replace(/\s+/g, ' ').trim(); +} + +function releaseUrl(version) { + return `${REPO_URL}/releases/tag/v${version}`; +} + +/** One blog post per minor version, same rule as the blog scaffold. */ +function blogUrl(version) { + return `${WEBSITE_URL}/blog/${releaseSlug(version)}-release-notes/`; +} + +/** + * Splits notes into the announcement-worthy highlights and the remaining + * user-visible changes. Group order (breaking → feature → fix → perf) is + * preserved inside both halves; `internal` is dropped entirely. + * + * @param {object[]} notes + * @returns {{ ordered: object[], highlights: object[], rest: object[] }} + */ +export function splitHighlights(notes) { + const ordered = groupNotes(notes) + .filter((group) => group.type !== 'internal') + .flatMap((group) => group.notes); + + return { + ordered, + highlights: ordered.filter((note) => note.highlight), + rest: ordered.filter((note) => !note.highlight), + }; +} + +function moreLine(count) { + return count === 1 + ? '…plus 1 more fix or improvement.' + : `…plus ${count} more fixes and improvements.`; +} + +/** + * Plain text on purpose: Telegram markdown is a bot-API entity format, and a + * hand-pasted post renders literal `*`/`[` characters. Bare URLs unfurl fine. + * + * @param {object[]} notes + * @param {{ version: string }} options + * @returns {string | null} a post guaranteed to fit TELEGRAM_MESSAGE_LIMIT, + * or null for an internal-only release with nothing public to announce + */ +export function renderTelegramPost(notes, { version }) { + const { ordered, highlights, rest } = splitHighlights(notes); + const leadIsHighlights = highlights.length > 0; + // A breaking change is never folded into the counter, highlighted or not: + // announcing one as "fixes and improvements" is worse than a longer post. + const lead = leadIsHighlights + ? ordered.filter((note) => note.highlight || note.type === 'breaking') + : rest; + + // An internal-only release is a legal shape — its authored GitHub body is + // empty too — and there is simply nothing to announce publicly. + if (lead.length === 0) { + return null; + } + + const footer = [ + `⬇️ Download: ${releaseUrl(version)}`, + `📝 Full notes: ${blogUrl(version)}`, + ].join('\n'); + + const buildPost = (visibleLead) => { + const lines = visibleLead.map((note) => { + const emoji = TYPE_EMOJI[note.type] ?? '•'; + const body = oneLine(note.body); + + return note.highlight + ? `${emoji} ${note.highlight} — ${body}` + : `${emoji} ${body}`; + }); + + // Everything public that this post does not spell out. + const hiddenCount = ordered.length - visibleLead.length; + const more = hiddenCount > 0 ? moreLine(hiddenCount) : null; + + return [ + `🎉 IPTVnator v${version} is out!`, + lines.join('\n'), + more, + footer, + ] + .filter(Boolean) + .join('\n\n'); + }; + + // Drop trailing entries into the counter until the post fits. Two classes + // of entry are never allowed to fall in there: hand-picked highlights + // (the release manager chose too many) and breaking changes (a warning + // silently reported as "fixes and improvements" is worse than no post). + for (let visible = lead.length; visible > 0; visible -= 1) { + const post = buildPost(lead.slice(0, visible)); + + if (post.length > TELEGRAM_MESSAGE_LIMIT) { + continue; + } + + const dropped = lead.slice(visible); + const droppedBreaking = dropped.filter( + (note) => note.type === 'breaking' + ).length; + + if (droppedBreaking > 0) { + throw new Error( + `${droppedBreaking} breaking change(s) do not fit Telegram's ${TELEGRAM_MESSAGE_LIMIT}-character limit — shorten those notes, or announce this release in several posts` + ); + } + + if (leadIsHighlights && dropped.length > 0) { + throw new Error( + `the ${lead.length} highlights do not fit Telegram's ${TELEGRAM_MESSAGE_LIMIT}-character limit — pick fewer or shorten their notes` + ); + } + + return post; + } + + throw new Error( + `even a single entry exceeds Telegram's ${TELEGRAM_MESSAGE_LIMIT}-character limit` + ); +} + +/** + * Reddit post: markdown body plus a suggested title on the first line, + * because Reddit takes the title separately from the body. + * + * @param {object[]} notes + * @param {{ version: string }} options + * @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); + + if (highlights.length === 0 && rest.length === 0) { + return null; + } + + const title = buildRedditTitle(version, highlights); + + const buildPost = (visibleRest, droppedCount) => { + const blocks = [`Suggested title: ${title}`, '---']; + + if (highlights.length > 0) { + blocks.push('## Highlights'); + + 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.` + ); + } + + blocks.push( + `[Download](${releaseUrl(version)}) · [Full release notes](${blogUrl(version)}) · [Changelog](${REPO_URL}/blob/master/CHANGELOG.md)` + ); + + return `${blocks.filter(Boolean).join('\n\n')}\n`; + }; + + // 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; + } + + throw new Error( + `the highlights alone exceed Reddit's ${REDDIT_POST_LIMIT}-character limit — pick fewer or shorten their notes` + ); +} diff --git a/tools/release/release-announcements.test.mjs b/tools/release/release-announcements.test.mjs new file mode 100644 index 000000000..66f5a4b9f --- /dev/null +++ b/tools/release/release-announcements.test.mjs @@ -0,0 +1,329 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + buildRedditTitle, + REDDIT_POST_LIMIT, + REDDIT_TITLE_LIMIT, + renderRedditPost, + renderTelegramPost, + splitHighlights, + TELEGRAM_MESSAGE_LIMIT, +} from './release-announcements.mjs'; + +function note(overrides = {}) { + return { + type: 'feature', + area: 'playback', + issues: [], + screenshot: null, + highlight: null, + unknownKeys: [], + body: 'Series now show an Up Next rail beside the player.', + sourcePath: '.changes/playback-up-next.md', + ...overrides, + }; +} + +describe('splitHighlights', () => { + it('separates highlight notes from the rest in group order', () => { + const { highlights, rest } = splitHighlights([ + note({ type: 'fix', body: 'Fixed resume.' }), + note({ highlight: 'Up Next rail' }), + note({ + type: 'breaking', + highlight: 'New settings layout', + body: 'Settings moved.', + }), + ]); + + // breaking sorts before feature regardless of input order + assert.deepEqual( + highlights.map((entry) => entry.highlight), + ['New settings layout', 'Up Next rail'] + ); + assert.deepEqual( + rest.map((entry) => entry.body), + ['Fixed resume.'] + ); + }); + + it('drops internal notes entirely', () => { + const { highlights, rest } = splitHighlights([ + note({ type: 'internal', body: 'Split the store.' }), + ]); + + assert.deepEqual(highlights, []); + assert.deepEqual(rest, []); + }); +}); + +describe('renderTelegramPost', () => { + it('leads with highlights and compresses the rest into a counter', () => { + const post = renderTelegramPost( + [ + note({ highlight: 'Up Next rail' }), + note({ type: 'fix', body: 'Stalker resume works again.' }), + note({ type: 'fix', body: 'EPG no longer flickers.' }), + ], + { version: '0.24.0' } + ); + + assert.match(post, /^🎉 IPTVnator v0\.24\.0 is out!/); + assert.match( + post, + /✨ Up Next rail — Series now show an Up Next rail beside the player\./ + ); + assert.match(post, /…plus 2 more fixes and improvements\./); + assert.doesNotMatch(post, /Stalker resume|EPG no longer/); + assert.match( + post, + /⬇️ Download: https:\/\/github\.com\/4gray\/iptvnator\/releases\/tag\/v0\.24\.0/ + ); + assert.match( + post, + /📝 Full notes: https:\/\/4gray\.github\.io\/iptvnator\/blog\/v0-24-release-notes\// + ); + }); + + it('uses the singular counter line for one hidden change', () => { + const post = renderTelegramPost( + [ + note({ highlight: 'Up Next rail' }), + note({ type: 'fix', body: 'Stalker resume works again.' }), + ], + { version: '0.24.0' } + ); + + assert.match(post, /…plus 1 more fix or improvement\./); + }); + + it('lists all changes when no note is highlighted', () => { + const post = renderTelegramPost( + [ + note({ type: 'fix', body: 'Stalker resume works again.' }), + note({ type: 'perf', body: 'Playlists import faster.' }), + ], + { version: '0.24.1' } + ); + + assert.match(post, /🔧 Stalker resume works again\./); + assert.match(post, /⚡ Playlists import faster\./); + assert.doesNotMatch(post, /…plus/); + }); + + it('omits internal notes and stays inside the Telegram limit', () => { + const notes = Array.from({ length: 120 }, (_, index) => + note({ + type: 'fix', + body: `Fix number ${index}: ${'detail '.repeat(10)}.`, + sourcePath: `.changes/fix-${index}.md`, + }) + ); + notes.push(note({ type: 'internal', body: 'Internal churn.' })); + + const post = renderTelegramPost(notes, { version: '0.24.0' }); + + assert.ok(post.length <= TELEGRAM_MESSAGE_LIMIT); + assert.doesNotMatch(post, /Internal churn/); + assert.match(post, /…plus \d+ more fixes and improvements\./); + }); + + it('never folds a breaking change into the counter', () => { + const post = renderTelegramPost( + [ + note({ highlight: 'Up Next rail' }), + note({ + type: 'breaking', + body: 'Legacy playlist storage is removed.', + }), + note({ type: 'fix', body: 'Stalker resume works again.' }), + ], + { version: '0.24.0' } + ); + + assert.match(post, /⚠️ Legacy playlist storage is removed\./); + assert.match(post, /…plus 1 more fix or improvement\./); + assert.doesNotMatch(post, /Stalker resume/); + }); + + it('refuses to fold a breaking change into the counter to make room', () => { + // No highlights, so the fitting loop is what truncates. A breaking + // change reported as "fixes and improvements" is not an option. + const notes = Array.from({ length: 14 }, (_, index) => + note({ + type: 'breaking', + body: `Breaking change ${index}: ${'detail '.repeat(50)}.`, + sourcePath: `.changes/breaking-${index}.md`, + }) + ); + + assert.throws( + () => renderTelegramPost(notes, { version: '0.24.0' }), + /breaking change\(s\) do not fit Telegram's 4096-character limit/ + ); + }); + + it('returns null for an internal-only release instead of throwing', () => { + assert.equal( + renderTelegramPost([note({ type: 'internal' })], { + version: '0.24.0', + }), + null + ); + }); + + it('refuses to silently drop a hand-picked highlight', () => { + const notes = Array.from({ length: 30 }, (_, index) => + note({ + highlight: `Feature ${index} with quite a long headline text`, + body: `${'Very long body copy. '.repeat(15)}`, + sourcePath: `.changes/feature-${index}.md`, + }) + ); + + assert.throws( + () => renderTelegramPost(notes, { version: '0.24.0' }), + /do not fit Telegram's 4096-character limit/ + ); + }); +}); + +describe('renderRedditPost', () => { + it('suggests a title, sections the highlights and collapses the rest', () => { + const post = renderRedditPost( + [ + note({ highlight: 'Up Next rail' }), + note({ type: 'fix', area: 'stalker', body: 'Resume works.' }), + ], + { version: '0.24.0' } + ); + + assert.match(post, /^Suggested title: IPTVnator v0\.24\.0 — Up Next rail\n/); + assert.match(post, /## Highlights/); + assert.match( + post, + /### Up Next rail\n\nSeries now show an Up Next rail beside the player\./ + ); + assert.match(post, /## Also in this release/); + assert.match(post, /\*\*Fixes\*\*\n\n- \*\*stalker\*\* — Resume works\./); + assert.match( + post, + /\[Download\]\(https:\/\/github\.com\/4gray\/iptvnator\/releases\/tag\/v0\.24\.0\)/ + ); + assert.match( + post, + /\[Full release notes\]\(https:\/\/4gray\.github\.io\/iptvnator\/blog\/v0-24-release-notes\/\)/ + ); + }); + + it('falls back to a plain title and full list without highlights', () => { + const post = renderRedditPost( + [note({ type: 'fix', body: 'Resume works.' })], + { version: '0.24.1' } + ); + + assert.match(post, /^Suggested title: IPTVnator v0\.24\.1 released\n/); + assert.match(post, /## What's changed/); + assert.doesNotMatch(post, /## Highlights/); + }); + + it('omits internal notes', () => { + const post = renderRedditPost( + [note(), note({ type: 'internal', body: 'Internal churn.' })], + { version: '0.24.0' } + ); + + assert.doesNotMatch(post, /Internal churn/); + }); + + it('keeps the suggested title inside the Reddit title limit', () => { + // Five highlights at the validated 60-character maximum overshoot the + // 300-character title cap while the body stays far below its own. + const notes = Array.from({ length: 5 }, (_, index) => + note({ + highlight: `H${index}`.padEnd(60, 'x'), + sourcePath: `.changes/feature-${index}.md`, + }) + ); + const post = renderRedditPost(notes, { version: '0.24.0' }); + const title = post.split('\n')[0].replace('Suggested title: ', ''); + + assert.ok( + title.length <= REDDIT_TITLE_LIMIT, + `title is ${title.length} characters` + ); + assert.match(title, /, and \d+ more$/); + }); + + it('names every highlight when they fit', () => { + assert.equal( + buildRedditTitle('0.24.0', [ + { highlight: 'Up Next rail' }, + { highlight: 'Faster imports' }, + ]), + 'IPTVnator v0.24.0 — Up Next rail, Faster imports' + ); + assert.equal(buildRedditTitle('0.24.0', []), 'IPTVnator v0.24.0 released'); + }); + + 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' })], { + version: '0.24.0', + }), + null + ); + }); + + it('links the patch blog post to its minor release page', () => { + const post = renderRedditPost([note()], { version: '0.24.2' }); + + assert.match(post, /blog\/v0-24-release-notes\//); + }); +}); diff --git a/tools/release/release-notes-render.mjs b/tools/release/release-notes-render.mjs index a8e4006b4..f18fbc0e8 100644 --- a/tools/release/release-notes-render.mjs +++ b/tools/release/release-notes-render.mjs @@ -222,12 +222,17 @@ export function upsertChangelogSection(changelog, section, version, marker) { } /** - * Blog entries carrying a `screenshot:` slug become their own subsection with - * an image slider; the rest stay bullets. + * Blog entries carrying a `screenshot:` slug or a `highlight:` headline become + * their own subsection (with an image slider when a screenshot exists); the + * rest stay bullets. */ function renderBlogGroup(group, { slug, links }) { - const bullets = group.notes.filter((note) => !note.screenshot); - const featured = group.notes.filter((note) => note.screenshot); + const bullets = group.notes.filter( + (note) => !note.screenshot && !note.highlight + ); + const featured = group.notes.filter( + (note) => note.screenshot || note.highlight + ); const blocks = [`## ${group.heading}`]; if (bullets.length > 0) { @@ -242,9 +247,23 @@ function renderBlogGroup(group, { slug, links }) { } for (const note of featured) { - // The heading is editorial work — a note body makes a terrible one. - // Leave a visible TODO instead of pretending otherwise; the whole - // scaffold ships as `draft: true` anyway. + // A `highlight:` headline is the editorial headline; without one the + // heading is editorial work a note body cannot stand in for — leave a + // visible TODO instead of pretending otherwise; the whole scaffold + // ships as `draft: true` anyway. + blocks.push( + note.highlight + ? `### ${escapeMdx(note.highlight)}` + : `### TODO headline (${note.area})` + ); + blocks.push( + `${escapeMdx(oneLine(note.body))}${formatReferences(note, links)}` + ); + + if (!note.screenshot) { + continue; + } + // Embedded in a single-quoted JS string inside MDX. Backslashes must // be escaped before apostrophes, or a body ending in `\` produces an // unterminated string and breaks the website build. @@ -258,10 +277,6 @@ function renderBlogGroup(group, { slug, links }) { ) .join('\n'); - blocks.push(`### TODO headline (${note.area})`); - blocks.push( - `${escapeMdx(oneLine(note.body))}${formatReferences(note, links)}` - ); blocks.push(``); } diff --git a/tools/release/release-notes.mjs b/tools/release/release-notes.mjs index 8f50c4027..b2e4a3035 100644 --- a/tools/release/release-notes.mjs +++ b/tools/release/release-notes.mjs @@ -4,7 +4,7 @@ * One file per user-visible change, written by the PR author while the * context is still fresh. The generator (build-release-notes.mjs) turns the * accumulated files into the GitHub release body, the CHANGELOG.md section, - * and the website blog scaffold. + * the website blog scaffold, and the Telegram/Reddit announcement drafts. * * Deliberately dependency-free: a hand-rolled parser for this tiny, closed * schema is more predictable than a YAML engine, and it can reject unknown @@ -27,13 +27,26 @@ export const TYPE_HEADINGS = { internal: 'Internal', }; -const KNOWN_KEYS = new Set(['type', 'area', 'issues', 'screenshot']); +const KNOWN_KEYS = new Set([ + 'type', + 'area', + 'issues', + 'screenshot', + 'highlight', +]); const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*$/; const DELIMITER = '---'; /** Keeps entries to a sentence or three; essays belong in the blog post. */ const MAX_BODY_LENGTH = 400; +/** + * A highlight is a headline, not a paragraph — and the limit is the hero + * card's single-line budget (`wrapText(headline, 60, 1)`). Anything longer is + * silently ellipsized there, so it is rejected at the source instead. + */ +const MAX_HIGHLIGHT_LENGTH = 60; + /** * Splits a note into its frontmatter lines and body. * @@ -61,36 +74,63 @@ function splitFrontmatter(content) { }; } +/** + * Free-form fields, where `#` is ordinary punctuation rather than a comment + * marker. Everything else in this schema is a closed vocabulary — enum, slug, + * issue numbers — whose values can never contain one. + */ +const PROSE_KEYS = new Set(['highlight']); + +/** + * Strips a surrounding quote pair. Both ends must carry the SAME quote + * character: `"Up Next" rail` is prose containing quotes, and stripping its + * ends independently would silently eat the opening one. + * + * @param {string} value + * @returns {string} + */ +function unquote(value) { + const quote = value[0]; + const isQuoted = + value.length >= 2 && + (quote === "'" || quote === '"') && + value.endsWith(quote); + + return (isQuoted ? value.slice(1, -1) : value).trim(); +} + /** * Parses a single `key: value` frontmatter line. * - * Trailing `# comment` text is stripped: no value in this schema (enum slug, - * area slug, issue numbers) can legitimately contain `#`, and the documented - * examples carry explanatory comments. + * Trailing `# comment` text is stripped from closed-vocabulary fields, whose + * documented examples carry explanatory comments. Prose fields keep it — + * `highlight: Sources #N chip` is a headline, not a commented-out value, and + * stripping there truncated the headline on every announcement surface. * * @param {string} line - * @returns {[string, string] | null} key/value pair, or null for blank lines + * @returns {[string, string] | null} key/value pair, or null for blank and + * whole-line comment lines */ function parseFrontmatterLine(line) { - const withoutComment = line.replace(/\s+#.*$/, '').trim(); + const trimmed = line.trim(); - if (!withoutComment) { + if (!trimmed || trimmed.startsWith('#')) { return null; } - const separatorIndex = withoutComment.indexOf(':'); + const separatorIndex = trimmed.indexOf(':'); if (separatorIndex === -1) { - throw new Error(`frontmatter line is not \`key: value\`: "${line.trim()}"`); + throw new Error(`frontmatter line is not \`key: value\`: "${trimmed}"`); } - const key = withoutComment.slice(0, separatorIndex).trim(); - const value = withoutComment - .slice(separatorIndex + 1) - .trim() - .replace(/^['"]|['"]$/g, ''); + const key = trimmed.slice(0, separatorIndex).trim(); + const rawValue = trimmed.slice(separatorIndex + 1); + const value = PROSE_KEYS.has(key) + ? rawValue + : rawValue.replace(/\s+#.*$/, ''); - return [key, value]; + return [key, unquote(value.trim())]; } /** @@ -114,7 +154,7 @@ function parseIssueList(value) { * * @param {string} content * @param {string} sourcePath path used in error messages and PR resolution - * @returns {{ type: string, area: string, issues: number[], screenshot: string | null, body: string, sourcePath: string }} + * @returns {{ type: string, area: string, issues: number[], screenshot: string | null, highlight: string | null, body: string, sourcePath: string }} */ export function parseNote(content, sourcePath) { const { frontmatterLines, body } = splitFrontmatter(content); @@ -141,6 +181,7 @@ export function parseNote(content, sourcePath) { area: fields.get('area') ?? '', issues: fields.has('issues') ? parseIssueList(fields.get('issues')) : [], screenshot: fields.get('screenshot') ?? null, + highlight: fields.get('highlight') ?? null, unknownKeys: [...fields.keys()].filter((key) => !KNOWN_KEYS.has(key)), body, sourcePath, @@ -188,6 +229,24 @@ export function validateNote(note) { ); } + if (note.highlight !== null) { + if (!note.highlight) { + errors.push( + '`highlight` is present but empty — give the feature a short headline or drop the key' + ); + } else if (note.highlight.length > MAX_HIGHLIGHT_LENGTH) { + errors.push( + `\`highlight\` is ${note.highlight.length} characters, max ${MAX_HIGHLIGHT_LENGTH} — it is a headline, the body carries the detail` + ); + } + + if (note.type === 'internal') { + errors.push( + '`highlight` is not allowed on `type: internal` — internal notes never reach announcements' + ); + } + } + for (const key of note.unknownKeys) { errors.push( `unknown frontmatter key: \`${key}\` (expected ${[...KNOWN_KEYS].join(', ')})` diff --git a/tools/release/release-notes.test.mjs b/tools/release/release-notes.test.mjs index 86c0eb5ad..61d30915c 100644 --- a/tools/release/release-notes.test.mjs +++ b/tools/release/release-notes.test.mjs @@ -45,6 +45,7 @@ function note(overrides = {}) { area: 'playback', issues: [], screenshot: null, + highlight: null, unknownKeys: [], body: 'Series now show an Up Next rail beside the player.', sourcePath: '.changes/playback-up-next.md', @@ -101,6 +102,85 @@ describe('parseNote', () => { assert.deepEqual(parsed.issues, [1204]); }); + it('parses an optional highlight headline', () => { + const parsed = parseNote( + [ + '---', + 'type: feature', + 'area: playback', + 'highlight: Up Next rail', + '---', + 'Series now show an Up Next rail.', + ].join('\n'), + 'x.md' + ); + + assert.equal(parsed.highlight, 'Up Next rail'); + assert.equal(note().highlight, null); + }); + + it('keeps `#` inside a highlight but still strips comments elsewhere', () => { + const parsed = parseNote( + [ + '---', + 'type: feature # one of feature | fix | perf', + 'area: portal', + 'highlight: Sources #N chip for alternative copies', + '---', + 'Body.', + ].join('\n'), + 'x.md' + ); + + assert.equal(parsed.type, 'feature'); + assert.equal( + parsed.highlight, + 'Sources #N chip for alternative copies' + ); + }); + + it('skips whole-line comments', () => { + const parsed = parseNote( + ['---', '# a note to the author', 'type: fix', 'area: m3u', '---', 'B.'].join( + '\n' + ), + 'x.md' + ); + + assert.equal(parsed.type, 'fix'); + assert.deepEqual(parsed.unknownKeys, []); + }); + + it('only strips a balanced quote pair', () => { + const parse = (value) => + parseNote( + ['---', 'type: feature', 'area: p', `highlight: ${value}`, '---', 'B.'].join( + '\n' + ), + 'x.md' + ).highlight; + + assert.equal(parse('"Up Next" rail'), '"Up Next" rail'); + assert.equal(parse('"Up Next rail"'), 'Up Next rail'); + assert.equal(parse("'Up Next rail'"), 'Up Next rail'); + assert.equal(parse('"'), '"'); + }); + + it('reduces a whitespace-only quoted value to empty', () => { + const parsed = parseNote( + ['---', 'type: feature', 'area: p', 'highlight: " "', '---', 'B.'].join( + '\n' + ), + 'x.md' + ); + + assert.equal(parsed.highlight, ''); + assert.match( + validateNote(parsed)[0], + /`highlight` is present but empty/ + ); + }); + it('records unknown keys instead of dropping them silently', () => { const parsed = parseNote( ['---', 'type: fix', 'area: m3u', 'scope: m3u', '---', 'Body.'].join( @@ -174,6 +254,32 @@ describe('validateNote', () => { ); }); + it('accepts a short highlight and rejects empty or oversized ones', () => { + assert.deepEqual( + validateNote(note({ highlight: 'Up Next rail' })), + [] + ); + assert.match( + validateNote(note({ highlight: '' }))[0], + /`highlight` is present but empty/ + ); + // The cap is the hero card's single-line budget, so a valid highlight + // always renders in full on every surface. + assert.deepEqual(validateNote(note({ highlight: 'x'.repeat(60) })), []); + assert.match( + validateNote(note({ highlight: 'x'.repeat(61) }))[0], + /max 60/ + ); + }); + + it('rejects a highlight on an internal note', () => { + const errors = validateNote( + note({ type: 'internal', highlight: 'Invisible work' }) + ); + + assert.match(errors[0], /`highlight` is not allowed on `type: internal`/); + }); + it('rejects non-numeric issues', () => { assert.match( validateNote(note({ issues: [Number.NaN] }))[0], @@ -320,6 +426,27 @@ describe('renderBlogScaffold', () => { ); }); + it('uses the highlight as the section headline instead of a TODO', () => { + const content = renderBlogScaffold( + [note({ highlight: 'Up Next rail', screenshot: 'up-next-rail' })], + { version: '0.24.0', date: '2026-08-01' } + ); + + assert.match(content, /### Up Next rail/); + assert.doesNotMatch(content, /### TODO headline/); + }); + + it('gives a highlight note without a screenshot its own section, no slider', () => { + const content = renderBlogScaffold( + [note({ highlight: 'Up Next rail' })], + { version: '0.24.0', date: '2026-08-01' } + ); + + assert.match(content, /### Up Next rail/); + assert.doesNotMatch(content, /BlogImageSlider\n {4}images/); + assert.doesNotMatch(content, / { const body = `${'Series show the rest of the season beside the player '.repeat(4)}now.`; const content = renderBlogScaffold( diff --git a/tools/release/verify-draft-release.mjs b/tools/release/verify-draft-release.mjs new file mode 100644 index 000000000..2d5485d44 --- /dev/null +++ b/tools/release/verify-draft-release.mjs @@ -0,0 +1,489 @@ +#!/usr/bin/env node +/** + * Waits for the `v` tag build and verifies the draft GitHub release + * it creates: run conclusion, draft status, authored body, and the complete + * required asset set. + * + * node tools/release/verify-draft-release.mjs # package.json version + * node tools/release/verify-draft-release.mjs 0.24.0 + * node tools/release/verify-draft-release.mjs --no-wait 0.24.0 + * + * Read-only: it never publishes, edits, or deletes anything. Publishing the + * release stays a manual act after this check and the installer smoke tests. + * + * The required set mirrors what `.github/workflows/build-and-make.yaml` + * uploads for a complete matrix build (verified against a real full run). + * When the build matrix gains or loses a target, update REQUIRED_ASSET_RULES + * in the same PR. + */ + +import { execFileSync, spawnSync } from 'node:child_process'; +import { readFileSync, realpathSync } from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +import { extractPublicSection } from './extract-changelog-section.mjs'; +import { REPO_URL } from './release-notes.mjs'; + +const workspaceRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../..' +); + +export const REPO_SLUG = REPO_URL.replace('https://github.com/', ''); +const WORKFLOW = 'build-and-make.yaml'; +const CLI_USAGE = + 'Usage: verify-draft-release.mjs [--no-wait] [--repo owner/name] []'; + +/** + * @param {string} version bare semver + * @returns {{ label: string, matches: (name: string) => boolean }[]} + */ +export function requiredAssetRules(version) { + const exact = (label, name) => ({ + label: `${label} (${name})`, + matches: (candidate) => candidate === name, + }); + + const rules = []; + + for (const arch of ['x64', 'arm64']) { + for (const extension of ['dmg', 'zip']) { + const base = `iptvnator-${version}-mac-${arch}.${extension}`; + + rules.push(exact('macOS', base)); + rules.push(exact('macOS blockmap', `${base}.blockmap`)); + } + } + + const windowsSetup = `iptvnator-${version}-windows-x64-setup.exe`; + + rules.push(exact('Windows', windowsSetup)); + rules.push(exact('Windows blockmap', `${windowsSetup}.blockmap`)); + + for (const arch of ['amd64', 'arm64', 'armv7l']) { + rules.push(exact('DEB', `iptvnator-${version}-linux-${arch}.deb`)); + } + + for (const arch of ['x86_64', 'arm64', 'armv7l']) { + rules.push( + exact('AppImage', `iptvnator-${version}-linux-${arch}.AppImage`) + ); + } + + for (const arch of ['amd64', 'armhf']) { + rules.push(exact('Snap', `iptvnator-${version}-linux-${arch}.snap`)); + } + + rules.push(exact('RPM', `iptvnator-${version}-linux-x86_64.rpm`)); + rules.push(exact('Flatpak', `iptvnator-${version}-linux-x86_64.flatpak`)); + + // Electron Builder has shipped both pacman artifact shapes; accept either. + // Compared as plain strings rather than through a regex built from the + // version: this function is exported, so escaping the interpolated value + // correctly would be a standing trap. Only the compression suffix, a + // literal pattern, is matched by regex. + const pacmanExact = `iptvnator-${version}-linux-x64.pacman`; + const pacmanPrefix = `iptvnator-${version}-linux-x86_64.pkg.tar.`; + + rules.push({ + label: `Pacman (${pacmanExact} or …-linux-x86_64.pkg.tar.*)`, + matches: (candidate) => + candidate === pacmanExact || + (candidate.startsWith(pacmanPrefix) && + /^[a-z0-9]+$/.test(candidate.slice(pacmanPrefix.length))), + }); + + for (const name of [ + 'latest.yml', + 'latest-mac.yml', + 'latest-linux.yml', + 'latest-linux-arm.yml', + 'latest-linux-arm64.yml', + ]) { + rules.push(exact('Updater metadata', name)); + } + + rules.push( + exact('Source archive', 'linux-frame-copy-runtime-sources.tar.xz') + ); + + return rules; +} + +/** + * @param {string[]} assetNames names attached to the release + * @param {string} version bare semver + * @returns {{ missing: string[], extras: string[] }} `missing` lists unmet + * rule labels; `extras` lists assets no rule claims (informational only — + * a new build target shows up here before the rules learn about it) + */ +export function verifyReleaseAssets(assetNames, version) { + const rules = requiredAssetRules(version); + const missing = rules + .filter((rule) => !assetNames.some((name) => rule.matches(name))) + .map((rule) => rule.label); + const extras = assetNames.filter( + (name) => !rules.some((rule) => rule.matches(name)) + ); + + return { missing, extras }; +} + +/** + * @param {string[]} args + * @returns {{ version: string | null, wait: boolean, repo: string } | null} + * `version: null` means "use package.json"; null result means bad usage + */ +export function parseVerifyArguments(args) { + const options = { version: null, wait: true, repo: REPO_SLUG }; + const positional = []; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + + if (arg === '--no-wait') { + options.wait = false; + } else if (arg === '--repo') { + const value = args[index + 1]; + + // Shape-checked here so a typo fails with this script's usage + // line instead of an opaque gh error several calls later. + if (!value || !/^[\w.-]+\/[\w.-]+$/.test(value)) { + return null; + } + + options.repo = value; + index += 1; + } else if (arg === '--') { + // pnpm forwards the npm-style separator verbatim; ignore it. + } else if (arg.startsWith('--')) { + return null; + } else { + positional.push(arg); + } + } + + if (positional.length > 1) { + return null; + } + + if (positional.length === 1) { + const version = positional[0].replace(/^v/, ''); + + if (!/^\d+\.\d+\.\d+$/.test(version)) { + return null; + } + + options.version = version; + } + + return options; +} + +/** A just-pushed tag's run is not immediately visible to the API. */ +export const RUN_POLL_ATTEMPTS = 10; +export const RUN_POLL_INTERVAL_MS = 6000; + +/** + * `gh run list` reports what is indexed right now — its `--limit` caps how + * many runs come back, it does not wait for one to appear. Run straight after + * `git push v`, the tag build is routinely not indexed yet, + * so poll for a bounded window before concluding the tag was never pushed. + * + * @returns {Promise} the newest run, or null after the window + */ +async function findTagRun({ repo, branch }, io) { + for (let attempt = 1; attempt <= RUN_POLL_ATTEMPTS; attempt += 1) { + const runs = io.listRuns({ repo, workflow: WORKFLOW, branch }); + + if (runs.length > 0) { + return runs[0]; + } + + if (attempt < RUN_POLL_ATTEMPTS) { + io.progress( + `No ${WORKFLOW} run for ${branch} yet (attempt ${attempt}/${RUN_POLL_ATTEMPTS}) — waiting…` + ); + await io.sleep(RUN_POLL_INTERVAL_MS); + } + } + + return null; +} + +/** + * The tag workflow appends GitHub's generated notes to the authored text + * (`FULL_BODY` in build-and-make.yaml), so a non-empty `body` proves nothing + * about the authored half — testing it for emptiness could never fail. The + * authored text is the CHANGELOG section this repo committed before tagging, + * so compare against that instead. + * + * @param {string} body the release body as published + * @param {string} version + * @param {{ readChangelog: Function }} io + * @returns {string[]} report lines + */ +function verifyAuthoredBody(body, version, io) { + const changelog = io.readChangelog(); + + if (changelog === null) { + return [ + 'NOTE: CHANGELOG.md is unreadable here, so the authored body was not verified.', + ]; + } + + const authored = extractPublicSection(changelog, version); + + if (authored === null) { + return [ + `WARNING: CHANGELOG.md has no section for ${version} — the tag build authors the body from it.`, + ]; + } + + if (authored === '') { + return [ + 'NOTE: internal-only release — no authored body is expected, only generated notes.', + ]; + } + + const normalize = (text) => text.replace(/\r\n/g, '\n').trim(); + + return normalize(body).includes(normalize(authored)) + ? ['Authored changelog section present in the release body.'] + : [ + 'WARNING: the release body does not contain the authored CHANGELOG section — it may carry only GitHub-generated notes.', + ]; +} + +/** + * Verification pipeline over an injectable gh boundary, so tests never touch + * the network. `io.watchRun` streams `gh run watch` to the terminal and throws + * on a failed run, `io.listRuns`/`io.viewRelease` return parsed `--json` + * payloads, `io.progress` reports transient status while waiting, and + * `io.sleep` paces the run poll. + * + * @param {{ version: string, wait: boolean, repo: string }} options + * @param {{ listRuns: Function, watchRun: Function, viewRelease: Function, readChangelog: Function, progress: Function, sleep: Function }} io + * @returns {Promise<{ exitCode: number, lines: string[] }>} + */ +export async function runVerification(options, io) { + const { version, wait, repo } = options; + const tag = `v${version}`; + const lines = []; + + if (wait) { + const run = await findTagRun({ repo, branch: tag }, io); + + if (run === null) { + return { + exitCode: 1, + lines: [ + `No ${WORKFLOW} run found for ${tag} in ${repo} after ${RUN_POLL_ATTEMPTS} attempts — was the tag pushed?`, + ], + }; + } + + if (run.status !== 'completed') { + io.progress(`Waiting for ${WORKFLOW} run ${run.databaseId} (${tag})…`); + io.watchRun({ repo, runId: run.databaseId }); + } else if (run.conclusion !== 'success') { + return { + exitCode: 1, + lines: [ + `${WORKFLOW} run for ${tag} completed with conclusion "${run.conclusion}" — fix the build before verifying assets.`, + ], + }; + } + } + + const release = io.viewRelease({ repo, tag }); + + if (release === null) { + return { + exitCode: 1, + lines: [`No release found for ${tag} in ${repo}.`], + }; + } + + const publishedAlready = !release.isDraft; + + lines.push( + publishedAlready + ? `Release ${tag} is already published — this gate runs before publication.` + : `Draft release ${tag} found.` + ); + + lines.push(...verifyAuthoredBody(release.body ?? '', version, io)); + + const assetNames = release.assets.map((asset) => asset.name); + const { missing, extras } = verifyReleaseAssets(assetNames, version); + + for (const extra of extras) { + lines.push(`NOTE: unrecognized asset ${extra} (not required by the rules).`); + } + + if (missing.length > 0) { + lines.push(`Missing ${missing.length} required asset(s):`); + lines.push(...missing.map((label) => ` - ${label}`)); + + return { exitCode: 1, lines }; + } + + lines.push( + `All ${requiredAssetRules(version).length} required assets present (${assetNames.length} attached).` + ); + + // A published release still gets its asset report — auditing one after the + // fact is useful — but never a success exit. Succeeding here would claim a + // pre-publication gate passed for a boundary already crossed. + if (publishedAlready) { + return { exitCode: 1, lines }; + } + + lines.push( + 'Next: verify the authored body text, smoke-test installers, then publish the release manually.' + ); + + return { exitCode: 0, lines }; +} + +function gh(args) { + return execFileSync('gh', args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); +} + +const liveIo = { + listRuns: ({ repo, workflow, branch }) => + JSON.parse( + gh([ + 'run', + 'list', + '--repo', + repo, + '--workflow', + workflow, + '--branch', + branch, + '--limit', + '1', + '--json', + 'databaseId,status,conclusion', + ]) + ), + watchRun: ({ repo, runId }) => { + const result = spawnSync( + 'gh', + ['run', 'watch', String(runId), '--repo', repo, '--exit-status'], + { stdio: 'inherit' } + ); + + // spawnSync reports a missing binary and a signalled child as + // `status: null` rather than throwing. Blaming the build for either + // would send the release manager after a build that is fine. + if (result.error) { + throw new Error(`could not run gh: ${result.error.message}`); + } + + if (result.signal) { + throw new Error( + `gh run watch was interrupted (${result.signal}) — run ${runId} was not judged` + ); + } + + if (result.status !== 0) { + throw new Error( + `tag build run ${runId} failed — fix the build before verifying assets` + ); + } + }, + viewRelease: ({ repo, tag }) => { + try { + return JSON.parse( + gh([ + 'release', + 'view', + tag, + '--repo', + repo, + '--json', + 'name,isDraft,body,assets', + ]) + ); + } catch (error) { + if (/release not found/i.test(`${error.stderr ?? ''}`)) { + return null; + } + + throw error; + } + }, + readChangelog: () => { + try { + return readFileSync( + path.join(workspaceRoot, 'CHANGELOG.md'), + 'utf8' + ); + } catch { + return null; + } + }, + progress: (message) => console.error(message), + // Deliberately not unref'd: a pending promise does not hold the event + // loop open, so an unref'd timer would let Node exit mid-poll — and an + // empty event loop exits 0, turning a wait into a silent false success. + sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), +}; + +async function main() { + const options = parseVerifyArguments(process.argv.slice(2)); + + if (options === null) { + console.error(CLI_USAGE); + process.exit(2); + } + + if (options.version === null) { + options.version = JSON.parse( + readFileSync(path.join(workspaceRoot, 'package.json'), 'utf8') + ).version; + console.error(`Using version ${options.version} from package.json.`); + } + + const { exitCode, lines } = await runVerification(options, liveIo); + + for (const line of lines) { + console.log(line); + } + + process.exit(exitCode); +} + +// realpath on both sides: Node resolves symlinks for `import.meta.url` but +// not for argv[1], so a checkout reached through a symlinked path (macOS +// /tmp and /var are symlinks) made this publication gate a silent exit-0 +// no-op — which reads exactly like a pass. +const isDirectRun = (() => { + if (!process.argv[1]) { + return false; + } + + try { + return ( + realpathSync(process.argv[1]) === + realpathSync(fileURLToPath(import.meta.url)) + ); + } catch { + return false; + } +})(); + +if (isDirectRun) { + main().catch((error) => { + console.error(error.message); + process.exit(1); + }); +} diff --git a/tools/release/verify-draft-release.test.mjs b/tools/release/verify-draft-release.test.mjs new file mode 100644 index 000000000..7232ce158 --- /dev/null +++ b/tools/release/verify-draft-release.test.mjs @@ -0,0 +1,432 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + parseVerifyArguments, + requiredAssetRules, + RUN_POLL_ATTEMPTS, + RUN_POLL_INTERVAL_MS, + runVerification, + verifyReleaseAssets, +} from './verify-draft-release.mjs'; + +/** The asset list of a real, complete v0.23.0 matrix build. */ +function completeAssets(version) { + return [ + `iptvnator-${version}-linux-amd64.deb`, + `iptvnator-${version}-linux-amd64.snap`, + `iptvnator-${version}-linux-arm64.AppImage`, + `iptvnator-${version}-linux-arm64.deb`, + `iptvnator-${version}-linux-armhf.snap`, + `iptvnator-${version}-linux-armv7l.AppImage`, + `iptvnator-${version}-linux-armv7l.deb`, + `iptvnator-${version}-linux-x64.pacman`, + `iptvnator-${version}-linux-x86_64.AppImage`, + `iptvnator-${version}-linux-x86_64.flatpak`, + `iptvnator-${version}-linux-x86_64.rpm`, + `iptvnator-${version}-mac-arm64.dmg`, + `iptvnator-${version}-mac-arm64.dmg.blockmap`, + `iptvnator-${version}-mac-arm64.zip`, + `iptvnator-${version}-mac-arm64.zip.blockmap`, + `iptvnator-${version}-mac-x64.dmg`, + `iptvnator-${version}-mac-x64.dmg.blockmap`, + `iptvnator-${version}-mac-x64.zip`, + `iptvnator-${version}-mac-x64.zip.blockmap`, + `iptvnator-${version}-windows-x64-setup.exe`, + `iptvnator-${version}-windows-x64-setup.exe.blockmap`, + 'latest-linux-arm.yml', + 'latest-linux-arm64.yml', + 'latest-linux.yml', + 'latest-mac.yml', + 'latest.yml', + 'linux-frame-copy-runtime-sources.tar.xz', + ]; +} + +function release(overrides = {}) { + return { + name: 'v0.24.0', + isDraft: true, + // Shaped like the real thing: authored section, then GitHub's + // generated notes appended by the tag workflow. + body: '### Features\n\n- **playback** — Up Next rail.\n\n## What\'s Changed\n* chore by @bot in #1\n', + assets: completeAssets('0.24.0').map((name) => ({ name })), + ...overrides, + }; +} + +/** `progressLog` collects transient status for assertions; runVerification + * only reads the known io methods and ignores the extra property. */ +function io(overrides = {}) { + const progressLog = []; + + return { + listRuns: () => [ + { databaseId: 42, status: 'completed', conclusion: 'success' }, + ], + watchRun: () => { + throw new Error('watchRun must not be called'); + }, + viewRelease: () => release(), + readChangelog: () => + '# Changelog\n\n\n\n# 0.24.0 (2026-08-01)\n\n### Features\n\n- **playback** — Up Next rail.\n', + progress: (message) => progressLog.push(message), + progressLog, + sleep: () => Promise.resolve(), + ...overrides, + }; +} + +describe('verifyReleaseAssets', () => { + it('accepts the complete real-world asset set with no extras', () => { + const { missing, extras } = verifyReleaseAssets( + completeAssets('0.24.0'), + '0.24.0' + ); + + assert.deepEqual(missing, []); + assert.deepEqual(extras, []); + }); + + it('every rule is matched by exactly the assets it names', () => { + // One required asset per rule: the counts must line up, otherwise a + // rule silently matches two files and a missing one goes unnoticed. + assert.equal( + requiredAssetRules('0.24.0').length, + completeAssets('0.24.0').length + ); + }); + + it('reports missing assets by label', () => { + const withoutSnap = completeAssets('0.24.0').filter( + (name) => !name.endsWith('.snap') + ); + const { missing } = verifyReleaseAssets(withoutSnap, '0.24.0'); + + assert.deepEqual(missing, [ + 'Snap (iptvnator-0.24.0-linux-amd64.snap)', + 'Snap (iptvnator-0.24.0-linux-armhf.snap)', + ]); + }); + + it('rejects assets from a different version', () => { + const { missing } = verifyReleaseAssets( + completeAssets('0.23.0'), + '0.24.0' + ); + + assert.ok(missing.length > 0); + }); + + it('accepts the alternate pacman artifact shape', () => { + const assets = completeAssets('0.24.0').map((name) => + name.endsWith('.pacman') + ? 'iptvnator-0.24.0-linux-x86_64.pkg.tar.zst' + : name + ); + + assert.deepEqual(verifyReleaseAssets(assets, '0.24.0').missing, []); + }); + + it('does not let the pacman pattern match across version dots', () => { + const { extras } = verifyReleaseAssets( + ['iptvnator-0x24y0-linux-x64.pacman'], + '0.24.0' + ); + + assert.deepEqual(extras, ['iptvnator-0x24y0-linux-x64.pacman']); + }); + + it('surfaces unrecognized assets as extras, not errors', () => { + const { missing, extras } = verifyReleaseAssets( + [...completeAssets('0.24.0'), 'iptvnator-0.24.0-win-arm64.exe'], + '0.24.0' + ); + + assert.deepEqual(missing, []); + assert.deepEqual(extras, ['iptvnator-0.24.0-win-arm64.exe']); + }); +}); + +describe('parseVerifyArguments', () => { + it('defaults to waiting and the canonical repo', () => { + assert.deepEqual(parseVerifyArguments([]), { + version: null, + wait: true, + repo: '4gray/iptvnator', + }); + }); + + it('normalizes a v-prefixed version and honours flags', () => { + assert.deepEqual( + parseVerifyArguments([ + '--no-wait', + '--repo', + 'fork/iptvnator', + 'v0.24.0', + ]), + { version: '0.24.0', wait: false, repo: 'fork/iptvnator' } + ); + }); + + it('ignores a bare `--` separator', () => { + assert.equal(parseVerifyArguments(['--', '0.24.0']).version, '0.24.0'); + }); + + it('rejects bad usage', () => { + for (const args of [ + ['0.24'], + ['0.24.0', 'extra'], + ['--unknown'], + ['--repo'], + ['--repo', '--no-wait'], + ['--repo', 'no-slash'], + ['--repo', 'too/many/parts'], + ]) { + assert.equal(parseVerifyArguments(args), null, args.join(' ')); + } + }); +}); + +describe('runVerification', () => { + const options = { version: '0.24.0', wait: true, repo: '4gray/iptvnator' }; + + it('passes a complete draft and points at the manual next steps', async () => { + const result = await runVerification(options, io()); + + assert.equal(result.exitCode, 0); + assert.match(result.lines[0], /Draft release v0\.24\.0 found\./); + assert.match( + result.lines.at(-2), + /All 27 required assets present \(27 attached\)\./ + ); + assert.match(result.lines.at(-1), /publish the release manually/); + }); + + it('polls before concluding the tag run does not exist', async () => { + let calls = 0; + const slept = []; + const harness = io({ + listRuns: () => { + calls += 1; + + return calls < 3 + ? [] + : [ + { + databaseId: 42, + status: 'completed', + conclusion: 'success', + }, + ]; + }, + sleep: (ms) => { + slept.push(ms); + + return Promise.resolve(); + }, + }); + const result = await runVerification(options, harness); + + assert.equal(calls, 3); + assert.deepEqual(slept, [RUN_POLL_INTERVAL_MS, RUN_POLL_INTERVAL_MS]); + assert.match(harness.progressLog[0], /No build-and-make\.yaml run for v0\.24\.0 yet/); + assert.equal(result.exitCode, 0); + }); + + it('gives up after the bounded poll window', async () => { + let calls = 0; + const result = await runVerification( + options, + io({ + listRuns: () => { + calls += 1; + + return []; + }, + }) + ); + + assert.equal(calls, RUN_POLL_ATTEMPTS); + assert.equal(result.exitCode, 1); + assert.match(result.lines[0], /No build-and-make\.yaml run found/); + assert.match( + result.lines[0], + new RegExp(`after ${RUN_POLL_ATTEMPTS} attempts`) + ); + }); + + it('fails on a completed run with a non-success conclusion', async () => { + const result = await runVerification( + options, + io({ + listRuns: () => [ + { + databaseId: 42, + status: 'completed', + conclusion: 'failure', + }, + ], + }) + ); + + assert.equal(result.exitCode, 1); + assert.match(result.lines[0], /conclusion "failure"/); + }); + + it('watches an in-progress run before checking the release', async () => { + const watched = []; + const result = await runVerification( + options, + io({ + listRuns: () => [ + { databaseId: 7, status: 'in_progress', conclusion: null }, + ], + watchRun: (request) => watched.push(request), + }) + ); + + assert.deepEqual(watched, [{ repo: '4gray/iptvnator', runId: 7 }]); + assert.equal(result.exitCode, 0); + }); + + it('skips the run lookup entirely with --no-wait', async () => { + const result = await runVerification( + { ...options, wait: false }, + io({ + listRuns: () => { + throw new Error('listRuns must not be called'); + }, + }) + ); + + assert.equal(result.exitCode, 0); + }); + + it('fails with the missing-asset list', async () => { + const result = await runVerification( + options, + io({ + viewRelease: () => + release({ + assets: completeAssets('0.24.0') + .filter((name) => !name.endsWith('.flatpak')) + .map((name) => ({ name })), + }), + }) + ); + + assert.equal(result.exitCode, 1); + assert.match(result.lines.at(-2), /Missing 1 required asset/); + assert.match(result.lines.at(-1), /Flatpak/); + }); + + it('fails when the release does not exist', async () => { + const result = await runVerification( + options, + io({ viewRelease: () => null }) + ); + + assert.equal(result.exitCode, 1); + assert.match(result.lines[0], /No release found for v0\.24\.0/); + }); + + it('never reports success once the release is published', async () => { + const result = await runVerification( + options, + io({ viewRelease: () => release({ isDraft: false }) }) + ); + + // The asset report is still useful for an after-the-fact audit, but a + // pre-publication gate must not pass after publication. + assert.equal(result.exitCode, 1); + assert.match(result.lines[0], /already published/); + assert.match(result.lines.at(-1), /All 27 required assets present/); + assert.ok( + !result.lines.some((line) => /publish the release manually/.test(line)) + ); + }); + + it('confirms the authored changelog section inside the combined body', async () => { + const result = await runVerification(options, io()); + + assert.equal(result.exitCode, 0); + assert.match( + result.lines[1], + /Authored changelog section present in the release body\./ + ); + }); + + it('warns when the body carries only GitHub-generated notes', async () => { + // The tag workflow appends generated notes to the authored text, so a + // non-empty body proves nothing — this is the case an emptiness check + // could never catch. + const result = await runVerification( + options, + io({ + viewRelease: () => + release({ body: "## What's Changed\n* chore by @bot in #1" }), + }) + ); + + assert.equal(result.exitCode, 0); + assert.match( + result.lines[1], + /does not contain the authored CHANGELOG section/ + ); + }); + + it('expects no authored body for an internal-only release', async () => { + const result = await runVerification( + options, + io({ + readChangelog: () => + '# 0.24.0 (2026-08-01)\n\n
\nInternal changes\n\n- **deps** — bump.\n\n
\n', + viewRelease: () => + release({ body: "## What's Changed\n* chore by @bot in #1" }), + }) + ); + + assert.equal(result.exitCode, 0); + assert.match(result.lines[1], /internal-only release/); + }); + + it('warns when the changelog has no section for the version', async () => { + const result = await runVerification( + options, + io({ readChangelog: () => '# Changelog\n\n\n' }) + ); + + assert.match(result.lines[1], /CHANGELOG\.md has no section for 0\.24\.0/); + }); + + it('does not claim to have verified an unreadable changelog', async () => { + const result = await runVerification( + options, + io({ readChangelog: () => null }) + ); + + assert.match(result.lines[1], /NOTE: CHANGELOG\.md is unreadable/); + }); + + it('notes unrecognized assets without failing', async () => { + const result = await runVerification( + options, + io({ + viewRelease: () => + release({ + assets: [ + ...completeAssets('0.24.0'), + 'iptvnator-0.24.0-win-arm64.exe', + ].map((name) => ({ name })), + }), + }) + ); + + assert.equal(result.exitCode, 0); + assert.match( + result.lines.find((line) => line.startsWith('NOTE:')), + /unrecognized asset iptvnator-0\.24\.0-win-arm64\.exe/ + ); + }); +});