🚀 Executive Summary
TL;DR: Manually updating `Changelog.md` is a time-consuming and error-prone task. This article provides a Python script that automates changelog generation by parsing Git history based on the Conventional Commits specification, ensuring consistent and accurate release notes.
🎯 Key Takeaways
- The script relies heavily on the Conventional Commits specification, requiring commit messages to adhere to the `type: description` format for proper parsing and categorization.
- It utilizes the `GitPython` library to interact with the Git repository, enabling programmatic access to commit history, tags, and message parsing.
- The core logic involves identifying the latest Git tag, iterating through subsequent commits, parsing their messages using regular expressions, grouping them by commit type, and prepending the new entries to the existing `Changelog.md` file.
- For optimal efficiency and accuracy, the script is best integrated into a CI/CD pipeline or a pre-release stage, ensuring the changelog is automatically updated before new version tags are created.
Auto-Generate Changelog.md from Conventional Commits
Hey there, Darian here. Let’s talk about a task that used to be a real drag for me: updating the changelog. Every release, I’d find myself manually digging through Git history, trying to piece together what changed. It was tedious, error-prone, and honestly, a waste of valuable time. I figured I was losing at least a couple of hours a week to this until I automated it. The solution? A simple Python script that leverages Conventional Commits to build the `Changelog.md` for me. It’s not just about saving time; it’s about ensuring our release notes are consistent, accurate, and professional every single time. Let’s get this set up.
Prerequisites
Before we dive in, make sure you have the following ready:
- Python 3.6 or newer installed on your machine.
- A Git repository that uses the Conventional Commits specification. This is non-negotiable for this script to work.
- Basic familiarity with running Python scripts from your terminal.
The Guide: Step-by-Step
Step 1: Project Setup
First things first, we need a place for our script to live. I’ll skip the standard virtualenv setup since you likely have your own workflow for that. Just make sure you’re working in an isolated Python environment to keep dependencies clean. The only third-party library we need is GitPython. You can get it by running this command in your terminal: pip install GitPython. Once that’s installed, create a new file named generate_changelog.py in your project’s root directory.
Step 2: The Core Python Script
This is where the magic happens. We’ll write a script that reads your Git history, filters for commits since the last tag, and then organizes them into a neat Markdown file. Paste the following code into your generate_changelog.py file.
import git
import re
from collections import defaultdict
from datetime import datetime
# --- Configuration ---
REPO_PATH = "." # Assumes the script is run from the repo's root
CHANGELOG_FILE = "Changelog.md"
COMMIT_TYPES = {
"feat": "Features",
"fix": "Bug Fixes",
"docs": "Documentation",
"style": "Styling",
"refactor": "Code Refactoring",
"perf": "Performance Improvements",
"test": "Tests",
"build": "Build System",
"ci": "Continuous Integration",
}
def get_latest_tag(repo):
"""Finds the most recent tag in the repository."""
try:
# Sorts tags by committer date and gets the latest one
latest_tag = sorted(repo.tags, key=lambda t: t.commit.committed_datetime)[-1]
return latest_tag
except IndexError:
# Handle case where there are no tags
return None
def get_commits_since_tag(repo, tag):
"""Gets all commits since the specified tag."""
if tag is None:
# If no tags, get all commits
return list(repo.iter_commits())
commits = list(repo.iter_commits(f'{tag.path}..HEAD'))
return commits
def parse_commit_message(message):
"""Parses a commit message based on Conventional Commits spec."""
match = re.match(r"^(?P<type>\w+)(?:\((?P<scope>.*)\))?!?: (?P<subject>.*)", message)
if not match:
return None
commit_data = match.groupdict()
# Handle breaking changes indicated by '!'
if '!' in message.split(':')[0]:
commit_data['breaking'] = True
else:
commit_data['breaking'] = False
return commit_data
def generate_changelog():
"""Main function to generate the changelog content."""
try:
repo = git.Repo(REPO_PATH)
except git.InvalidGitRepositoryError:
print("Error: This script must be run from within a Git repository.")
return
latest_tag = get_latest_tag(repo)
commits = get_commits_since_tag(repo, latest_tag)
if not commits:
print("No new commits since the last tag. Changelog is up to date.")
return
# Group commits by their type
grouped_commits = defaultdict(list)
breaking_changes = []
for commit in commits:
parsed_data = parse_commit_message(commit.message.split('\n')[0])
if parsed_data:
commit_type = parsed_data.get('type')
if commit_type in COMMIT_TYPES:
scope = f"**{parsed_data.get('scope')}:** " if parsed_data.get('scope') else ""
log_entry = f"- {scope}{parsed_data.get('subject')} (`{commit.hexsha[:7]}`)"
grouped_commits[commit_type].append(log_entry)
if parsed_data.get('breaking'):
breaking_changes.append(f"- {parsed_data.get('subject')} (`{commit.hexsha[:7]}`)")
# Build the Markdown string for the new entry
new_version_tag = "Unreleased" # Or generate a new version number
today_date = datetime.now().strftime("%Y-%m-%d")
new_changelog_entry = [f"## [{new_version_tag}] - {today_date}\n"]
if breaking_changes:
new_changelog_entry.append("### BREAKING CHANGES")
new_changelog_entry.extend(breaking_changes)
new_changelog_entry.append("") # Add a newline for spacing
for commit_type, heading in COMMIT_TYPES.items():
if commit_type in grouped_commits:
new_changelog_entry.append(f"### {heading}")
new_changelog_entry.extend(sorted(grouped_commits[commit_type]))
new_changelog_entry.append("")
# Read the existing changelog and prepend the new entry
try:
with open(CHANGELOG_FILE, 'r') as f:
existing_content = f.read()
except FileNotFoundError:
existing_content = "# Changelog\n\n"
# We insert the new content after the main header
final_content = existing_content.split('\n', 2)
header = final_content[0]
rest_of_content = final_content[2] if len(final_content) > 2 else ""
full_new_log = f"{header}\n\n" + "\n".join(new_changelog_entry) + rest_of_content
with open(CHANGELOG_FILE, 'w') as f:
f.write(full_new_log)
print(f"Successfully updated {CHANGELOG_FILE}")
if __name__ == "__main__":
generate_changelog()
Let’s quickly break down the logic. The script initializes a connection to your Git repo using `GitPython`. It then finds the most recent tag to use as a starting point. If there are no tags, it processes the entire commit history. It iterates through each commit since that tag, parses the first line of the message using a regular expression to identify its type (`feat`, `fix`, etc.), and groups them into a dictionary. Finally, it constructs a Markdown-formatted string and prepends it to your existing `Changelog.md` file, preserving past entries.
Pro Tip: In my production setups, I don’t run this manually. I integrate it into our CI/CD pipeline. It runs automatically as part of the pre-release stage, right before a new version tag is created. This ensures the changelog is always in sync with the code being deployed.
Step 3: Running and Automating the Script
To run it, just open your terminal in the root of your Git repository and execute: python3 generate_changelog.py. It will either update your `Changelog.md` or tell you there’s nothing new to add.
For automation, you have a few options. A simple one is a scheduled job. For instance, you could set up a cron job to run it weekly, which is a good way to keep an internal changelog fresh. A safe cron command would look like this, avoiding any sensitive paths:
0 2 * * 1 python3 generate_changelog.py
This example runs the script every Monday at 2 AM. However, the most effective automation, as I mentioned, is tying it to your deployment or versioning process via Git hooks or a CI/CD pipeline step.
Common Pitfalls
Here’s where I’ve stumbled in the past, so you can avoid it:
- Inconsistent Commit Messages: The script is unforgiving. If a commit message doesn’t strictly follow the `type: description` format, it gets ignored. I once spent an hour debugging why features weren’t showing up, only to find a developer used “Feature:” instead of “feat:”. Enforcing commit message linting is a lifesaver here.
- Running from the Wrong Directory: The script assumes it’s being run from the root of the repository. If you run it from a different location, `GitPython` won’t find the `.git` directory and will fail. Always `cd` into the repo first.
- Missing Tags: If your repository doesn’t have any tags, the script will generate a changelog from the very first commit. This might be what you want, but for a mature project, it can create a massive, overwhelming file. It’s best to create an initial tag (e.g., `v0.1.0`) before the first run.
Conclusion
And there you have it. A reliable, automated system for generating changelogs. By investing a small amount of time upfront, you reclaim hours down the line and dramatically improve the quality of your release documentation. This script is a solid starting point; feel free to customize it to fit your team’s specific needs, like adding support for different commit types or changing the output format. Happy coding.
🤖 Frequently Asked Questions
âť“ How can I automate `Changelog.md` generation using Conventional Commits?
You can automate `Changelog.md` generation by employing a Python script that uses the `GitPython` library to read your Git repository’s history, parse commit messages adhering to the Conventional Commits specification, and then format and prepend these changes as new entries to your `Changelog.md` file.
âť“ How does this automated changelog generation compare to manual updates or other tools?
This automated approach drastically reduces the manual effort, tedium, and potential for human error associated with updating changelogs, ensuring consistency and accuracy. While dedicated tools exist, this custom Python script offers flexibility for specific team requirements and seamless integration into existing Python-based workflows or CI/CD pipelines.
âť“ What is a common implementation pitfall when using this automated changelog script?
A common pitfall is inconsistent commit messages. The script strictly expects the Conventional Commits format (`type: description`). Deviations, such as using ‘Feature:’ instead of ‘feat:’, will cause those commits to be ignored, resulting in an incomplete changelog. Implementing commit message linting is crucial to prevent this.
Leave a Reply