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
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@v2.0.3`.
- The action now lives at the repository root and is used as `Waveful/ChangelogAction@v2.0.4`.
- 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@v2.0.3
uses: Waveful/ChangelogAction@v2.0.4
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
target-branch: master
Expand Down
2 changes: 1 addition & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ runs:
Before writing, read all provided information and identify the distinct changes.
PR details and commit details may overlap; combine them, do not duplicate.
Merge multiple items addressing the same feature, bug, or area into a single bullet.
Put all PRs and commits whose only changes are dependency updates into one single bullet, formatted as "Update some dependencies (X, Y, Z)" with the updated dependency names in place of X, Y, Z.
Put all PRs and commits whose only changes are dependency updates into one single bullet, formatted as "Update some dependencies (X, Y, Z)" with the updated dependency names in place of X, Y, Z. Only add this bullet when at least one dependency was actually updated; never write "(none)", "(unknown)", or empty parentheses.
Never describe the same change more than once.
Focus on what changed from the user's perspective rather than listing every commit or PR.

Expand Down
30 changes: 29 additions & 1 deletion scripts/normalize_changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@
from pathlib import Path


NOOP_DEPENDENCY_BULLET_RE = re.compile(
r"^[ \t]*(?:[•*-]|\d+[.)])[ \t]+"
r"(?:update(?:d|s)?|bump(?:ed|s)?|upgrade(?:d|s)?)\s+"
r"(?:some\s+)?dependenc(?:y|ies)\s*"
r"\(\s*(?:none|n/?a|not applicable|no dependencies?)\s*\)"
r"[.!?]?[ \t]*$",
flags=re.IGNORECASE | re.MULTILINE,
)


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)
Expand All @@ -22,14 +32,32 @@ def normalize(text):
return text


def remove_noop_dependency_bullets(text):
return "\n".join(
line for line in text.splitlines() if not NOOP_DEPENDENCY_BULLET_RE.match(line)
)


CHANGELOG_FILTERS = (
remove_noop_dependency_bullets,
)


def filter_changelog(text):
for changelog_filter in CHANGELOG_FILTERS:
text = changelog_filter(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"
filtered = filter_changelog(content.strip())
normalized = normalize(filtered) + "\n"
path.write_text(normalized, encoding="utf-8")


Expand Down
37 changes: 37 additions & 0 deletions tests/test_normalize_changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,24 @@ def test_preserves_heading_text(self):
result = normalize_mod.normalize(text)
self.assertIn("## deployed-2026-03-24T1317Z", result)

def test_filter_removes_noop_dependency_bullet(self):
text = "## v1.0\n\n• Added login\n• Update some dependencies (none)\n• Fixed logout"
result = normalize_mod.filter_changelog(text)
self.assertIn("• Added login", result)
self.assertIn("• Fixed logout", result)
self.assertNotIn("• Added login\n\n• Fixed logout", result)
self.assertNotIn("Update some dependencies (none)", result)

def test_filter_preserves_named_dependency_bullet(self):
text = "## v1.0\n\n• Update some dependencies (firebase, sentry)"
result = normalize_mod.filter_changelog(text)
self.assertIn("• Update some dependencies (firebase, sentry)", result)

def test_normalize_does_not_filter_changelog_content(self):
text = "## v1.0\n\n• Update some dependencies (none)"
result = normalize_mod.normalize(text)
self.assertIn("• Update some dependencies (none) ", 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)
Expand Down Expand Up @@ -101,6 +119,25 @@ def test_main_normalizes_file_in_place(self):
if line.startswith("•"):
self.assertTrue(line.endswith(" "))

def test_main_filters_before_normalizing_file(self):
with tempfile.TemporaryDirectory() as tmpdir:
path = Path(tmpdir) / "input.md"
path.write_text(
"## v1.0\n"
"• Added login\n"
"• Update some dependencies (none)\n"
"• Fixed logout\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("• Added login ", result)
self.assertIn("• Fixed logout ", result)
self.assertNotIn("Update some dependencies (none)", result)


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