Files
iptvnator/tools/release/check-release-note-gate.mjs
4grayandClaude Opus 5 4e5132cbb5 ci(release): gate PRs on an authored release note (#1257)
* 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>
2026-07-25 21:51:04 +02:00

156 lines
4.9 KiB
JavaScript

#!/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/<area>-<short-slug>.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();
}