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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
__pycache__/
*.pyc
*.pyo
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ GitHub Action to generate a `CHANGELOG.md` on new tags, with optional Telegram n

## Breaking changes in 2.x

- The action now lives at the repository root and is used as `Waveful/ChangelogAction@2`.
- The action now lives at the repository root and is used as `Waveful/ChangelogAction@v2.0.1`.
- The old split actions (`/generate`, `/notify`, `/annotate`) have been removed from the default setup.
- Changelog generation is always the default behavior.
- Telegram notification only runs when `telegram-bot-token`, `telegram-chat-id`, and `changelog-url` are all provided.
Expand Down Expand Up @@ -67,7 +67,7 @@ jobs:
steps:
- name: Run changelog action
id: changelog
uses: Waveful/ChangelogAction@2
uses: Waveful/ChangelogAction@v2.0.1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
target-branch: master
Expand Down
4 changes: 4 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,8 @@ runs:
github_output.write(f"entry-file={path.resolve()}\n")
PY

python3 "${{ github.action_path }}/scripts/normalize_changelog.py" "$CHANGELOG_FILE"

- name: Update CHANGELOG.md
shell: bash
run: |
Expand Down Expand Up @@ -354,6 +356,8 @@ runs:

changelog_path.write_text(updated, encoding="utf-8")
PY

python3 "${{ github.action_path }}/scripts/normalize_changelog.py" CHANGELOG.md
env:
CHANGELOG_FILE: ${{ steps.changelog.outputs.entry-file }}

Expand Down
37 changes: 37 additions & 0 deletions scripts/normalize_changelog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
"""Normalize markdown formatting in changelog text.

Ensures bullet lines (•) render on separate lines in markdown previews
and that ## headings are properly separated from surrounding content.
"""

import argparse
import re
from pathlib import Path


def normalize(text):
# Ensure blank line before ## headings (when not already preceded by one)
text = re.sub(r"(?<!\n)\n(## )", r"\n\n\1", text)
# Ensure blank line after ## headings
text = re.sub(r"^(## .+)$\n(?!\n)", r"\1\n\n", text, flags=re.MULTILINE)
# Append two trailing spaces to every • line (markdown hard line break)
text = re.sub(r"^(•.+?)[ \t]*$", r"\1 ", text, flags=re.MULTILINE)
# Collapse triple+ newlines into one blank line
text = re.sub(r"\n{3,}", r"\n\n", text)
return text


def main():
parser = argparse.ArgumentParser(description="Normalize changelog markdown in-place.")
parser.add_argument("file", help="Markdown file to normalize")
args = parser.parse_args()

path = Path(args.file)
content = path.read_text(encoding="utf-8")
normalized = normalize(content.strip()) + "\n"
path.write_text(normalized, encoding="utf-8")


if __name__ == "__main__":
main()
106 changes: 106 additions & 0 deletions tests/test_normalize_changelog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import importlib.util
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock


ROOT = Path(__file__).resolve().parents[1]
MODULE_PATH = ROOT / "scripts" / "normalize_changelog.py"


def load_module():
spec = importlib.util.spec_from_file_location("normalize_changelog", MODULE_PATH)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
return module


normalize_mod = load_module()


class NormalizeChangelogTests(unittest.TestCase):
def test_adds_blank_line_after_heading(self):
text = "## v1.0\n• First bullet\n• Second bullet"
result = normalize_mod.normalize(text)
self.assertIn("## v1.0\n\n", result)

def test_adds_blank_line_before_heading(self):
text = "• Last bullet\n## v2.0\n• New bullet"
result = normalize_mod.normalize(text)
self.assertIn("\n\n## v2.0", result)

def test_adds_trailing_spaces_to_bullet_lines(self):
text = "## v1.0\n\n• First bullet\n• Second bullet"
result = normalize_mod.normalize(text)
for line in result.split("\n"):
if line.startswith("•"):
self.assertTrue(
line.endswith(" "), f"Missing trailing spaces: {line!r}"
)

def test_replaces_existing_trailing_whitespace_with_two_spaces(self):
text = "## v1.0\n\n• Bullet with tabs\t\t"
result = normalize_mod.normalize(text)
bullet = [l for l in result.split("\n") if l.startswith("•")][0]
self.assertTrue(bullet.endswith(" "))
self.assertNotIn("\t", bullet)

def test_does_not_double_existing_blank_lines(self):
text = "## v1.0\n\n• First \n• Second \n\n## v2.0\n\n• Third "
result = normalize_mod.normalize(text)
self.assertNotIn("\n\n\n", result)

def test_collapses_triple_newlines(self):
text = "## v1.0\n\n\n\n• First bullet"
result = normalize_mod.normalize(text)
self.assertNotIn("\n\n\n", result)
self.assertIn("## v1.0\n\n• First bullet", result)

def test_preserves_heading_text(self):
text = "## deployed-2026-03-24T1317Z\n• Some change"
result = normalize_mod.normalize(text)
self.assertIn("## deployed-2026-03-24T1317Z", result)

def test_idempotent(self):
text = "## v1.0\n\n• First \n• Second \n\n## v2.0\n\n• Third "
first = normalize_mod.normalize(text)
second = normalize_mod.normalize(first)
self.assertEqual(first, second)

def test_full_changelog_with_header(self):
text = (
"# Changelog\n\n"
"## v2.0\n• New feature\n• Bug fix\n\n"
"## v1.0\n• Initial release"
)
result = normalize_mod.normalize(text)
self.assertIn("## v2.0\n\n• New feature \n• Bug fix ", result)
self.assertIn("\n\n## v1.0\n\n• Initial release ", result)
self.assertNotIn("\n\n\n", result)

def test_blank_line_before_heading_when_prev_bullet_has_trailing_spaces(self):
text = "• Last bullet \n## v2.0\n• New bullet"
result = normalize_mod.normalize(text)
self.assertIn("\n\n## v2.0", result)

def test_main_normalizes_file_in_place(self):
with tempfile.TemporaryDirectory() as tmpdir:
path = Path(tmpdir) / "input.md"
path.write_text("## v1.0\n• First\n• Second\n", encoding="utf-8")

with mock.patch.object(sys, "argv", ["prog", str(path)]):
normalize_mod.main()

result = path.read_text(encoding="utf-8")
self.assertIn("## v1.0\n\n", result)
self.assertTrue(result.endswith("\n"))
for line in result.split("\n"):
if line.startswith("•"):
self.assertTrue(line.endswith(" "))


if __name__ == "__main__":
unittest.main()