2626 Mapping ,
2727 MutableMapping ,
2828 Optional ,
29+ Tuple ,
2930 TypeVar ,
3031 Union ,
3132 cast ,
@@ -122,6 +123,7 @@ def __init__(
122123 token : str ,
123124 changelog : str ,
124125 changelog_ignore : List [str ],
126+ changelog_format : str ,
125127 ssh : bool ,
126128 gpg : bool ,
127129 draft : bool ,
@@ -190,7 +192,13 @@ def __init__(
190192 self ._clone_registry = False
191193 self ._token = token
192194 self .__versions_toml_cache : Optional [Dict [str , Any ]] = None
193- self ._changelog = Changelog (self , changelog , changelog_ignore )
195+ self ._changelog_format = changelog_format
196+ # Only initialize Changelog if using custom format
197+ self ._changelog = (
198+ None
199+ if changelog_format in ["github" , "conventional" ]
200+ else Changelog (self , changelog , changelog_ignore )
201+ )
194202 self ._ssh = ssh
195203 self ._gpg = gpg
196204 self ._draft = draft
@@ -983,6 +991,141 @@ def _tag_exists(self, version: str) -> bool:
983991 # If we can't check, assume it doesn't exist
984992 return False
985993
994+ def _generate_conventional_changelog (
995+ self , version_tag : str , sha : str , previous_tag : Optional [str ] = None
996+ ) -> str :
997+ """Generate changelog from conventional commits.
998+
999+ Args:
1000+ version_tag: The version tag being released
1001+ sha: Commit SHA for the release
1002+ previous_tag: Previous release tag to generate changelog from
1003+
1004+ Returns:
1005+ Formatted changelog based on conventional commits
1006+ """
1007+ # Determine commit range
1008+ if previous_tag :
1009+ commit_range = f"{ previous_tag } ..{ sha } "
1010+ else :
1011+ # For first release, get all commits up to this one
1012+ commit_range = sha
1013+
1014+ # Get commit messages
1015+ try :
1016+ log_output = self ._git .command (
1017+ "log" ,
1018+ commit_range ,
1019+ "--format=%s|%h|%an" ,
1020+ "--no-merges" ,
1021+ )
1022+ except Exception as e :
1023+ logger .warning (f"Could not get commits for conventional changelog: { e } " )
1024+ return f"## { version_tag } \n \n Release created.\n "
1025+
1026+ # Parse commits into categories based on conventional commit format
1027+ # Format: type(scope): description
1028+ categories : Dict [str , List [Tuple [str , str , str ]]] = {
1029+ "breaking" : [], # BREAKING CHANGE or !
1030+ "feat" : [], # Features
1031+ "fix" : [], # Bug fixes
1032+ "perf" : [], # Performance improvements
1033+ "refactor" : [], # Refactoring
1034+ "docs" : [], # Documentation
1035+ "test" : [], # Tests
1036+ "build" : [], # Build system
1037+ "ci" : [], # CI/CD
1038+ "chore" : [], # Chores
1039+ "style" : [], # Code style
1040+ "revert" : [], # Reverts
1041+ "other" : [], # Non-conventional commits
1042+ }
1043+
1044+ for line in log_output .strip ().split ("\n " ):
1045+ if not line :
1046+ continue
1047+ parts = line .split ("|" , 2 )
1048+ if len (parts ) < 3 :
1049+ continue
1050+ message , commit_hash , author = parts
1051+
1052+ # Check for breaking (keep commit in both breaking and its type category)
1053+ if "BREAKING CHANGE" in message or re .match (r"^\w+!:" , message ):
1054+ categories ["breaking" ].append ((message , commit_hash , author ))
1055+
1056+ # Parse conventional commit format (supports optional "!" for breaking)
1057+ match = re .match (r"^(\w+)(\(.+\))?(!)?: (.+)$" , message )
1058+ if match :
1059+ commit_type = match .group (1 ).lower ()
1060+ if commit_type in categories :
1061+ categories [commit_type ].append ((message , commit_hash , author ))
1062+ else :
1063+ categories ["other" ].append ((message , commit_hash , author ))
1064+ else :
1065+ categories ["other" ].append ((message , commit_hash , author ))
1066+
1067+ # Build changelog
1068+ changelog = f"## { version_tag } \n \n "
1069+
1070+ # Section configurations (type: title)
1071+ sections = [
1072+ ("breaking" , "Breaking Changes" ),
1073+ ("feat" , "Features" ),
1074+ ("fix" , "Bug Fixes" ),
1075+ ("perf" , "Performance Improvements" ),
1076+ ("refactor" , "Code Refactoring" ),
1077+ ("docs" , "Documentation" ),
1078+ ("test" , "Tests" ),
1079+ ("build" , "Build System" ),
1080+ ("ci" , "CI/CD" ),
1081+ ("style" , "Code Style" ),
1082+ ("chore" , "Chores" ),
1083+ ("revert" , "Reverts" ),
1084+ ]
1085+
1086+ has_any_commits = (
1087+ any (categories [cat_key ] for cat_key , _ in sections ) or categories ["other" ]
1088+ )
1089+
1090+ repo_url = f"{ self ._gh_url } /{ self ._repo .full_name } "
1091+
1092+ for cat_key , title in sections :
1093+ commits = categories [cat_key ]
1094+ if commits :
1095+ changelog += f"### { title } \n \n "
1096+ for message , commit_hash , author in commits :
1097+ changelog += (
1098+ f"- { message } ([`{ commit_hash } `]"
1099+ f"({ repo_url } /commit/{ commit_hash } )) - { author } \n "
1100+ )
1101+ changelog += "\n "
1102+
1103+ # Add other commits if any
1104+ if categories ["other" ]:
1105+ changelog += "### Other Changes\n \n "
1106+ for message , commit_hash , author in categories ["other" ]:
1107+ changelog += (
1108+ f"- { message } ([`{ commit_hash } `]"
1109+ f"({ repo_url } /commit/{ commit_hash } )) - { author } \n "
1110+ )
1111+ changelog += "\n "
1112+
1113+ # If no commits were found, add an informative message
1114+ if not has_any_commits :
1115+ if previous_tag :
1116+ changelog += "No new commits since the previous release.\n "
1117+ else :
1118+ changelog += "Initial release.\n "
1119+
1120+ # Add compare link if we have a previous tag
1121+ if previous_tag :
1122+ changelog += (
1123+ f"**Full Changelog**: { repo_url } /compare/"
1124+ f"{ previous_tag } ...{ version_tag } \n "
1125+ )
1126+
1127+ return changelog
1128+
9861129 def create_issue_for_manual_tag (self , failures : list [tuple [str , str , str ]]) -> None :
9871130 """Create an issue requesting manual intervention for failed releases.
9881131
@@ -1286,16 +1429,38 @@ def create_release(self, version: str, sha: str, is_latest: bool = True) -> None
12861429 version_tag = self ._get_version_tag (version )
12871430 logger .debug (f"Release { version_tag } target: { target } " )
12881431 # Check if a release for this tag already exists before doing work
1432+ # Also fetch releases list for later use in changelog generation
1433+ releases = []
12891434 try :
1290- for release in self ._repo .get_releases ():
1435+ releases = list (self ._repo .get_releases ())
1436+ for release in releases :
12911437 if release .tag_name == version_tag :
12921438 logger .info (
12931439 f"Release for tag { version_tag } already exists, skipping"
12941440 )
12951441 return
12961442 except GithubException as e :
12971443 logger .warning (f"Could not check for existing releases: { e } " )
1298- log = self ._changelog .get (version_tag , sha )
1444+
1445+ # Generate release notes based on format
1446+ if self ._changelog_format == "github" :
1447+ log = "" # Empty body triggers GitHub to auto-generate notes
1448+ logger .info ("Using GitHub auto-generated release notes" )
1449+ elif self ._changelog_format == "conventional" :
1450+ # Find previous release for conventional changelog
1451+ previous_tag = None
1452+ if releases :
1453+ # Find the most recent release before this one
1454+ for release in releases :
1455+ if release .tag_name != version_tag :
1456+ previous_tag = release .tag_name
1457+ break
1458+
1459+ logger .info ("Generating conventional commits changelog" )
1460+ log = self ._generate_conventional_changelog (version_tag , sha , previous_tag )
1461+ else : # custom format
1462+ log = self ._changelog .get (version_tag , sha ) if self ._changelog else ""
1463+
12991464 if not self ._draft :
13001465 # Always create tags via the CLI as the GitHub API has a bug which
13011466 # only allows tags to be created for SHAs which are the the HEAD
@@ -1313,6 +1478,7 @@ def create_release(self, version: str, sha: str, is_latest: bool = True) -> None
13131478 target_commitish = target ,
13141479 draft = self ._draft ,
13151480 make_latest = make_latest_str ,
1481+ generate_release_notes = (self ._changelog_format == "github" ),
13161482 )
13171483 logger .info (f"GitHub release { version_tag } created successfully" )
13181484
0 commit comments