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

Extends the release tooling around the .changes notes pipeline:

- New optional `highlight:` frontmatter field marks a release's headline
  changes (max 80 chars, rejected on `type: internal`).
- `--format telegram` renders a paste-ready plain-text post guaranteed to
  fit the 4096-character limit (highlights lead, the rest collapses into a
  counter); `--format reddit` renders markdown with a suggested title line,
  a Highlights section, and a collapsed change list. Both are stdout dry
  runs and must run before `--consume`.
- The blog scaffold uses the highlight as a ready section heading instead
  of a TODO, and highlight notes without screenshots get their own section.
- `release:verify:draft` waits for the v* tag build via `gh run watch`,
  then checks draft status, the authored body, and the complete 27-asset
  set (verified against a real full matrix build). Read-only; publishing
  stays manual.
- `release:cards:generate` renders branded 1200x630 highlight cards (with
  a framed mock-server screenshot strip when the note names one, purely
  typographic otherwise) plus a release hero card into dist/, matching the
  website palette. Copying into the website tree stays a manual act.

Docs: .changes/README.md, release-notes and release-cut skills (both
copies byte-identical). No release note: release tooling only, no runtime
behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-25 00:28:44 +02:00
1 parent 069b8b3cc9
commit dcd28c1334
19 files changed
+2190 -30

No files matched your search

