mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 09:26:14 -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>
156 lines
4.9 KiB
JavaScript
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();
|
|
}
|