diff --git a/.claude/skills/release-cut/SKILL.md b/.claude/skills/release-cut/SKILL.md
new file mode 100644
index 000000000..2007a7795
--- /dev/null
+++ b/.claude/skills/release-cut/SKILL.md
@@ -0,0 +1,78 @@
+---
+name: release-cut
+description: Cut an IPTVnator release — bump the version, generate release notes from .changes/, scaffold the website post, tag, and verify the draft. Use when asked to release, cut a version, prepare release notes, or publish a new version.
+---
+
+# Release Cut
+
+The pipeline turns accumulated `.changes/*.md` notes into all three release
+surfaces. Order matters: **the tag build extracts the CHANGELOG section into
+the GitHub release body and fails if it is missing**, so the changelog step
+is not optional.
+
+## Sequence
+
+1. **Pick the version** — deliberate choice, edit `version` in the root
+ `package.json`. Bare semver only: any suffix flips electron-updater into
+ prerelease mode and leaks into installer version fields.
+
+2. **Review the notes** — read every file in `.changes/`. Fix wording (user
+ language, not reviewer language), then:
+
+ ```bash
+ pnpm run release:notes:validate
+ ```
+
+3. **Generate the changelog section** (idempotent per version — rerunning
+ replaces the section, so regenerate freely until it reads well):
+
+ ```bash
+ pnpm run release:notes:changelog
+ ```
+
+4. **Scaffold the website post**:
+
+ ```bash
+ pnpm run release:notes:blog
+ ```
+
+ Output is `apps/website/src/content/blog/v0-XX-release-notes.mdx` with
+ `draft: true`. The narrative intro, headlines, and `description` are
+ editorial — fill every `TODO` by hand. One post per **minor** version:
+ for a patch release, edit the existing post (the scaffold refuses to
+ overwrite without `--force`).
+
+5. **Screenshots** — only from the release capture script against the mock
+ servers (`tools/release/`), never from a real playlist or account: real
+ streams, logos, and TMDB artwork are copyrighted, and credentials must
+ never reach a published image. Output goes to
+ `apps/website/public/blog/v0-XX/screenshots/`.
+
+6. **Consume the notes** (the only destructive step):
+
+ ```bash
+ node tools/release/build-release-notes.mjs --consume
+ ```
+
+7. **Commit, tag, push**:
+
+ ```bash
+ git add CHANGELOG.md .changes apps/website package.json
+ git commit -m "chore(release): v0.XX.0"
+ git tag v0.XX.0 && git push && git push --tags
+ ```
+
+8. **Verify the draft release** once `build-and-make.yaml` finishes: authored
+ notes on top, GitHub's generated commit list below, all platform assets
+ present (`.dmg`/`.zip` + `latest-mac.yml`, `.exe`/`.msi` + `latest.yml`,
+ `.deb`/`.rpm`/`.AppImage`/`.snap`/`.flatpak` + `latest-linux*.yml`,
+ blockmaps). Publish manually; flip the blog post to `draft: false`.
+
+## Failure modes
+
+- **create-release fails with "CHANGELOG.md has no section for X"** — step 3
+ was skipped. Run it, commit, delete and re-push the tag.
+- **Snap store publication** is a separate manual flow after the public
+ release exists (`publish-snap.yaml`).
+- Post-release checklist candidates: i18n drift (`pnpm run i18n:check`),
+ update the website `v0-XX` blog assets, announce in Telegram.
diff --git a/.claude/skills/release-notes/SKILL.md b/.claude/skills/release-notes/SKILL.md
new file mode 100644
index 000000000..7ccb1338d
--- /dev/null
+++ b/.claude/skills/release-notes/SKILL.md
@@ -0,0 +1,70 @@
+---
+name: release-notes
+description: Write the .changes/ release note that every PR with a user-visible change must include. Use when creating or finishing a PR that changes behavior in apps/ or libs/, when the "Release note gate" CI check fails, or when deciding whether the no-release-note label applies.
+---
+
+# Release Notes (`.changes/`)
+
+Every PR with a user-visible change adds **one** note file under `.changes/`.
+At release time the notes become the GitHub release body, the `CHANGELOG.md`
+section, and the website blog scaffold. CI enforces this: the **Release note
+gate** check fails any PR that touches runtime code under `apps/` or `libs/`
+without an added `.changes/*.md` file or the `no-release-note` label.
+
+## File format
+
+Name: `.changes/-.md` — `area` matches the
+conventional-commit scope of the PR.
+
+```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` / `internal` |
+| `area` | yes | lowercase slug = conventional-commit scope |
+| `issues` | no | `[1187]` or bare `1187` — issues this PR closes |
+| `screenshot` | no | slug from the release screenshot manifest |
+
+- **No version field.** The release version is chosen at release time.
+- **Never write a PR number.** The generator resolves it from git.
+- Unknown keys fail validation — this is what catches typos like `scopr:`.
+
+## Writing the body
+
+One to three sentences, present tense, max 400 characters, **written for a
+user, not a reviewer**:
+
+- ❌ "Refactor `WebVideoControlsAdapter` to hoist volume state"
+- ✅ "The player now remembers volume between episodes"
+- ❌ "Fix off-by-one in `resolveEnrichmentSeasonNumber`"
+- ✅ "Series with a season marker in the title no longer show the wrong season"
+
+`type: internal` is for changes worth recording but invisible to users
+(dependency bumps with behavior risk, packaging moves). They are excluded
+from the release body and blog, and collapsed in `CHANGELOG.md`.
+
+## When to skip (`no-release-note` label)
+
+Test-only changes, docs, CI/workflow plumbing, pure refactors with no
+behavior change. The gate auto-exempts `*.spec.ts`, `*.e2e.ts`,
+`__snapshots__/`, `apps/website/`, `apps/*-e2e/`, `apps/*-mock-server/`,
+`libs/shared/testing/` and `*.md` — if only those changed, no label needed.
+
+## Verify before finishing
+
+```bash
+pnpm run release:notes:validate
+```
+
+Full format reference: `.changes/README.md`. Gate policy:
+`tools/release/check-release-note-gate.mjs`.
diff --git a/.codex/skills/release-cut/SKILL.md b/.codex/skills/release-cut/SKILL.md
new file mode 100644
index 000000000..2007a7795
--- /dev/null
+++ b/.codex/skills/release-cut/SKILL.md
@@ -0,0 +1,78 @@
+---
+name: release-cut
+description: Cut an IPTVnator release — bump the version, generate release notes from .changes/, scaffold the website post, tag, and verify the draft. Use when asked to release, cut a version, prepare release notes, or publish a new version.
+---
+
+# Release Cut
+
+The pipeline turns accumulated `.changes/*.md` notes into all three release
+surfaces. Order matters: **the tag build extracts the CHANGELOG section into
+the GitHub release body and fails if it is missing**, so the changelog step
+is not optional.
+
+## Sequence
+
+1. **Pick the version** — deliberate choice, edit `version` in the root
+ `package.json`. Bare semver only: any suffix flips electron-updater into
+ prerelease mode and leaks into installer version fields.
+
+2. **Review the notes** — read every file in `.changes/`. Fix wording (user
+ language, not reviewer language), then:
+
+ ```bash
+ pnpm run release:notes:validate
+ ```
+
+3. **Generate the changelog section** (idempotent per version — rerunning
+ replaces the section, so regenerate freely until it reads well):
+
+ ```bash
+ pnpm run release:notes:changelog
+ ```
+
+4. **Scaffold the website post**:
+
+ ```bash
+ pnpm run release:notes:blog
+ ```
+
+ Output is `apps/website/src/content/blog/v0-XX-release-notes.mdx` with
+ `draft: true`. The narrative intro, headlines, and `description` are
+ editorial — fill every `TODO` by hand. One post per **minor** version:
+ for a patch release, edit the existing post (the scaffold refuses to
+ overwrite without `--force`).
+
+5. **Screenshots** — only from the release capture script against the mock
+ servers (`tools/release/`), never from a real playlist or account: real
+ streams, logos, and TMDB artwork are copyrighted, and credentials must
+ never reach a published image. Output goes to
+ `apps/website/public/blog/v0-XX/screenshots/`.
+
+6. **Consume the notes** (the only destructive step):
+
+ ```bash
+ node tools/release/build-release-notes.mjs --consume
+ ```
+
+7. **Commit, tag, push**:
+
+ ```bash
+ git add CHANGELOG.md .changes apps/website package.json
+ git commit -m "chore(release): v0.XX.0"
+ git tag v0.XX.0 && git push && git push --tags
+ ```
+
+8. **Verify the draft release** once `build-and-make.yaml` finishes: authored
+ notes on top, GitHub's generated commit list below, all platform assets
+ present (`.dmg`/`.zip` + `latest-mac.yml`, `.exe`/`.msi` + `latest.yml`,
+ `.deb`/`.rpm`/`.AppImage`/`.snap`/`.flatpak` + `latest-linux*.yml`,
+ blockmaps). Publish manually; flip the blog post to `draft: false`.
+
+## Failure modes
+
+- **create-release fails with "CHANGELOG.md has no section for X"** — step 3
+ was skipped. Run it, commit, delete and re-push the tag.
+- **Snap store publication** is a separate manual flow after the public
+ release exists (`publish-snap.yaml`).
+- Post-release checklist candidates: i18n drift (`pnpm run i18n:check`),
+ update the website `v0-XX` blog assets, announce in Telegram.
diff --git a/.codex/skills/release-notes/SKILL.md b/.codex/skills/release-notes/SKILL.md
new file mode 100644
index 000000000..7ccb1338d
--- /dev/null
+++ b/.codex/skills/release-notes/SKILL.md
@@ -0,0 +1,70 @@
+---
+name: release-notes
+description: Write the .changes/ release note that every PR with a user-visible change must include. Use when creating or finishing a PR that changes behavior in apps/ or libs/, when the "Release note gate" CI check fails, or when deciding whether the no-release-note label applies.
+---
+
+# Release Notes (`.changes/`)
+
+Every PR with a user-visible change adds **one** note file under `.changes/`.
+At release time the notes become the GitHub release body, the `CHANGELOG.md`
+section, and the website blog scaffold. CI enforces this: the **Release note
+gate** check fails any PR that touches runtime code under `apps/` or `libs/`
+without an added `.changes/*.md` file or the `no-release-note` label.
+
+## File format
+
+Name: `.changes/-.md` — `area` matches the
+conventional-commit scope of the PR.
+
+```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` / `internal` |
+| `area` | yes | lowercase slug = conventional-commit scope |
+| `issues` | no | `[1187]` or bare `1187` — issues this PR closes |
+| `screenshot` | no | slug from the release screenshot manifest |
+
+- **No version field.** The release version is chosen at release time.
+- **Never write a PR number.** The generator resolves it from git.
+- Unknown keys fail validation — this is what catches typos like `scopr:`.
+
+## Writing the body
+
+One to three sentences, present tense, max 400 characters, **written for a
+user, not a reviewer**:
+
+- ❌ "Refactor `WebVideoControlsAdapter` to hoist volume state"
+- ✅ "The player now remembers volume between episodes"
+- ❌ "Fix off-by-one in `resolveEnrichmentSeasonNumber`"
+- ✅ "Series with a season marker in the title no longer show the wrong season"
+
+`type: internal` is for changes worth recording but invisible to users
+(dependency bumps with behavior risk, packaging moves). They are excluded
+from the release body and blog, and collapsed in `CHANGELOG.md`.
+
+## When to skip (`no-release-note` label)
+
+Test-only changes, docs, CI/workflow plumbing, pure refactors with no
+behavior change. The gate auto-exempts `*.spec.ts`, `*.e2e.ts`,
+`__snapshots__/`, `apps/website/`, `apps/*-e2e/`, `apps/*-mock-server/`,
+`libs/shared/testing/` and `*.md` — if only those changed, no label needed.
+
+## Verify before finishing
+
+```bash
+pnpm run release:notes:validate
+```
+
+Full format reference: `.changes/README.md`. Gate policy:
+`tools/release/check-release-note-gate.mjs`.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9e5ef8c4f..9f8b1031d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -42,6 +42,49 @@ jobs:
env:
SHELLCHECK_OPTS: --severity=warning
+ release-note-gate:
+ name: Release note gate
+ if: github.event_name == 'pull_request'
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ permissions:
+ contents: read
+ pull-requests: read
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ # The gate scripts are dependency-free Node, so this job skips
+ # pnpm install entirely and stays cheap.
+ - name: Validate release note format
+ run: node tools/release/build-release-notes.mjs --validate
+
+ # Labels are fetched live rather than read from the (stale) event
+ # payload, so applying `no-release-note` and re-running the check
+ # works without a new push. Policy lives in a unit-tested script,
+ # not in workflow bash.
+ - name: Require a release note for user-visible changes
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ run: |
+ set -euo pipefail
+
+ gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?per_page=100" \
+ --paginate --jq '[.[] | {filename, status}]' |
+ jq -s 'add // []' > /tmp/pr-files.json
+
+ gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/labels?per_page=100" \
+ --paginate --jq '[.[].name]' |
+ jq -s 'add // []' > /tmp/pr-labels.json
+
+ jq -n \
+ --slurpfile files /tmp/pr-files.json \
+ --slurpfile labels /tmp/pr-labels.json \
+ '{files: $files[0], labels: $labels[0]}' |
+ node tools/release/check-release-note-gate.mjs
+
lint:
name: Lint
runs-on: ubuntu-latest
diff --git a/.gitignore b/.gitignore
index eabdfdcd4..13da340a5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -72,7 +72,17 @@ Thumbs.db
.gemini
.cursor
.agent
-.claude
+# Agent config stays local, except the skills the repo owns. Claude Code only
+# discovers skills under .claude/skills/, so the release skills are committed
+# there as well as under .codex/skills/. Personal skills in .claude/skills/
+# remain ignored — each shared skill is opted in by name.
+.claude/*
+!.claude/skills/
+.claude/skills/*
+!.claude/skills/release-notes/
+!.claude/skills/release-notes/**
+!.claude/skills/release-cut/
+!.claude/skills/release-cut/**
.codex/*
!.codex/skills/
!.codex/skills/**
diff --git a/AGENTS.md b/AGENTS.md
index 1c608607c..aa1215c03 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -16,7 +16,7 @@ This file provides guidance to coding agents working in this repository.
- Use scoped path aliases from `tsconfig.base.json` such as `@iptvnator/services`, `@iptvnator/shared/interfaces`, and `@iptvnator/ui/components`. Do not add new imports from legacy bare aliases such as `services`, `shared-interfaces`, `components`, `m3u-state`, or `database`.
- Every Nx project should keep `scope:*`, `domain:*`, and `type:*` tags in `project.json` so `@nx/enforce-module-boundaries` remains useful for humans and agents.
- See `docs/architecture/nx-workspace-boundaries.md` for the current Nx tag and alias policy.
-- Repository-specific skills are committed under `.codex/skills/`. If an external agent does not support skills, treat those files as concise ownership docs.
+- Repository-specific skills are committed under `.codex/skills/`. Claude Code only discovers skills under `.claude/skills/`, so `release-notes` and `release-cut` are mirrored there and the two copies must be kept in sync; every other entry in `.claude/skills/` is personal and stays gitignored. If an external agent does not support skills, treat those files as concise ownership docs.
## Documentation After Changes
@@ -39,6 +39,8 @@ 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.
- 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`.
- 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 df74fe116..ae30b0305 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -32,6 +32,8 @@ 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.
- 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`.
- 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.
@@ -69,7 +71,7 @@ pnpm nx show projects
- Do not add new imports from legacy bare aliases such as `services`, `shared-interfaces`, `components`, `m3u-state`, or `database`.
- Every Nx project should keep `scope:*`, `domain:*`, and `type:*` tags in `project.json`.
- See `docs/architecture/nx-workspace-boundaries.md` for the current Nx tag and alias policy.
-- Repository-specific skills are committed under `.codex/skills/`. If Claude Code does not load skills directly, treat those files as concise ownership docs.
+- Repository-specific skills are committed under `.codex/skills/`. Claude Code only discovers skills under `.claude/skills/`, so `release-notes` and `release-cut` are mirrored there and the two copies must be kept in sync; every other entry in `.claude/skills/` is personal and stays gitignored. If an agent does not load skills directly, treat those files as concise ownership docs.
### Building and Serving
diff --git a/tools/release/check-release-note-gate.mjs b/tools/release/check-release-note-gate.mjs
new file mode 100644
index 000000000..b774466c3
--- /dev/null
+++ b/tools/release/check-release-note-gate.mjs
@@ -0,0 +1,155 @@
+#!/usr/bin/env node
+/**
+ * Release-note gate policy for pull requests.
+ *
+ * The CI job feeds it `{files: [{filename, status}], labels: [...]}` on
+ * stdin (from the GitHub PR API) and it decides whether the PR needs a
+ * `.changes/*.md` note. Kept as a pure function so the policy is unit-tested
+ * instead of living in workflow bash.
+ *
+ * Policy: a PR that touches runtime code under `apps/` or `libs/` must add
+ * one release note, unless it carries the `no-release-note` label. Test-only,
+ * website, e2e, and mock-server changes never require a note.
+ */
+
+import process from 'node:process';
+import { fileURLToPath } from 'node:url';
+
+export const GATE_LABEL = 'no-release-note';
+
+/** Paths that count as user-visible runtime code. */
+const TRIGGER_PREFIXES = ['apps/', 'libs/'];
+
+/**
+ * Changes matching any of these never require a note, even under a trigger
+ * prefix. Mirrors the "when a note is not needed" list in .changes/README.md.
+ */
+const EXEMPT_PATTERNS = [
+ /^apps\/website\//, // marketing site, not the app
+ /^apps\/[^/]*-e2e\//, // e2e projects
+ /^apps\/[^/]*mock-server\//, // dev/e2e fixtures
+ /\.spec\.[jt]s$/,
+ /\.e2e\.[jt]s$/,
+ /\/__snapshots__\//,
+ /\/testing\//, // libs/shared/testing and test-helper folders
+ /\.md$/, // docs anywhere
+];
+
+/**
+ * @param {{ filename: string }} file
+ * @returns {boolean} true when this change requires a release note
+ */
+function requiresNote(file) {
+ const { filename } = file;
+
+ if (!TRIGGER_PREFIXES.some((prefix) => filename.startsWith(prefix))) {
+ return false;
+ }
+
+ return !EXEMPT_PATTERNS.some((pattern) => pattern.test(filename));
+}
+
+/**
+ * Only a note this PR actually authored satisfies the gate.
+ *
+ * `status: 'added'` exclusively: the PR files API compares base…head, so a
+ * note created and then renamed inside the same PR still reports as `added`.
+ * A `renamed` entry therefore means the file already existed on the base
+ * branch — moving another PR's unconsumed note is not documenting this
+ * change, and the generator resolves PR links from the commit that added a
+ * path, which a rename does not provide.
+ *
+ * Direct children only: `loadNotes()` reads the immediate `.changes/`
+ * directory, so a nested `.changes/sub/note.md` would satisfy a prefix check
+ * while never being validated or rendered into any release surface.
+ *
+ * @param {{ filename: string, status: string }} file
+ * @returns {boolean} true when this file satisfies the gate
+ */
+function isAddedNote(file) {
+ const match = file.filename.match(/^\.changes\/([^/]+)\.md$/);
+
+ return Boolean(match) && match[1] !== 'README' && file.status === 'added';
+}
+
+/**
+ * @param {{ files: {filename: string, status: string}[], labels: string[] }} input
+ * @returns {{ ok: boolean, message: string }}
+ */
+export function evaluateGate({ files, labels }) {
+ if (labels.includes(GATE_LABEL)) {
+ return {
+ ok: true,
+ message: `Label \`${GATE_LABEL}\` present — release note not required.`,
+ };
+ }
+
+ const triggering = files.filter(requiresNote);
+
+ if (triggering.length === 0) {
+ return {
+ ok: true,
+ message:
+ 'No runtime code changed under apps/ or libs/ — release note not required.',
+ };
+ }
+
+ const notes = files.filter(isAddedNote);
+
+ if (notes.length > 0) {
+ return {
+ ok: true,
+ message: `Release note present: ${notes.map((note) => note.filename).join(', ')}`,
+ };
+ }
+
+ const shown = triggering.slice(0, 10).map((file) => ` ${file.filename}`);
+ const more =
+ triggering.length > shown.length
+ ? [` … and ${triggering.length - shown.length} more`]
+ : [];
+
+ return {
+ ok: false,
+ message: [
+ 'This PR changes runtime code but adds no release note.',
+ '',
+ 'Changed files that require one:',
+ ...shown,
+ ...more,
+ '',
+ 'Add a file like `.changes/-.md` describing the',
+ 'change for a user (see .changes/README.md for the format), or apply',
+ `the \`${GATE_LABEL}\` label if this PR has no user-visible effect`,
+ '(pure refactor, CI plumbing, tests).',
+ ].join('\n'),
+ };
+}
+
+function main() {
+ let raw = '';
+
+ process.stdin.setEncoding('utf8');
+ process.stdin.on('data', (chunk) => {
+ raw += chunk;
+ });
+ process.stdin.on('end', () => {
+ const input = JSON.parse(raw);
+
+ if (!Array.isArray(input.files) || !Array.isArray(input.labels)) {
+ console.error(
+ 'Expected {files: [{filename, status}], labels: [...]} on stdin.'
+ );
+ process.exit(2);
+ }
+
+ const verdict = evaluateGate(input);
+
+ console.log(verdict.message);
+ process.exit(verdict.ok ? 0 : 1);
+ });
+}
+
+if (process.argv[1] === fileURLToPath(import.meta.url)) {
+ main();
+}
diff --git a/tools/release/project.json b/tools/release/project.json
index d3d47110b..5bc632856 100644
--- a/tools/release/project.json
+++ b/tools/release/project.json
@@ -11,10 +11,12 @@
"{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"
+ "{workspaceRoot}/tools/release/check-release-note-gate.mjs",
+ "{workspaceRoot}/tools/release/release-notes.test.mjs",
+ "{workspaceRoot}/tools/release/release-note-gate.test.mjs"
],
"options": {
- "command": "node --test tools/release/release-notes.test.mjs",
+ "command": "node --test tools/release/release-notes.test.mjs tools/release/release-note-gate.test.mjs",
"cwd": "{workspaceRoot}"
}
},
diff --git a/tools/release/release-note-gate.test.mjs b/tools/release/release-note-gate.test.mjs
new file mode 100644
index 000000000..2d6a59c3b
--- /dev/null
+++ b/tools/release/release-note-gate.test.mjs
@@ -0,0 +1,143 @@
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+
+import {
+ evaluateGate,
+ GATE_LABEL,
+} from './check-release-note-gate.mjs';
+
+function file(filename, status = 'modified') {
+ return { filename, status };
+}
+
+function gate(files, labels = []) {
+ return evaluateGate({ files, labels });
+}
+
+describe('evaluateGate', () => {
+ it('passes when runtime code ships with an added note', () => {
+ const verdict = gate([
+ file('libs/ui/playback/src/lib/player.ts'),
+ file('.changes/playback-volume-memory.md', 'added'),
+ ]);
+
+ assert.equal(verdict.ok, true);
+ assert.match(verdict.message, /playback-volume-memory/);
+ });
+
+ it('fails when runtime code ships without a note', () => {
+ const verdict = gate([file('apps/web/src/app/app.component.ts')]);
+
+ assert.equal(verdict.ok, false);
+ assert.match(verdict.message, /app\.component\.ts/);
+ assert.match(verdict.message, /\.changes\/-\.md/);
+ assert.match(verdict.message, new RegExp(GATE_LABEL));
+ });
+
+ it('accepts the escape label', () => {
+ const verdict = gate(
+ [file('apps/web/src/app/app.component.ts')],
+ [GATE_LABEL]
+ );
+
+ assert.equal(verdict.ok, true);
+ });
+
+ it('a modified existing note does not satisfy the gate', () => {
+ const verdict = gate([
+ file('libs/m3u-state/src/lib/reducer.ts'),
+ file('.changes/old-note.md', 'modified'),
+ ]);
+
+ assert.equal(verdict.ok, false);
+ });
+
+ it('a renamed note does not satisfy the gate', () => {
+ // A note added and renamed within one PR still reports as `added`;
+ // `renamed` means it came from the base branch, i.e. another PR's.
+ const verdict = gate([
+ file('libs/m3u-state/src/lib/reducer.ts'),
+ file('.changes/m3u-better-name.md', 'renamed'),
+ ]);
+
+ assert.equal(verdict.ok, false);
+ });
+
+ it('a nested note does not satisfy the gate', () => {
+ // loadNotes() only reads the immediate directory, so a nested file
+ // would never be validated or rendered into a release surface.
+ const verdict = gate([
+ file('libs/m3u-state/src/lib/reducer.ts'),
+ file('.changes/subdir/m3u-note.md', 'added'),
+ ]);
+
+ assert.equal(verdict.ok, false);
+ });
+
+ it('accepts a top-level note regardless of surrounding path noise', () => {
+ const verdict = gate([
+ file('libs/m3u-state/src/lib/reducer.ts'),
+ file('.changes/m3u-long-urls.md', 'added'),
+ ]);
+
+ assert.equal(verdict.ok, true);
+ });
+
+ it('.changes/README.md is not a note', () => {
+ const verdict = gate([
+ file('libs/m3u-state/src/lib/reducer.ts'),
+ file('.changes/README.md', 'added'),
+ ]);
+
+ assert.equal(verdict.ok, false);
+ });
+
+ it('ignores changes outside apps/ and libs/', () => {
+ const verdict = gate([
+ file('tools/release/build-release-notes.mjs'),
+ file('.github/workflows/ci.yml'),
+ file('CLAUDE.md'),
+ file('package.json'),
+ ]);
+
+ assert.equal(verdict.ok, true);
+ assert.match(verdict.message, /not required/);
+ });
+
+ it('exempts tests, snapshots, e2e, website, mock servers and docs', () => {
+ const verdict = gate([
+ file('apps/web/src/app/app.component.spec.ts'),
+ file('apps/web-e2e/src/xtream.e2e.ts'),
+ file('apps/electron-backend-e2e/src/search.e2e.ts'),
+ file('apps/website/src/pages/index.astro'),
+ file('apps/xtream-mock-server/src/main.ts'),
+ file('apps/stalker-mock-server/src/main.ts'),
+ file('libs/shared/testing/src/lib/helpers.ts'),
+ file('libs/ui/playback/src/lib/__snapshots__/player.snap'),
+ file('libs/ui/playback/README.md'),
+ ]);
+
+ assert.equal(verdict.ok, true);
+ });
+
+ it('one runtime file among exempt ones still triggers the gate', () => {
+ const verdict = gate([
+ file('apps/web/src/app/app.component.spec.ts'),
+ file('apps/web/src/app/app.component.ts'),
+ ]);
+
+ assert.equal(verdict.ok, false);
+ assert.match(verdict.message, /app\.component\.ts/);
+ assert.doesNotMatch(verdict.message, /spec\.ts/);
+ });
+
+ it('truncates long file listings in the failure message', () => {
+ const files = Array.from({ length: 15 }, (_, index) =>
+ file(`libs/services/src/lib/file-${index}.ts`)
+ );
+ const verdict = gate(files);
+
+ assert.equal(verdict.ok, false);
+ assert.match(verdict.message, /… and 5 more/);
+ });
+});