diff --git a/.changes/README.md b/.changes/README.md index b8be5c01e..dcf8e4b35 100644 --- a/.changes/README.md +++ b/.changes/README.md @@ -3,8 +3,8 @@ Every PR with a user-visible change drops one file here describing that change in plain language. At release time `tools/release/build-release-notes.mjs` turns the accumulated files into the -GitHub release body, the `CHANGELOG.md` section, and a blog-post scaffold for -the website — then deletes them. +GitHub release body, the `CHANGELOG.md` section, a blog-post scaffold for +the website, and Telegram/Reddit announcement drafts — then deletes them. The point is to write the note **while the context is still fresh**, instead of reconstructing three months of work from commit titles at release time. @@ -19,6 +19,7 @@ type: feature area: playback issues: [1187] screenshot: up-next-rail +highlight: Up Next rail --- Series now show an "Up Next" rail beside the player on wide windows: the rest @@ -31,6 +32,7 @@ of the current season, watch progress, and click-to-play inline. | `area` | yes | lowercase slug, same as the conventional-commit scope | | `issues` | no | issue numbers this closes — `[1187]` or `1187` | | `screenshot` | no | slug from `tools/release/screenshots.manifest.json` | +| `highlight` | no | short headline (max 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//`; 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 diff --git a/.claude/skills/release-cut/SKILL.md b/.claude/skills/release-cut/SKILL.md index 2ff98f9b0..0030a7094 100644 --- a/.claude/skills/release-cut/SKILL.md +++ b/.claude/skills/release-cut/SKILL.md @@ -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//`. 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`. diff --git a/.claude/skills/release-notes/SKILL.md b/.claude/skills/release-notes/SKILL.md index 308456234..bd2d825ba 100644 --- a/.claude/skills/release-notes/SKILL.md +++ b/.claude/skills/release-notes/SKILL.md @@ -17,6 +17,7 @@ type: fix area: stalker issues: [1234] screenshot: optional-manifest-slug +highlight: Optional short headline --- Stalker series now resume the correct episode. @@ -25,6 +26,10 @@ Stalker series now resume the correct episode. `type` is `breaking`, `feature`, `fix`, `perf`, or `internal`. Omit optional fields instead of inventing values. Never add a version or PR number. +`highlight` (max 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. diff --git a/.codex/skills/release-cut/SKILL.md b/.codex/skills/release-cut/SKILL.md index 2ff98f9b0..0030a7094 100644 --- a/.codex/skills/release-cut/SKILL.md +++ b/.codex/skills/release-cut/SKILL.md @@ -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//`. 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`. diff --git a/.codex/skills/release-notes/SKILL.md b/.codex/skills/release-notes/SKILL.md index 308456234..bd2d825ba 100644 --- a/.codex/skills/release-notes/SKILL.md +++ b/.codex/skills/release-notes/SKILL.md @@ -17,6 +17,7 @@ type: fix area: stalker issues: [1234] screenshot: optional-manifest-slug +highlight: Optional short headline --- Stalker series now resume the correct episode. @@ -25,6 +26,10 @@ Stalker series now resume the correct episode. `type` is `breaking`, `feature`, `fix`, `perf`, or `internal`. Omit optional fields instead of inventing values. Never add a version or PR number. +`highlight` (max 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. diff --git a/package.json b/package.json index 28acfda84..2e00e0bde 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,11 @@ "release:notes:github": "node tools/release/build-release-notes.mjs --format github", "release:notes:changelog": "node tools/release/build-release-notes.mjs --format changelog", "release:notes:blog": "node tools/release/build-release-notes.mjs --format blog", + "release:notes:telegram": "node tools/release/build-release-notes.mjs --format telegram", + "release:notes:reddit": "node tools/release/build-release-notes.mjs --format reddit", + "release:verify:draft": "node tools/release/verify-draft-release.mjs", + "release:cards:dry-run": "node tools/release/generate-highlight-cards.mjs --dry-run", + "release:cards:generate": "node tools/release/generate-highlight-cards.mjs --generate", "release:screenshots": "tsx tools/release/capture-release-screenshots.ts", "lint": "nx run-many --target=lint --all", "build": "nx build electron-backend" diff --git a/tools/release/build-release-notes.mjs b/tools/release/build-release-notes.mjs index 39a921d97..de6780671 100644 --- a/tools/release/build-release-notes.mjs +++ b/tools/release/build-release-notes.mjs @@ -6,6 +6,8 @@ * node tools/release/build-release-notes.mjs --version 0.24.0 --format github * node tools/release/build-release-notes.mjs --version 0.24.0 --format changelog * node tools/release/build-release-notes.mjs --version 0.24.0 --format blog + * node tools/release/build-release-notes.mjs --version 0.24.0 --format telegram + * node tools/release/build-release-notes.mjs --version 0.24.0 --format reddit * node tools/release/build-release-notes.mjs --version 0.24.0 --consume * * Every mode except `--consume` is a safe dry run: nothing is deleted unless @@ -18,6 +20,10 @@ import path from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; +import { + renderRedditPost, + renderTelegramPost, +} from './release-announcements.mjs'; import { loadNotes } from './release-notes.mjs'; import { manifestSlugs } from './screenshot-guards.mjs'; import { @@ -38,7 +44,7 @@ const BLOG_DIR = path.join(workspaceRoot, 'apps/website/src/content/blog'); /** Generated sections are inserted directly below this marker. */ const CHANGELOG_MARKER = ''; -const FORMATS = new Set(['github', 'changelog', 'blog']); +const FORMATS = new Set(['github', 'changelog', 'blog', 'telegram', 'reddit']); function parseArgs(argv) { const options = { @@ -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, diff --git a/tools/release/build-release-notes.test.mjs b/tools/release/build-release-notes.test.mjs index e7d77f9dd..86839af4a 100644 --- a/tools/release/build-release-notes.test.mjs +++ b/tools/release/build-release-notes.test.mjs @@ -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']); diff --git a/tools/release/generate-highlight-cards.mjs b/tools/release/generate-highlight-cards.mjs new file mode 100644 index 000000000..9cf9f61fd --- /dev/null +++ b/tools/release/generate-highlight-cards.mjs @@ -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 ] [--dir ] [--out ]'; + +export function parseCardArguments(args) { + const options = { + mode: null, + theme: 'dark', + version: null, + dir: '.changes', + out: null, + }; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + const takeValue = () => { + const value = args[index + 1]; + + if (!value || value.startsWith('--')) { + return null; + } + + index += 1; + + return value; + }; + + if (arg === '--dry-run' || arg === '--generate') { + if (options.mode) { + return null; + } + + options.mode = arg.slice(2); + } else if (arg === '--theme') { + const value = takeValue(); + + if (value !== 'dark' && value !== 'light') { + return null; + } + + options.theme = value; + } else if (arg === '--version') { + const value = takeValue(); + + if (!value || !/^\d+\.\d+\.\d+$/.test(value)) { + return null; + } + + options.version = value; + } else if (arg === '--dir' || arg === '--out') { + const value = takeValue(); + + if (!value) { + return null; + } + + options[arg.slice(2)] = value; + } else if (arg !== '--') { + return null; + } + } + + return options.mode ? options : null; +} + +/** + * Composites the bottom screenshot strip: resize to the strip width, crop to + * the visible height, round the corners via a dest-in mask. + * + * @param {string} screenshotPath + * @returns {Promise<{ input: Buffer, left: number, top: number }>} + */ +async function prepareShotOverlay(screenshotPath) { + const stripHeight = CARD_HEIGHT - SHOT_TOP; + const resized = sharp(screenshotPath).resize({ width: SHOT_WIDTH }); + const metadata = await resized.png().toBuffer({ resolveWithObject: true }); + const visibleHeight = Math.min(stripHeight, metadata.info.height); + const cropped = await sharp(metadata.data) + .extract({ + left: 0, + top: 0, + width: SHOT_WIDTH, + height: visibleHeight, + }) + .composite([ + { + input: Buffer.from(buildShotMaskSvg(SHOT_WIDTH, visibleHeight)), + blend: 'dest-in', + }, + ]) + .png() + .toBuffer(); + + return { input: cropped, left: SHOT_LEFT, top: SHOT_TOP }; +} + +/** + * @param {{ feature: object[], hero: object }} plan + * @param {string} version + * @param {string} outputDir + * @returns {Promise} written file paths + */ +export async function renderCards(plan, version, outputDir) { + mkdirSync(outputDir, { recursive: true }); + + 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); + }); +} diff --git a/tools/release/highlight-cards.mjs b/tools/release/highlight-cards.mjs new file mode 100644 index 000000000..42499863b --- /dev/null +++ b/tools/release/highlight-cards.mjs @@ -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, '''); +} + +/** + * 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 [ + '', + ``, + ``, + ``, + '', + ``, + ``, + ``, + '', + '', + ].join(''); +} + +function backgroundRects() { + return [ + ``, + ``, + ].join(''); +} + +function brandHeader(version) { + const chipX = 262; + + return [ + `IPTVnator`, + ``, + `v${escapeXml(version)}`, + ].join(''); +} + +function textLines(lines, { x, y, size, weight, fill, lineHeight }) { + return lines + .map( + (line, index) => + `${escapeXml(line)}` + ) + .join(''); +} + +/** + * Feature card: brand header, headline, muted body one-liner, and either a + * framed screenshot strip along the bottom or (without a screenshot) an + * accent rule under a larger, vertically centered headline. + * + * @param {object} job entry from planHighlightCards().feature + * @param {string} version + * @returns {string} SVG document; the screenshot itself is composited by the + * renderer inside the frame this SVG draws + */ +export function buildFeatureCardSvg(job, version) { + const parts = [ + ``, + backgroundDefs(), + backgroundRects(), + brandHeader(version), + ]; + + if (job.screenshotPath) { + const headline = wrapText(job.headline, 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( + `` + ); + } 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( + `` + ); + parts.push( + textLines(body, { + x: 64, + y: headlineY + headline.length * 74 + 8, + size: 26, + weight: 400, + fill: BRAND.muted, + lineHeight: 38, + }) + ); + } + + parts.push(''); + + return parts.join(''); +} + +/** + * Hero card: big version, the highlight names as an accent-bulleted list, + * and the per-type note counts along the bottom. + * + * @param {object} hero planHighlightCards().hero + * @returns {string} + */ +export function buildHeroCardSvg(hero) { + const listed = hero.headlines.slice(0, 4); + const omitted = hero.headlines.length - listed.length; + const parts = [ + ``, + backgroundDefs(), + backgroundRects(), + `IPTVnator`, + `v${escapeXml(hero.version)}`, + ``, + ]; + + listed.forEach((headline, index) => { + const y = 330 + index * 56; + + parts.push( + `` + ); + parts.push( + `${escapeXml(wrapText(headline, 60, 1)[0])}` + ); + }); + + if (omitted > 0) { + parts.push( + `…and ${omitted} more` + ); + } + + if (hero.counts) { + parts.push( + `${escapeXml(hero.counts)}` + ); + } + + parts.push(''); + + return parts.join(''); +} + +/** + * Rounded-corner alpha mask for the screenshot strip (dest-in composite). + * + * @param {number} width + * @param {number} height + * @returns {string} + */ +export function buildShotMaskSvg(width, height) { + return ``; +} diff --git a/tools/release/highlight-cards.test.mjs b/tools/release/highlight-cards.test.mjs new file mode 100644 index 000000000..308cf64a8 --- /dev/null +++ b/tools/release/highlight-cards.test.mjs @@ -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(` & "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 & "vod"', + body: "The app's import.", + screenshotPath: null, + }, + '0.24.0' + ); + + assert.match(svg, /Support <live> & "vod"/); + assert.match(svg, /v0\.24\.0/); + assert.doesNotMatch(svg, //); + }); + + it('draws the screenshot frame only when a screenshot exists', () => { + const withShot = buildFeatureCardSvg( + { + headline: 'H', + body: 'B', + screenshotPath: '/shots/x-dark.png', + }, + '0.24.0' + ); + const without = buildFeatureCardSvg( + { headline: 'H', body: 'B', screenshotPath: null }, + '0.24.0' + ); + + // The frame rect is the only element using the surface frame color. + assert.match(withShot, /fill="#2e2e28"/); + assert.doesNotMatch(without, /fill="#2e2e28"/); + }); + + it('lists at most four highlights on the hero and counts the rest', () => { + const svg = buildHeroCardSvg({ + fileName: 'hero.png', + version: '0.24.0', + releaseSlug: 'v0-24', + headlines: ['One', 'Two', 'Three', 'Four', 'Five'], + counts: '5 features', + }); + + assert.match(svg, />FourFive { + it('requires a mode and defaults the theme', () => { + assert.equal(parseCardArguments([]), null); + assert.deepEqual(parseCardArguments(['--dry-run']), { + mode: 'dry-run', + theme: 'dark', + version: null, + dir: '.changes', + out: null, + }); + }); + + it('parses the full flag set', () => { + assert.deepEqual( + parseCardArguments([ + '--generate', + '--theme', + 'light', + '--version', + '0.24.0', + '--dir', + 'notes', + '--out', + 'cards', + ]), + { + mode: 'generate', + theme: 'light', + version: '0.24.0', + dir: 'notes', + out: 'cards', + } + ); + }); + + it('rejects bad usage', () => { + for (const args of [ + ['--dry-run', '--generate'], + ['--generate', '--theme', 'sepia'], + ['--generate', '--version', '0.24'], + ['--generate', '--nope'], + ['--generate', '--out'], + ]) { + assert.equal(parseCardArguments(args), null, args.join(' ')); + } + }); +}); + +describe('renderCards', () => { + it('renders 1200×630 PNGs plus the hero pair from a real screenshot', async () => { + const shotsDir = makeTempDir(); + const outputDir = path.join(makeTempDir(), 'cards'); + const screenshotPath = path.join(shotsDir, 'up-next-rail-dark.png'); + + await sharp({ + create: { + width: 1280, + height: 720, + channels: 3, + background: { r: 20, g: 20, b: 24 }, + }, + }) + .png() + .toFile(screenshotPath); + + const plan = planHighlightCards( + [ + note({ highlight: 'Up Next rail', screenshot: 'up-next-rail' }), + note({ + type: 'perf', + highlight: 'Faster imports', + body: 'Playlists import faster.', + sourcePath: '.changes/m3u-faster-imports.md', + }), + ], + { ...planOptions, screenshotsDir: shotsDir } + ); + const written = await renderCards(plan, '0.24.0', outputDir); + + assert.deepEqual( + written.map((file) => path.basename(file)), + [ + 'card-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); + } + }); +}); diff --git a/tools/release/project.json b/tools/release/project.json index e9003ea6a..8c3518d36 100644 --- a/tools/release/project.json +++ b/tools/release/project.json @@ -10,6 +10,10 @@ "inputs": [ "{workspaceRoot}/tools/release/release-notes.mjs", "{workspaceRoot}/tools/release/release-notes-render.mjs", + "{workspaceRoot}/tools/release/release-announcements.mjs", + "{workspaceRoot}/tools/release/verify-draft-release.mjs", + "{workspaceRoot}/tools/release/highlight-cards.mjs", + "{workspaceRoot}/tools/release/generate-highlight-cards.mjs", "{workspaceRoot}/tools/release/extract-changelog-section.mjs", "{workspaceRoot}/tools/release/check-release-note-gate.mjs", "{workspaceRoot}/tools/release/build-release-notes.mjs", @@ -19,10 +23,13 @@ "{workspaceRoot}/tools/release/release-notes.test.mjs", "{workspaceRoot}/tools/release/release-note-gate.test.mjs", "{workspaceRoot}/tools/release/build-release-notes.test.mjs", + "{workspaceRoot}/tools/release/release-announcements.test.mjs", + "{workspaceRoot}/tools/release/verify-draft-release.test.mjs", + "{workspaceRoot}/tools/release/highlight-cards.test.mjs", "{workspaceRoot}/tools/release/screenshot-guards.test.mjs" ], "options": { - "command": "node --test tools/release/release-notes.test.mjs tools/release/release-note-gate.test.mjs tools/release/build-release-notes.test.mjs tools/release/screenshot-guards.test.mjs", + "command": "node --test tools/release/release-notes.test.mjs tools/release/release-note-gate.test.mjs tools/release/build-release-notes.test.mjs tools/release/release-announcements.test.mjs tools/release/verify-draft-release.test.mjs tools/release/highlight-cards.test.mjs tools/release/screenshot-guards.test.mjs", "cwd": "{workspaceRoot}" } }, diff --git a/tools/release/release-announcements.mjs b/tools/release/release-announcements.mjs new file mode 100644 index 000000000..6d055a865 --- /dev/null +++ b/tools/release/release-announcements.mjs @@ -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`; +} diff --git a/tools/release/release-announcements.test.mjs b/tools/release/release-announcements.test.mjs new file mode 100644 index 000000000..3944d0fee --- /dev/null +++ b/tools/release/release-announcements.test.mjs @@ -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\//); + }); +}); diff --git a/tools/release/release-notes-render.mjs b/tools/release/release-notes-render.mjs index a8e4006b4..f18fbc0e8 100644 --- a/tools/release/release-notes-render.mjs +++ b/tools/release/release-notes-render.mjs @@ -222,12 +222,17 @@ export function upsertChangelogSection(changelog, section, version, marker) { } /** - * Blog entries carrying a `screenshot:` slug become their own subsection with - * an image slider; the rest stay bullets. + * Blog entries carrying a `screenshot:` slug or a `highlight:` headline become + * their own subsection (with an image slider when a screenshot exists); the + * rest stay bullets. */ function renderBlogGroup(group, { slug, links }) { - const bullets = group.notes.filter((note) => !note.screenshot); - const featured = group.notes.filter((note) => note.screenshot); + const bullets = group.notes.filter( + (note) => !note.screenshot && !note.highlight + ); + const featured = group.notes.filter( + (note) => note.screenshot || note.highlight + ); const blocks = [`## ${group.heading}`]; if (bullets.length > 0) { @@ -242,9 +247,23 @@ function renderBlogGroup(group, { slug, links }) { } for (const note of featured) { - // The heading is editorial work — a note body makes a terrible one. - // Leave a visible TODO instead of pretending otherwise; the whole - // scaffold ships as `draft: true` anyway. + // A `highlight:` headline is the editorial headline; without one the + // heading is editorial work a note body cannot stand in for — leave a + // visible TODO instead of pretending otherwise; the whole scaffold + // ships as `draft: true` anyway. + blocks.push( + note.highlight + ? `### ${escapeMdx(note.highlight)}` + : `### TODO headline (${note.area})` + ); + blocks.push( + `${escapeMdx(oneLine(note.body))}${formatReferences(note, links)}` + ); + + if (!note.screenshot) { + continue; + } + // Embedded in a single-quoted JS string inside MDX. Backslashes must // be escaped before apostrophes, or a body ending in `\` produces an // unterminated string and breaks the website build. @@ -258,10 +277,6 @@ function renderBlogGroup(group, { slug, links }) { ) .join('\n'); - blocks.push(`### TODO headline (${note.area})`); - blocks.push( - `${escapeMdx(oneLine(note.body))}${formatReferences(note, links)}` - ); blocks.push(``); } diff --git a/tools/release/release-notes.mjs b/tools/release/release-notes.mjs index 8f50c4027..8b1c751c1 100644 --- a/tools/release/release-notes.mjs +++ b/tools/release/release-notes.mjs @@ -4,7 +4,7 @@ * One file per user-visible change, written by the PR author while the * context is still fresh. The generator (build-release-notes.mjs) turns the * accumulated files into the GitHub release body, the CHANGELOG.md section, - * and the website blog scaffold. + * the website blog scaffold, and the Telegram/Reddit announcement drafts. * * Deliberately dependency-free: a hand-rolled parser for this tiny, closed * schema is more predictable than a YAML engine, and it can reject unknown @@ -27,13 +27,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(', ')})` diff --git a/tools/release/release-notes.test.mjs b/tools/release/release-notes.test.mjs index 86c0eb5ad..0b5c729d9 100644 --- a/tools/release/release-notes.test.mjs +++ b/tools/release/release-notes.test.mjs @@ -45,6 +45,7 @@ function note(overrides = {}) { area: 'playback', issues: [], screenshot: null, + highlight: null, unknownKeys: [], body: 'Series now show an Up Next rail beside the player.', sourcePath: '.changes/playback-up-next.md', @@ -101,6 +102,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, / { const body = `${'Series show the rest of the season beside the player '.repeat(4)}now.`; const content = renderBlogScaffold( diff --git a/tools/release/verify-draft-release.mjs b/tools/release/verify-draft-release.mjs new file mode 100644 index 000000000..5ee490abe --- /dev/null +++ b/tools/release/verify-draft-release.mjs @@ -0,0 +1,358 @@ +#!/usr/bin/env node +/** + * Waits for the `v` tag build and verifies the draft GitHub release + * it creates: run conclusion, draft status, authored body, and the complete + * required asset set. + * + * node tools/release/verify-draft-release.mjs # package.json version + * node tools/release/verify-draft-release.mjs 0.24.0 + * node tools/release/verify-draft-release.mjs --no-wait 0.24.0 + * + * Read-only: it never publishes, edits, or deletes anything. Publishing the + * release stays a manual act after this check and the installer smoke tests. + * + * The required set mirrors what `.github/workflows/build-and-make.yaml` + * uploads for a complete matrix build (verified against a real full run). + * When the build matrix gains or loses a target, update REQUIRED_ASSET_RULES + * in the same PR. + */ + +import { execFileSync, spawnSync } from 'node:child_process'; +import { readFileSync } 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] []'; + +/** + * @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); + } +} diff --git a/tools/release/verify-draft-release.test.mjs b/tools/release/verify-draft-release.test.mjs new file mode 100644 index 000000000..ceb2fa4c4 --- /dev/null +++ b/tools/release/verify-draft-release.test.mjs @@ -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/ + ); + }); +});