mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
* ci(release): gate PRs on an authored release note Second slice of the release-notes pipeline (#1256 landed the format and generator): make the .changes/ habit survive contact with reality. - "Release note gate" job in ci.yml, PR-only: validates every .changes/*.md, then requires an added note (or the no-release-note label) when the PR touches runtime code under apps/ or libs/. Tests, e2e projects, the website, mock servers, shared testing helpers, snapshots and docs are auto-exempt. - Policy lives in tools/release/check-release-note-gate.mjs as a pure function fed PR files+labels as JSON — unit-tested (10 cases) instead of encoded in workflow bash. The failure message lists the triggering files and names the exact fix. - Labels are fetched live rather than from the stale event payload, so applying the label and re-running the check works without a new push. - The job is dependency-free Node: no pnpm install, runs in seconds. - release-notes and release-cut skills added under .claude/skills/ and mirrored to .codex/skills/; CLAUDE.md/AGENTS.md sections updated to point at the gate and the skills. The no-release-note label itself was created in the repository. Tests: 47 passing in release-tools (10 new gate cases); gate-step shell verified with shellcheck at the CI severity; ci.yml YAML-parse checked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(agents): make the release skills discoverable by Claude Code too `.codex/skills/**` was un-ignored so Codex picks up repository skills in any clone, but `.claude` was ignored wholesale — and Claude Code only discovers skills under `.claude/skills/`. The release-notes and release-cut skills therefore existed only on whichever machine authored them. Mirror both skills into `.claude/skills/` and opt them in by name rather than un-ignoring the directory: contributors keep personal skills there (i18n-fill, website, …) which must stay local and out of `git status`. CLAUDE.md/AGENTS.md updated so the "skills live under .codex/skills/" claim does not go stale, including the requirement to keep mirrored copies in sync. The CI gate and the CLAUDE.md/AGENTS.md section remain the load-bearing enforcement; skills only carry the detail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(ci): only a note this PR authored satisfies the release-note gate Review follow-ups on #1257 (Codex P2 ×2, Greptile P1). - Drop `renamed` from the accepted statuses. 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 means the file already existed on the base branch. Accepting it let a runtime-code PR pass by moving another PR's unconsumed note, which documents nothing and gives the generator no adding commit to resolve a PR link from. - Require a direct child of `.changes/`. `loadNotes()` reads only the immediate directory, so `.changes/sub/note.md` satisfied the old prefix check while never being validated or rendered into any release surface. Tests: renamed and nested notes now assert a failing gate (12 gate cases). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
144 lines
4.7 KiB
JavaScript
144 lines
4.7 KiB
JavaScript
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\/<area>-<short-slug>\.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/);
|
|
});
|
|
});
|