Skip to content

Release v0.4.0: Real-World Usage #10

Release v0.4.0: Real-World Usage

Release v0.4.0: Real-World Usage #10

Workflow file for this run

name: Update Changelog
on:
push:
branches:
- main
- master
workflow_dispatch:
jobs:
update-changelog:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: '3.x'
- name: Generate Changelog
run: |
python3 << 'EOF'
import subprocess
import sys
from datetime import datetime
from collections import defaultdict
def run_git_log(args):
try:
result = subprocess.run(['git'] + args, capture_output=True, text=True, check=True)
# FIX #1: Split by a null character instead of a newline to handle multi-line bodies.
return result.stdout.strip().split('\x00') if result.stdout.strip() else []
except subprocess.CalledProcessError as e:
print(f"Error running git: {e}")
sys.exit(1)
def get_commits_by_tag():
try:
latest_tag = subprocess.run(
['git', 'describe', '--tags', '--abbrev=0'],
capture_output=True, text=True, check=True
).stdout.strip()
tag_date = subprocess.run(
['git', 'log', '-1', '--format=%ad', '--date=short', latest_tag],
capture_output=True, text=True, check=True
).stdout.strip()
tagged = run_git_log([
'log', latest_tag,
# FIX #2: Add the %x00 null character as a safe delimiter for commits.
'--pretty=format:%s|||%b|||%an|||%ad|||%H%x00',
'--date=short',
'--invert-grep',
'--grep=docs: update changelog',
'--grep=changelog.yml',
'--grep=\\[skip ci\\]'
])
unreleased = run_git_log([
'log', f'{latest_tag}..HEAD',
# FIX #3: Add the delimiter here as well.
'--pretty=format:%s|||%b|||%an|||%ad|||%H%x00',
'--date=short',
'--invert-grep',
'--grep=docs: update changelog',
'--grep=changelog.yml',
'--grep=\\[skip ci\\]'
])
return {
'tagged': {latest_tag: {'commits': tagged, 'date': tag_date}},
'unreleased': unreleased
}
except subprocess.CalledProcessError:
all_commits = run_git_log([
'log',
# FIX #4: And add the delimiter here for the fallback case.
'--pretty=format:%s|||%b|||%an|||%ad|||%H%x00',
'--date=short',
'--invert-grep',
'--grep=docs: update changelog',
'--grep=changelog.yml',
'--grep=\\[skip ci\\]'
])
return {
'tagged': {},
'unreleased': all_commits
}
def categorize_commit(subject, body):
text = (subject + ' ' + body).lower()
if any(x in text for x in ['security', 'vulnerability', 'cve', 'exploit']):
return 'security'
if any(x in text for x in ['breaking change', 'breaking:', 'break:']):
return 'breaking'
if any(x in text for x in ['deprecat', 'obsolete', 'phase out']):
return 'deprecated'
if any(x in text for x in ['remove', 'delete', 'drop', 'eliminate']):
return 'removed'
# Correction: Check for 'added' keywords before 'fixed' keywords.
if any(x in text for x in ['add', 'new', 'create', 'implement', 'feat', 'feature']):
return 'added'
if any(x in text for x in ['fix', 'resolve', 'correct', 'patch', 'bug', 'issue']):
return 'fixed'
return 'changed'
def format_commit_entry(commit):
entry = f"- **{commit['subject']}** ({commit['date']} – {commit['author']})"
body = commit['body'].replace('\\n', '\n')
if body.strip():
lines = [line.strip() for line in body.splitlines() if line.strip()]
for line in lines:
entry += f"\n {line}"
return entry + "\n"
def parse_commits(lines):
commits = []
for line in lines:
if not line: continue
parts = line.split('|||')
if len(parts) >= 5:
subject, body, author, date, hash_id = map(str.strip, parts)
commits.append({
'subject': subject,
'body': body,
'author': author,
'date': date,
'hash': hash_id[:7]
})
return commits
def build_changelog(commits_by_version):
sections = [
('security', 'Security'),
('breaking', 'Breaking Changes'),
('deprecated', 'Deprecated'),
('added', 'Added'),
('changed', 'Changed'),
('fixed', 'Fixed'),
('removed', 'Removed')
]
lines = [
"# Changelog",
"",
"All notable changes to this project will be documented in this file.",
"",
"The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),",
"and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).",
""
]
unreleased = parse_commits(commits_by_version['unreleased'])
if unreleased:
lines.append("## [Unreleased]")
lines.append("")
categorized = defaultdict(list)
for commit in unreleased:
cat = categorize_commit(commit['subject'], commit['body'])
categorized[cat].append(commit)
for key, label in sections:
if categorized[key]:
lines.append(f"### {label}")
for commit in categorized[key]:
lines.append(format_commit_entry(commit))
lines.append("")
else:
lines.append("## [Unreleased]\n")
lines.append("_No unreleased changes._\n")
for tag, info in commits_by_version['tagged'].items():
commits = parse_commits(info['commits'])
lines.append(f"## [{tag}] - {info['date']}\n")
categorized = defaultdict(list)
for commit in commits:
cat = categorize_commit(commit['subject'], commit['body'])
categorized[cat].append(commit)
for key, label in sections:
if categorized[key]:
lines.append(f"### {label}")
for commit in categorized[key]:
lines.append(format_commit_entry(commit))
lines.append("")
return "\n".join(lines)
try:
commit_data = get_commits_by_tag()
changelog = build_changelog(commit_data)
with open("CHANGELOG.md", "w", encoding="utf-8") as f:
f.write(changelog)
print("Changelog generated.")
except Exception as e:
print(f"Error generating changelog: {e}")
sys.exit(1)
EOF
- name: Commit Updated Changelog
run: |
git config --local user.email "[email protected]"
git config --local user.name "GitHub Action"
git add CHANGELOG.md
if git diff --staged --quiet; then
echo "No changes to commit"
else
git commit -m "docs: update changelog [skip ci]"
git push
fi