Skip to content

Commit 84a8938

Browse files
arnavk23CopilotIanButterworth
authored
Add changelog_format for compatibility with multiple changelogs (#467)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Ian Butterworth <i.r.butterworth@gmail.com>
1 parent cbbc4fc commit 84a8938

File tree

9 files changed

+597
-13
lines changed

9 files changed

+597
-13
lines changed

action.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,10 @@ inputs:
113113
changelog_ignore:
114114
description: Labels for issues and pull requests to be ignored (comma-delimited)
115115
required: false
116+
changelog_format:
117+
description: "Changelog format: custom (default), github (auto-generated), or conventional (conventional commits)"
118+
required: false
119+
default: custom
116120
runs:
117121
using: docker
118122
image: docker://ghcr.io/juliaregistries/tagbot:1.23.5

tagbot/action/__main__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,16 @@ def get_input(key: str, default: str = "") -> str:
4949
else:
5050
ignore = Changelog.DEFAULT_IGNORE
5151

52+
changelog_format = get_input("changelog_format", "custom").lower()
53+
# Validate changelog_format
54+
valid_formats = ["custom", "github", "conventional"]
55+
if changelog_format not in valid_formats:
56+
logger.warning(
57+
f"Invalid changelog_format '{changelog_format}', using 'custom'. "
58+
f"Valid formats: {', '.join(valid_formats)}"
59+
)
60+
changelog_format = "custom"
61+
5262
repo = Repo(
5363
repo=os.getenv("GITHUB_REPOSITORY", ""),
5464
registry=get_input("registry"),
@@ -57,6 +67,7 @@ def get_input(key: str, default: str = "") -> str:
5767
token=token,
5868
changelog=get_input("changelog"),
5969
changelog_ignore=ignore,
70+
changelog_format=changelog_format,
6071
ssh=bool(ssh),
6172
gpg=bool(gpg),
6273
draft=get_input("draft").lower() in ["true", "yes"],

tagbot/action/repo.py

Lines changed: 169 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
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\nRelease 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

tagbot/local/__main__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ def main(
4747
token=token,
4848
changelog=changelog,
4949
changelog_ignore=[],
50+
changelog_format="custom",
5051
ssh=False,
5152
gpg=False,
5253
draft=draft,

test/action/test_backfilling.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ def _repo(
1818
token="x",
1919
changelog="",
2020
ignore=[],
21+
changelog_format="custom",
2122
ssh=False,
2223
gpg=False,
2324
draft=False,
@@ -36,6 +37,7 @@ def _repo(
3637
token=token,
3738
changelog=changelog,
3839
changelog_ignore=ignore,
40+
changelog_format=changelog_format,
3941
ssh=ssh,
4042
gpg=gpg,
4143
draft=draft,

test/action/test_changelog.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ def _changelog(*, template="", ignore=set(), subdir=None):
2121
token="x",
2222
changelog=template,
2323
changelog_ignore=ignore,
24+
changelog_format="custom",
2425
ssh=False,
2526
gpg=False,
2627
draft=False,

0 commit comments

Comments
 (0)