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
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ permissions:
contents: read

jobs:
validate:
validate-changelog:
runs-on: ubuntu-latest
steps:
- name: Checkout
Expand Down
14 changes: 11 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,11 +30,21 @@ 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.

### 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
Expand Down
23 changes: 23 additions & 0 deletions internal/changelog/classifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)?`)
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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 {
Expand Down
47 changes: 47 additions & 0 deletions internal/changelog/classifier_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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() {
Expand Down
10 changes: 9 additions & 1 deletion internal/changelog/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions internal/changelog/realworld_internal_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading