From 7b59f56f6271b09a359e5bfa11c183a9454f7c1f Mon Sep 17 00:00:00 2001 From: John Judd Date: Thu, 18 Jun 2026 17:49:43 -0500 Subject: [PATCH 1/3] add pr validation for changelog to catch errors BEFORE releasegen runs --- CHANGELOG.md | 10 ++++ internal/changelog/classifier.go | 23 +++++++++ internal/changelog/classifier_test.go | 47 +++++++++++++++++++ internal/changelog/parser.go | 10 +++- internal/changelog/realworld_internal_test.go | 32 +++++++++++++ 5 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 internal/changelog/realworld_internal_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c6bd8e..8d51dd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 external `ensure_changelog` workflow, which has been removed from this repository in favor of the validate-driven check. +### Fixed +- Classifier no longer treats `###` heading references in prose as real + markdown headings. The internal `heading3Prefix` regex is now anchored + to the start of a line (multiline mode), and fenced code blocks are + stripped from `[Unreleased]` bodies before classification. Before this + fix, an `### Added` entry whose body documented `### Changed` / `### Removed` + in an inline code span — and happened to mention `BREAKING CHANGE` while + describing the marker rule — would be misclassified as a major bump. + `ValidateSection` benefits from the same protection. + ## [[v1.1.1](https://github.com/C2FO/releasegen/releases/tag/v1.1.1)] - 2026-06-18 ### Documentation - Documented the classic-branch-protection setup gap in the GitHub App Setup diff --git a/internal/changelog/classifier.go b/internal/changelog/classifier.go index 5c0c6d2..204febe 100644 --- a/internal/changelog/classifier.go +++ b/internal/changelog/classifier.go @@ -14,6 +14,21 @@ import ( // be opted into deliberately. const breakingMarker = "BREAKING CHANGE" +// fencedCodeRE matches a triple-backtick fenced code block, including any +// info string after the opening fence and the trailing newline. We strip +// these from the unreleased section before classification so a documented +// example like ```\n### Changed\n``` cannot masquerade as a real heading. +// +//nolint:gochecknoglobals // compiled once, intentionally package-scoped. +var fencedCodeRE = regexp.MustCompile("(?ms)^```[^\\n]*\\n.*?\\n```\\s*$") + +// stripFencedCode removes any fenced code blocks from s. Heading detection +// runs against the result so prose-at-column-0 inside a fence never becomes +// a phantom `### Changed` or `### Added` heading. +func stripFencedCode(s string) string { + return fencedCodeRE.ReplaceAllString(s, "") +} + var ( breakingHeadingRE = regexp.MustCompile(heading3Prefix + `(?:Change|Remove)[sd]?`) addedRE = regexp.MustCompile(heading3Prefix + `Add(?:s|ed)?`) @@ -42,6 +57,11 @@ func Classify(unreleased string, custom map[string]config.BumpType) (config.Bump if strings.TrimSpace(unreleased) == "" { return config.BumpNone, ErrNoChangesDetected } + // Strip fenced code blocks so documented examples (```\n### Changed\n```) + // can't masquerade as real headings. The anchored heading3Prefix already + // handles inline-code cases like `### Changed` because those never sit + // at column 0. + unreleased = stripFencedCode(unreleased) bump := classifyCustom(unreleased, custom) @@ -132,6 +152,9 @@ func ValidateSection(unreleased string, custom map[string]config.BumpType) []err if strings.TrimSpace(unreleased) == "" { return []error{ErrNoChangesDetected} } + // Same defense as Classify: strip fenced code blocks so example syntax + // inside docs cannot be reported as an unknown heading. + unreleased = stripFencedCode(unreleased) headings := h3HeadingRE.FindAllStringSubmatch(unreleased, -1) if len(headings) == 0 { diff --git a/internal/changelog/classifier_test.go b/internal/changelog/classifier_test.go index cec3634..2709585 100644 --- a/internal/changelog/classifier_test.go +++ b/internal/changelog/classifier_test.go @@ -115,6 +115,36 @@ func (s *ClassifierTestSuite) TestClassify() { wantErr: true, wantErrType: changelog.ErrUnrecognizedChangeType, }, + { + // Regression: prose mentioning `### Changed` / `BREAKING CHANGE` + // inside an inline code span MUST NOT trigger a major bump. This + // is the exact shape of text that wrongly bumped v1.1.1 to v2.0.0 + // before heading3Prefix gained its line anchor. + name: "Inline-code heading reference in body is not a heading", + section: "### Added\n" + + "- New validate subcommand documents that `### Changed` and `### Removed`\n" + + " must include the `BREAKING CHANGE` marker.\n", + wantBump: config.BumpMinor, + }, + { + // A documented example inside a fenced code block must also be + // ignored even though the inner lines start at column 0. + name: "Fenced code block with heading-like content is ignored", + section: "### Added\n" + + "- Example of a bad changelog:\n" + + "```\n" + + "### Changed\n" + + "- oops, no marker\n" + + "```\n", + wantBump: config.BumpMinor, + }, + { + // An H4 heading should not register as an H3. Defense in depth. + name: "H4 ####Changed is not an H3 heading", + section: "#### Changed\n- subheading prose only", + wantErr: true, + wantErrType: changelog.ErrUnrecognizedChangeType, + }, } for _, tt := range tests { s.Run(tt.name, func() { @@ -199,6 +229,23 @@ func (s *ClassifierTestSuite) TestValidateSection() { wantContain: []string{"no ### heading"}, wantSentinel: []error{changelog.ErrUnrecognizedChangeType}, }, + { + // Regression: inline-code heading references must not be reported + // as unknown headings. Pair with the Classify regression above. + name: "inline-code heading reference is ignored by validation", + section: "### Added\n" + + "- Prose that mentions `### Whimsy` only as documentation.\n", + wantNumErrs: 0, + }, + { + name: "fenced-block heading-like content is ignored by validation", + section: "### Added\n" + + "- See bad example below:\n" + + "```\n" + + "### Bogus\n" + + "```\n", + wantNumErrs: 0, + }, } for _, tt := range tests { s.Run(tt.name, func() { diff --git a/internal/changelog/parser.go b/internal/changelog/parser.go index a56ceed..18a1e77 100644 --- a/internal/changelog/parser.go +++ b/internal/changelog/parser.go @@ -7,7 +7,15 @@ import ( const ( heading2Prefix = `(?i)##\s*` - heading3Prefix = `(?i)###\s*` + // heading3Prefix matches a real `### ` markdown heading at the start of + // a line. The `(?m)` flag scopes `^` to line starts (not just the start + // of the whole string), and `\s*` keeps tolerance for stylistic + // variations like `###Heading` or `### Heading`. Anchoring to a line + // start is critical: without it, prose that mentions `### Changed` + // inside an inline code span — exactly the kind of text this very + // changelog contains when documenting validation rules — would be + // mistaken for a heading and could trigger a spurious major bump. + heading3Prefix = `(?im)^###\s*` // SemverPattern is a regex pattern that matches a semantic version string // (per https://semver.org/spec/v2.0.0.html). Exposed for callers that need diff --git a/internal/changelog/realworld_internal_test.go b/internal/changelog/realworld_internal_test.go new file mode 100644 index 0000000..9100be8 --- /dev/null +++ b/internal/changelog/realworld_internal_test.go @@ -0,0 +1,32 @@ +package changelog + +import ( + "os" + "testing" + + "github.com/c2fo/releasegen/internal/config" +) + +// TestClassify_AgainstActualRepoChangelog is a sanity check that runs the +// repo's own CHANGELOG.md through Classify and asserts it does not produce +// a major bump from prose. Guards against regression of the inline-code +// false-match that bumped v1.1.1 to v2.0.0. +func TestClassify_AgainstActualRepoChangelog(t *testing.T) { + data, err := os.ReadFile("../../CHANGELOG.md") + if err != nil { + t.Skipf("no CHANGELOG.md at repo root: %v", err) + } + section := ExtractUnreleased(string(data)) + if section == "" { + t.Skip("repo [Unreleased] section is empty") + } + custom := map[string]config.BumpType{"documentation": config.BumpPatch} + bump, err := Classify(section, custom) + if err != nil { + t.Fatalf("classify: %v", err) + } + if bump == config.BumpMajor { + t.Fatalf("real repo [Unreleased] classified as MAJOR; section:\n%s", section) + } + t.Logf("repo [Unreleased] classifies as %v (correct: not major)", bump) +} From 023fb1c71dd7f92a781ba8c127327ce85c2aacfd Mon Sep 17 00:00:00 2001 From: John Judd Date: Thu, 18 Jun 2026 17:51:58 -0500 Subject: [PATCH 2/3] Revert "chore: release version v2.0.0 (funkyshu) [skip ci]" This reverts commit 5e579040c225dbb627e8eaf1d279e8c0242cbc76. --- CHANGELOG.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d51dd4..05a7357 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] - -## [[v2.0.0](https://github.com/C2FO/releasegen/releases/tag/v2.0.0)] - 2026-06-18 ### Added - New `releasegen validate` subcommand. Parses every `## [Unreleased]` section in the repository and reports **every** malformed heading it finds — both @@ -32,7 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 new lines. Modules are scoped by where `CHANGELOG.md` lives, with the root changelog catching every file not claimed by a submodule changelog. The base ref is configurable via `--base-ref` / `RELEASEGEN_BASE_REF` / - `validate.base_ref` and defaults to `origin/` on GitHub + `validate.base_ref` and defaults to `origin/$GITHUB_BASE_REF` on GitHub Actions pull-request runs, else `origin/main`. Subsumes the prior external `ensure_changelog` workflow, which has been removed from this repository in favor of the validate-driven check. From df1826d68984a2e725cb3ffd9990567ca0234761 Mon Sep 17 00:00:00 2001 From: John Judd Date: Thu, 18 Jun 2026 17:55:37 -0500 Subject: [PATCH 3/3] rename validate job to validate-changelog --- .github/workflows/{validate.yml => validate-changelog.yml} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename .github/workflows/{validate.yml => validate-changelog.yml} (97%) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate-changelog.yml similarity index 97% rename from .github/workflows/validate.yml rename to .github/workflows/validate-changelog.yml index 3756b36..1f0436d 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate-changelog.yml @@ -7,7 +7,7 @@ permissions: contents: read jobs: - validate: + validate-changelog: runs-on: ubuntu-latest steps: - name: Checkout