From d8fcdb25273ddffc2766d944452f81d275fa6281 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:14:20 +0000 Subject: [PATCH 1/2] fix(changelog): Ignore #skip-changelog inside Markdown code A commit or PR body that only quotes the magic word in a code span or fenced block no longer drops the change from the changelog and auto-versioning. The squash commit for #950 documented the bump commit's message in backticks, which skipped it and left the 3.7.0 release with no commits to version. Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- docs/src/content/docs/changelogs.md | 3 + .../__tests__/changelog-generate.test.ts | 29 ++++++++++ src/utils/__tests__/changelog-utils.test.ts | 39 +++++++++++++ src/utils/changelog.ts | 57 ++++++++++++++++++- 4 files changed, 126 insertions(+), 2 deletions(-) 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..f05d677a 100644 --- a/src/utils/__tests__/changelog-utils.test.ts +++ b/src/utils/__tests__/changelog-utils.test.ts @@ -23,12 +23,51 @@ 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', + ], + [ + '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~~~'], + ])('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..97931453 100644 --- a/src/utils/changelog.ts +++ b/src/utils/changelog.ts @@ -206,6 +206,59 @@ const MAX_LEFTOVERS = 24; if (SUBSECTION_HEADER_LEVEL <= VERSION_HEADER_LEVEL) { throw new Error('Subsection headers should nest under version headers!'); } + +/** Opening line of a Markdown fenced code block: up to 3 spaces, then ``` or ~~~. */ +const CODE_FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})/; +/** + * A Markdown inline code span: a backtick run, then content, then a run of + * the same length. Spans may wrap lines (squash-merge commit messages are + * hard-wrapped), but never cross a blank line. + */ +const INLINE_CODE_SPAN = /(?= fence.length && + line.trim() === close[1] + ) { + fence = null; + } + continue; + } + const open = line.match(CODE_FENCE_OPEN); + if (open) { + fence = open[1]; + continue; + } + prose.push(line); + } + return prose.join('\n').replace(INLINE_CODE_SPAN, ''); +} + +/** + * 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; + } + return stripMarkdownCode(text).includes(SKIP_CHANGELOG_MAGIC_WORD); +} + /** * A single changeset with name and description */ @@ -1305,7 +1358,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 +1929,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( From ceccf6b6ac09dd5661714a1ca67785516b992848 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:23:29 +0000 Subject: [PATCH 2/2] ref(changelog): Find the skip magic word with the marked lexer Replace the hand-rolled code fence and code span stripping with marked's lexer, which the changelog code already uses. Only prose and HTML tokens are checked, so code anywhere (including in lists and tables) is ignored while hidden HTML comments still count. --- src/utils/__tests__/changelog-utils.test.ts | 8 ++++ src/utils/changelog.ts | 53 ++++++--------------- 2 files changed, 22 insertions(+), 39 deletions(-) diff --git a/src/utils/__tests__/changelog-utils.test.ts b/src/utils/__tests__/changelog-utils.test.ts index f05d677a..ea8f9fd2 100644 --- a/src/utils/__tests__/changelog-utils.test.ts +++ b/src/utils/__tests__/changelog-utils.test.ts @@ -40,6 +40,9 @@ describe('hasSkipChangelogMagicWord', () => { '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', @@ -63,6 +66,11 @@ describe('hasSkipChangelogMagicWord', () => { '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); }); diff --git a/src/utils/changelog.ts b/src/utils/changelog.ts index 97931453..ab699296 100644 --- a/src/utils/changelog.ts +++ b/src/utils/changelog.ts @@ -207,44 +207,6 @@ if (SUBSECTION_HEADER_LEVEL <= VERSION_HEADER_LEVEL) { throw new Error('Subsection headers should nest under version headers!'); } -/** Opening line of a Markdown fenced code block: up to 3 spaces, then ``` or ~~~. */ -const CODE_FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})/; -/** - * A Markdown inline code span: a backtick run, then content, then a run of - * the same length. Spans may wrap lines (squash-merge commit messages are - * hard-wrapped), but never cross a blank line. - */ -const INLINE_CODE_SPAN = /(?= fence.length && - line.trim() === close[1] - ) { - fence = null; - } - continue; - } - const open = line.match(CODE_FENCE_OPEN); - if (open) { - fence = open[1]; - continue; - } - prose.push(line); - } - return prose.join('\n').replace(INLINE_CODE_SPAN, ''); -} - /** * Checks whether a commit message or PR body opts out of the changelog with * the {@link SKIP_CHANGELOG_MAGIC_WORD}. @@ -256,7 +218,20 @@ export function hasSkipChangelogMagicWord(text: string | undefined): boolean { if (!text?.includes(SKIP_CHANGELOG_MAGIC_WORD)) { return false; } - return stripMarkdownCode(text).includes(SKIP_CHANGELOG_MAGIC_WORD); + // 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; } /**