diff --git a/docs/src/content/docs/changelogs.md b/docs/src/content/docs/changelogs.md index c11b41b7..226e2532 100644 --- a/docs/src/content/docs/changelogs.md +++ b/docs/src/content/docs/changelogs.md @@ -262,6 +262,9 @@ chore: Update dependencies #skip-changelog ``` +Mentions inside Markdown code (inline code spans or fenced blocks) are +ignored, so you can write about the magic word without skipping the change. + ### Skip label PRs with the `skip-changelog` label are automatically excluded. diff --git a/src/utils/__tests__/changelog-generate.test.ts b/src/utils/__tests__/changelog-generate.test.ts index 2882e818..04293ef0 100644 --- a/src/utils/__tests__/changelog-generate.test.ts +++ b/src/utils/__tests__/changelog-generate.test.ts @@ -174,6 +174,35 @@ describe('generateChangesetFromGit', () => { } }); + it('counts a commit that only quotes the skip magic word in code', async () => { + // Squash-merged feature commit that documents the magic word, wrapped + // across lines as git does, next to a real skipped bump commit. + setup( + [ + { + hash: 'abcdef1234567890', + title: 'feat(publish): Add development version bump', + body: + 'It then commits `meta: Bump new\n' + + 'development version` + `#skip-changelog` with `--all`.', + }, + { + hash: 'bcdef1234567890a', + title: 'meta: Bump new development version', + body: '#skip-changelog', + }, + ], + null, + ); + + const result = await generateChangesetFromGit(dummyGit, '1.0.0'); + + expect(result.totalCommits).toBe(1); + expect(result.bumpType).toBe('minor'); + expect(result.changelog).toContain('Add development version bump'); + expect(result.changelog).not.toContain('Bump new development version'); + }); + // ============================================================================ // Basic output formatting tests - use snapshots // ============================================================================ diff --git a/src/utils/__tests__/changelog-utils.test.ts b/src/utils/__tests__/changelog-utils.test.ts index d2d7b2bc..ea8f9fd2 100644 --- a/src/utils/__tests__/changelog-utils.test.ts +++ b/src/utils/__tests__/changelog-utils.test.ts @@ -23,12 +23,59 @@ import { getNormalizedReleaseConfig, clearReleaseConfigCache, SKIP_CHANGELOG_MAGIC_WORD, + hasSkipChangelogMagicWord, type CurrentPRInfo, } from '../changelog'; // Mock the logger to capture warnings vi.mock('../../logger'); +describe('hasSkipChangelogMagicWord', () => { + it.each([ + ['on its own line', 'chore: Update deps\n\n#skip-changelog'], + ['inline in prose', 'Internal cleanup, #skip-changelog please'], + ['with CRLF line endings', 'Internal.\r\n\r\n#skip-changelog\r\n'], + ['after an unmatched backtick', 'A stray ` here\n#skip-changelog'], + [ + 'after a closed fenced block', + '```yaml\nlabels: [a]\n```\n#skip-changelog', + ], + ['in emphasis', 'Internal only. **#skip-changelog**'], + ['in a list item', '- Internal refactor\n- #skip-changelog'], + ['in an HTML comment', 'Description\n\n'], + [ + 'outside a code span on another paragraph', + 'Run `craft\n\nprepare` #skip-changelog', + ], + ])('detects the magic word %s', (_, text) => { + expect(hasSkipChangelogMagicWord(text)).toBe(true); + }); + + it.each([ + ['when absent', 'feat: Add a thing'], + ['for an empty body', ''], + ['for an undefined body', undefined], + ['in an inline code span', 'Commits `#skip-changelog` after release'], + ['in a double-backtick span', 'Uses `` `#skip-changelog` `` verbatim'], + [ + 'in a code span wrapped across lines', + 'commits `meta: Bump new\ndevelopment version` + `#skip-changelog` with `--all`', + ], + [ + 'in a fenced code block', + 'Example:\n\n```\nchore: x\n\n#skip-changelog\n```\n', + ], + ['in a tilde fenced block', '~~~\n#skip-changelog\n~~~'], + ['in a code span in a list item', '- Commits `#skip-changelog` on bump'], + [ + 'in a code span in a table', + '| Word | Effect |\n| --- | --- |\n| `#skip-changelog` | Skips |', + ], + ])('ignores the magic word %s', (_, text) => { + expect(hasSkipChangelogMagicWord(text)).toBe(false); + }); +}); + describe('shouldExcludePR', () => { // Config must match NormalizedReleaseConfig structure const baseConfig = { diff --git a/src/utils/changelog.ts b/src/utils/changelog.ts index 40c73c9b..ab699296 100644 --- a/src/utils/changelog.ts +++ b/src/utils/changelog.ts @@ -206,6 +206,34 @@ const MAX_LEFTOVERS = 24; if (SUBSECTION_HEADER_LEVEL <= VERSION_HEADER_LEVEL) { throw new Error('Subsection headers should nest under version headers!'); } + +/** + * Checks whether a commit message or PR body opts out of the changelog with + * the {@link SKIP_CHANGELOG_MAGIC_WORD}. + * + * Mentions inside Markdown code are ignored, so a message that only quotes the + * magic word (e.g. documents it as `` `#skip-changelog` ``) is not skipped. + */ +export function hasSkipChangelogMagicWord(text: string | undefined): boolean { + if (!text?.includes(SKIP_CHANGELOG_MAGIC_WORD)) { + return false; + } + // Check only tokens that hold prose or HTML (e.g. a hidden `` + // comment) directly. Containers like lists and tables repeat their + // children's source in `raw`, and code tokens are never prose. + let found = false; + marked.walkTokens(marked.lexer(text), token => { + if ( + (token.type === 'text' || token.type === 'html') && + !('tokens' in token && token.tokens?.length) && + token.raw.includes(SKIP_CHANGELOG_MAGIC_WORD) + ) { + found = true; + } + }); + return found; +} + /** * A single changeset with name and description */ @@ -1305,7 +1333,7 @@ export function shouldExcludePR( body?: string, ): boolean { // Check for magic word in body - if (body?.includes(SKIP_CHANGELOG_MAGIC_WORD)) { + if (hasSkipChangelogMagicWord(body)) { return true; } @@ -1876,7 +1904,7 @@ async function fetchRawCommitInfo( ): Promise { // Early filter: skip commits with magic word in commit body (optimization to avoid GitHub API calls) const gitCommits = (await getChangesSince(git, rev, until)).filter( - ({ body }) => !body.includes(SKIP_CHANGELOG_MAGIC_WORD), + ({ body }) => !hasSkipChangelogMagicWord(body), ); const githubCommits = await getPRAndLabelsFromCommit(