+34 -6
View File
@@ -3,8 +3,8 @@
Every PR with a user-visible change drops one file here describing that change
in plain language. At release time
`tools/release/build-release-notes.mjs` turns the accumulated files into the
GitHub release body, the `CHANGELOG.md` section, and a blog-post scaffold for
the website — then deletes them.
GitHub release body, the `CHANGELOG.md` section, a blog-post scaffold for
the website, and Telegram/Reddit announcement drafts — then deletes them.
The point is to write the note **while the context is still fresh**, instead of
reconstructing three months of work from commit titles at release time.
@@ -19,6 +19,7 @@ type: feature
area: playback
issues: [1187]
screenshot: up-next-rail
highlight: Up Next rail
---
Series now show an "Up Next" rail beside the player on wide windows: the rest
@@ -31,6 +32,7 @@ of the current season, watch progress, and click-to-play inline.
| `area` | yes | lowercase slug, same as the conventional-commit scope |
| `issues` | no | issue numbers this closes — `[1187]` or `1187` |
| `screenshot` | no | slug from `tools/release/screenshots.manifest.json` |
| `highlight` | no | short headline (max 80 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,13 @@ 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. 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 +78,8 @@ pnpm run release:notes:validate
pnpm run release:notes:github
pnpm run release:notes:changelog
pnpm run release:notes:blog
pnpm run release:notes:telegram
pnpm run release:notes:reddit
node tools/release/build-release-notes.mjs --consume
```
@@ -83,10 +94,16 @@ 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: Telegram plain
text guaranteed to fit the 4096-character limit, Reddit markdown with a
suggested post title on the first line. Render and save them **before**
`--consume` — the changelog keeps the entries, but the `highlight:` metadata
lives only in the note files. Publishing is manual; nothing posts anywhere.
The release sequence is: bump the version → `release:notes:changelog` →
`release:notes:blog` → `--consume` → commit → tag → push. The tag build then
@@ -116,6 +133,17 @@ 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/<vX-Y>/`; copying a
card into the website tree is a deliberate manual act.
`release:cards:dry-run` lists what would be rendered.
```bash
pnpm nx run electron-backend:build-e2e # once, before capturing
pnpm run release:screenshots # all shots, both themes
+14 -4
View File
@@ -30,7 +30,15 @@ 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 announcement drafts and save the output outside the repository:
`pnpm run release:notes:telegram` and `pnpm run release:notes:reddit`.
They must run before `--consume` — the `highlight:` metadata lives only in
the note files. Publishing them is manual and happens after the release.
6. Render highlight cards after the screenshots:
`pnpm run release:cards:generate` writes branded 1200×630 cards plus a
hero to `dist/release-highlight-cards/<vX-Y>/`. 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`.
The consume command is the destructive boundary: it deletes the direct note
@@ -54,10 +62,12 @@ 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,
GitHub release. Run `pnpm run release:verify:draft` — it waits for the tag
build via `gh run watch`, then checks draft status, the authored body, and the
complete required asset set (macOS, Windows, DEB, RPM, Pacman, AppImage, Snap,
Flatpak, updater metadata, blockmaps, and
`linux-frame-copy-runtime-sources.tar.xz`.
`linux-frame-copy-runtime-sources.tar.xz`). It is read-only and never
publishes. Still review the authored text and generated commits by eye.
After verification, manually publish the GitHub release. That publication
automatically verifies its Snap assets and uploads them to `edge`.
+5
View File
@@ -17,6 +17,7 @@ type: fix
area: stalker
issues: [1234]
screenshot: optional-manifest-slug
highlight: Optional short headline
---
Stalker series now resume the correct episode.
@@ -25,6 +26,10 @@ Stalker series now resume the correct episode.
`type` is `breaking`, `feature`, `fix`, `perf`, or `internal`. Omit optional
fields instead of inventing values. Never add a version or PR number.
`highlight` (max 80 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.
+14 -4
View File
@@ -30,7 +30,15 @@ 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 announcement drafts and save the output outside the repository:
`pnpm run release:notes:telegram` and `pnpm run release:notes:reddit`.
They must run before `--consume` — the `highlight:` metadata lives only in
the note files. Publishing them is manual and happens after the release.
6. Render highlight cards after the screenshots:
`pnpm run release:cards:generate` writes branded 1200×630 cards plus a
hero to `dist/release-highlight-cards/<vX-Y>/`. 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`.
The consume command is the destructive boundary: it deletes the direct note
@@ -54,10 +62,12 @@ 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,
GitHub release. Run `pnpm run release:verify:draft` — it waits for the tag
build via `gh run watch`, then checks draft status, the authored body, and the
complete required asset set (macOS, Windows, DEB, RPM, Pacman, AppImage, Snap,
Flatpak, updater metadata, blockmaps, and
`linux-frame-copy-runtime-sources.tar.xz`.
`linux-frame-copy-runtime-sources.tar.xz`). It is read-only and never
publishes. Still review the authored text and generated commits by eye.
After verification, manually publish the GitHub release. That publication
automatically verifies its Snap assets and uploads them to `edge`.
+5
View File
@@ -17,6 +17,7 @@ type: fix
area: stalker
issues: [1234]
screenshot: optional-manifest-slug
highlight: Optional short headline
---
Stalker series now resume the correct episode.
@@ -25,6 +26,10 @@ Stalker series now resume the correct episode.
`type` is `breaking`, `feature`, `fix`, `perf`, or `internal`. Omit optional
fields instead of inventing values. Never add a version or PR number.
`highlight` (max 80 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
View File
@@ -77,6 +77,11 @@
"release:notes:github": "node tools/release/build-release-notes.mjs --format github",
"release:notes:changelog": "node tools/release/build-release-notes.mjs --format changelog",
"release:notes:blog": "node tools/release/build-release-notes.mjs --format blog",
"release:notes:telegram": "node tools/release/build-release-notes.mjs --format telegram",
"release:notes:reddit": "node tools/release/build-release-notes.mjs --format reddit",
"release:verify:draft": "node tools/release/verify-draft-release.mjs",
"release:cards:dry-run": "node tools/release/generate-highlight-cards.mjs --dry-run",
"release:cards:generate": "node tools/release/generate-highlight-cards.mjs --generate",
"release:screenshots": "tsx tools/release/capture-release-screenshots.ts",
"lint": "nx run-many --target=lint --all",
"build": "nx build electron-backend"
+18 -1
View File
@@ -6,6 +6,8 @@
* node tools/release/build-release-notes.mjs --version 0.24.0 --format github
* node tools/release/build-release-notes.mjs --version 0.24.0 --format changelog
* node tools/release/build-release-notes.mjs --version 0.24.0 --format blog
* node tools/release/build-release-notes.mjs --version 0.24.0 --format telegram
* node tools/release/build-release-notes.mjs --version 0.24.0 --format reddit
* node tools/release/build-release-notes.mjs --version 0.24.0 --consume
*
* Every mode except `--consume` is a safe dry run: nothing is deleted unless
@@ -18,6 +20,10 @@ import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import {
renderRedditPost,
renderTelegramPost,
} from './release-announcements.mjs';
import { loadNotes } from './release-notes.mjs';
import { manifestSlugs } from './screenshot-guards.mjs';
import {
@@ -38,7 +44,7 @@ const BLOG_DIR = path.join(workspaceRoot, 'apps/website/src/content/blog');
/** Generated sections are inserted directly below this marker. */
const CHANGELOG_MARKER = '<!-- next-release -->';
const FORMATS = new Set(['github', 'changelog', 'blog']);
const FORMATS = new Set(['github', 'changelog', 'blog', 'telegram', 'reddit']);
function parseArgs(argv) {
const options = {
@@ -294,6 +300,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') {
process.stdout.write(`${renderTelegramPost(notes, { version })}\n`);
}
if (options.format === 'reddit') {
process.stdout.write(renderRedditPost(notes, { version }));
}
if (options.format === 'changelog') {
const section = renderChangelogSection(notes, {
version,
@@ -103,6 +103,37 @@ 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('still rejects a genuinely unknown argument', () => {
const result = runCli(['--validate', '--nope']);
+271
View File
@@ -0,0 +1,271 @@
#!/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, readFileSync } 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,
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 };
}
/**
* @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 });
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);
}
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,
});
if (plan.feature.length === 0) {
console.error(
'No `highlight:` notes found — mark the headline changes in .changes/ first.'
);
process.exit(1);
}
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(
` ${path.relative(workspaceRoot, job.screenshotPath)}`
);
}
console.error('\nRun `pnpm run release:screenshots` first.');
process.exit(1);
}
const outputDir = path.resolve(
workspaceRoot,
options.out ?? path.join('dist/release-highlight-cards', slug)
);
console.log(`${plan.feature.length} highlight card(s) + hero for v${options.version}:`);
for (const job of plan.feature) {
const shot = job.screenshotPath
? path.relative(workspaceRoot, job.screenshotPath)
: 'no screenshot (typographic card)';
console.log(` ${job.fileName} — "${job.headline}" (${shot})`);
}
if (options.mode === 'dry-run') {
console.log(`\nWould write to ${path.relative(workspaceRoot, outputDir)}/.`);
return;
}
const written = await renderCards(plan, options.version, outputDir);
for (const file of written) {
console.log(`Wrote ${path.relative(workspaceRoot, file)}`);
}
console.log(
`\nReview the images, then copy what you publish (e.g. hero.jpg → apps/website/public/blog/${slug}/hero.jpg).`
);
}
const isDirectRun =
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isDirectRun) {
main().catch((error) => {
console.error(error.message);
process.exit(1);
});
}
+329
View File
@@ -0,0 +1,329 @@
/**
* 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;
const BRAND = {
backgroundTop: '#0a0a08',
backgroundBottom: '#141412',
text: '#f0f0eb',
muted: '#8a8a80',
accent: '#20a8a8',
accentBright: '#38c4c4',
warm: '#d4a853',
frame: '#2e2e28',
};
const FONT_STACK = "'DM Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif";
/** Human count labels for the hero footer, singular and plural. */
const COUNT_LABELS = {
breaking: ['breaking change', 'breaking changes'],
feature: ['feature', 'features'],
fix: ['fix', 'fixes'],
perf: ['performance win', 'performance wins'],
};
export function escapeXml(text) {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/**
* Greedy word wrap by character budget. SVG has no automatic text layout and
* font metrics vary by host, so the budget is conservative; a single word
* longer than the budget gets its own line rather than being cut.
*
* @param {string} text
* @param {number} maxChars
* @param {number} maxLines
* @returns {string[]} at most maxLines lines, the last one ellipsized on overflow
*/
export function wrapText(text, maxChars, maxLines) {
const words = text.replace(/\s+/g, ' ').trim().split(' ');
const lines = [];
let current = '';
for (const word of words) {
const candidate = current ? `${current} ${word}` : word;
if (candidate.length <= maxChars || !current) {
current = candidate;
continue;
}
lines.push(current);
current = word;
}
if (current) {
lines.push(current);
}
if (lines.length > maxLines) {
const kept = lines.slice(0, maxLines);
const last = kept[maxLines - 1];
kept[maxLines - 1] =
`${last.slice(0, Math.max(1, maxChars - 1)).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[], 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 feature = highlights.map((note) => {
const slug =
note.screenshot ??
path.basename(note.sourcePath, '.md').toLowerCase();
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,
hero: {
fileName: 'hero.png',
version,
releaseSlug,
headlines: highlights.map((note) => note.highlight),
counts,
},
};
}
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('');
}
function textLines(lines, { x, y, size, weight, fill, lineHeight }) {
return lines
.map(
(line, index) =>
`<text x="${x}" y="${y + index * lineHeight}" font-family="${FONT_STACK}" font-size="${size}" font-weight="${weight}" fill="${fill}">${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, 34, 2);
const body = wrapText(job.body, 88, 2);
parts.push(
textLines(headline, {
x: 64,
y: 180,
size: 52,
weight: 800,
fill: BRAND.text,
lineHeight: 62,
})
);
parts.push(
textLines(body, {
x: 64,
y: 180 + headline.length * 62,
size: 23,
weight: 400,
fill: BRAND.muted,
lineHeight: 32,
})
);
// 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, 30, 2);
const body = wrapText(job.body, 74, 3);
const headlineY = 250;
parts.push(
textLines(headline, {
x: 64,
y: headlineY,
size: 62,
weight: 800,
fill: BRAND.text,
lineHeight: 74,
})
);
parts.push(
`<rect x="64" y="${headlineY + headline.length * 74 - 44}" width="120" height="6" rx="3" fill="${BRAND.warm}"/>`
);
parts.push(
textLines(body, {
x: 64,
y: headlineY + headline.length * 74 + 8,
size: 26,
weight: 400,
fill: BRAND.muted,
lineHeight: 38,
})
);
}
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}"/>`
);
parts.push(
`<text x="100" y="${y}" font-family="${FONT_STACK}" font-size="30" font-weight="600" fill="${BRAND.text}">${escapeXml(wrapText(headline, 60, 1)[0])}</text>`
);
});
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>`;
}
+296
View File
@@ -0,0 +1,296 @@
import assert from 'node:assert/strict';
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { after, describe, it } from 'node:test';
import sharp from 'sharp';
import {
buildFeatureCardSvg,
buildHeroCardSvg,
CARD_HEIGHT,
CARD_WIDTH,
escapeXml,
planHighlightCards,
wrapText,
} from './highlight-cards.mjs';
import {
parseCardArguments,
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', () => {
it('wraps on word boundaries within the budget', () => {
assert.deepEqual(wrapText('one two three four', 9, 3), [
'one two',
'three',
'four',
]);
});
it('gives an overlong single word its own line instead of cutting it', () => {
assert.deepEqual(wrapText('supercalifragilistic ok', 10, 3), [
'supercalifragilistic',
'ok',
]);
});
it('ellipsizes the last kept line on overflow', () => {
const lines = wrapText('aaa bbb ccc ddd eee', 3, 2);
assert.equal(lines.length, 2);
assert.match(lines[1], /…$/);
});
});
describe('escapeXml', () => {
it('escapes markup and quote characters', () => {
assert.equal(
escapeXml(`<a> & "b" 'c'`),
'&lt;a&gt; &amp; &quot;b&quot; &apos;c&apos;'
);
});
});
describe('planHighlightCards', () => {
it('plans one card per highlight in group order plus a hero', () => {
const plan = planHighlightCards(
[
note({ type: 'fix', body: 'Not highlighted.' }),
note({
highlight: 'Up Next rail',
screenshot: 'up-next-rail',
}),
note({
type: 'breaking',
highlight: 'New settings',
sourcePath: '.changes/settings-rework.md',
}),
],
planOptions
);
assert.deepEqual(
plan.feature.map((job) => job.fileName),
['card-settings-rework.png', 'card-up-next-rail.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('never plans a card for internal notes', () => {
const plan = planHighlightCards(
[note({ type: 'internal', highlight: 'Nope' })],
planOptions
);
assert.deepEqual(plan.feature, []);
assert.deepEqual(plan.hero.headlines, []);
});
it('respects the requested screenshot theme', () => {
const plan = planHighlightCards(
[note({ highlight: 'Up Next rail', screenshot: 'up-next-rail' })],
{ ...planOptions, theme: 'light' }
);
assert.match(plan.feature[0].screenshotPath, /-light\.png$/);
});
});
describe('card SVGs', () => {
it('escapes user text and carries the version chip', () => {
const svg = buildFeatureCardSvg(
{
slug: 'x',
fileName: 'card-x.png',
headline: 'Support <live> & "vod"',
body: "The app's import.",
screenshotPath: null,
},
'0.24.0'
);
assert.match(svg, /Support &lt;live&gt; &amp; &quot;vod&quot;/);
assert.match(svg, /v0\.24\.0/);
assert.doesNotMatch(svg, /<live>/);
});
it('draws the screenshot frame only when a screenshot exists', () => {
const withShot = buildFeatureCardSvg(
{
headline: 'H',
body: 'B',
screenshotPath: '/shots/x-dark.png',
},
'0.24.0'
);
const without = buildFeatureCardSvg(
{ headline: 'H', body: 'B', screenshotPath: null },
'0.24.0'
);
// The frame rect is the only element using the surface frame color.
assert.match(withShot, /fill="#2e2e28"/);
assert.doesNotMatch(without, /fill="#2e2e28"/);
});
it('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-up-next-rail.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);
}
});
});
+8 -1
View File
@@ -10,6 +10,10 @@
"inputs": [
"{workspaceRoot}/tools/release/release-notes.mjs",
"{workspaceRoot}/tools/release/release-notes-render.mjs",
"{workspaceRoot}/tools/release/release-announcements.mjs",
"{workspaceRoot}/tools/release/verify-draft-release.mjs",
"{workspaceRoot}/tools/release/highlight-cards.mjs",
"{workspaceRoot}/tools/release/generate-highlight-cards.mjs",
"{workspaceRoot}/tools/release/extract-changelog-section.mjs",
"{workspaceRoot}/tools/release/check-release-note-gate.mjs",
"{workspaceRoot}/tools/release/build-release-notes.mjs",
@@ -19,10 +23,13 @@
"{workspaceRoot}/tools/release/release-notes.test.mjs",
"{workspaceRoot}/tools/release/release-note-gate.test.mjs",
"{workspaceRoot}/tools/release/build-release-notes.test.mjs",
"{workspaceRoot}/tools/release/release-announcements.test.mjs",
"{workspaceRoot}/tools/release/verify-draft-release.test.mjs",
"{workspaceRoot}/tools/release/highlight-cards.test.mjs",
"{workspaceRoot}/tools/release/screenshot-guards.test.mjs"
],
"options": {
"command": "node --test tools/release/release-notes.test.mjs tools/release/release-note-gate.test.mjs tools/release/build-release-notes.test.mjs tools/release/screenshot-guards.test.mjs",
"command": "node --test tools/release/release-notes.test.mjs tools/release/release-note-gate.test.mjs tools/release/build-release-notes.test.mjs tools/release/release-announcements.test.mjs tools/release/verify-draft-release.test.mjs tools/release/highlight-cards.test.mjs tools/release/screenshot-guards.test.mjs",
"cwd": "{workspaceRoot}"
}
},
+181
View File
@@ -0,0 +1,181 @@
/**
* 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;
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 {{ highlights: object[], rest: object[] }}
*/
export function splitHighlights(notes) {
const ordered = groupNotes(notes)
.filter((group) => group.type !== 'internal')
.flatMap((group) => group.notes);
return {
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} a post guaranteed to fit TELEGRAM_MESSAGE_LIMIT
*/
export function renderTelegramPost(notes, { version }) {
const { highlights, rest } = splitHighlights(notes);
const lead = highlights.length > 0 ? highlights : rest;
const leadIsHighlights = highlights.length > 0;
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}`;
});
const hiddenCount =
(leadIsHighlights ? rest.length : 0) +
(lead.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. Highlights
// are hand-picked, so running out of room means the release manager chose
// too many — say so instead of silently cutting a chosen highlight.
for (let visible = lead.length; visible > 0; visible -= 1) {
const post = buildPost(lead.slice(0, visible));
if (post.length <= TELEGRAM_MESSAGE_LIMIT) {
if (leadIsHighlights && visible < lead.length) {
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}
*/
export function renderRedditPost(notes, { version }) {
const { highlights, rest } = splitHighlights(notes);
const title =
highlights.length > 0
? `IPTVnator v${version} — ${highlights.map((note) => note.highlight).join(', ')}`
: `IPTVnator v${version} released`;
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 (rest.length > 0) {
blocks.push(
highlights.length > 0
? '## Also in this release'
: "## What's changed"
);
for (const group of groupNotes(rest)) {
const entries = group.notes
.map((note) => `- **${note.area}** — ${oneLine(note.body)}`)
.join('\n');
blocks.push(`**${group.heading}**\n\n${entries}`);
}
}
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`;
}
@@ -0,0 +1,198 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
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('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('links the patch blog post to its minor release page', () => {
const post = renderRedditPost([note()], { version: '0.24.2' });
assert.match(post, /blog\/v0-24-release-notes\//);
});
});
+26 -11
View File
@@ -222,12 +222,17 @@ export function upsertChangelogSection(changelog, section, version, marker) {
}
/**
* Blog entries carrying a `screenshot:` slug become their own subsection with
* an image slider; the rest stay bullets.
* Blog entries carrying a `screenshot:` slug or a `highlight:` headline become
* their own subsection (with an image slider when a screenshot exists); the
* rest stay bullets.
*/
function renderBlogGroup(group, { slug, links }) {
const bullets = group.notes.filter((note) => !note.screenshot);
const featured = group.notes.filter((note) => note.screenshot);
const bullets = group.notes.filter(
(note) => !note.screenshot && !note.highlight
);
const featured = group.notes.filter(
(note) => note.screenshot || note.highlight
);
const blocks = [`## ${group.heading}`];
if (bullets.length > 0) {
@@ -242,9 +247,23 @@ function renderBlogGroup(group, { slug, links }) {
}
for (const note of featured) {
// The heading is editorial work — a note body makes a terrible one.
// Leave a visible TODO instead of pretending otherwise; the whole
// scaffold ships as `draft: true` anyway.
// A `highlight:` headline is the editorial headline; without one the
// heading is editorial work a note body cannot stand in for — leave a
// visible TODO instead of pretending otherwise; the whole scaffold
// ships as `draft: true` anyway.
blocks.push(
note.highlight
? `### ${escapeMdx(note.highlight)}`
: `### TODO headline (${note.area})`
);
blocks.push(
`${escapeMdx(oneLine(note.body))}${formatReferences(note, links)}`
);
if (!note.screenshot) {
continue;
}
// Embedded in a single-quoted JS string inside MDX. Backslashes must
// be escaped before apostrophes, or a body ending in `\` produces an
// unterminated string and breaks the website build.
@@ -258,10 +277,6 @@ function renderBlogGroup(group, { slug, links }) {
)
.join('\n');
blocks.push(`### TODO headline (${note.area})`);
blocks.push(
`${escapeMdx(oneLine(note.body))}${formatReferences(note, links)}`
);
blocks.push(`<BlogImageSlider\n images={[\n${images}\n ]}\n/>`);
}
+31 -3
View File
@@ -4,7 +4,7 @@
* One file per user-visible change, written by the PR author while the
* context is still fresh. The generator (build-release-notes.mjs) turns the
* accumulated files into the GitHub release body, the CHANGELOG.md section,
* and the website blog scaffold.
* the website blog scaffold, and the Telegram/Reddit announcement drafts.
*
* Deliberately dependency-free: a hand-rolled parser for this tiny, closed
* schema is more predictable than a YAML engine, and it can reject unknown
@@ -27,13 +27,22 @@ 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. */
const MAX_HIGHLIGHT_LENGTH = 80;
/**
* Splits a note into its frontmatter lines and body.
*
@@ -114,7 +123,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 +150,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 +198,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(', ')})`
+62
View File
@@ -45,6 +45,7 @@ function note(overrides = {}) {
area: 'playback',
issues: [],
screenshot: null,
highlight: null,
unknownKeys: [],
body: 'Series now show an Up Next rail beside the player.',
sourcePath: '.changes/playback-up-next.md',
@@ -101,6 +102,23 @@ 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('records unknown keys instead of dropping them silently', () => {
const parsed = parseNote(
['---', 'type: fix', 'area: m3u', 'scope: m3u', '---', 'Body.'].join(
@@ -174,6 +192,29 @@ 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/
);
assert.match(
validateNote(note({ highlight: 'x'.repeat(81) }))[0],
/max 80/
);
});
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 +361,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(
+358
View File
@@ -0,0 +1,358 @@
#!/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 } from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
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.
const pacmanPattern = new RegExp(
`^iptvnator-${version.replace(/\./g, '\\.')}-linux-(x64\\.pacman|x86_64\\.pkg\\.tar\\.[a-z0-9]+)$`
);
rules.push({
label: `Pacman (iptvnator-${version}-linux-x64.pacman or .pkg.tar.*)`,
matches: (candidate) => pacmanPattern.test(candidate),
});
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];
if (!value || value.startsWith('--')) {
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;
}
/**
* 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; the other two return parsed `--json` payloads.
*
* @param {{ version: string, wait: boolean, repo: string }} options
* @param {{ listRuns: Function, watchRun: Function, viewRelease: Function }} io
* @returns {{ exitCode: number, lines: string[] }}
*/
export function runVerification(options, io) {
const { version, wait, repo } = options;
const tag = `v${version}`;
const lines = [];
if (wait) {
const runs = io.listRuns({ repo, workflow: WORKFLOW, branch: tag });
if (runs.length === 0) {
return {
exitCode: 1,
lines: [
`No ${WORKFLOW} run found for ${tag} in ${repo} — was the tag pushed?`,
],
};
}
const run = runs[0];
if (run.status !== 'completed') {
lines.push(`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}.`],
};
}
lines.push(
release.isDraft
? `Draft release ${tag} found.`
: `WARNING: release ${tag} is already published, not a draft.`
);
if (!release.body?.trim()) {
lines.push(
'WARNING: authored release body is empty (expected only for an internal-only release).'
);
}
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).`
);
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' }
);
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;
}
},
};
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 } = runVerification(options, liveIo);
for (const line of lines) {
console.log(line);
}
process.exit(exitCode);
}
const isDirectRun =
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isDirectRun) {
try {
main();
} catch (error) {
console.error(error.message);
process.exit(1);
}
}
+304
View File
@@ -0,0 +1,304 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
parseVerifyArguments,
requiredAssetRules,
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,
body: '### Features\n\n- entry',
assets: completeAssets('0.24.0').map((name) => ({ name })),
...overrides,
};
}
function io(overrides = {}) {
return {
listRuns: () => [
{ databaseId: 42, status: 'completed', conclusion: 'success' },
],
watchRun: () => {
throw new Error('watchRun must not be called');
},
viewRelease: () => release(),
...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'],
]) {
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', () => {
const result = 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('fails when no tag build run exists', () => {
const result = runVerification(options, io({ listRuns: () => [] }));
assert.equal(result.exitCode, 1);
assert.match(result.lines[0], /No build-and-make\.yaml run found/);
});
it('fails on a completed run with a non-success conclusion', () => {
const result = 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', () => {
const watched = [];
const result = 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', () => {
const result = 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', () => {
const result = 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', () => {
const result = runVerification(
options,
io({ viewRelease: () => null })
);
assert.equal(result.exitCode, 1);
assert.match(result.lines[0], /No release found for v0\.24\.0/);
});
it('warns on a published release and an empty body but still verifies', () => {
const result = runVerification(
options,
io({ viewRelease: () => release({ isDraft: false, body: ' ' }) })
);
assert.equal(result.exitCode, 0);
assert.match(result.lines[0], /WARNING: release v0\.24\.0 is already published/);
assert.match(result.lines[1], /WARNING: authored release body is empty/);
});
it('notes unrecognized assets without failing', () => {
const result = 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/
);
});
});