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: 4 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ inputs:
changelog_ignore:
description: Labels for issues and pull requests to be ignored (comma-delimited)
required: false
changelog_format:
description: "Changelog format: custom (default), github (auto-generated), or conventional (conventional commits)"
required: false
default: custom
runs:
using: docker
image: docker://ghcr.io/juliaregistries/tagbot:1.23.5
Expand Down
11 changes: 11 additions & 0 deletions tagbot/action/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,16 @@ def get_input(key: str, default: str = "") -> str:
else:
ignore = Changelog.DEFAULT_IGNORE

changelog_format = get_input("changelog_format", "custom").lower()
# Validate changelog_format
valid_formats = ["custom", "github", "conventional"]
if changelog_format not in valid_formats:
logger.warning(
f"Invalid changelog_format '{changelog_format}', using 'custom'. "
f"Valid formats: {', '.join(valid_formats)}"
)
changelog_format = "custom"

repo = Repo(
repo=os.getenv("GITHUB_REPOSITORY", ""),
registry=get_input("registry"),
Expand All @@ -57,6 +67,7 @@ def get_input(key: str, default: str = "") -> str:
token=token,
changelog=get_input("changelog"),
changelog_ignore=ignore,
changelog_format=changelog_format,
ssh=bool(ssh),
gpg=bool(gpg),
draft=get_input("draft").lower() in ["true", "yes"],
Expand Down
172 changes: 169 additions & 3 deletions tagbot/action/repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
Mapping,
MutableMapping,
Optional,
Tuple,
TypeVar,
Union,
cast,
Expand Down Expand Up @@ -122,6 +123,7 @@ def __init__(
token: str,
changelog: str,
changelog_ignore: List[str],
changelog_format: str,
ssh: bool,
gpg: bool,
draft: bool,
Expand Down Expand Up @@ -190,7 +192,13 @@ def __init__(
self._clone_registry = False
self._token = token
self.__versions_toml_cache: Optional[Dict[str, Any]] = None
self._changelog = Changelog(self, changelog, changelog_ignore)
self._changelog_format = changelog_format
# Only initialize Changelog if using custom format
self._changelog = (
None
if changelog_format in ["github", "conventional"]
else Changelog(self, changelog, changelog_ignore)
)
self._ssh = ssh
self._gpg = gpg
self._draft = draft
Expand Down Expand Up @@ -983,6 +991,141 @@ def _tag_exists(self, version: str) -> bool:
# If we can't check, assume it doesn't exist
return False

def _generate_conventional_changelog(
self, version_tag: str, sha: str, previous_tag: Optional[str] = None
) -> str:
"""Generate changelog from conventional commits.

Args:
version_tag: The version tag being released
sha: Commit SHA for the release
previous_tag: Previous release tag to generate changelog from

Returns:
Formatted changelog based on conventional commits
"""
# Determine commit range
if previous_tag:
commit_range = f"{previous_tag}..{sha}"
else:
# For first release, get all commits up to this one
commit_range = sha

# Get commit messages
try:
log_output = self._git.command(
"log",
commit_range,
"--format=%s|%h|%an",
"--no-merges",
)
except Exception as e:
logger.warning(f"Could not get commits for conventional changelog: {e}")
return f"## {version_tag}\n\nRelease created.\n"

# Parse commits into categories based on conventional commit format
# Format: type(scope): description
categories: Dict[str, List[Tuple[str, str, str]]] = {
"breaking": [], # BREAKING CHANGE or !
"feat": [], # Features
"fix": [], # Bug fixes
"perf": [], # Performance improvements
"refactor": [], # Refactoring
"docs": [], # Documentation
"test": [], # Tests
"build": [], # Build system
"ci": [], # CI/CD
"chore": [], # Chores
"style": [], # Code style
"revert": [], # Reverts
"other": [], # Non-conventional commits
}

for line in log_output.strip().split("\n"):
if not line:
continue
parts = line.split("|", 2)
if len(parts) < 3:
continue
message, commit_hash, author = parts

# Check for breaking (keep commit in both breaking and its type category)
if "BREAKING CHANGE" in message or re.match(r"^\w+!:", message):
categories["breaking"].append((message, commit_hash, author))

# Parse conventional commit format (supports optional "!" for breaking)
match = re.match(r"^(\w+)(\(.+\))?(!)?: (.+)$", message)
if match:
commit_type = match.group(1).lower()
if commit_type in categories:
categories[commit_type].append((message, commit_hash, author))
else:
categories["other"].append((message, commit_hash, author))
else:
categories["other"].append((message, commit_hash, author))

# Build changelog
changelog = f"## {version_tag}\n\n"

# Section configurations (type: title)
sections = [
("breaking", "Breaking Changes"),
("feat", "Features"),
("fix", "Bug Fixes"),
("perf", "Performance Improvements"),
("refactor", "Code Refactoring"),
("docs", "Documentation"),
("test", "Tests"),
("build", "Build System"),
("ci", "CI/CD"),
("style", "Code Style"),
("chore", "Chores"),
("revert", "Reverts"),
]

has_any_commits = (
any(categories[cat_key] for cat_key, _ in sections) or categories["other"]
)

repo_url = f"{self._gh_url}/{self._repo.full_name}"

for cat_key, title in sections:
commits = categories[cat_key]
if commits:
changelog += f"### {title}\n\n"
for message, commit_hash, author in commits:
changelog += (
f"- {message} ([`{commit_hash}`]"
f"({repo_url}/commit/{commit_hash})) - {author}\n"
)
changelog += "\n"

# Add other commits if any
if categories["other"]:
changelog += "### Other Changes\n\n"
for message, commit_hash, author in categories["other"]:
changelog += (
f"- {message} ([`{commit_hash}`]"
f"({repo_url}/commit/{commit_hash})) - {author}\n"
)
changelog += "\n"

# If no commits were found, add an informative message
if not has_any_commits:
if previous_tag:
changelog += "No new commits since the previous release.\n"
else:
changelog += "Initial release.\n"

# Add compare link if we have a previous tag
if previous_tag:
changelog += (
f"**Full Changelog**: {repo_url}/compare/"
f"{previous_tag}...{version_tag}\n"
)

return changelog

def create_issue_for_manual_tag(self, failures: list[tuple[str, str, str]]) -> None:
"""Create an issue requesting manual intervention for failed releases.

Expand Down Expand Up @@ -1286,16 +1429,38 @@ def create_release(self, version: str, sha: str, is_latest: bool = True) -> None
version_tag = self._get_version_tag(version)
logger.debug(f"Release {version_tag} target: {target}")
# Check if a release for this tag already exists before doing work
# Also fetch releases list for later use in changelog generation
releases = []
try:
for release in self._repo.get_releases():
releases = list(self._repo.get_releases())
for release in releases:
if release.tag_name == version_tag:
logger.info(
f"Release for tag {version_tag} already exists, skipping"
)
return
except GithubException as e:
logger.warning(f"Could not check for existing releases: {e}")
log = self._changelog.get(version_tag, sha)

# Generate release notes based on format
if self._changelog_format == "github":
log = "" # Empty body triggers GitHub to auto-generate notes
logger.info("Using GitHub auto-generated release notes")
elif self._changelog_format == "conventional":
# Find previous release for conventional changelog
previous_tag = None
if releases:
# Find the most recent release before this one
for release in releases:
if release.tag_name != version_tag:
previous_tag = release.tag_name
break

logger.info("Generating conventional commits changelog")
log = self._generate_conventional_changelog(version_tag, sha, previous_tag)
else: # custom format
log = self._changelog.get(version_tag, sha) if self._changelog else ""

if not self._draft:
# Always create tags via the CLI as the GitHub API has a bug which
# only allows tags to be created for SHAs which are the the HEAD
Expand All @@ -1313,6 +1478,7 @@ def create_release(self, version: str, sha: str, is_latest: bool = True) -> None
target_commitish=target,
draft=self._draft,
make_latest=make_latest_str,
generate_release_notes=(self._changelog_format == "github"),
)
logger.info(f"GitHub release {version_tag} created successfully")

Expand Down
1 change: 1 addition & 0 deletions tagbot/local/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ def main(
token=token,
changelog=changelog,
changelog_ignore=[],
changelog_format="custom",
ssh=False,
gpg=False,
draft=draft,
Expand Down
2 changes: 2 additions & 0 deletions test/action/test_backfilling.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ def _repo(
token="x",
changelog="",
ignore=[],
changelog_format="custom",
ssh=False,
gpg=False,
draft=False,
Expand All @@ -36,6 +37,7 @@ def _repo(
token=token,
changelog=changelog,
changelog_ignore=ignore,
changelog_format=changelog_format,
ssh=ssh,
gpg=gpg,
draft=draft,
Expand Down
1 change: 1 addition & 0 deletions test/action/test_changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ def _changelog(*, template="", ignore=set(), subdir=None):
token="x",
changelog=template,
changelog_ignore=ignore,
changelog_format="custom",
ssh=False,
gpg=False,
draft=False,
Expand Down
Loading