chore(release): author release notes in .changes instead of reconstructing them (#1256)

* chore(release): author release notes in .changes instead of reconstructing them

CHANGELOG.md has been frozen at 0.12.0 since 2023 while the app shipped
0.23.0, semantic-release sat in devDependencies with no config, and the real
user-facing notes were a 280-line MDX post written from memory at release
time. The gap was never version math — it was authored notes captured while
the context is still fresh.

Add a `.changes/*.md` note format (type, area, issues, screenshot; no version
field, since the release version is chosen deliberately) plus a generator that
composes the GitHub release body, the CHANGELOG.md section and a blog-post
scaffold from the accumulated notes.

Changesets was considered and rejected: it versions multiple published
packages, and this repo has exactly one private package. Its `version` step
would also rewrite CHANGELOG.md into a flatter format than the blog post and
fight the deliberate, updater-constrained version choice.

- hand-rolled frontmatter parser over a YAML engine: the schema is closed, so
  it can reject unknown keys, which is what catches typos
- PR numbers are resolved from the commit that added the note, never written
  by the author
- MDX-significant characters in note bodies are escaped so a stray `<` cannot
  break the website build
- blog scaffold ships `draft: true` with explicit TODO headings; the prose is
  editorial work, only the inventory is mechanical
- revive CHANGELOG.md with an honest pointer for 0.13.0-0.23.0 rather than
  fabricating the missing history
- drop the five unused semantic-release/conventional-changelog packages

Docs: `.changes/README.md`, plus a "Release Notes For User-Visible Changes"
section mirrored in CLAUDE.md and AGENTS.md, and a PR template checkbox for
contributors who never read either.

Tests: 26 unit tests in tools/release/release-notes.test.mjs covering parsing,
validation, grouping and all three renderers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(release): default the notes version to package.json and harden alt escaping

Review follow-ups on the release-notes generator.

- `--version` now defaults to the root package.json version, so the
  `release🎶*` package scripts run bare instead of failing on a missing
  argument. Bumping package.json is the deliberate act that starts a release,
  which makes it the right single source of truth; `--version` remains as an
  override for dry runs before the bump. The notice goes to stderr so
  `--format github` keeps a pipeable stdout.
- Escape backslashes before apostrophes when building the MDX `alt` string
  literal. A note body ending in a backslash previously produced an
  unterminated string and would have broken the website build.
- Document that release posts are one per minor version, in the slug helper,
  the overwrite error, and `.changes/README.md` — a patch release edits the
  existing post rather than creating a second one.

Tests: +1 regression test for the alt escaping, verified to fail without the
fix (27 total, all passing).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(ci): put authored notes into the tag release body, fail-closed

Wires the .changes pipeline into the release workflow (Codex review P1 on
#1256). Calling the generator from the tag build cannot work — --consume
deletes .changes/ before the tag exists — so the tag build reads what the
generator already wrote: release-meta now fills BODY from the CHANGELOG.md
section matching the tag's version via tools/release/extract-changelog-section.mjs.

generate_release_notes stays on, so GitHub's commit list renders below the
authored notes; the existing draft-metadata repair step already concatenates
RELEASE_BODY with the generated notes, so the rare duplicate-draft path keeps
the same layering unchanged.

The extractor exits non-zero when the section is missing or empty, failing
the release instead of silently shipping PR-title-only notes. A hotfix tag
cut without running release:notes:changelog therefore fails at create-release
by design; the error message names the exact commands to run.

Tests: 5 new extractor tests (32 total in release-tools, all passing);
packaging suite (247) re-run green since build-and-make.yaml is one of its
inputs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(release): escape all regex metacharacters in the changelog extractor

CodeQL flagged the version-to-RegExp interpolation in
extract-changelog-section.mjs (regex injection + incomplete escaping): only
dots were escaped, and while the CLI validates its argument as bare semver
before calling, the exported extractSection() carries no such guarantee on
its own. Escape the full metacharacter set so no caller can inject pattern
syntax, with tests covering wildcard dots, alternation, `.*` and backslashes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(release): make changelog generation idempotent per version

Codex review P2 on #1256: rerunning `release:notes:changelog` for the same
version — the normal move after correcting a note before --consume —
prepended a second section instead of replacing the first, leaving duplicate
release entries.

Extract the marker insertion into upsertChangelogSection(): it removes any
existing section for the version, then rebuilds around the marker rather than
string-replacing into it, so the blank-line count on both sides stays exact
on both the fresh-insert and replace paths. The CLI reports when a section
was replaced.

Tests: 4 new cases (insert, replace-not-duplicate, neighbours untouched,
missing marker); 37 total passing. End-to-end rerun verified: one heading,
latest date wins, extractor output unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 authored and GitHub committed 2026-07-25 18:22:43 +02:00
1 parent 9885178f32
commit 270350c2e1
14 files changed
+1699 -1668

No files matched your search

+86
View File
@@ -0,0 +1,86 @@
# Release notes (`.changes/`)
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.
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.
## File
Name it `<area>-<short-slug>.md`, e.g. `.changes/playback-up-next-rail.md`.
```markdown
---
type: feature
area: playback
issues: [1187]
screenshot: up-next-rail
---
Series now show an "Up Next" rail beside the player on wide windows: the rest
of the current season, watch progress, and click-to-play inline.
```
| Field | Required | Value |
| ------------ | -------- | ----------------------------------------------------------- |
| `type` | yes | `breaking`, `feature`, `fix`, `perf`, or `internal` |
| `area` | yes | lowercase slug, same as the conventional-commit scope |
| `issues` | no | issue numbers this closes — `[1187]` or `1187` |
| `screenshot` | no | slug from the release screenshot manifest |
There is **no version field**. The release version is chosen deliberately at
release time, not derived from these files.
You never write a PR number: the generator resolves it from the commit that
added the file.
## Writing the body
One to three sentences, present tense, **written for a user, not a reviewer**.
The body is capped at 400 characters — depth belongs in the blog post.
- ❌ "Refactor `WebVideoControlsAdapter` to hoist volume state into the session"
- ✅ "The player now remembers volume between episodes"
- ❌ "Fix off-by-one in `resolveEnrichmentSeasonNumber`"
- ✅ "Series whose title carries a season marker no longer show the wrong season"
`type: internal` is for changes with no user-visible effect that are still worth
recording (dependency bumps with behaviour risk, packaging moves). They stay out
of the release body and blog post, and land collapsed in `CHANGELOG.md`.
## When a note is not needed
Skip the note — and apply the `no-release-note` label — for test-only changes,
docs, CI/workflow plumbing, and pure refactors with no behaviour change.
## Commands
```bash
pnpm run release:notes:validate
pnpm run release:notes:github
pnpm run release:notes:changelog
pnpm run release:notes:blog
node tools/release/build-release-notes.mjs --consume
```
The release version comes from the root `package.json` — bump it first, then
generate. `--version 0.24.0` overrides it for a dry run before the bump.
Only `--consume` deletes anything; every other mode is a safe dry run.
The release sequence is: bump the version → `release:notes:changelog` →
`release:notes:blog` → `--consume` → commit → tag → push. The tag build then
extracts the new `CHANGELOG.md` section into the GitHub release body
(`tools/release/extract-changelog-section.mjs`) and **fails the release** if
the section is missing — a tag cut without the changelog step cannot silently
ship PR-title-only notes.
The website publishes **one post per minor version** (`v0-18` … `v0-22`), and
release screenshots live under the matching `blog/v0-24/` directory. A patch
release therefore edits the existing post rather than generating a new one, so
`--format blog` refuses to overwrite unless you pass `--force`.
+23
View File
@@ -0,0 +1,23 @@
<!--
Thanks for contributing to IPTVnator!
Keep the description short — what changed and why is enough.
-->
## What changed
## Why
## Release note
Changes a user could notice need one file in `.changes/` describing the change
in plain language — see
[`.changes/README.md`](https://github.com/4gray/iptvnator/blob/master/.changes/README.md).
It becomes the release notes and the website post, so it is worth a minute.
- [ ] Added a note under `.changes/`
- [ ] Not needed (test-only, docs, CI, or a refactor with no behavior change)
## Checks
- [ ] Tests added or updated for the changed behavior
- [ ] `pnpm run lint` and the affected `pnpm nx test <project>` pass
+9 -1
View File
@@ -1450,7 +1450,15 @@ jobs:
if [ "${IS_TAG_BUILD}" = "true" ]; then
NAME="Release v${VERSION}"
TAG="${GITHUB_REF_NAME}"
BODY=""
# Authored release notes: the release flow writes this
# CHANGELOG section from .changes/*.md before tagging
# (see .changes/README.md), so at tag time the changelog
# is the authored source of truth. The extractor exits
# non-zero when the section is missing, failing the
# release rather than silently shipping PR-title-only
# notes. generate_release_notes stays on below, so the
# GitHub commit list still renders under this body.
BODY="$(node tools/release/extract-changelog-section.mjs "${VERSION}")"
elif [ "${EVENT_NAME}" = "pull_request" ]; then
NAME="v${VERSION} — PR #${PR_NUMBER} @ ${SHORT_SHA} [test]"
TAG="test-pr-${PR_NUMBER}"
+10
View File
@@ -33,6 +33,16 @@ This file provides guidance to coding agents working in this repository.
- Repo docs are canonical even when they were originally drafted by an LLM.
- Final task summaries should state whether docs were updated and which doc changed.
## Release Notes For User-Visible Changes
- Any change a user could notice — new behavior, changed behavior, bug fix, performance win, breaking change — must add one note file under `.changes/` in the same PR. Format, field table, and writing rules: `.changes/README.md`.
- Name it `<area>-<short-slug>.md`; `area` matches the conventional-commit scope. There is no version field — the release version is chosen at release time.
- Write the body for a user, not a reviewer: "the player now remembers volume between episodes", not "hoist volume state into the session". Max 400 characters; depth belongs in the release blog post.
- 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.
- Validate before finishing: `pnpm run release:notes:validate`.
- 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.
## Regression Prevention And Test Updates
- Before the final summary for any feature, behavior change, bug fix, data-flow change, Electron IPC/database change, or user-visible UI workflow change, complete a test impact pass. Identify the affected projects and decide whether unit, integration, E2E, build, lint, or manual/CDP verification is required.
+13
View File
@@ -1,3 +1,16 @@
# Changelog
Releases **0.13.0 – 0.23.0** were published as
[GitHub releases](https://github.com/4gray/iptvnator/releases) and
[website posts](https://4gray.github.io/iptvnator/blog/) rather than collected
here. This file resumes from the next release onwards; the entries below are
kept as they were written.
New sections are generated from `.changes/*.md` and inserted directly below
this marker — see `.changes/README.md`.
<!-- next-release -->
# [0.12.0](https://github.com/4gray/iptvnator/compare/v0.11.1...v0.12.0) (2023-03-11)
+10
View File
@@ -26,6 +26,16 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- Repo docs are canonical even when they were originally drafted by an LLM.
- Final task summaries should state whether docs were updated and which doc changed.
## Release Notes For User-Visible Changes
- Any change a user could notice — new behavior, changed behavior, bug fix, performance win, breaking change — must add one note file under `.changes/` in the same PR. Format, field table, and writing rules: `.changes/README.md`.
- Name it `<area>-<short-slug>.md`; `area` matches the conventional-commit scope. There is no version field — the release version is chosen at release time.
- Write the body for a user, not a reviewer: "the player now remembers volume between episodes", not "hoist volume state into the session". Max 400 characters; depth belongs in the release blog post.
- 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.
- Validate before finishing: `pnpm run release:notes:validate`.
- 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.
## Regression Prevention And Test Updates
- Before the final summary for any feature, behavior change, bug fix, data-flow change, Electron IPC/database change, or user-visible UI workflow change, Claude Code must complete a test impact pass. Identify the affected projects and decide whether unit, integration, E2E, build, lint, or manual/CDP verification is required.
+4 -5
View File
@@ -61,6 +61,10 @@
"release:artwork:manifest": "tsx tools/release/generate-marketing-artwork.ts --manifest",
"release:artwork:generate": "tsx tools/release/generate-marketing-artwork.ts --generate",
"release:artwork:validate": "tsx tools/release/generate-marketing-artwork.ts --validate",
"release:notes:validate": "node tools/release/build-release-notes.mjs --validate",
"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",
"lint": "nx run-many --target=lint --all",
"build": "nx build electron-backend"
},
@@ -157,9 +161,6 @@
"@nx/workspace": "22.7.1",
"@playwright/test": "^1.36.0",
"@schematics/angular": "21.2.9",
"@semantic-release/changelog": "6.0.3",
"@semantic-release/git": "10.0.1",
"@semantic-release/npm": "12.0.1",
"@swc-node/register": "1.11.1",
"@swc/core": "1.15.8",
"@swc/helpers": "0.5.18",
@@ -177,7 +178,6 @@
"@typescript-eslint/utils": "^8.46.2",
"angular-eslint": "21.3.1",
"astro": "5.18.1",
"conventional-changelog-cli": "5.0.0",
"cors": "2.8.6",
"drizzle-kit": "0.31.5",
"electron": "^41.7.2",
@@ -204,7 +204,6 @@
"nx": "22.7.1",
"nx-electron": "22.0.0",
"prettier": "^3.8.1",
"semantic-release": "24.2.9",
"sharp": "0.34.5",
"tailwindcss": "^3.4.19",
"ts-jest": "^29.4.5",
-1662
View File
File diff suppressed because it is too large. Load diff
+312
View File
@@ -0,0 +1,312 @@
#!/usr/bin/env node
/**
* Composes release surfaces from the notes accumulated in `.changes/`.
*
* node tools/release/build-release-notes.mjs --validate
* 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 --consume
*
* Every mode except `--consume` is a safe dry run: nothing is deleted unless
* `--consume` is passed explicitly.
*/
import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import { loadNotes } from './release-notes.mjs';
import {
releaseSlug,
renderBlogScaffold,
renderChangelogSection,
renderGithubBody,
upsertChangelogSection,
} from './release-notes-render.mjs';
const workspaceRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../..'
);
const CHANGELOG_PATH = path.join(workspaceRoot, 'CHANGELOG.md');
const BLOG_DIR = path.join(workspaceRoot, 'apps/website/src/content/blog');
/** Generated sections are inserted directly below this marker. */
const CHANGELOG_MARKER = '<!-- next-release -->';
const FORMATS = new Set(['github', 'changelog', 'blog']);
function parseArgs(argv) {
const options = {
format: null,
version: null,
previous: null,
date: new Date().toISOString().slice(0, 10),
dir: '.changes',
validate: false,
consume: false,
force: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
const takeValue = () => {
const value = argv[index + 1];
if (value === undefined || value.startsWith('--')) {
throw new Error(`${arg} requires a value`);
}
index += 1;
return value;
};
switch (arg) {
case '--format':
options.format = takeValue();
break;
case '--version':
options.version = takeValue();
break;
case '--previous':
options.previous = takeValue();
break;
case '--date':
options.date = takeValue();
break;
case '--dir':
options.dir = takeValue();
break;
case '--validate':
options.validate = true;
break;
case '--consume':
options.consume = true;
break;
case '--force':
options.force = true;
break;
default:
throw new Error(`unknown argument: ${arg}`);
}
}
return options;
}
function git(args) {
return execFileSync('git', args, {
cwd: workspaceRoot,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
}
/**
* Resolves each note to the commit that added it, and to a PR number when the
* commit subject carries one. Authors never write a PR number themselves —
* they cannot know it while the branch is still local.
*
* @param {object[]} notes
* @returns {Map<string, { commit?: string, pr?: number }>}
*/
function resolveLinks(notes) {
const links = new Map();
for (const note of notes) {
let output = '';
try {
output = git([
'log',
'--diff-filter=A',
'--format=%H%x00%s',
'-1',
'--',
note.sourcePath,
]);
} catch {
// Not a git checkout, or the note is not committed yet. Entries
// simply render without a reference link.
}
if (!output) {
continue;
}
const [commit, subject = ''] = output.split('\0');
const prMatch = subject.match(/\(#(\d+)\)\s*$/);
links.set(note.sourcePath, {
commit,
pr: prMatch ? Number(prMatch[1]) : undefined,
});
}
return links;
}
/** Latest `v*` tag, used for the CHANGELOG compare link. */
function detectPreviousVersion() {
try {
return git(['describe', '--tags', '--abbrev=0', '--match=v*']).replace(
/^v/,
''
);
} catch {
return null;
}
}
/**
* The release version is the one in the root package.json — bumping it is the
* deliberate act that starts a release. `--version` stays available as an
* override (dry runs, previewing a version before the bump), but the default
* keeps a single source of truth and lets the package scripts run bare.
*/
function resolveVersion(options) {
let version = options.version;
if (!version) {
version = JSON.parse(
readFileSync(path.join(workspaceRoot, 'package.json'), 'utf8')
).version;
// stderr, so `--format github` keeps a clean pipeable stdout.
console.error(`Using version ${version} from package.json.`);
}
if (!/^\d+\.\d+\.\d+$/.test(version)) {
throw new Error(
`version must be a bare semver like 0.24.0, got "${version}"`
);
}
return version;
}
function writeChangelog(section, version) {
const { content, replaced } = upsertChangelogSection(
readFileSync(CHANGELOG_PATH, 'utf8'),
section,
version,
CHANGELOG_MARKER
);
if (replaced) {
console.error(`Replacing existing CHANGELOG.md section for ${version}.`);
}
writeFileSync(CHANGELOG_PATH, content, 'utf8');
return CHANGELOG_PATH;
}
function writeBlogScaffold(content, version, force) {
const target = path.join(
BLOG_DIR,
`${releaseSlug(version)}-release-notes.mdx`
);
if (existsSync(target) && !force) {
throw new Error(
[
`${path.relative(workspaceRoot, target)} already exists.`,
'The website publishes one post per minor version, so a patch',
'release edits the existing post instead of creating a new one.',
'Pass --force only to regenerate it from scratch.',
].join(' ')
);
}
writeFileSync(target, content, 'utf8');
return target;
}
function main() {
const options = parseArgs(process.argv.slice(2));
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}`);
}
console.error(
'\nSee .changes/README.md for the expected format.'
);
process.exit(1);
}
if (options.validate) {
console.log(`${notes.length} release note(s) valid.`);
}
if (options.format) {
if (!FORMATS.has(options.format)) {
throw new Error(
`unknown --format "${options.format}" (expected ${[...FORMATS].join(', ')})`
);
}
const version = resolveVersion(options);
const links = resolveLinks(notes);
if (notes.length === 0) {
throw new Error(
`no notes found in ${path.relative(workspaceRoot, notesDir)}/ — nothing to render`
);
}
if (options.format === 'github') {
process.stdout.write(`${renderGithubBody(notes, { links })}\n`);
}
if (options.format === 'changelog') {
const section = renderChangelogSection(notes, {
version,
date: options.date,
previousVersion: options.previous ?? detectPreviousVersion(),
links,
});
const target = writeChangelog(section, version);
console.log(`Updated ${path.relative(workspaceRoot, target)}`);
}
if (options.format === 'blog') {
const content = renderBlogScaffold(notes, {
version,
date: options.date,
links,
});
const target = writeBlogScaffold(content, version, options.force);
console.log(`Wrote ${path.relative(workspaceRoot, target)}`);
}
}
if (options.consume) {
for (const note of notes) {
rmSync(note.sourcePath);
}
console.log(`Removed ${notes.length} consumed note(s).`);
}
if (!options.format && !options.validate && !options.consume) {
throw new Error(
'nothing to do — pass --validate, --format <github|changelog|blog>, or --consume'
);
}
}
try {
main();
} catch (error) {
console.error(error.message);
process.exit(1);
}
@@ -0,0 +1,99 @@
#!/usr/bin/env node
/**
* Prints the CHANGELOG.md section for one version to stdout.
*
* node tools/release/extract-changelog-section.mjs 0.24.0
*
* Used by the tag-build release job to put the authored notes into the
* GitHub release body. `.changes/*.md` files are consumed before the tag
* exists, so at tag time the changelog section IS the authored source of
* truth — this reads what the generator already wrote.
*
* Exits non-zero when the section is missing so a forgotten
* `release:notes:changelog` fails the release instead of silently shipping
* PR-title-only notes.
*/
import { readFileSync } from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
const workspaceRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../..'
);
/**
* @param {string} changelog full CHANGELOG.md content
* @param {string} version bare semver, e.g. `0.24.0`
* @returns {string | null} section body without its own H1 heading
*/
export function extractSection(changelog, version) {
const lines = changelog.replace(/\r\n/g, '\n').split('\n');
// The CLI validates its argument, but this function is exported on its
// own — escape every regex metacharacter rather than only dots so no
// caller can inject pattern syntax.
const escapedVersion = version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Matches both heading shapes used in this file:
// # [0.24.0](compare-url) (2026-08-01)
// # 0.24.0 (2026-08-01)
const headingPattern = new RegExp(
`^#\\s+\\[?${escapedVersion}\\]?\\s*[( ]`
);
const start = lines.findIndex((line) => headingPattern.test(line));
if (start === -1) {
return null;
}
let end = lines.length;
for (let index = start + 1; index < lines.length; index += 1) {
if (/^#\s/.test(lines[index])) {
end = index;
break;
}
}
return lines.slice(start + 1, end).join('\n').trim();
}
function main() {
const version = process.argv[2];
if (!version || !/^\d+\.\d+\.\d+$/.test(version)) {
console.error(
'Usage: extract-changelog-section.mjs <version> (for example 0.24.0)'
);
process.exit(2);
}
const changelogPath = path.join(workspaceRoot, 'CHANGELOG.md');
const section = extractSection(readFileSync(changelogPath, 'utf8'), version);
if (section === null) {
console.error(
[
`CHANGELOG.md has no section for ${version}.`,
'The release flow writes it before tagging:',
' pnpm run release:notes:changelog',
' node tools/release/build-release-notes.mjs --consume',
'Commit the changelog, then re-tag.',
].join('\n')
);
process.exit(1);
}
if (section === '') {
console.error(`CHANGELOG.md section for ${version} is empty.`);
process.exit(1);
}
process.stdout.write(`${section}\n`);
}
// Allow importing extractSection from tests without running the CLI.
if (process.argv[1] === fileURLToPath(import.meta.url)) {
main();
}
+32
View File
@@ -0,0 +1,32 @@
{
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"name": "release-tools",
"projectType": "library",
"sourceRoot": "tools/release",
"targets": {
"test": {
"executor": "nx:run-commands",
"cache": true,
"inputs": [
"{workspaceRoot}/tools/release/release-notes.mjs",
"{workspaceRoot}/tools/release/release-notes-render.mjs",
"{workspaceRoot}/tools/release/extract-changelog-section.mjs",
"{workspaceRoot}/tools/release/release-notes.test.mjs"
],
"options": {
"command": "node --test tools/release/release-notes.test.mjs",
"cwd": "{workspaceRoot}"
}
},
"lint": {
"inputs": [
"default",
"{workspaceRoot}/eslint.config.mjs",
"{workspaceRoot}/tools/eslint-rules/**/*",
"{workspaceRoot}/tools/eslint/**/*"
],
"command": "eslint \"tools/release/*.mjs\""
}
},
"tags": ["scope:tools", "domain:release", "type:tool"]
}
+324
View File
@@ -0,0 +1,324 @@
/**
* Renderers turning grouped `.changes/*.md` notes into the three release
* surfaces: the GitHub release body, the CHANGELOG.md section, and the
* website blog scaffold.
*/
import { extractSection } from './extract-changelog-section.mjs';
import { groupNotes, REPO_URL } from './release-notes.mjs';
const MONTHS = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
/**
* @param {string} isoDate `YYYY-MM-DD`
* @returns {string} e.g. `August 1, 2026`
*/
export function formatLongDate(isoDate) {
const [year, month, day] = isoDate.split('-').map(Number);
return `${MONTHS[month - 1]} ${day}, ${year}`;
}
/**
* Deliberately minor-scoped: the website publishes one release post per minor
* version (`v0-18` … `v0-22`), and its screenshot assets live under the same
* directory. A patch release therefore reuses its minor's post rather than
* starting a new one.
*
* @param {string} version
* @returns {string} e.g. `v0-24` for both `0.24.0` and `0.24.1`
*/
export function releaseSlug(version) {
const [major, minor] = version.split('.');
return `v${major}-${minor}`;
}
/** Collapses a note body to a single line for list entries. */
function oneLine(body) {
return body.replace(/\s+/g, ' ').trim();
}
/** Trims to a word boundary; used for image alt text, never for prose. */
function truncate(text, max) {
if (text.length <= max) {
return text;
}
const cut = text.slice(0, max);
const lastSpace = cut.lastIndexOf(' ');
return `${(lastSpace > max / 2 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`;
}
/**
* MDX parses `<` and `{` as markup. Note bodies are plain prose written by
* humans and agents, so escape them rather than letting a stray character
* break the website build. The closing counterparts are escaped too, so a
* body like `<live>` renders as written instead of half-escaped.
*/
function escapeMdx(text) {
return text
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\{/g, '&#123;')
.replace(/\}/g, '&#125;');
}
/**
* @param {object} note
* @param {Map<string, { pr?: number, commit?: string }>} links
* @returns {string} trailing `([#123](url), closes [#45](url))` or ''
*/
function formatReferences(note, links) {
const parts = [];
const link = links.get(note.sourcePath);
if (link?.pr) {
parts.push(`[#${link.pr}](${REPO_URL}/pull/${link.pr})`);
} else if (link?.commit) {
parts.push(
`[${link.commit.slice(0, 7)}](${REPO_URL}/commit/${link.commit})`
);
}
for (const issue of note.issues) {
parts.push(`closes [#${issue}](${REPO_URL}/issues/${issue})`);
}
return parts.length > 0 ? ` (${parts.join(', ')})` : '';
}
function formatEntry(note, links) {
return `- **${note.area}** — ${oneLine(note.body)}${formatReferences(note, links)}`;
}
/**
* GitHub release body. `internal` notes are omitted: the release page is read
* by users, and GitHub still appends its own full commit list below.
*
* @param {object[]} notes
* @param {{ links?: Map<string, object> }} [options]
* @returns {string}
*/
export function renderGithubBody(notes, { links = new Map() } = {}) {
const sections = groupNotes(notes)
.filter((group) => group.type !== 'internal')
.map((group) => {
const entries = group.notes
.map((note) => formatEntry(note, links))
.join('\n');
return `## ${group.heading}\n\n${entries}`;
});
return sections.join('\n\n');
}
/**
* CHANGELOG.md section. Unlike the release body this keeps `internal` notes,
* collapsed, so the file stays a complete record.
*
* @param {object[]} notes
* @param {{ version: string, date: string, previousVersion?: string | null, links?: Map<string, object> }} options
* @returns {string}
*/
export function renderChangelogSection(
notes,
{ version, date, previousVersion = null, links = new Map() }
) {
const heading = previousVersion
? `# [${version}](${REPO_URL}/compare/v${previousVersion}...v${version}) (${date})`
: `# ${version} (${date})`;
const blocks = [heading];
for (const group of groupNotes(notes)) {
const entries = group.notes
.map((note) => formatEntry(note, links))
.join('\n');
if (group.type === 'internal') {
blocks.push(
`<details>\n<summary>Internal changes</summary>\n\n${entries}\n\n</details>`
);
continue;
}
blocks.push(`### ${group.heading}\n\n${entries}`);
}
return `${blocks.join('\n\n')}\n`;
}
/**
* Inserts a version section below the marker, replacing any existing section
* for the same version — rerunning `--format changelog` after correcting a
* note must not prepend a duplicate.
*
* @param {string} changelog full CHANGELOG.md content
* @param {string} section rendered section (from renderChangelogSection)
* @param {string} version bare semver the section describes
* @param {string} marker insertion marker line
* @returns {{ content: string, replaced: boolean }}
*/
export function upsertChangelogSection(changelog, section, version, marker) {
if (!changelog.includes(marker)) {
throw new Error(
`changelog is missing the \`${marker}\` marker that new sections are inserted below`
);
}
let current = changelog;
const replaced = extractSection(current, version) !== null;
if (replaced) {
const lines = current.split('\n');
const headingIndex = lines.findIndex(
(line) =>
line.startsWith(`# [${version}]`) ||
line.startsWith(`# ${version} `)
);
let end = lines.length;
for (let index = headingIndex + 1; index < lines.length; index += 1) {
if (/^#\s/.test(lines[index])) {
end = index;
break;
}
}
current = [...lines.slice(0, headingIndex), ...lines.slice(end)].join(
'\n'
);
}
// Rebuild around the marker instead of string-replacing into it, so the
// blank-line count on both sides of the section stays exact regardless of
// whether a removal just happened.
const markerIndex = current.indexOf(marker);
const before = current.slice(0, markerIndex);
const after = current
.slice(markerIndex + marker.length)
.replace(/^\n+/, '');
return {
content: `${before}${marker}\n\n${section.trim()}\n\n${after}`,
replaced,
};
}
/**
* Blog entries carrying a `screenshot:` slug become their own subsection with
* an image slider; 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 blocks = [`## ${group.heading}`];
if (bullets.length > 0) {
blocks.push(
bullets
.map(
(note) =>
`- **${note.area}** — ${escapeMdx(oneLine(note.body))}${formatReferences(note, links)}`
)
.join('\n')
);
}
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.
// 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.
const alt = truncate(oneLine(note.body), 120)
.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'");
const images = ['dark', 'light']
.map(
(theme) =>
` {\n src: '/iptvnator/blog/${slug}/screenshots/${note.screenshot}-${theme}.png',\n alt: '${alt}',\n },`
)
.join('\n');
blocks.push(`### TODO headline (${note.area})`);
blocks.push(
`${escapeMdx(oneLine(note.body))}${formatReferences(note, links)}`
);
blocks.push(`<BlogImageSlider\n images={[\n${images}\n ]}\n/>`);
}
return blocks.join('\n\n');
}
/**
* Scaffold for `apps/website/src/content/blog/v0-24-release-notes.mdx`.
* Deliberately incomplete: `draft: true`, TODO markers for the narrative and
* description. The prose is editorial work, only the inventory is mechanical.
*
* @param {object[]} notes
* @param {{ version: string, date: string, links?: Map<string, object> }} options
* @returns {string}
*/
export function renderBlogScaffold(notes, { version, date, links = new Map() }) {
const slug = releaseSlug(version);
const shortVersion = slug.replace('-', '.');
const sections = groupNotes(notes)
.filter((group) => group.type !== 'internal')
.map((group) => renderBlogGroup(group, { slug, links }));
const frontmatter = [
'---',
`title: ${shortVersion} - Release Notes`,
'description: TODO — one sentence naming the two or three headline changes.',
'featured: true',
`pubDate: ${date}`,
'author: 4gray',
`heroImage: /iptvnator/blog/${slug}/hero.jpg`,
'tags:',
' - release',
' - release-notes',
` - ${shortVersion}`,
'draft: true',
'---',
].join('\n');
const imports = [
"import BlogImageSlider from '../../components/blog/BlogImageSlider.astro';",
"import ReleaseMeta from '../../components/blog/ReleaseMeta.astro';",
].join('\n');
const meta = [
'<ReleaseMeta',
` version="v${version}"`,
` releaseDate="${formatLongDate(date)}"`,
" channels={['Desktop', 'PWA']}",
'/>',
].join('\n');
return [
frontmatter,
imports,
'{/* TODO: narrative intro — what this release is about, not what it contains. */}',
meta,
...sections,
'',
].join('\n\n');
}
+260
View File
@@ -0,0 +1,260 @@
/**
* Parsing and validation for `.changes/*.md` release notes.
*
* 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.
*
* Deliberately dependency-free: a hand-rolled parser for this tiny, closed
* schema is more predictable than a YAML engine, and it can reject unknown
* keys — which is what catches agent typos.
*/
import { readdirSync, readFileSync } from 'node:fs';
import path from 'node:path';
export const REPO_URL = 'https://github.com/4gray/iptvnator';
/** Render order. `internal` is last and is excluded from user-facing output. */
export const NOTE_TYPES = ['breaking', 'feature', 'fix', 'perf', 'internal'];
export const TYPE_HEADINGS = {
breaking: 'Breaking changes',
feature: 'Features',
fix: 'Fixes',
perf: 'Performance',
internal: 'Internal',
};
const KNOWN_KEYS = new Set(['type', 'area', 'issues', 'screenshot']);
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;
/**
* Splits a note into its frontmatter lines and body.
*
* @param {string} content raw file content
* @returns {{ frontmatterLines: string[], body: string }}
*/
function splitFrontmatter(content) {
const lines = content.replace(/\r\n/g, '\n').split('\n');
if (lines[0]?.trim() !== DELIMITER) {
throw new Error('missing opening `---` frontmatter delimiter');
}
const closingIndex = lines.findIndex(
(line, index) => index > 0 && line.trim() === DELIMITER
);
if (closingIndex === -1) {
throw new Error('missing closing `---` frontmatter delimiter');
}
return {
frontmatterLines: lines.slice(1, closingIndex),
body: lines.slice(closingIndex + 1).join('\n').trim(),
};
}
/**
* Parses a single `key: value` frontmatter line.
*
* Trailing `# comment` text is stripped: no value in this schema (enum slug,
* area slug, issue numbers) can legitimately contain `#`, and the documented
* examples carry explanatory comments.
*
* @param {string} line
* @returns {[string, string] | null} key/value pair, or null for blank lines
*/
function parseFrontmatterLine(line) {
const withoutComment = line.replace(/\s+#.*$/, '').trim();
if (!withoutComment) {
return null;
}
const separatorIndex = withoutComment.indexOf(':');
if (separatorIndex === -1) {
throw new Error(`frontmatter line is not \`key: value\`: "${line.trim()}"`);
}
const key = withoutComment.slice(0, separatorIndex).trim();
const value = withoutComment
.slice(separatorIndex + 1)
.trim()
.replace(/^['"]|['"]$/g, '');
return [key, value];
}
/**
* @param {string} value e.g. `[1187, 1188]` or `1187`
* @returns {number[]}
*/
function parseIssueList(value) {
const inner = value.startsWith('[') ? value.replace(/^\[|\]$/g, '') : value;
return inner
.split(',')
.map((entry) => entry.trim().replace(/^#/, ''))
.filter(Boolean)
.map((entry) => Number(entry));
}
/**
* Parses a note file into a structured record. Shape errors throw; semantic
* errors are reported by validateNote() so a run can list every problem at
* once instead of failing on the first one.
*
* @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 }}
*/
export function parseNote(content, sourcePath) {
const { frontmatterLines, body } = splitFrontmatter(content);
const fields = new Map();
for (const line of frontmatterLines) {
const parsed = parseFrontmatterLine(line);
if (!parsed) {
continue;
}
const [key, value] = parsed;
if (fields.has(key)) {
throw new Error(`duplicate frontmatter key: \`${key}\``);
}
fields.set(key, value);
}
return {
type: fields.get('type') ?? '',
area: fields.get('area') ?? '',
issues: fields.has('issues') ? parseIssueList(fields.get('issues')) : [],
screenshot: fields.get('screenshot') ?? null,
unknownKeys: [...fields.keys()].filter((key) => !KNOWN_KEYS.has(key)),
body,
sourcePath,
};
}
/**
* @param {ReturnType<typeof parseNote>} note
* @returns {string[]} human-readable problems, empty when the note is valid
*/
export function validateNote(note) {
const errors = [];
if (!note.type) {
errors.push('`type` is required');
} else if (!NOTE_TYPES.includes(note.type)) {
errors.push(
`unknown \`type\`: "${note.type}" (expected one of ${NOTE_TYPES.join(', ')})`
);
}
if (!note.area) {
errors.push('`area` is required (use the conventional-commit scope)');
} else if (!SLUG_PATTERN.test(note.area)) {
errors.push(`\`area\` must be a lowercase slug, got "${note.area}"`);
}
if (!note.body) {
errors.push('body is empty — describe the change for a user');
} else if (note.body.length > MAX_BODY_LENGTH) {
errors.push(
`body is ${note.body.length} characters, max ${MAX_BODY_LENGTH} — keep it to a few sentences and put detail in the blog post`
);
}
for (const issue of note.issues) {
if (!Number.isInteger(issue) || issue <= 0) {
errors.push(`\`issues\` must be positive integers, got "${issue}"`);
}
}
if (note.screenshot !== null && !SLUG_PATTERN.test(note.screenshot)) {
errors.push(
`\`screenshot\` must be a lowercase slug from the screenshot manifest, got "${note.screenshot}"`
);
}
for (const key of note.unknownKeys) {
errors.push(
`unknown frontmatter key: \`${key}\` (expected ${[...KNOWN_KEYS].join(', ')})`
);
}
return errors;
}
/**
* Reads and parses every note in a directory, sorted by filename so output
* ordering is stable across machines.
*
* @param {string} directory
* @returns {{ notes: object[], errors: string[] }}
*/
export function loadNotes(directory) {
let entries = [];
try {
entries = readdirSync(directory);
} catch (error) {
if (error.code === 'ENOENT') {
return { notes: [], errors: [] };
}
throw error;
}
const files = entries
.filter((name) => name.endsWith('.md') && name !== 'README.md')
.sort();
const notes = [];
const errors = [];
for (const name of files) {
const sourcePath = path.join(directory, name);
try {
const note = parseNote(readFileSync(sourcePath, 'utf8'), sourcePath);
const problems = validateNote(note);
if (problems.length > 0) {
errors.push(...problems.map((problem) => `${name}: ${problem}`));
continue;
}
notes.push(note);
} catch (error) {
errors.push(`${name}: ${error.message}`);
}
}
return { notes, errors };
}
/**
* Groups notes by type in render order, dropping empty groups.
*
* @param {object[]} notes
* @returns {{ type: string, heading: string, notes: object[] }[]}
*/
export function groupNotes(notes) {
return NOTE_TYPES.map((type) => ({
type,
heading: TYPE_HEADINGS[type],
notes: notes.filter((note) => note.type === type),
})).filter((group) => group.notes.length > 0);
}
+517
View File
@@ -0,0 +1,517 @@
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { after, describe, it } from 'node:test';
import {
groupNotes,
loadNotes,
parseNote,
validateNote,
} from './release-notes.mjs';
import {
formatLongDate,
releaseSlug,
renderBlogScaffold,
renderChangelogSection,
renderGithubBody,
upsertChangelogSection,
} from './release-notes-render.mjs';
import { extractSection } from './extract-changelog-section.mjs';
const tempDirs = [];
function makeNotesDir(files) {
const directory = mkdtempSync(path.join(tmpdir(), 'release-notes-'));
tempDirs.push(directory);
for (const [name, content] of Object.entries(files)) {
writeFileSync(path.join(directory, name), content, 'utf8');
}
return directory;
}
function note(overrides = {}) {
return {
type: 'feature',
area: 'playback',
issues: [],
screenshot: null,
unknownKeys: [],
body: 'Series now show an Up Next rail beside the player.',
sourcePath: '.changes/playback-up-next.md',
...overrides,
};
}
after(() => {
for (const directory of tempDirs) {
rmSync(directory, { recursive: true, force: true });
}
});
describe('parseNote', () => {
it('parses frontmatter, issue list and body', () => {
const parsed = parseNote(
[
'---',
'type: feature',
'area: playback',
'issues: [1187, 1188]',
'screenshot: up-next-rail',
'---',
'',
'Series now show an Up Next rail.',
'',
].join('\n'),
'.changes/playback-up-next.md'
);
assert.equal(parsed.type, 'feature');
assert.equal(parsed.area, 'playback');
assert.deepEqual(parsed.issues, [1187, 1188]);
assert.equal(parsed.screenshot, 'up-next-rail');
assert.equal(parsed.body, 'Series now show an Up Next rail.');
assert.deepEqual(parsed.unknownKeys, []);
});
it('strips trailing comments and quotes, and accepts a bare issue', () => {
const parsed = parseNote(
[
'---',
'type: fix # one of feature | fix | perf',
"area: 'm3u'",
'issues: 1204',
'---',
'Playlists with very long URLs parse again.',
].join('\n'),
'x.md'
);
assert.equal(parsed.type, 'fix');
assert.equal(parsed.area, 'm3u');
assert.deepEqual(parsed.issues, [1204]);
});
it('records unknown keys instead of dropping them silently', () => {
const parsed = parseNote(
['---', 'type: fix', 'area: m3u', 'scope: m3u', '---', 'Body.'].join(
'\n'
),
'x.md'
);
assert.deepEqual(parsed.unknownKeys, ['scope']);
});
it('rejects a missing or unterminated frontmatter block', () => {
assert.throws(
() => parseNote('type: fix\nBody.', 'x.md'),
/missing opening/
);
assert.throws(
() => parseNote('---\ntype: fix\nBody.', 'x.md'),
/missing closing/
);
});
it('rejects duplicate keys', () => {
assert.throws(
() =>
parseNote(
['---', 'type: fix', 'type: feature', '---', 'Body.'].join(
'\n'
),
'x.md'
),
/duplicate frontmatter key/
);
});
});
describe('validateNote', () => {
it('accepts a well-formed note', () => {
assert.deepEqual(validateNote(note()), []);
});
it('rejects an unknown type', () => {
const errors = validateNote(note({ type: 'chore' }));
assert.equal(errors.length, 1);
assert.match(errors[0], /unknown `type`: "chore"/);
});
it('rejects an unknown frontmatter key', () => {
const errors = validateNote(note({ unknownKeys: ['scope'] }));
assert.match(errors[0], /unknown frontmatter key: `scope`/);
});
it('rejects an empty body and an essay', () => {
assert.match(validateNote(note({ body: '' }))[0], /body is empty/);
assert.match(
validateNote(note({ body: 'x'.repeat(401) }))[0],
/max 400/
);
});
it('rejects a non-slug area and screenshot', () => {
assert.match(
validateNote(note({ area: 'Playback Engine' }))[0],
/`area` must be a lowercase slug/
);
assert.match(
validateNote(note({ screenshot: 'Up Next' }))[0],
/`screenshot` must be a lowercase slug/
);
});
it('rejects non-numeric issues', () => {
assert.match(
validateNote(note({ issues: [Number.NaN] }))[0],
/`issues` must be positive integers/
);
});
});
describe('loadNotes', () => {
it('returns an empty result for a missing directory', () => {
const result = loadNotes(path.join(tmpdir(), 'definitely-not-here-9d3f'));
assert.deepEqual(result, { notes: [], errors: [] });
});
it('skips README.md and reports per-file errors', () => {
const directory = makeNotesDir({
'README.md': '# How to write notes',
'a-good.md': '---\ntype: fix\narea: m3u\n---\nParses again.',
'b-bad.md': '---\ntype: chore\narea: m3u\n---\nNope.',
});
const { notes, errors } = loadNotes(directory);
assert.equal(notes.length, 1);
assert.equal(notes[0].area, 'm3u');
assert.equal(errors.length, 1);
assert.match(errors[0], /^b-bad\.md: unknown `type`/);
});
it('sorts notes by filename for stable output', () => {
const directory = makeNotesDir({
'z-last.md': '---\ntype: fix\narea: zzz\n---\nLast.',
'a-first.md': '---\ntype: fix\narea: aaa\n---\nFirst.',
});
assert.deepEqual(
loadNotes(directory).notes.map((entry) => entry.area),
['aaa', 'zzz']
);
});
});
describe('groupNotes', () => {
it('orders groups by severity and drops empty ones', () => {
const grouped = groupNotes([
note({ type: 'fix' }),
note({ type: 'breaking' }),
note({ type: 'feature' }),
]);
assert.deepEqual(
grouped.map((group) => group.type),
['breaking', 'feature', 'fix']
);
});
});
describe('renderGithubBody', () => {
it('groups entries, prefixes the area and links the PR', () => {
const links = new Map([
['.changes/playback-up-next.md', { commit: 'abc1234', pr: 1231 }],
]);
const body = renderGithubBody([note({ issues: [1187] })], { links });
assert.match(body, /^## Features\n\n- \*\*playback\*\* — Series now/);
assert.match(body, /\[#1231\]\(https:\/\/github\.com\/4gray\/iptvnator\/pull\/1231\)/);
assert.match(body, /closes \[#1187\]/);
});
it('falls back to a commit link when the note carries no PR', () => {
const links = new Map([
['.changes/playback-up-next.md', { commit: 'abcdef1234567' }],
]);
assert.match(renderGithubBody([note()], { links }), /\[abcdef1\]\(.*\/commit\/abcdef1234567\)/);
});
it('omits internal notes from the user-facing body', () => {
const body = renderGithubBody([
note({ type: 'internal', body: 'Split the store feature.' }),
note(),
]);
assert.match(body, /## Features/);
assert.doesNotMatch(body, /Internal|Split the store feature/);
});
});
describe('renderChangelogSection', () => {
it('links the compare range when a previous version is known', () => {
const section = renderChangelogSection([note()], {
version: '0.24.0',
date: '2026-08-01',
previousVersion: '0.23.0',
});
assert.match(
section,
/^# \[0\.24\.0\]\(https:\/\/github\.com\/4gray\/iptvnator\/compare\/v0\.23\.0\.\.\.v0\.24\.0\) \(2026-08-01\)/
);
});
it('keeps internal notes but collapses them', () => {
const section = renderChangelogSection(
[note(), note({ type: 'internal', body: 'Split the store.' })],
{ version: '0.24.0', date: '2026-08-01' }
);
assert.match(section, /### Features/);
assert.match(section, /<summary>Internal changes<\/summary>/);
assert.match(section, /Split the store\./);
});
});
describe('renderBlogScaffold', () => {
it('emits a draft with TODO markers and a release meta block', () => {
const content = renderBlogScaffold([note()], {
version: '0.24.0',
date: '2026-08-01',
});
assert.match(content, /^---\ntitle: v0\.24 - Release Notes/);
assert.match(content, /draft: true/);
assert.match(content, /TODO/);
assert.match(content, /releaseDate="August 1, 2026"/);
});
it('gives screenshot notes their own section with a dark/light slider', () => {
const content = renderBlogScaffold(
[note({ screenshot: 'up-next-rail' })],
{ version: '0.24.0', date: '2026-08-01' }
);
assert.match(content, /### TODO headline \(playback\)/);
assert.match(
content,
/\/iptvnator\/blog\/v0-24\/screenshots\/up-next-rail-dark\.png/
);
assert.match(
content,
/\/iptvnator\/blog\/v0-24\/screenshots\/up-next-rail-light\.png/
);
});
it('truncates a long body for image alt text without cutting mid-word', () => {
const body = `${'Series show the rest of the season beside the player '.repeat(4)}now.`;
const content = renderBlogScaffold(
[note({ body, screenshot: 'up-next-rail' })],
{ version: '0.24.0', date: '2026-08-01' }
);
const alt = content.match(/alt: '([^']*)'/)[1];
assert.ok(alt.length <= 121, `alt was ${alt.length} characters`);
assert.match(alt, /…$/);
assert.doesNotMatch(alt, /\s…$/);
});
it('escapes backslashes and apostrophes inside the alt string literal', () => {
const content = renderBlogScaffold(
[
note({
body: "Windows paths like C:\\Users no longer break the app's import.",
screenshot: 'windows-import',
}),
],
{ version: '0.24.0', date: '2026-08-01' }
);
const alt = content.match(/alt: '(.*)',/)[1];
assert.match(alt, /C:\\\\Users/);
assert.match(alt, /app\\'s/);
// An odd number of trailing backslashes would escape the closing quote.
assert.doesNotMatch(alt, /(^|[^\\])(\\\\)*\\$/);
});
it('escapes characters MDX would parse as markup', () => {
const content = renderBlogScaffold(
[note({ body: 'Channels named <live> and {vod} now sort correctly.' })],
{ version: '0.24.0', date: '2026-08-01' }
);
assert.doesNotMatch(content, /<live>/);
assert.doesNotMatch(content, /\{vod\}/);
assert.match(content, /&lt;live&gt;/);
assert.match(content, /&#123;vod&#125;/);
});
it('imports only the components it emits', () => {
const content = renderBlogScaffold([note()], {
version: '0.24.0',
date: '2026-08-01',
});
assert.match(content, /import ReleaseMeta from/);
assert.match(content, /import BlogImageSlider from/);
});
});
describe('upsertChangelogSection', () => {
const marker = '<!-- next-release -->';
const base = [
'# Changelog',
'',
marker,
'',
'# [0.12.0](https://example.com) (2023-03-11)',
'',
'- old entry',
].join('\n');
it('inserts a new section below the marker', () => {
const { content, replaced } = upsertChangelogSection(
base,
'# [0.24.0](url) (2026-08-01)\n\n### Features\n\n- entry',
'0.24.0',
marker
);
assert.equal(replaced, false);
assert.match(
content,
/<!-- next-release -->\n\n# \[0\.24\.0\]\(url\) \(2026-08-01\)/
);
assert.match(content, /- entry\n\n# \[0\.12\.0\]/);
});
it('replaces an existing section for the same version instead of duplicating', () => {
const first = upsertChangelogSection(
base,
'# [0.24.0](url) (2026-08-01)\n\n- v1 entry',
'0.24.0',
marker
).content;
const { content, replaced } = upsertChangelogSection(
first,
'# [0.24.0](url) (2026-08-02)\n\n- v2 entry',
'0.24.0',
marker
);
assert.equal(replaced, true);
assert.equal(content.match(/^# \[0\.24\.0\]/gm).length, 1);
assert.match(content, /v2 entry/);
assert.doesNotMatch(content, /v1 entry/);
assert.match(content, /- v2 entry\n\n# \[0\.12\.0\]/);
});
it('keeps other versions untouched when replacing', () => {
const first = upsertChangelogSection(
base,
'# [0.24.0](url) (2026-08-01)\n\n- v1',
'0.24.0',
marker
).content;
const { content } = upsertChangelogSection(
first,
'# [0.24.1](url) (2026-08-09)\n\n- patch',
'0.24.1',
marker
);
assert.match(content, /0\.24\.1.*\n\n- patch\n\n# \[0\.24\.0\]/);
assert.match(content, /- v1\n\n# \[0\.12\.0\]/);
});
it('throws when the marker is missing', () => {
assert.throws(
() => upsertChangelogSection('# Changelog', '# s', '0.24.0', marker),
/missing the/
);
});
});
describe('extractSection', () => {
const changelog = [
'# Changelog',
'',
'Intro paragraph with a pointer.',
'',
'<!-- next-release -->',
'',
'# [0.24.0](https://github.com/4gray/iptvnator/compare/v0.23.0...v0.24.0) (2026-08-01)',
'',
'### Features',
'',
'- **playback** — Up Next rail.',
'',
'<details>',
'<summary>Internal changes</summary>',
'',
'- **deps** — parser bump.',
'',
'</details>',
'',
'# [0.12.0](https://github.com/4gray/iptvnator/compare/v0.11.1...v0.12.0) (2023-03-11)',
'',
'### Bug Fixes',
'',
'- old entry',
].join('\n');
it('returns the section body without its own heading', () => {
const section = extractSection(changelog, '0.24.0');
assert.match(section, /^### Features/);
assert.match(section, /Up Next rail/);
assert.match(section, /<\/details>$/);
});
it('stops at the next release heading', () => {
const section = extractSection(changelog, '0.24.0');
assert.doesNotMatch(section, /0\.12\.0|old entry/);
});
it('matches the plain heading shape without a compare link', () => {
const plain = '# 0.24.0 (2026-08-01)\n\n### Fixes\n\n- entry';
assert.match(extractSection(plain, '0.24.0'), /^### Fixes/);
});
it('does not match a different patch of the same minor', () => {
assert.equal(extractSection(changelog, '0.24.1'), null);
});
it('returns null when the version is absent', () => {
assert.equal(extractSection(changelog, '9.9.9'), null);
});
it('treats regex metacharacters in the version as literals', () => {
// `.` must not act as a wildcard: 0x24y0 would match an unescaped 0.24.0
assert.equal(extractSection('# 0x24y0 (2026-08-01)\n\n- e', '0.24.0'), null);
// Injected pattern syntax must not throw or widen the match.
assert.equal(extractSection(changelog, '0.24.0|0.12.0'), null);
assert.equal(extractSection(changelog, '.*'), null);
assert.equal(extractSection(changelog, '0.24\\.0'), null);
});
});
describe('helpers', () => {
it('formats dates and release slugs', () => {
assert.equal(formatLongDate('2026-08-01'), 'August 1, 2026');
assert.equal(releaseSlug('0.24.0'), 'v0-24');
});
});