diff --git a/.changes/README.md b/.changes/README.md
index dcf8e4b35..a73a5972b 100644
--- a/.changes/README.md
+++ b/.changes/README.md
@@ -104,6 +104,9 @@ 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.
+An internal-only release has nothing to announce: both formats then print an
+explanation on stderr, leave stdout empty, and exit 0 — the same shape
+`extract-changelog-section.mjs --public` uses for its empty public body.
The release sequence is: bump the version → `release:notes:changelog` →
`release:notes:blog` → `--consume` → commit → tag → push. The tag build then
diff --git a/AGENTS.md b/AGENTS.md
index 964f5274f..e45dbd3b6 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -66,11 +66,14 @@ This file provides guidance to coding agents working in this repository.
- Name it `-.md`; `area` matches the conventional-commit scope. There is no version field — the release version is chosen at release time.
- Write the body for a user, not a reviewer: "the player now remembers volume between episodes", not "hoist volume state into the session". Max 400 characters; depth belongs in the release blog post.
- `type: internal` records invisible maintenance. Internal notes stay collapsed in `CHANGELOG.md`, are omitted from the blog scaffold, and are removed from the authored public GitHub body by `extract-changelog-section.mjs --public`; GitHub's generated commit list remains separate, so an internal-only release can have an empty authored body.
+- `highlight: ` (max 80 characters, rejected on `type: internal`) marks a note as one of the release's two or three headline changes. Highlights lead the Telegram/Reddit announcement drafts, become ready-made blog section headings, and are the input the highlight-card generator renders from. A release where everything is a highlight has none.
- Skip the note for test-only changes, docs, CI/workflow plumbing, and pure refactors with no behavior change. When skipping on a PR that touches `apps/**` or `libs/**`, apply the `no-release-note` label.
- CI enforces this: the "Release note gate" job in `.github/workflows/ci.yml` fails PRs that change runtime code without an added `.changes/*.md` or the label (policy in `tools/release/check-release-note-gate.mjs`; tests/e2e/website/mock-server/docs paths are auto-exempt).
- The `release-notes` skill covers writing notes; the `release-cut` skill covers the full release sequence.
- Validate before finishing: `pnpm run release:notes:validate`.
+- Announcement drafts and highlight cards are built from the same notes: `pnpm run release:notes:telegram` and `pnpm run release:notes:reddit` print paste-ready posts to stdout (Telegram is guaranteed to fit its 4096-character limit), and `pnpm run release:cards:generate` renders branded 1200×630 highlight cards plus a release hero into `dist/release-highlight-cards//`. All three read `highlight:` metadata that exists only in the note files, so they must run before `build-release-notes.mjs --consume`; the cards additionally need `release:screenshots` to have run. Nothing is posted or copied into the website tree automatically.
- Pushes to `master` and `v*` can publish Docker images. A `v*` tag build creates a draft GitHub release.
+- `pnpm run release:verify:draft` waits for that tag build (polling until the run is indexed, then `gh run watch`) and verifies the draft's status, authored body, and complete required asset set. It is read-only and deliberately fails on an already-published release, because it is the gate that runs before publication.
- Publishing the GitHub release verifies its Snap assets and automatically uploads them to `edge`; installed-Snap smoke and candidate/stable promotion remain manual.
- Release-post screenshots come only from the release capture script running against the mock servers. Never add a screenshot taken from a real playlist or account to `apps/website/public/blog/**` — real streams, logos, and metadata are copyrighted, and credentials must never reach a published image.
- Final task summaries should state whether a release note was added or why it was skipped.
diff --git a/CLAUDE.md b/CLAUDE.md
index ab4c7e62d..e7109e7c5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -32,11 +32,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- Name it `-.md`; `area` matches the conventional-commit scope. There is no version field — the release version is chosen at release time.
- Write the body for a user, not a reviewer: "the player now remembers volume between episodes", not "hoist volume state into the session". Max 400 characters; depth belongs in the release blog post.
- `type: internal` records invisible maintenance. Internal notes stay collapsed in `CHANGELOG.md`, are omitted from the blog scaffold, and are removed from the authored public GitHub body by `extract-changelog-section.mjs --public`; GitHub's generated commit list remains separate, so an internal-only release can have an empty authored body.
+- `highlight: ` (max 80 characters, rejected on `type: internal`) marks a note as one of the release's two or three headline changes. Highlights lead the Telegram/Reddit announcement drafts, become ready-made blog section headings, and are the input the highlight-card generator renders from. A release where everything is a highlight has none.
- Skip the note for test-only changes, docs, CI/workflow plumbing, and pure refactors with no behavior change. When skipping on a PR that touches `apps/**` or `libs/**`, apply the `no-release-note` label.
- CI enforces this: the "Release note gate" job in `.github/workflows/ci.yml` fails PRs that change runtime code without an added `.changes/*.md` or the label (policy in `tools/release/check-release-note-gate.mjs`; tests/e2e/website/mock-server/docs paths are auto-exempt).
- The `release-notes` skill covers writing notes; the `release-cut` skill covers the full release sequence.
- Validate before finishing: `pnpm run release:notes:validate`.
+- Announcement drafts and highlight cards are built from the same notes: `pnpm run release:notes:telegram` and `pnpm run release:notes:reddit` print paste-ready posts to stdout (Telegram is guaranteed to fit its 4096-character limit), and `pnpm run release:cards:generate` renders branded 1200×630 highlight cards plus a release hero into `dist/release-highlight-cards//`. All three read `highlight:` metadata that exists only in the note files, so they must run before `build-release-notes.mjs --consume`; the cards additionally need `release:screenshots` to have run. Nothing is posted or copied into the website tree automatically.
- Pushes to `master` and `v*` can publish Docker images. A `v*` tag build creates a draft GitHub release.
+- `pnpm run release:verify:draft` waits for that tag build (polling until the run is indexed, then `gh run watch`) and verifies the draft's status, authored body, and complete required asset set. It is read-only and deliberately fails on an already-published release, because it is the gate that runs before publication.
- Publishing the GitHub release verifies its Snap assets and automatically uploads them to `edge`; installed-Snap smoke and candidate/stable promotion remain manual.
- Release-post screenshots come only from the release capture script running against the mock servers. Never add a screenshot taken from a real playlist or account to `apps/website/public/blog/**` — real streams, logos, and metadata are copyrighted, and credentials must never reach a published image.
- Final task summaries should state whether a release note was added or why it was skipped.
diff --git a/tools/release/build-release-notes.mjs b/tools/release/build-release-notes.mjs
index de6780671..648a29bdb 100644
--- a/tools/release/build-release-notes.mjs
+++ b/tools/release/build-release-notes.mjs
@@ -241,6 +241,27 @@ function writeBlogScaffold(content, version, force) {
return target;
}
+/**
+ * An internal-only release has nothing to announce publicly. That is a legal
+ * release shape, so it prints an explanation on stderr and leaves stdout
+ * empty — matching `extract-changelog-section.mjs --public`, which allows an
+ * empty public body for exactly the same case — rather than failing.
+ *
+ * @param {string | null} post
+ * @param {string} label
+ */
+function writeAnnouncement(post, label) {
+ if (post === null) {
+ console.error(
+ `Internal-only release: no public ${label} announcement to render.`
+ );
+
+ return;
+ }
+
+ process.stdout.write(post.endsWith('\n') ? post : `${post}\n`);
+}
+
function main() {
const options = parseArgs(process.argv.slice(2));
const notesDir = path.resolve(workspaceRoot, options.dir);
@@ -304,11 +325,11 @@ function main() {
// 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`);
+ writeAnnouncement(renderTelegramPost(notes, { version }), 'Telegram');
}
if (options.format === 'reddit') {
- process.stdout.write(renderRedditPost(notes, { version }));
+ writeAnnouncement(renderRedditPost(notes, { version }), 'Reddit');
}
if (options.format === 'changelog') {
diff --git a/tools/release/build-release-notes.test.mjs b/tools/release/build-release-notes.test.mjs
index 86839af4a..8c513c7f1 100644
--- a/tools/release/build-release-notes.test.mjs
+++ b/tools/release/build-release-notes.test.mjs
@@ -6,7 +6,7 @@
*/
import assert from 'node:assert/strict';
-import { execFileSync } from 'node:child_process';
+import { spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
@@ -36,24 +36,24 @@ function makeNotesDir() {
}
/**
+ * spawnSync rather than execFileSync: a successful run also writes to stderr
+ * (the resolved-version notice, the internal-only announcement explanation),
+ * and execFileSync only hands back stdout.
+ *
* @returns {{ status: number, stdout: string, stderr: string }}
*/
function runCli(args) {
- try {
- const stdout = execFileSync(process.execPath, [CLI, ...args], {
- cwd: workspaceRoot,
- encoding: 'utf8',
- stdio: ['ignore', 'pipe', 'pipe'],
- });
+ const result = spawnSync(process.execPath, [CLI, ...args], {
+ cwd: workspaceRoot,
+ encoding: 'utf8',
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
- return { status: 0, stdout, stderr: '' };
- } catch (error) {
- return {
- status: error.status ?? 1,
- stdout: error.stdout ?? '',
- stderr: error.stderr ?? '',
- };
- }
+ return {
+ status: result.status ?? 1,
+ stdout: result.stdout ?? '',
+ stderr: result.stderr ?? '',
+ };
}
after(() => {
@@ -134,6 +134,32 @@ describe('build-release-notes CLI arguments', () => {
assert.match(result.stdout, /- \*\*playback\*\* — An example note\./);
});
+ it('reports an internal-only release instead of failing on announcements', () => {
+ const directory = mkdtempSync(path.join(tmpdir(), 'release-cli-'));
+
+ tempDirs.push(directory);
+ writeFileSync(
+ path.join(directory, 'deps-bump.md'),
+ '---\ntype: internal\narea: deps\n---\n\nBumped a parser.\n',
+ 'utf8'
+ );
+
+ for (const format of ['telegram', 'reddit']) {
+ const result = runCli([
+ '--format',
+ format,
+ '--version',
+ '0.24.0',
+ '--dir',
+ directory,
+ ]);
+
+ assert.equal(result.status, 0, format);
+ assert.equal(result.stdout, '', format);
+ assert.match(result.stderr, /Internal-only release/, format);
+ }
+ });
+
it('still rejects a genuinely unknown argument', () => {
const result = runCli(['--validate', '--nope']);
diff --git a/tools/release/highlight-cards.mjs b/tools/release/highlight-cards.mjs
index 42499863b..24ea5490a 100644
--- a/tools/release/highlight-cards.mjs
+++ b/tools/release/highlight-cards.mjs
@@ -112,9 +112,11 @@ export function planHighlightCards(notes, options) {
const highlights = ordered.filter((note) => note.highlight);
const feature = highlights.map((note) => {
- const slug =
- note.screenshot ??
- path.basename(note.sourcePath, '.md').toLowerCase();
+ // Named after the note file, never the screenshot slug: filenames are
+ // unique within `.changes/`, while two highlights may legitimately
+ // point at the same manifest shot — naming cards after it would let
+ // one silently overwrite the other.
+ const slug = path.basename(note.sourcePath, '.md').toLowerCase();
return {
slug,
diff --git a/tools/release/highlight-cards.test.mjs b/tools/release/highlight-cards.test.mjs
index 308cf64a8..9ffa6cb10 100644
--- a/tools/release/highlight-cards.test.mjs
+++ b/tools/release/highlight-cards.test.mjs
@@ -109,7 +109,7 @@ describe('planHighlightCards', () => {
assert.deepEqual(
plan.feature.map((job) => job.fileName),
- ['card-settings-rework.png', 'card-up-next-rail.png']
+ ['card-settings-rework.png', 'card-playback-up-next.png']
);
assert.equal(
plan.feature[1].screenshotPath,
@@ -123,6 +123,34 @@ describe('planHighlightCards', () => {
assert.equal(plan.hero.counts, '1 breaking change · 1 feature · 1 fix');
});
+ it('keeps card filenames unique when two highlights share a screenshot', () => {
+ const plan = planHighlightCards(
+ [
+ note({
+ highlight: 'Rail on wide windows',
+ screenshot: 'up-next-rail',
+ sourcePath: '.changes/playback-up-next.md',
+ }),
+ note({
+ highlight: 'Rail progress bars',
+ screenshot: 'up-next-rail',
+ sourcePath: '.changes/playback-rail-progress.md',
+ }),
+ ],
+ planOptions
+ );
+
+ assert.deepEqual(
+ plan.feature.map((job) => job.fileName),
+ ['card-playback-up-next.png', 'card-playback-rail-progress.png']
+ );
+ // Both still read the same shared screenshot.
+ assert.equal(
+ plan.feature[0].screenshotPath,
+ plan.feature[1].screenshotPath
+ );
+ });
+
it('never plans a card for internal notes', () => {
const plan = planHighlightCards(
[note({ type: 'internal', highlight: 'Nope' })],
@@ -278,7 +306,7 @@ describe('renderCards', () => {
assert.deepEqual(
written.map((file) => path.basename(file)),
[
- 'card-up-next-rail.png',
+ 'card-playback-up-next.png',
'card-m3u-faster-imports.png',
'hero.png',
'hero.jpg',
diff --git a/tools/release/release-announcements.mjs b/tools/release/release-announcements.mjs
index 6d055a865..714b0771f 100644
--- a/tools/release/release-announcements.mjs
+++ b/tools/release/release-announcements.mjs
@@ -72,13 +72,20 @@ function moreLine(count) {
*
* @param {object[]} notes
* @param {{ version: string }} options
- * @returns {string} a post guaranteed to fit TELEGRAM_MESSAGE_LIMIT
+ * @returns {string | null} a post guaranteed to fit TELEGRAM_MESSAGE_LIMIT,
+ * or null for an internal-only release with nothing public to announce
*/
export function renderTelegramPost(notes, { version }) {
const { highlights, rest } = splitHighlights(notes);
const lead = highlights.length > 0 ? highlights : rest;
const leadIsHighlights = highlights.length > 0;
+ // An internal-only release is a legal shape — its authored GitHub body is
+ // empty too — and there is simply nothing to announce publicly.
+ if (lead.length === 0) {
+ return null;
+ }
+
const footer = [
`⬇️ Download: ${releaseUrl(version)}`,
`📝 Full notes: ${blogUrl(version)}`,
@@ -137,11 +144,15 @@ export function renderTelegramPost(notes, { version }) {
*
* @param {object[]} notes
* @param {{ version: string }} options
- * @returns {string}
+ * @returns {string | null} null for an internal-only release
*/
export function renderRedditPost(notes, { version }) {
const { highlights, rest } = splitHighlights(notes);
+ if (highlights.length === 0 && rest.length === 0) {
+ return null;
+ }
+
const title =
highlights.length > 0
? `IPTVnator v${version} — ${highlights.map((note) => note.highlight).join(', ')}`
diff --git a/tools/release/release-announcements.test.mjs b/tools/release/release-announcements.test.mjs
index 3944d0fee..538a1c728 100644
--- a/tools/release/release-announcements.test.mjs
+++ b/tools/release/release-announcements.test.mjs
@@ -126,6 +126,15 @@ describe('renderTelegramPost', () => {
assert.match(post, /…plus \d+ more fixes and improvements\./);
});
+ it('returns null for an internal-only release instead of throwing', () => {
+ assert.equal(
+ renderTelegramPost([note({ type: 'internal' })], {
+ version: '0.24.0',
+ }),
+ null
+ );
+ });
+
it('refuses to silently drop a hand-picked highlight', () => {
const notes = Array.from({ length: 30 }, (_, index) =>
note({
@@ -190,6 +199,15 @@ describe('renderRedditPost', () => {
assert.doesNotMatch(post, /Internal churn/);
});
+ it('returns null for an internal-only release', () => {
+ assert.equal(
+ renderRedditPost([note({ type: 'internal' })], {
+ version: '0.24.0',
+ }),
+ null
+ );
+ });
+
it('links the patch blog post to its minor release page', () => {
const post = renderRedditPost([note()], { version: '0.24.2' });
diff --git a/tools/release/verify-draft-release.mjs b/tools/release/verify-draft-release.mjs
index 5ee490abe..f890c7b8f 100644
--- a/tools/release/verify-draft-release.mjs
+++ b/tools/release/verify-draft-release.mjs
@@ -79,13 +79,19 @@ export function requiredAssetRules(version) {
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]+)$`
- );
+ // Compared as plain strings rather than through a regex built from the
+ // version: this function is exported, so escaping the interpolated value
+ // correctly would be a standing trap. Only the compression suffix, a
+ // literal pattern, is matched by regex.
+ const pacmanExact = `iptvnator-${version}-linux-x64.pacman`;
+ const pacmanPrefix = `iptvnator-${version}-linux-x86_64.pkg.tar.`;
rules.push({
- label: `Pacman (iptvnator-${version}-linux-x64.pacman or .pkg.tar.*)`,
- matches: (candidate) => pacmanPattern.test(candidate),
+ label: `Pacman (${pacmanExact} or …-linux-x86_64.pkg.tar.*)`,
+ matches: (candidate) =>
+ candidate === pacmanExact ||
+ (candidate.startsWith(pacmanPrefix) &&
+ /^[a-z0-9]+$/.test(candidate.slice(pacmanPrefix.length))),
});
for (const name of [
@@ -141,7 +147,9 @@ export function parseVerifyArguments(args) {
} else if (arg === '--repo') {
const value = args[index + 1];
- if (!value || value.startsWith('--')) {
+ // Shape-checked here so a typo fails with this script's usage
+ // line instead of an opaque gh error several calls later.
+ if (!value || !/^[\w.-]+\/[\w.-]+$/.test(value)) {
return null;
}
@@ -173,36 +181,67 @@ export function parseVerifyArguments(args) {
return options;
}
+/** A just-pushed tag's run is not immediately visible to the API. */
+export const RUN_POLL_ATTEMPTS = 10;
+export const RUN_POLL_INTERVAL_MS = 6000;
+
+/**
+ * `gh run list` reports what is indexed right now — its `--limit` caps how
+ * many runs come back, it does not wait for one to appear. Run straight after
+ * `git push v`, the tag build is routinely not indexed yet,
+ * so poll for a bounded window before concluding the tag was never pushed.
+ *
+ * @returns {Promise