diff --git a/.changeset/gentle-rams-swim.md b/.changeset/gentle-rams-swim.md new file mode 100644 index 000000000..4a5c64b37 --- /dev/null +++ b/.changeset/gentle-rams-swim.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 2838 +--- +**test:/chore:/ci:/docs:/refactor:/perf:/revert: PRs no longer publish under the user-facing Enhancement heading in release notes** — the release-notes classifier now routes recognized non-user-facing conventional-commit types to an Internal bucket and omits them from the published GitHub release notes (and the Discord announcement's user-facing sections). Previously these internal-work PRs rendered as Enhancements alongside genuinely user-facing changes. feat:/fix: classification is unchanged, and untyped or anchor-defeated titles still fall back to Enhancement. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f4fb49e5e..a5313e912 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -217,7 +217,7 @@ then run `scripts/release-notes/format-github-release-notes.cjs --apply` to rewrite the body into the project's curated format: an **Install** block, followed by **What's Changed** grouped into **Feature** / **Enhancement** / **Fix** sections (classified by each PR's conventional-commit title prefix — -`feat` → Feature, `fix` → Fix, everything else → Enhancement), then +`feat` → Feature, `fix` → Fix, non-user-facing types `test`/`chore`/`ci`/`docs`/`refactor`/`perf`/`revert` → omitted from the user-facing notes, everything else → Enhancement), then **New Contributors** and the **Full Changelog** link. To re-format an existing release by hand (e.g. backfilling an older release): @@ -910,7 +910,7 @@ Defensive normalization at trust boundaries must validate both the value's type - **CommonJS** (`.cjs`) — the project uses `require()`, not ESM `import` - **No external dependencies in core** — `gsd-tools.cjs` and all lib files use only Node.js built-ins -- **Conventional commits** — `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `ci:`. The full grammar is `(): ` (enforced by `hooks/gsd-validate-commit.sh`; subject ≤72 chars, lowercase, imperative mood, no trailing period). When the work resolves a tracked issue, put the issue number in the scope: `fix(#1520): randomize mktemp temp paths on BSD/macOS`. The same convention applies to PR titles — release notes are grouped by the title's type prefix (`feat` → Feature, `fix` → Fix, everything else → Enhancement). +- **Conventional commits** — `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `ci:`. The full grammar is `(): ` (enforced by `hooks/gsd-validate-commit.sh`; subject ≤72 chars, lowercase, imperative mood, no trailing period). When the work resolves a tracked issue, put the issue number in the scope: `fix(#1520): randomize mktemp temp paths on BSD/macOS`. The same convention applies to PR titles — release notes are grouped by the title's type prefix (`feat` → Feature, `fix` → Fix, non-user-facing types omitted, everything else → Enhancement). ## File Structure diff --git a/scripts/release-notes/conventional-title.cjs b/scripts/release-notes/conventional-title.cjs index e1955bf44..0625fce66 100644 --- a/scripts/release-notes/conventional-title.cjs +++ b/scripts/release-notes/conventional-title.cjs @@ -30,18 +30,36 @@ const HEADER_RE = /^([a-z]+)(\([^)]*\))?(!)?:/i; // An issue reference inside a scope: `(#123)`, `(#123, core)`, etc. const ISSUE_REF_IN_SCOPE_RE = /#\d+/; +// #2716: recognized non-user-facing conventional-commit types. A title whose +// START-anchored type prefix is one of these is internal work (tests, chores, +// CI, docs, refactors, perf, reverts) and must NOT render under the user-facing +// "Enhancement" heading in release notes. Only a CLEAN prefix match qualifies — +// untyped or anchor-defeated titles fall through to the visible Enhancement +// fallback (a safety net so possibly-user-facing content is never hidden). +const NON_USER_FACING_TYPES = new Set([ + 'docs', 'refactor', 'test', 'ci', 'chore', 'perf', 'revert', +]); + /** * Classify a clean conventional title into a changelog bucket. * Callers that hold a full changelog bullet line (with a `* ` marker and a * ` by @author` suffix) must strip those first; this operates on the title. * * @param {string} title - * @returns {'Feature'|'Fix'|'Enhancement'} + * @returns {'Feature'|'Fix'|'Enhancement'|'Internal'} */ function classifyBucket(title) { const t = String(title == null ? '' : title).trim(); if (FEATURE_RE.test(t)) return 'Feature'; if (FIX_RE.test(t)) return 'Fix'; + // #2716: a clean non-user-facing type prefix → Internal (omitted from user-facing + // release-note sections). The HEADER_RE anchor ensures a leading tag/prefix + // (e.g. `[security] fix(...)`) does NOT match here — those keep falling through + // to the visible Enhancement fallback. + const headerMatch = HEADER_RE.exec(t); + if (headerMatch && NON_USER_FACING_TYPES.has(headerMatch[1].toLowerCase())) { + return 'Internal'; + } return 'Enhancement'; } diff --git a/scripts/release-notes/format-github-release-notes.cjs b/scripts/release-notes/format-github-release-notes.cjs index 3e487b93e..fd45d6568 100644 --- a/scripts/release-notes/format-github-release-notes.cjs +++ b/scripts/release-notes/format-github-release-notes.cjs @@ -8,9 +8,9 @@ const { runMain, ExitError } = require('../lib/cli-exit.cjs'); const { classifyBucket } = require('./conventional-title.cjs'); /** - * Classify a What's-Changed bullet line into 'Feature', 'Fix', or 'Enhancement'. + * Classify a What's-Changed bullet line into 'Feature', 'Fix', 'Enhancement', or 'Internal'. * @param {string} bulletLine - Full bullet line including the leading `* ` or `- ` marker. - * @returns {'Feature'|'Fix'|'Enhancement'} + * @returns {'Feature'|'Fix'|'Enhancement'|'Internal'} */ function classifyTitle(bulletLine) { // Strip leading `* ` or `- ` marker @@ -83,7 +83,11 @@ function formatReleaseNotes({ generatedBody, version, prerelease, packageName }) const category = classifyTitle(trimmed); if (category === 'Feature') featureBullets.push(trimmed); else if (category === 'Fix') fixBullets.push(trimmed); - else enhancementBullets.push(trimmed); + else if (category === 'Internal') { + // #2716: non-user-facing work (test/chore/ci/docs/refactor/perf/revert) + // is omitted from the user-facing "What's Changed" section entirely. + continue; + } else enhancementBullets.push(trimmed); continue; } diff --git a/tests/conventional-title.property.test.cjs b/tests/conventional-title.property.test.cjs index d835b3c64..80efe4578 100644 --- a/tests/conventional-title.property.test.cjs +++ b/tests/conventional-title.property.test.cjs @@ -71,7 +71,7 @@ describe('classifyBucket — properties', () => { fc.assert( fc.property(fc.string(), (title) => { const bucket = classifyBucket(title); - assert.ok(['Feature', 'Fix', 'Enhancement'].includes(bucket)); + assert.ok(['Feature', 'Fix', 'Enhancement', 'Internal'].includes(bucket)); }) ); }); diff --git a/tests/conventional-title.test.cjs b/tests/conventional-title.test.cjs index cd410d8bc..55cf2d92b 100644 --- a/tests/conventional-title.test.cjs +++ b/tests/conventional-title.test.cjs @@ -39,8 +39,8 @@ describe('classifyBucket', () => { assert.equal(classifyBucket('fix: another fix'), 'Fix'); }); - test('chore(#N): -> Enhancement (catch-all)', () => { - assert.equal(classifyBucket('chore(#2): some chore'), 'Enhancement'); + test('chore(#N): -> Internal (#2716 — non-user-facing type)', () => { + assert.equal(classifyBucket('chore(#2): some chore'), 'Internal'); }); test('untyped title -> Enhancement (catch-all)', () => { @@ -148,3 +148,34 @@ describe('evaluatePrTitle — rejected titles', () => { assert.ok(r.message.length > 0); }); }); + +// --------------------------------------------------------------------------- +// #2716: non-user-facing conventional types (test/chore/ci/docs/refactor/perf/ +// revert) classify to 'Internal', NOT 'Enhancement' — they must not render under +// the user-facing Enhancement heading in release notes. Untyped and anchor- +// defeated titles stay in the visible Enhancement fallback (safety net). +// --------------------------------------------------------------------------- + +describe('classifyBucket #2716: non-user-facing types → Internal', () => { + for (const type of ['test', 'chore', 'ci', 'docs', 'refactor', 'perf', 'revert']) { + test(`${type}(#N): → Internal (not Enhancement)`, () => { + assert.equal(classifyBucket(`${type}(#100): some internal work`), 'Internal'); + }); + test(`${type}: (no scope) → Internal`, () => { + assert.equal(classifyBucket(`${type}: some internal work`), 'Internal'); + }); + } + + test('feat/fix classification is unchanged', () => { + assert.equal(classifyBucket('feat(#5): new thing'), 'Feature'); + assert.equal(classifyBucket('fix(#6): fix thing'), 'Fix'); + }); + + test('untyped title stays in Enhancement (safety net)', () => { + assert.equal(classifyBucket('Main changes'), 'Enhancement'); + }); + + test('[security] fix(...) stays in Enhancement (anchor-defeated safety net)', () => { + assert.equal(classifyBucket('[security] fix(config): the case'), 'Enhancement'); + }); +}); diff --git a/tests/discord-release-summary.test.cjs b/tests/discord-release-summary.test.cjs index 0b3271c32..fb364fa4e 100644 --- a/tests/discord-release-summary.test.cjs +++ b/tests/discord-release-summary.test.cjs @@ -64,12 +64,16 @@ describe('discord release summary', () => { '## What\'s Changed', '* feat: add thing by @trek-e in https://github.com/open-gsd/gsd-core/pull/10', '* fix: repair thing by @trek-e in https://github.com/open-gsd/gsd-core/pull/11', + '* enhance: improve thing by @trek-e in https://github.com/open-gsd/gsd-core/pull/13', '* docs: explain thing by @trek-e in https://github.com/open-gsd/gsd-core/pull/12', ].join('\n')); assert.deepEqual(sections.get('Feature'), ['add thing (#10)']); assert.deepEqual(sections.get('Fix'), ['repair thing (#11)']); - assert.deepEqual(sections.get('Enhancement'), ['explain thing (#12)']); + // enhance: is a genuine user-facing enhancement (not in the non-user-facing set). + assert.ok(sections.get('Enhancement') && sections.get('Enhancement').length === 1, 'enhance: bullet lands in Enhancement'); + // #2716: docs: routes to the Internal section, not Enhancement. + assert.deepEqual(sections.get('Internal'), ['explain thing (#12)']); }); test('cleans common GitHub release-note link noise without deleting issue references', () => { diff --git a/tests/format-github-release-notes.test.cjs b/tests/format-github-release-notes.test.cjs index fa563ac3b..c899b0b6f 100644 --- a/tests/format-github-release-notes.test.cjs +++ b/tests/format-github-release-notes.test.cjs @@ -48,17 +48,17 @@ describe('classifyTitle', () => { ); }); - test('returns Enhancement for chore(#2): title', () => { + test('returns Internal for chore(#2): title (#2716)', () => { assert.equal( classifyTitle('* chore(#2): some chore by @trek-e in https://github.com/open-gsd/gsd-core/pull/5'), - 'Enhancement' + 'Internal' ); }); - test('returns Enhancement for docs: title', () => { + test('returns Internal for docs: title (#2716)', () => { assert.equal( classifyTitle('* docs: documentation update by @trek-e in https://github.com/open-gsd/gsd-core/pull/6'), - 'Enhancement' + 'Internal' ); }); @@ -84,7 +84,7 @@ describe('classifyTitle', () => { const SAMPLE_BODY = `## What's Changed * feat(#39): milestone-prefixed phase IDs by @trek-e in https://github.com/open-gsd/gsd-core/pull/565 * fix(#557): milestone erased on update by @trek-e in https://github.com/open-gsd/gsd-core/pull/563 -* chore(#2): update dependencies by @trek-e in https://github.com/open-gsd/gsd-core/pull/560 +* enhance(#2): smoother phase transitions by @trek-e in https://github.com/open-gsd/gsd-core/pull/560 ## New Contributors * @someone made their first contribution in https://github.com/open-gsd/gsd-core/pull/123 @@ -217,4 +217,31 @@ describe('formatReleaseNotes', () => { assert.ok(secondIdx !== -1, 'second feature bullet should be present'); assert.ok(firstIdx < secondIdx, 'first feature should appear before second feature'); }); + + test('#2716: a test:-titled PR is omitted from the rendered release notes entirely', () => { + const body = `## What's Changed +* feat(#39): real feature by @trek-e in https://github.com/open-gsd/gsd-core/pull/565 +* test(#99): add regression coverage by @trek-e in https://github.com/open-gsd/gsd-core/pull/900 + +## New Contributors + +**Full Changelog**: https://github.com/open-gsd/gsd-core/compare/v1.2.0...v1.3.0-rc.1 +`; + const out = formatReleaseNotes({ + generatedBody: body, + version: '1.3.0-rc.1', + prerelease: true, + packageName: '@opengsd/gsd-core', + }); + // The feature bullet must appear under ### Feature. + assert.ok(out.includes('feat(#39): real feature'), 'feature bullet must be present'); + assert.ok(out.includes('### Feature'), 'Feature section must render'); + // The test:-titled PR must NOT appear anywhere in the user-facing output. + assert.ok( + !out.includes('test(#99): add regression coverage'), + 'a test:-titled PR must be omitted from the rendered release notes (#2716)', + ); + // And there must be no user-facing "Enhancement" section here (only the feature). + assert.ok(!out.includes('### Enhancement'), 'no Enhancement section expected when the only non-feature is Internal'); + }); });