1+ name : Update Changelog
2+
3+ on :
4+ push :
5+ branches :
6+ - main
7+ - master
8+ workflow_dispatch :
9+
10+ jobs :
11+ update-changelog :
12+ runs-on : ubuntu-latest
13+
14+ steps :
15+ - name : Checkout Repository
16+ uses : actions/checkout@v4
17+ with :
18+ fetch-depth : 0
19+ token : ${{ secrets.GITHUB_TOKEN }}
20+
21+ - name : Setup Python
22+ uses : actions/setup-python@v4
23+ with :
24+ python-version : ' 3.x'
25+
26+ - name : Generate Changelog
27+ run : |
28+ python3 << 'EOF'
29+ import subprocess
30+ import sys
31+ from datetime import datetime
32+ from collections import defaultdict
33+
34+ def run_git_log(args):
35+ try:
36+ result = subprocess.run(['git'] + args, capture_output=True, text=True, check=True)
37+ # FIX #1: Split by a null character instead of a newline to handle multi-line bodies.
38+ return result.stdout.strip().split('\x00') if result.stdout.strip() else []
39+ except subprocess.CalledProcessError as e:
40+ print(f"Error running git: {e}")
41+ sys.exit(1)
42+
43+ def get_commits_by_tag():
44+ try:
45+ latest_tag = subprocess.run(
46+ ['git', 'describe', '--tags', '--abbrev=0'],
47+ capture_output=True, text=True, check=True
48+ ).stdout.strip()
49+
50+ tag_date = subprocess.run(
51+ ['git', 'log', '-1', '--format=%ad', '--date=short', latest_tag],
52+ capture_output=True, text=True, check=True
53+ ).stdout.strip()
54+
55+ tagged = run_git_log([
56+ 'log', latest_tag,
57+ # FIX #2: Add the %x00 null character as a safe delimiter for commits.
58+ '--pretty=format:%s|||%b|||%an|||%ad|||%H%x00',
59+ '--date=short',
60+ '--invert-grep',
61+ '--grep=docs: update changelog',
62+ '--grep=changelog.yml',
63+ '--grep=\\[skip ci\\]'
64+ ])
65+
66+ unreleased = run_git_log([
67+ 'log', f'{latest_tag}..HEAD',
68+ # FIX #3: Add the delimiter here as well.
69+ '--pretty=format:%s|||%b|||%an|||%ad|||%H%x00',
70+ '--date=short',
71+ '--invert-grep',
72+ '--grep=docs: update changelog',
73+ '--grep=changelog.yml',
74+ '--grep=\\[skip ci\\]'
75+ ])
76+
77+ return {
78+ 'tagged': {latest_tag: {'commits': tagged, 'date': tag_date}},
79+ 'unreleased': unreleased
80+ }
81+
82+ except subprocess.CalledProcessError:
83+ all_commits = run_git_log([
84+ 'log',
85+ # FIX #4: And add the delimiter here for the fallback case.
86+ '--pretty=format:%s|||%b|||%an|||%ad|||%H%x00',
87+ '--date=short',
88+ '--invert-grep',
89+ '--grep=docs: update changelog',
90+ '--grep=changelog.yml',
91+ '--grep=\\[skip ci\\]'
92+ ])
93+ return {
94+ 'tagged': {},
95+ 'unreleased': all_commits
96+ }
97+
98+ def categorize_commit(subject, body):
99+ text = (subject + ' ' + body).lower()
100+ if any(x in text for x in ['security', 'vulnerability', 'cve', 'exploit']):
101+ return 'security'
102+ if any(x in text for x in ['breaking change', 'breaking:', 'break:']):
103+ return 'breaking'
104+ if any(x in text for x in ['deprecat', 'obsolete', 'phase out']):
105+ return 'deprecated'
106+ if any(x in text for x in ['remove', 'delete', 'drop', 'eliminate']):
107+ return 'removed'
108+ # Correction: Check for 'added' keywords before 'fixed' keywords.
109+ if any(x in text for x in ['add', 'new', 'create', 'implement', 'feat', 'feature']):
110+ return 'added'
111+ if any(x in text for x in ['fix', 'resolve', 'correct', 'patch', 'bug', 'issue']):
112+ return 'fixed'
113+ return 'changed'
114+
115+ def format_commit_entry(commit):
116+ entry = f"- **{commit['subject']}** ({commit['date']} – {commit['author']})"
117+ body = commit['body'].replace('\\n', '\n')
118+ if body.strip():
119+ lines = [line.strip() for line in body.splitlines() if line.strip()]
120+ for line in lines:
121+ entry += f"\n {line}"
122+ return entry + "\n"
123+
124+ def parse_commits(lines):
125+ commits = []
126+ for line in lines:
127+ if not line: continue
128+ parts = line.split('|||')
129+ if len(parts) >= 5:
130+ subject, body, author, date, hash_id = map(str.strip, parts)
131+ commits.append({
132+ 'subject': subject,
133+ 'body': body,
134+ 'author': author,
135+ 'date': date,
136+ 'hash': hash_id[:7]
137+ })
138+ return commits
139+
140+ def build_changelog(commits_by_version):
141+ sections = [
142+ ('security', 'Security'),
143+ ('breaking', 'Breaking Changes'),
144+ ('deprecated', 'Deprecated'),
145+ ('added', 'Added'),
146+ ('changed', 'Changed'),
147+ ('fixed', 'Fixed'),
148+ ('removed', 'Removed')
149+ ]
150+
151+ lines = [
152+ "# Changelog",
153+ "",
154+ "All notable changes to this project will be documented in this file.",
155+ "",
156+ "The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),",
157+ "and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).",
158+ ""
159+ ]
160+
161+ unreleased = parse_commits(commits_by_version['unreleased'])
162+ if unreleased:
163+ lines.append("## [Unreleased]")
164+ lines.append("")
165+ categorized = defaultdict(list)
166+ for commit in unreleased:
167+ cat = categorize_commit(commit['subject'], commit['body'])
168+ categorized[cat].append(commit)
169+
170+ for key, label in sections:
171+ if categorized[key]:
172+ lines.append(f"### {label}")
173+ for commit in categorized[key]:
174+ lines.append(format_commit_entry(commit))
175+ lines.append("")
176+ else:
177+ lines.append("## [Unreleased]\n")
178+ lines.append("_No unreleased changes._\n")
179+
180+ for tag, info in commits_by_version['tagged'].items():
181+ commits = parse_commits(info['commits'])
182+ lines.append(f"## [{tag}] - {info['date']}\n")
183+ categorized = defaultdict(list)
184+ for commit in commits:
185+ cat = categorize_commit(commit['subject'], commit['body'])
186+ categorized[cat].append(commit)
187+
188+ for key, label in sections:
189+ if categorized[key]:
190+ lines.append(f"### {label}")
191+ for commit in categorized[key]:
192+ lines.append(format_commit_entry(commit))
193+ lines.append("")
194+
195+ return "\n".join(lines)
196+
197+ try:
198+ commit_data = get_commits_by_tag()
199+ changelog = build_changelog(commit_data)
200+ with open("CHANGELOG.md", "w", encoding="utf-8") as f:
201+ f.write(changelog)
202+ print("Changelog generated.")
203+ except Exception as e:
204+ print(f"Error generating changelog: {e}")
205+ sys.exit(1)
206+ EOF
207+
208+ - name : Commit Updated Changelog
209+ run : |
210+ git config --local user.email "[email protected] " 211+ git config --local user.name "GitHub Action"
212+ git add CHANGELOG.md
213+ if git diff --staged --quiet; then
214+ echo "No changes to commit"
215+ else
216+ git commit -m "docs: update changelog [skip ci]"
217+ git push
218+ fi
0 commit comments