mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(release): announcement formats, highlight cards, and draft verification (#1480)
This commit is contained in:
1 parent
7a3d5eae56
commit
29ca94aa43
22 files changed
+3850
-64
No files matched your search
+52
-7
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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']);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
/**
|
||||
* Pure layout layer for release highlight cards: plans which cards a release
|
||||
* gets from its `highlight:` notes and builds the SVG for each. Rendering to
|
||||
* PNG (sharp) lives in generate-highlight-cards.mjs; everything here is
|
||||
* deterministic string work, so it is unit-testable without an image library.
|
||||
*
|
||||
* Card size is the 1200×630 Open Graph format — right for Telegram/Reddit
|
||||
* link previews and reusable as a blog hero.
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
|
||||
import { groupNotes } from './release-notes.mjs';
|
||||
|
||||
export const CARD_WIDTH = 1200;
|
||||
export const CARD_HEIGHT = 630;
|
||||
|
||||
/** Screenshot strip: fills the card bottom under the text block. */
|
||||
export const SHOT_WIDTH = 880;
|
||||
export const SHOT_TOP = 330;
|
||||
export const SHOT_LEFT = (CARD_WIDTH - SHOT_WIDTH) / 2;
|
||||
export const SHOT_RADIUS = 14;
|
||||
|
||||
/** Left margin, and the width text may occupy before the right margin. */
|
||||
const TEXT_LEFT = 64;
|
||||
export const TEXT_MAX_WIDTH = CARD_WIDTH - TEXT_LEFT * 2;
|
||||
/** Hero bullet lines start further right, after the accent dot. */
|
||||
const HERO_BULLET_LEFT = 100;
|
||||
export const HERO_BULLET_MAX_WIDTH = CARD_WIDTH - HERO_BULLET_LEFT - TEXT_LEFT;
|
||||
|
||||
/** Feature-card text block, laid out to always clear the screenshot frame. */
|
||||
const HEADLINE_TOP = 168;
|
||||
const HEADLINE_LINE_HEIGHT = 58;
|
||||
const BODY_LINE_HEIGHT = 32;
|
||||
const BODY_GAP = 8;
|
||||
/** Lowest permitted body baseline: the frame starts at SHOT_TOP - 2. */
|
||||
export const TEXT_BOTTOM = SHOT_TOP - 24;
|
||||
|
||||
const BRAND = {
|
||||
backgroundTop: '#0a0a08',
|
||||
backgroundBottom: '#141412',
|
||||
text: '#f0f0eb',
|
||||
muted: '#8a8a80',
|
||||
accent: '#20a8a8',
|
||||
accentBright: '#38c4c4',
|
||||
warm: '#d4a853',
|
||||
frame: '#2e2e28',
|
||||
};
|
||||
|
||||
const FONT_STACK = "'DM Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif";
|
||||
|
||||
/** Human count labels for the hero footer, singular and plural. */
|
||||
const COUNT_LABELS = {
|
||||
breaking: ['breaking change', 'breaking changes'],
|
||||
feature: ['feature', 'features'],
|
||||
fix: ['fix', 'fixes'],
|
||||
perf: ['performance win', 'performance wins'],
|
||||
};
|
||||
|
||||
export function escapeXml(text) {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-character advance width as a fraction of the font size.
|
||||
*
|
||||
* Counting characters is not a width budget: 34 `W` at font-size 52 measures
|
||||
* ~1948px where only ~1072px are available, so a character-capped line still
|
||||
* overflowed the canvas.
|
||||
*
|
||||
* The model is deliberately inverted — narrow characters are enumerated and
|
||||
* **everything else is assumed wide**. Enumerating the wide ones instead is a
|
||||
* game that cannot be won: successive passes each found another
|
||||
* under-estimated glyph (`W`, then CJK and emoji, then the `ae` ligature),
|
||||
* and any glyph the list misses crops the card silently. In this shape the
|
||||
* estimate can only ever run high, and running high costs an early line
|
||||
* break nobody sees.
|
||||
*/
|
||||
const WIDEST_FACTOR = 1.25;
|
||||
|
||||
/**
|
||||
* ASCII advance factors. The SVG names `DM Sans` but nothing guarantees it is
|
||||
* installed, so every host resolves the fallback chain differently and the
|
||||
* same line renders at different widths: `r` measures 0.389 em here and about
|
||||
* 0.49 em in the environment that reported this. These factors sit above the
|
||||
* widest of those observations with margin, because the failure that matters
|
||||
* is one-directional — an over-estimate wraps early, an under-estimate crops.
|
||||
*/
|
||||
const ASCII_FACTORS = new Map([
|
||||
[' ', 0.4],
|
||||
...[...".,;:'\"`!|()[]{}/\\-ilIjtfr"].map((character) => [character, 0.55]),
|
||||
...[...'MWmw@%&#'].map((character) => [character, WIDEST_FACTOR]),
|
||||
]);
|
||||
|
||||
/**
|
||||
* @param {string} character a single code point
|
||||
* @returns {number} advance width as a fraction of the font size
|
||||
*/
|
||||
function advanceFactor(character) {
|
||||
const known = ASCII_FACTORS.get(character);
|
||||
|
||||
if (known !== undefined) {
|
||||
return known;
|
||||
}
|
||||
|
||||
if (character >= 'a' && character <= 'z') {
|
||||
return 0.75;
|
||||
}
|
||||
|
||||
if (character >= 'A' && character <= 'Z') {
|
||||
return 0.92;
|
||||
}
|
||||
|
||||
if (character >= '0' && character <= '9') {
|
||||
return 0.75;
|
||||
}
|
||||
|
||||
// Everything else: accented Latin, ligatures, Cyrillic, Greek, CJK, kana,
|
||||
// hangul, emoji, and whatever else a headline turns out to carry.
|
||||
return WIDEST_FACTOR;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @param {number} fontSize
|
||||
* @returns {number} estimated rendered width in pixels
|
||||
*/
|
||||
export function estimateTextWidth(text, fontSize) {
|
||||
let units = 0;
|
||||
|
||||
// Iterating the string yields code points, so an emoji counts once.
|
||||
for (const character of text) {
|
||||
units += advanceFactor(character);
|
||||
}
|
||||
|
||||
return units * fontSize;
|
||||
}
|
||||
|
||||
/** Splits one overlong word into chunks that each fit `maxWidth`. */
|
||||
function breakWord(word, maxWidth, fontSize) {
|
||||
const chunks = [];
|
||||
let chunk = '';
|
||||
|
||||
for (const character of word) {
|
||||
if (
|
||||
chunk &&
|
||||
estimateTextWidth(chunk + character, fontSize) > maxWidth
|
||||
) {
|
||||
chunks.push(chunk);
|
||||
chunk = character;
|
||||
continue;
|
||||
}
|
||||
|
||||
chunk += character;
|
||||
}
|
||||
|
||||
if (chunk) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Greedy word wrap by estimated rendered width. SVG has no automatic text
|
||||
* layout, so the wrap has to decide the breaks itself; every returned line is
|
||||
* estimated to fit `maxWidth`, including when a single word does not — such a
|
||||
* word is broken rather than left to run off the canvas.
|
||||
*
|
||||
* @param {string} text
|
||||
* @param {{ maxWidth: number, fontSize: number, maxLines: number }} options
|
||||
* @returns {string[]} at most maxLines lines, the last ellipsized on overflow
|
||||
*/
|
||||
export function wrapText(text, { maxWidth, fontSize, maxLines }) {
|
||||
const words = text.replace(/\s+/g, ' ').trim().split(' ').filter(Boolean);
|
||||
const lines = [];
|
||||
let current = '';
|
||||
|
||||
for (const word of words) {
|
||||
if (estimateTextWidth(word, fontSize) > maxWidth) {
|
||||
if (current) {
|
||||
lines.push(current);
|
||||
}
|
||||
|
||||
lines.push(...breakWord(word, maxWidth, fontSize));
|
||||
// Keep the final chunk open so a following short word can join it.
|
||||
current = lines.pop() ?? '';
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidate = current ? `${current} ${word}` : word;
|
||||
|
||||
if (!current || estimateTextWidth(candidate, fontSize) <= maxWidth) {
|
||||
current = candidate;
|
||||
continue;
|
||||
}
|
||||
|
||||
lines.push(current);
|
||||
current = word;
|
||||
}
|
||||
|
||||
if (current) {
|
||||
lines.push(current);
|
||||
}
|
||||
|
||||
if (lines.length > maxLines) {
|
||||
const kept = lines.slice(0, maxLines);
|
||||
let last = kept[maxLines - 1];
|
||||
|
||||
while (
|
||||
last.length > 1 &&
|
||||
estimateTextWidth(`${last}…`, fontSize) > maxWidth
|
||||
) {
|
||||
last = last.slice(0, -1);
|
||||
}
|
||||
|
||||
kept[maxLines - 1] = `${last.trimEnd()}…`;
|
||||
|
||||
return kept;
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* One card per `highlight:` note, plus one release hero card. The screenshot
|
||||
* (when the note names one) is read from the published blog directory the
|
||||
* capture run writes to, in the requested theme.
|
||||
*
|
||||
* @param {object[]} notes parsed `.changes` notes
|
||||
* @param {{ version: string, releaseSlug: string, screenshotsDir: string, theme: string }} options
|
||||
* @returns {{ feature: object[], publicNoteCount: number, hero: object }}
|
||||
*/
|
||||
export function planHighlightCards(notes, options) {
|
||||
const { version, releaseSlug, screenshotsDir, theme } = options;
|
||||
const ordered = groupNotes(notes)
|
||||
.filter((group) => group.type !== 'internal')
|
||||
.flatMap((group) => group.notes);
|
||||
const highlights = ordered.filter((note) => note.highlight);
|
||||
|
||||
const takenSlugs = new Set();
|
||||
|
||||
const feature = highlights.map((note) => {
|
||||
// Named after the note file, never the screenshot slug: filenames are
|
||||
// unique within `.changes/`, while two highlights may legitimately
|
||||
// point at the same manifest shot — naming cards after it would let
|
||||
// one silently overwrite the other.
|
||||
const base = cardSlug(path.basename(note.sourcePath, '.md'));
|
||||
// Normalizing can collapse two distinct names onto one, so uniqueness
|
||||
// is re-established here rather than assumed.
|
||||
let slug = base;
|
||||
|
||||
for (let suffix = 2; takenSlugs.has(slug); suffix += 1) {
|
||||
slug = `${base}-${suffix}`;
|
||||
}
|
||||
|
||||
takenSlugs.add(slug);
|
||||
|
||||
return {
|
||||
slug,
|
||||
fileName: `card-${slug}.png`,
|
||||
headline: note.highlight,
|
||||
body: note.body,
|
||||
screenshotPath: note.screenshot
|
||||
? path.join(screenshotsDir, `${note.screenshot}-${theme}.png`)
|
||||
: null,
|
||||
};
|
||||
});
|
||||
|
||||
const counts = groupNotes(ordered)
|
||||
.map((group) => {
|
||||
const [singular, plural] = COUNT_LABELS[group.type];
|
||||
|
||||
return `${group.notes.length} ${group.notes.length === 1 ? singular : plural}`;
|
||||
})
|
||||
.join(' · ');
|
||||
|
||||
return {
|
||||
feature,
|
||||
// Public notes, not total: an internal-only release has nothing to put
|
||||
// on a card, which is a legal release shape rather than an error.
|
||||
publicNoteCount: ordered.length,
|
||||
hero: {
|
||||
fileName: 'hero.png',
|
||||
version,
|
||||
releaseSlug,
|
||||
headlines: highlights.map((note) => note.highlight),
|
||||
counts,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Files this generator owns in an output directory. */
|
||||
export function isOwnedCardFile(fileName) {
|
||||
return (
|
||||
/^card-[a-z0-9-]+\.png$/.test(fileName) ||
|
||||
fileName === 'hero.png' ||
|
||||
fileName === 'hero.jpg'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every emitted filename must satisfy isOwnedCardFile(), or a later run cannot
|
||||
* reclaim the card it wrote. Note filenames are conventionally lowercase slugs
|
||||
* but nothing enforces it, so normalize rather than trust: `player_new-ui.md`
|
||||
* would otherwise produce a card no cleanup pass can ever remove.
|
||||
*
|
||||
* @param {string} noteBaseName note filename without its `.md` extension
|
||||
* @returns {string}
|
||||
*/
|
||||
export function cardSlug(noteBaseName) {
|
||||
const normalized = noteBaseName
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
return normalized || 'note';
|
||||
}
|
||||
|
||||
function backgroundDefs() {
|
||||
return [
|
||||
'<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>`;
|
||||
}
|
||||
@@ -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'`),
|
||||
'<a> & "b" 'c''
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('planHighlightCards', () => {
|
||||
it('plans one card per highlight in group order plus a hero', () => {
|
||||
const plan = planHighlightCards(
|
||||
[
|
||||
note({ type: 'fix', body: 'Not highlighted.' }),
|
||||
note({
|
||||
highlight: 'Up Next rail',
|
||||
screenshot: 'up-next-rail',
|
||||
}),
|
||||
note({
|
||||
type: 'breaking',
|
||||
highlight: 'New settings',
|
||||
sourcePath: '.changes/settings-rework.md',
|
||||
}),
|
||||
],
|
||||
planOptions
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
plan.feature.map((job) => job.fileName),
|
||||
['card-settings-rework.png', 'card-playback-up-next.png']
|
||||
);
|
||||
assert.equal(
|
||||
plan.feature[1].screenshotPath,
|
||||
path.join('/blog/v0-24/screenshots', 'up-next-rail-dark.png')
|
||||
);
|
||||
assert.equal(plan.feature[0].screenshotPath, null);
|
||||
assert.deepEqual(plan.hero.headlines, [
|
||||
'New settings',
|
||||
'Up Next rail',
|
||||
]);
|
||||
assert.equal(plan.hero.counts, '1 breaking change · 1 feature · 1 fix');
|
||||
});
|
||||
|
||||
it('keeps card filenames unique when two highlights share a screenshot', () => {
|
||||
const plan = planHighlightCards(
|
||||
[
|
||||
note({
|
||||
highlight: 'Rail on wide windows',
|
||||
screenshot: 'up-next-rail',
|
||||
sourcePath: '.changes/playback-up-next.md',
|
||||
}),
|
||||
note({
|
||||
highlight: 'Rail progress bars',
|
||||
screenshot: 'up-next-rail',
|
||||
sourcePath: '.changes/playback-rail-progress.md',
|
||||
}),
|
||||
],
|
||||
planOptions
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
plan.feature.map((job) => job.fileName),
|
||||
['card-playback-up-next.png', 'card-playback-rail-progress.png']
|
||||
);
|
||||
// Both still read the same shared screenshot.
|
||||
assert.equal(
|
||||
plan.feature[0].screenshotPath,
|
||||
plan.feature[1].screenshotPath
|
||||
);
|
||||
});
|
||||
|
||||
it('reports the public note count so an internal-only release is detectable', () => {
|
||||
assert.equal(
|
||||
planHighlightCards(
|
||||
[note({ type: 'internal', body: 'Churn.' })],
|
||||
planOptions
|
||||
).publicNoteCount,
|
||||
0
|
||||
);
|
||||
assert.equal(
|
||||
planHighlightCards(
|
||||
[note(), note({ type: 'internal', body: 'Churn.' })],
|
||||
planOptions
|
||||
).publicNoteCount,
|
||||
1
|
||||
);
|
||||
});
|
||||
|
||||
it('emits only filenames the ownership predicate can reclaim', () => {
|
||||
// Nothing enforces the lowercase-slug filename convention, and a card
|
||||
// the cleanup pass cannot recognize is a card that lives forever.
|
||||
const plan = planHighlightCards(
|
||||
[
|
||||
note({
|
||||
highlight: 'New UI',
|
||||
sourcePath: '.changes/Player_New-UI.md',
|
||||
}),
|
||||
note({
|
||||
highlight: 'Other',
|
||||
sourcePath: '.changes/player.new.ui.md',
|
||||
}),
|
||||
],
|
||||
planOptions
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
plan.feature.map((job) => job.fileName),
|
||||
['card-player-new-ui.png', 'card-player-new-ui-2.png']
|
||||
);
|
||||
for (const job of plan.feature) {
|
||||
assert.equal(isOwnedCardFile(job.fileName), true, job.fileName);
|
||||
}
|
||||
});
|
||||
|
||||
it('claims only the files it writes', () => {
|
||||
for (const owned of ['card-playback-up-next.png', 'hero.png', 'hero.jpg']) {
|
||||
assert.equal(isOwnedCardFile(owned), true, owned);
|
||||
}
|
||||
|
||||
for (const foreign of [
|
||||
'notes.txt',
|
||||
'card-Upper.png',
|
||||
'screenshot-dashboard-dark.png',
|
||||
'hero.webp',
|
||||
]) {
|
||||
assert.equal(isOwnedCardFile(foreign), false, foreign);
|
||||
}
|
||||
});
|
||||
|
||||
it('never plans a card for internal notes', () => {
|
||||
const plan = planHighlightCards(
|
||||
[note({ type: 'internal', highlight: 'Nope' })],
|
||||
planOptions
|
||||
);
|
||||
|
||||
assert.deepEqual(plan.feature, []);
|
||||
assert.deepEqual(plan.hero.headlines, []);
|
||||
});
|
||||
|
||||
it('respects the requested screenshot theme', () => {
|
||||
const plan = planHighlightCards(
|
||||
[note({ highlight: 'Up Next rail', screenshot: 'up-next-rail' })],
|
||||
{ ...planOptions, theme: 'light' }
|
||||
);
|
||||
|
||||
assert.match(plan.feature[0].screenshotPath, /-light\.png$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('card SVGs', () => {
|
||||
it('escapes user text and carries the version chip', () => {
|
||||
const svg = buildFeatureCardSvg(
|
||||
{
|
||||
slug: 'x',
|
||||
fileName: 'card-x.png',
|
||||
headline: 'Support <live> & "vod"',
|
||||
body: "The app's import.",
|
||||
screenshotPath: null,
|
||||
},
|
||||
'0.24.0'
|
||||
);
|
||||
|
||||
assert.match(svg, /Support <live> & "vod"/);
|
||||
assert.match(svg, /v0\.24\.0/);
|
||||
assert.doesNotMatch(svg, /<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`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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}"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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\//);
|
||||
});
|
||||
});
|
||||
@@ -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/>`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(', ')})`
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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/
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user