mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
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:
1 parent
069b8b3cc9
commit
dcd28c1334
19 files changed
+2190
-30
No files matched your search
+34
-6
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = {
|
||||
@@ -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']);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>`;
|
||||
}
|
||||
@@ -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'`),
|
||||
'<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-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 <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('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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,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\//);
|
||||
});
|
||||
});
|
||||
@@ -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,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(', ')})`
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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/
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user