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 "action@github.com"
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