#!/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(); }