Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/src/content/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,10 @@ changelog:
commit_patterns:
- "^(?<type>\\w+(?:\\((?<scope>[^)]+)\\))?!:\\s*)"
semver: major
- title: Security πŸ”’
commit_patterns:
- "^(?<type>security(?:\\((?<scope>[^)]+)\\))?!?:\\s*)"
semver: patch
- title: New Features ✨
commit_patterns:
- "^(?<type>feat(?:\\((?<scope>[^)]+)\\))?!?:\\s*)"
Expand Down
2 changes: 2 additions & 0 deletions src/utils/__tests__/changelog-extract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
130 changes: 130 additions & 0 deletions src/utils/__tests__/changelog-generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});

// ============================================================================
Expand Down
52 changes: 52 additions & 0 deletions src/utils/__tests__/changelog-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -260,6 +299,19 @@ describe('stripTitle', () => {
);
});

it('works with security type', () => {
const pattern = /^(?<type>security(?:\((?<scope>[^)]+)\))?!?:\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 =
/^(?<type>(?:build|refactor|meta|chore|ci|ref|perf|tests?|style)(?:\((?<scope>[^)]+)\))?!?:\s*)/;
Expand Down
7 changes: 7 additions & 0 deletions src/utils/changelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -877,6 +877,13 @@ export const DEFAULT_RELEASE_CONFIG: ReleaseConfig = {
commit_patterns: ['^(?<type>\\w+(?:\\((?<scope>[^)]+)\\))?!:\\s*)'],
semver: 'major',
},
{
title: 'Security πŸ”’',
commit_patterns: [
'^(?<type>security(?:\\((?<scope>[^)]+)\\))?!?:\\s*)',
],
semver: 'patch',
},
{
title: 'New Features ✨',
commit_patterns: ['^(?<type>feat(?:\\((?<scope>[^)]+)\\))?!?:\\s*)'],
Expand Down
Loading