diff --git a/docs/developer/releaser-tool.rst b/docs/developer/releaser-tool.rst index 2f525c32..2b1d3b9e 100644 --- a/docs/developer/releaser-tool.rst +++ b/docs/developer/releaser-tool.rst @@ -100,3 +100,6 @@ process: 7. Finally, it creates a draft release on GitHub with the changelog notes. 8. If releasing a bugfix, it offers to port the changelog to the ``main`` or ``master`` branch. + +GitHub release descriptions are normalized without paragraph wrapping +before submission. This does not modify the changelog file. diff --git a/openwisp_utils/releaser/release.py b/openwisp_utils/releaser/release.py index da860466..4d0386f0 100644 --- a/openwisp_utils/releaser/release.py +++ b/openwisp_utils/releaser/release.py @@ -23,6 +23,7 @@ demote_markdown_headings, format_file_with_docstrfmt, get_current_branch, + normalize_markdown, rst_to_markdown, ) from openwisp_utils.releaser.version import ( @@ -360,6 +361,7 @@ def main(): release_body_md = rst_to_markdown(release_body_rst) try: + release_body_md = normalize_markdown(release_body_md) release_url = gh.create_release(tag_name, release_title, release_body_md) print(f"📦 Draft release created on GitHub: {release_url}") except SkipSignal: diff --git a/openwisp_utils/releaser/tests/test_release.py b/openwisp_utils/releaser/tests/test_release.py index f2431543..97d0e6b5 100644 --- a/openwisp_utils/releaser/tests/test_release.py +++ b/openwisp_utils/releaser/tests/test_release.py @@ -29,6 +29,53 @@ def test_feature_release_flow_markdown(mock_all, mocker): assert "## Markdown Changelog" in release_call_args[2] +@pytest.mark.parametrize( + ("changelog_format", "latest_block", "converted_body"), + [ + ( + "md", + "## Version 1.2.1 [2025-08-11]\n\n### Features\n\n- A wrapped\n" " item.", + None, + ), + ( + "rst", + "Version 1.2.1 [2025-08-11]\n" + "--------------------------\n\n" + "Features\n~~~~~~~~\n\n" + "- A wrapped\n item.", + "# Features\n\n- A wrapped item.", + ), + ], +) +def test_release_body_is_normalized( + mock_all, mocker, changelog_format, latest_block, converted_body +): + """Tests that both changelog formats pass a normalized body to GitHub.""" + mock_config, mock_gh = mock_all["check_prerequisites"].return_value + mock_config["changelog_format"] = changelog_format + mock_config["changelog_path"] = f"CHANGES.{changelog_format}" + mock_all["get_release_block_from_file"].return_value = latest_block + if converted_body: + mocker.patch( + "openwisp_utils.releaser.release.rst_to_markdown", + return_value=converted_body, + ) + normalizer = mocker.patch( + "openwisp_utils.releaser.release.normalize_markdown", + side_effect=lambda value: f"normalized: {value}", + ) + run_release() + expected_body = "# Features\n\n- A wrapped\n item." + if converted_body: + expected_body = converted_body + normalizer.assert_called_once_with(expected_body) + assert mock_gh.create_release.call_args.args == ( + "1.3.0", + "1.3.0 [2025-08-11]", + f"normalized: {expected_body}", + ) + + def test_release_flow_manual_bump(mock_all): """Tests the flow where automatic version bumping fails and the user is prompted to do it manually.""" mock_all["bump_version"].return_value = False diff --git a/openwisp_utils/releaser/tests/test_utils.py b/openwisp_utils/releaser/tests/test_utils.py index 468aaf52..975fc36c 100644 --- a/openwisp_utils/releaser/tests/test_utils.py +++ b/openwisp_utils/releaser/tests/test_utils.py @@ -12,6 +12,7 @@ SkipSignal, branch_exists, format_file_with_docstrfmt, + normalize_markdown, retryable_request, ) @@ -104,6 +105,30 @@ def test_demote_markdown_headings(): assert result.strip() == expected_md.strip() +def test_normalize_markdown(): + """Test that Markdown paragraphs are unwrapped without losing structure.""" + markdown = """## Features + +- Configured nginx to + [serve precompressed files](https://example.com), improving delivery. + - Nested item. + +> A wrapped +> quote. + +```python +long_code_line = "must stay intact" +```""" + normalized = normalize_markdown(markdown) + assert ( + "- Configured nginx to [serve precompressed files](https://example.com), " + "improving delivery." in normalized + ) + assert " - Nested item." in normalized + assert "> A wrapped quote." in normalized + assert '``` python\nlong_code_line = "must stay intact"\n```' in normalized + + @patch("openwisp_utils.releaser.utils.subprocess.run") @patch("builtins.print") def test_format_file_with_docstrfmt_success(mock_print, mock_subprocess): diff --git a/openwisp_utils/releaser/utils.py b/openwisp_utils/releaser/utils.py index 032e29a1..8926d247 100644 --- a/openwisp_utils/releaser/utils.py +++ b/openwisp_utils/releaser/utils.py @@ -103,6 +103,13 @@ def rst_to_markdown(text): ).strip() +def normalize_markdown(text): + """Normalize GitHub-Flavored Markdown without wrapping paragraphs.""" + return pypandoc.convert_text( + text, "gfm", format="gfm", extra_args=["--wrap=none"] + ).strip() + + def _call_docstrfmt(file_path): return subprocess.run( [