feat(release): announcement formats, highlight cards, and draft verification (#1480)

This commit is contained in:
4gray authored and GitHub committed 2026-08-29 10:16:07 +02:00
1 parent 7a3d5eae56
commit 29ca94aa43
22 files changed
+3850 -64

No files matched your search

+52 -7
View File
@@ -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<version>/`, 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
+17 -5
View File
@@ -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`.
+5
View File
@@ -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.
+17 -5
View File
@@ -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`.
+5
View File
@@ -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.
+4 -1
View File
@@ -66,11 +66,14 @@ This file provides guidance to coding agents working in this repository.
- Name it `<area>-<short-slug>.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: <short headline>` (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<version>/`. All three read `highlight:` metadata that exists only in the note files, so they must run before `build-release-notes.mjs --consume`; the cards additionally need `release:screenshots` to have run. Nothing is posted or copied into the website tree automatically.
- Pushes to `master` and `v*` can publish Docker images. A `v*` tag build creates a draft GitHub release.
- `pnpm run release:verify:draft` waits for that tag build (polling until the run is indexed, then `gh run watch`) and verifies the draft's status, authored body, and complete required asset set. It is read-only and deliberately fails on an already-published release, because it is the gate that runs before publication.
- Publishing the GitHub release verifies its Snap assets and automatically uploads them to `edge`; installed-Snap smoke and candidate/stable promotion remain manual.
- 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.
+4 -1
View File
@@ -32,11 +32,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- Name it `<area>-<short-slug>.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: <short headline>` (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<version>/`. All three read `highlight:` metadata that exists only in the note files, so they must run before `build-release-notes.mjs --consume`; the cards additionally need `release:screenshots` to have run. Nothing is posted or copied into the website tree automatically.
- Pushes to `master` and `v*` can publish Docker images. A `v*` tag build creates a draft GitHub release.
- `pnpm run release:verify:draft` waits for that tag build (polling until the run is indexed, then `gh run watch`) and verifies the draft's status, authored body, and complete required asset set. It is read-only and deliberately fails on an already-published release, because it is the gate that runs before publication.
- Publishing the GitHub release verifies its Snap assets and automatically uploads them to `edge`; installed-Snap smoke and candidate/stable promotion remain manual.
- 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.
+218
View File
@@ -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/<area>-<slug>.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/<vX-Y>-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<version>/` |
| Screenshots | `release:screenshots` | `apps/website/public/blog/<vX-Y>/screenshots/` |
Run the two stdout commands as `pnpm --silent run …` whenever the output is
redirected to a file or a clipboard. Without it pnpm prints its lifecycle
banner (`> iptvnator@0.23.0 release:notes:telegram …`) to the same stdout, and
the saved post starts with two lines of build noise.
### The ordering constraint that matters
`build-release-notes.mjs --consume` is the destructive boundary: it deletes the
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 (<area>)`.
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<version>/`, 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
```
+5
View File
@@ -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"
+39 -1
View File
@@ -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 = '<!-- next-release -->';
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,
+72 -15
View File
@@ -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']);
+383
View File
@@ -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 <semver>] [--dir <notes-dir>] [--out <dir>]';
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<string[]>} 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);
});
}
+562
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/**
* 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 [
'<defs>',
`<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">`,
`<stop offset="0" stop-color="${BRAND.backgroundTop}"/>`,
`<stop offset="1" stop-color="${BRAND.backgroundBottom}"/>`,
'</linearGradient>',
`<radialGradient id="glow" cx="0.85" cy="0.1" r="0.9">`,
`<stop offset="0" stop-color="${BRAND.accent}" stop-opacity="0.16"/>`,
`<stop offset="1" stop-color="${BRAND.accent}" stop-opacity="0"/>`,
'</radialGradient>',
'</defs>',
].join('');
}
function backgroundRects() {
return [
`<rect width="${CARD_WIDTH}" height="${CARD_HEIGHT}" fill="url(#bg)"/>`,
`<rect width="${CARD_WIDTH}" height="${CARD_HEIGHT}" fill="url(#glow)"/>`,
].join('');
}
function brandHeader(version) {
const chipX = 262;
return [
`<text x="64" y="84" font-family="${FONT_STACK}" font-size="34" font-weight="700" fill="${BRAND.text}">IPTVnator</text>`,
`<rect x="${chipX}" y="56" rx="16" ry="16" width="${34 + `v${version}`.length * 13}" height="36" fill="none" stroke="${BRAND.accent}" stroke-width="2"/>`,
`<text x="${chipX + 17}" y="81" font-family="${FONT_STACK}" font-size="22" font-weight="600" fill="${BRAND.accentBright}">v${escapeXml(version)}</text>`,
].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 `<text x="${x}" y="${y + index * lineHeight}" font-family="${FONT_STACK}" font-size="${size}" font-weight="${weight}" fill="${fill}"${clamp}>${escapeXml(line)}</text>`;
})
.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 = [
`<svg width="${CARD_WIDTH}" height="${CARD_HEIGHT}" viewBox="0 0 ${CARD_WIDTH} ${CARD_HEIGHT}" xmlns="http://www.w3.org/2000/svg">`,
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(
`<rect x="${SHOT_LEFT - 2}" y="${SHOT_TOP - 2}" width="${SHOT_WIDTH + 4}" height="${CARD_HEIGHT - SHOT_TOP + 4}" rx="${SHOT_RADIUS + 2}" fill="${BRAND.frame}"/>`
);
} 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(
`<rect x="${TEXT_LEFT}" y="${headlineY + headline.length * 74 - 44}" width="120" height="6" rx="3" fill="${BRAND.warm}"/>`
);
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('</svg>');
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 = [
`<svg width="${CARD_WIDTH}" height="${CARD_HEIGHT}" viewBox="0 0 ${CARD_WIDTH} ${CARD_HEIGHT}" xmlns="http://www.w3.org/2000/svg">`,
backgroundDefs(),
backgroundRects(),
`<text x="64" y="96" font-family="${FONT_STACK}" font-size="34" font-weight="700" fill="${BRAND.text}">IPTVnator</text>`,
`<text x="64" y="220" font-family="${FONT_STACK}" font-size="104" font-weight="800" fill="${BRAND.text}">v${escapeXml(hero.version)}</text>`,
`<rect x="64" y="252" width="160" height="6" rx="3" fill="${BRAND.warm}"/>`,
];
listed.forEach((headline, index) => {
const y = 330 + index * 56;
parts.push(
`<circle cx="74" cy="${y - 10}" r="6" fill="${BRAND.accentBright}"/>`
);
// 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(
`<text x="100" y="${330 + listed.length * 56}" font-family="${FONT_STACK}" font-size="26" fill="${BRAND.muted}">…and ${omitted} more</text>`
);
}
if (hero.counts) {
parts.push(
`<text x="64" y="${CARD_HEIGHT - 48}" font-family="${FONT_STACK}" font-size="24" fill="${BRAND.muted}">${escapeXml(hero.counts)}</text>`
);
}
parts.push('</svg>');
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 `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg"><rect width="${width}" height="${height}" rx="${SHOT_RADIUS}" fill="#fff"/></svg>`;
}
+690
View File
@@ -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(`<a> & "b" 'c'`),
'&lt;a&gt; &amp; &quot;b&quot; &apos;c&apos;'
);
});
});
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 <live> & "vod"',
body: "The app's import.",
screenshotPath: null,
},
'0.24.0'
);
assert.match(svg, /Support &lt;live&gt; &amp; &quot;vod&quot;/);
assert.match(svg, /v0\.24\.0/);
assert.doesNotMatch(svg, /<live>/);
});
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(/<text[^>]*\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, />Four</);
assert.doesNotMatch(svg, />Five</);
assert.match(svg, /…and 1 more/);
assert.match(svg, /5 features/);
});
});
describe('parseCardArguments', () => {
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 = [
'<svg xmlns="http://www.w3.org/2000/svg" width="6000" height="240">',
`<text x="0" y="150" font-family="DM Sans, Helvetica Neue, Helvetica, Arial, sans-serif" font-size="${fontSize}" font-weight="800" fill="#fff">`,
escapeXml(text),
'</text></svg>',
].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`
);
}
});
});
+8 -1
View File
@@ -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}"
}
},
+290
View File
@@ -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`
);
}
@@ -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\//);
});
});
+26 -11
View File
@@ -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(`<BlogImageSlider\n images={[\n${images}\n ]}\n/>`);
}
+76 -17
View File
@@ -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(', ')})`
+127
View File
@@ -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, /<BlogImageSlider/);
});
it('truncates a long body for image alt text without cutting mid-word', () => {
const body = `${'Series show the rest of the season beside the player '.repeat(4)}now.`;
const content = renderBlogScaffold(
+489
View File
@@ -0,0 +1,489 @@
#!/usr/bin/env node
/**
* Waits for the `v<version>` 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] [<version>]';
/**
* @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 <remote> v<version>`, the tag build is routinely not indexed yet,
* so poll for a bounded window before concluding the tag was never pushed.
*
* @returns {Promise<object | null>} 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);
});
}
+432
View File
@@ -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<!-- next-release -->\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<details>\n<summary>Internal changes</summary>\n\n- **deps** — bump.\n\n</details>\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<!-- next-release -->\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/
);
});
});