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/); + }); +});