diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md index 8670d540..d0816998 100644 --- a/docs/src/content/docs/configuration.md +++ b/docs/src/content/docs/configuration.md @@ -237,6 +237,10 @@ changelog: commit_patterns: - "^(?\\w+(?:\\((?[^)]+)\\))?!:\\s*)" semver: major + - title: Security 🔒 + commit_patterns: + - "^(?security(?:\\((?[^)]+)\\))?!?:\\s*)" + semver: patch - title: New Features ✨ commit_patterns: - "^(?feat(?:\\((?[^)]+)\\))?!?:\\s*)" diff --git a/src/utils/__tests__/changelog-extract.test.ts b/src/utils/__tests__/changelog-extract.test.ts index c64fb637..c60a4889 100644 --- a/src/utils/__tests__/changelog-extract.test.ts +++ b/src/utils/__tests__/changelog-extract.test.ts @@ -24,6 +24,8 @@ describe('extractScope', () => { ['docs(readme): update docs', 'readme'], ['chore(deps): update dependencies', 'deps'], ['feat(my-long_scope): mixed separators', 'my-long-scope'], + ['security(auth): patch login', 'auth'], + ['security(auth)!: rotate keys', 'auth'], ])('extracts scope from "%s" as "%s"', (title, expected) => { expect(extractScope(title)).toBe(expected); }); diff --git a/src/utils/__tests__/changelog-generate.test.ts b/src/utils/__tests__/changelog-generate.test.ts index 80a78dd3..8c7a8273 100644 --- a/src/utils/__tests__/changelog-generate.test.ts +++ b/src/utils/__tests__/changelog-generate.test.ts @@ -607,6 +607,136 @@ changelog: const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); expect(result.changelog).toMatchSnapshot(); }); + + it('categorizes security commits under Security section with patch bump', async () => { + setup( + [ + { + hash: 'abc123', + title: 'security: patch XSS in renderer', + body: '', + pr: { + local: '1', + remote: { number: '1', author: { login: 'alice' } }, + }, + }, + { + hash: 'def456', + title: 'security(auth): fix login bypass', + body: '', + pr: { + local: '2', + remote: { number: '2', author: { login: 'bob' } }, + }, + }, + ], + null, + ); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); + expect(result.changelog).toContain('### Security 🔒'); + expect(result.changelog).toContain('Patch XSS in renderer'); + expect(result.changelog).toContain('Fix login bypass'); + expect(result.bumpType).toBe('patch'); + }); + + it('renders Security section above New Features and Bug Fixes', async () => { + setup( + [ + { + hash: 'aaa111', + title: 'feat: new widget', + body: '', + pr: { + local: '1', + remote: { number: '1', author: { login: 'alice' } }, + }, + }, + { + hash: 'bbb222', + title: 'security: patch XSS', + body: '', + pr: { + local: '2', + remote: { number: '2', author: { login: 'bob' } }, + }, + }, + { + hash: 'ccc333', + title: 'fix: off-by-one', + body: '', + pr: { + local: '3', + remote: { number: '3', author: { login: 'charlie' } }, + }, + }, + ], + null, + ); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); + const securityIdx = result.changelog.indexOf('### Security 🔒'); + const featuresIdx = result.changelog.indexOf('### New Features ✨'); + const fixesIdx = result.changelog.indexOf('### Bug Fixes 🐛'); + expect(securityIdx).toBeGreaterThanOrEqual(0); + expect(featuresIdx).toBeGreaterThanOrEqual(0); + expect(fixesIdx).toBeGreaterThanOrEqual(0); + expect(securityIdx).toBeLessThan(featuresIdx); + expect(featuresIdx).toBeLessThan(fixesIdx); + // A feat: commit dominates aggregation: security is patch, feat is minor. + // If Security were accidentally tagged as minor, this would still be minor + // so we test that separately below. + expect(result.bumpType).toBe('minor'); + }); + + it('security alone does not escalate above patch when mixed with docs', async () => { + // Guards against accidentally setting semver: 'minor' on the Security category. + // docs: is patch, security: is patch -> aggregate must be patch. + setup( + [ + { + hash: 'aaa111', + title: 'security: patch XSS', + body: '', + pr: { + local: '1', + remote: { number: '1', author: { login: 'alice' } }, + }, + }, + { + hash: 'bbb222', + title: 'docs: update readme', + body: '', + pr: { + local: '2', + remote: { number: '2', author: { login: 'bob' } }, + }, + }, + ], + null, + ); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); + expect(result.bumpType).toBe('patch'); + }); + + it('escalates breaking security commits to major via Breaking Changes', async () => { + setup( + [ + { + hash: 'abc123', + title: 'security!: rotate signing keys', + body: '', + pr: { + local: '1', + remote: { number: '1', author: { login: 'alice' } }, + }, + }, + ], + null, + ); + const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10); + expect(result.changelog).toContain('### Breaking Changes 🛠'); + expect(result.changelog).not.toContain('### Security 🔒'); + expect(result.bumpType).toBe('major'); + }); }); // ============================================================================ diff --git a/src/utils/__tests__/changelog-utils.test.ts b/src/utils/__tests__/changelog-utils.test.ts index 553c9cc4..30dcb5f1 100644 --- a/src/utils/__tests__/changelog-utils.test.ts +++ b/src/utils/__tests__/changelog-utils.test.ts @@ -134,6 +134,45 @@ describe('getBumpTypeForPR', () => { expect(getBumpTypeForPR(prInfo)).toBe('patch'); }); + it('returns patch for security commits', () => { + const prInfo = { ...basePRInfo, title: 'security: patch XSS' }; + expect(getBumpTypeForPR(prInfo)).toBe('patch'); + }); + + it('returns patch for scoped security commits', () => { + const prInfo = { ...basePRInfo, title: 'security(auth): patch login' }; + expect(getBumpTypeForPR(prInfo)).toBe('patch'); + }); + + it('returns major for breaking security commits', () => { + const prInfo = { ...basePRInfo, title: 'security!: rotate keys' }; + expect(getBumpTypeForPR(prInfo)).toBe('major'); + }); + + it('returns major for scoped breaking security commits', () => { + const prInfo = { ...basePRInfo, title: 'security(auth)!: rotate keys' }; + expect(getBumpTypeForPR(prInfo)).toBe('major'); + }); + + it('matches security prefix case-insensitively', () => { + // commit_patterns are compiled with /i in normalizeReleaseConfig. + const prInfo = { ...basePRInfo, title: 'Security(Auth): Fix bypass' }; + expect(getBumpTypeForPR(prInfo)).toBe('patch'); + }); + + it('does not match security-lookalike prefixes', () => { + // Guard against accidentally widening the pattern (e.g. to `security\w*:`). + expect( + getBumpTypeForPR({ ...basePRInfo, title: 'securityfix: foo' }), + ).toBeNull(); + expect( + getBumpTypeForPR({ ...basePRInfo, title: 'securityaudit: foo' }), + ).toBeNull(); + expect( + getBumpTypeForPR({ ...basePRInfo, title: 'secure: foo' }), + ).toBeNull(); + }); + it('returns null for unrecognized commit types', () => { const prInfo = { ...basePRInfo, title: 'random commit' }; expect(getBumpTypeForPR(prInfo)).toBeNull(); @@ -260,6 +299,19 @@ describe('stripTitle', () => { ); }); + it('works with security type', () => { + const pattern = /^(?security(?:\((?[^)]+)\))?!?:\s*)/; + expect(stripTitle('security: patch xss', pattern, false)).toBe( + 'Patch xss', + ); + expect(stripTitle('security(auth): patch login', pattern, false)).toBe( + 'Patch login', + ); + expect(stripTitle('security(auth): patch login', pattern, true)).toBe( + '(auth) Patch login', + ); + }); + it('works with build/chore/test/style types', () => { const pattern = /^(?(?:build|refactor|meta|chore|ci|ref|perf|tests?|style)(?:\((?[^)]+)\))?!?:\s*)/; diff --git a/src/utils/changelog.ts b/src/utils/changelog.ts index b2eb618f..61f68ef3 100644 --- a/src/utils/changelog.ts +++ b/src/utils/changelog.ts @@ -877,6 +877,13 @@ export const DEFAULT_RELEASE_CONFIG: ReleaseConfig = { commit_patterns: ['^(?\\w+(?:\\((?[^)]+)\\))?!:\\s*)'], semver: 'major', }, + { + title: 'Security 🔒', + commit_patterns: [ + '^(?security(?:\\((?[^)]+)\\))?!?:\\s*)', + ], + semver: 'patch', + }, { title: 'New Features ✨', commit_patterns: ['^(?feat(?:\\((?[^)]+)\\))?!?:\\s*)'],