From bbd93e5c3ba2335515b35e1a163f305db51478d7 Mon Sep 17 00:00:00 2001 From: mignot Date: Thu, 5 Nov 2026 15:35:21 +0100 Subject: [PATCH 1/2] fix(release): preserve fenced headings in changelog notes --- CHANGELOG.md | 4 ++++ release/github-release.py | 11 +--------- release/test_github_release.py | 8 ++++++++ release/test_validate_release.py | 4 ++++ release/validate-release.py | 35 +++++++++++++++++++++++++------- 5 files changed, 45 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34032e7..a1bf7b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,10 @@ All contributors (including maintainers) should update `CHANGELOG.md` when creat ## [Unreleased] +### Fixed + +- Release validation and publication preserve complete changelog notes containing fenced Markdown headings and ignore fenced version examples. + ### Changed - Adopt CI-owned tags and changelog-based GitHub publication for this repository, preserving script-based release preparation. diff --git a/release/github-release.py b/release/github-release.py index fe58081..9c71ad2 100644 --- a/release/github-release.py +++ b/release/github-release.py @@ -19,16 +19,7 @@ def release_notes(changelog, tag): if not re.fullmatch(r'v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)', tag): raise ValueError('Expected a stable vX.Y.Z tag') - lines = changelog.splitlines() - starts = [i for i, line in enumerate(lines) if re.fullmatch(r'## \[' + re.escape(tag[1:]) + r'\](?:[^\S\n][^\n]*)?', line)] - if len(starts) != 1: - raise ValueError('Expected one version changelog section') - end = next((i for i in range(starts[0] + 1, len(lines)) if lines[i].startswith('## ')), len(lines)) - body = '\n'.join(lines[starts[0] + 1:end]).strip() - body = re.sub(r'', '', body, flags=re.DOTALL).strip() - if not re.sub(r'^#{1,6}\s.*$', '', body, flags=re.MULTILINE).strip(): - raise ValueError('Version changelog section is empty') - return body + return VALIDATION['changelog_section'](changelog, tag[1:]) def verify_release(release, tag): diff --git a/release/test_github_release.py b/release/test_github_release.py index f839f2a..523dc11 100644 --- a/release/test_github_release.py +++ b/release/test_github_release.py @@ -59,6 +59,14 @@ def test_changelog(self): with self.assertRaises(ValueError): module.release_notes('## [2.3.0]\nA', 'v2.3.0-rc.1') + def test_fenced_headings_preserve_complete_release_notes(self): + for fence in ('```md', '~~~markdown', '````md'): + close = fence.split('m')[0] + notes = '- Example syntax:\n\n' + fence + '\n## Example\n## [2.3.0]\n' + close + '\n\n- Other fix.' + text = '```md\n## [2.3.0]\n- Example version\n```\n\n## [2.3.0]\n\n' + notes + '\n\n## [2.2.0]\n- Old' + with self.subTest(fence=fence): + self.assertEqual(module.release_notes(text, TAG), notes) + def test_create_then_retry_without_duplicate(self): api = FakeAPI([module.APIError(404), RELEASE]) self.assertEqual(module.publish(api, TAG, COMMIT, 'Notes'), 'created') diff --git a/release/test_validate_release.py b/release/test_validate_release.py index b998597..2ac3ba3 100644 --- a/release/test_validate_release.py +++ b/release/test_validate_release.py @@ -52,6 +52,10 @@ def check(self, after, policy_path='policy.json'): finally: os.chdir(previous) + def test_validation_ignores_fenced_example_versions(self): + self.write('CHANGELOG.md', '```md\n## [1.0.0]\n- Example\n```\n\n## [1.0.0]\n- Real\n```md\n## Example\n```\n- Last\n') + self.assertEqual(self.check(self.commit()), {'release': False}) + def test_no_bump_is_noop_even_without_tag(self): self.assertEqual(self.check(self.commit()), {'release': False}) diff --git a/release/validate-release.py b/release/validate-release.py index 6d70794..9c708ce 100644 --- a/release/validate-release.py +++ b/release/validate-release.py @@ -99,19 +99,40 @@ def policy_at(commit, path): return policy +def changelog_section(text, version): + lines = text.splitlines() + headings = [] + fence = None + for index, line in enumerate(lines): + marker = re.match(r"^ {0,3}(`{3,}|~{3,})(.*)$", line) + if marker: + run, tail = marker.groups() + if fence is None and (run[0] != "`" or "`" not in tail): + fence = run + elif fence and run[0] == fence[0] and len(run) >= len(fence) and not tail.strip(): + fence = None + continue + if fence is None and line.startswith("## "): + headings.append((index, line)) + matches = [index for index, line in headings + if re.fullmatch(r"## \[" + re.escape(version) + r"\](?:[^\S\n][^\n]*)?", line)] + if len(matches) != 1: + raise ValueError("exactly one versioned changelog section required") + end = next((index for index, _ in headings if index > matches[0]), len(lines)) + body = "\n".join(lines[matches[0] + 1:end]).strip() + body = re.sub(r"", "", body, flags=re.DOTALL).strip() + if not re.sub(r"^#{1,6}\s.*$", "", body, flags=re.MULTILINE).strip(): + raise ValueError("versioned changelog section is empty") + return body + + def check_current(commit, policy): version = version_at(commit, policy["version"]) for mirror in policy["mirrors"]: if version_at(commit, mirror) != version: raise ValueError("version mirror mismatch") changelog = read_file(commit, policy["changelog"]) - sections = list(re.finditer(r"^## \[" + re.escape(version) + r"\](?:[^\S\n][^\n]*)?$", changelog, re.MULTILINE)) - if len(sections) != 1: - raise ValueError("exactly one versioned changelog section required") - body = re.split(r"^## ", changelog[sections[0].end():], maxsplit=1, flags=re.MULTILINE)[0] - body = re.sub(r"", "", body, flags=re.DOTALL) - if not any(line.strip() and not line.lstrip().startswith("#") for line in body.splitlines()): - raise ValueError("versioned changelog section is empty") + changelog_section(changelog, version) git("check-ref-format", f"refs/tags/{policy['tag_prefix']}{version}") return version From 84a73457403e20021217fe52ee650bd8de3cdca5 Mon Sep 17 00:00:00 2001 From: mignot Date: Thu, 5 Nov 2026 15:37:36 +0100 Subject: [PATCH 2/2] fix(release): pin workflows to fence-aware release helpers --- .github/workflows/release-github.yml | 2 +- .github/workflows/release-tag.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release-github.yml b/.github/workflows/release-github.yml index addeb0a..c080ec4 100644 --- a/.github/workflows/release-github.yml +++ b/.github/workflows/release-github.yml @@ -28,7 +28,7 @@ jobs: - uses: actions/checkout@v5 with: repository: BehindTheMusicTree/github-workflows - ref: 8ae00ab34e74ffe16977cf28bdc93ecd6724ed6d + ref: bbd93e5c3ba2335515b35e1a163f305db51478d7 persist-credentials: false path: foundation - name: Validate and publish the existing tag diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 69c5fee..3e293f4 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -45,7 +45,7 @@ jobs: - uses: actions/checkout@v5 with: repository: BehindTheMusicTree/github-workflows - ref: f304059fd7e9a9d0743a9e6a76ae11ea6e12f121 + ref: bbd93e5c3ba2335515b35e1a163f305db51478d7 persist-credentials: false path: foundation - name: Validate the exact push @@ -68,7 +68,7 @@ jobs: - uses: actions/checkout@v5 with: repository: BehindTheMusicTree/github-workflows - ref: f304059fd7e9a9d0743a9e6a76ae11ea6e12f121 + ref: bbd93e5c3ba2335515b35e1a163f305db51478d7 persist-credentials: false - name: Require App configuration env: