Skip to content

Commit e2e633e

Browse files
authored
[CICD] Add changelog sync and installation test scripts (flagos-ai#440)
1 parent bf263bd commit e2e633e

3 files changed

Lines changed: 482 additions & 0 deletions

File tree

packaging/CHANGELOG-MANAGEMENT.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Changelog Management
2+
3+
This document describes how to manage changelogs for FlagCX packages.
4+
5+
## Overview
6+
7+
`packaging/sync-changelog.py` generates Debian and RPM changelogs from two data sources:
8+
9+
1. **`docs/CHANGELOG.md`** (primary) - Human-written release summaries
10+
2. **Git tags** (fallback) - Auto-generated from commit history between tags
11+
12+
When a version exists in `docs/CHANGELOG.md`, its curated entries are used. For versions that only have a git tag (e.g. upstream hasn't updated the CHANGELOG yet), the script extracts commit summaries between tags.
13+
14+
## Usage
15+
16+
```bash
17+
python3 packaging/sync-changelog.py
18+
```
19+
20+
Output:
21+
- `packaging/debian/changelog` (Debian format)
22+
- `packaging/rpm/specs/flagcx.spec` (%changelog section)
23+
24+
## Data Source Priority
25+
26+
| Source | When used | Entry style |
27+
|--------|-----------|-------------|
28+
| `docs/CHANGELOG.md` | Version has entry in file | Curated, 3-5 bullet points |
29+
| Git tags + commits | Version has tag but no CHANGELOG entry | Per-commit summaries (filtered) |
30+
| Fallback | Tag exists but no commits found | "New upstream release vX.Y.Z" |
31+
32+
The git tag fallback filters out:
33+
- Merge commits
34+
- Dependabot version bumps (`Bump ...`)
35+
- `[CRL]`, `[PAL]`, etc. prefixes are stripped for readability
36+
- PR numbers (`(#123)`) are removed
37+
38+
## Version Normalization
39+
40+
Versions are normalized to three-part format: `0.7` becomes `0.7.0`. This prevents duplicates when `docs/CHANGELOG.md` uses `v0.7` but git tags use `v0.7.0`.
41+
42+
## Build Integration
43+
44+
The sync script runs automatically during package builds:
45+
46+
- **Local builds**: Both `build-flagcx.sh` and `build-flagcx-rpm.sh` call it
47+
- **CI/CD**: Runs as part of the Docker build process
48+
49+
## Best Practices
50+
51+
1. Prefer editing `docs/CHANGELOG.md` for polished release notes
52+
2. Git tag fallback is automatic - no action needed for new tags
53+
3. Run `sync-changelog.py` before committing packaging changes to verify output
54+
4. Never hand-edit generated files (`debian/changelog`, spec `%changelog`)

packaging/sync-changelog.py

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Convert project changelog to Debian changelog and RPM %changelog format.
4+
5+
Data sources (priority order):
6+
1. docs/CHANGELOG.md - parsed entries with full descriptions
7+
2. git tags - date + commit summaries between tags
8+
3. Fallback - "New upstream release vX.Y.Z"
9+
"""
10+
11+
import re
12+
import subprocess
13+
import sys
14+
from datetime import datetime
15+
from pathlib import Path
16+
17+
18+
def normalize_version(version):
19+
"""Normalize version string: '0.7' -> '0.7.0', '0.10.0' -> '0.10.0'."""
20+
parts = version.split('.')
21+
while len(parts) < 3:
22+
parts.append('0')
23+
return '.'.join(parts)
24+
25+
26+
def run_git(*args):
27+
"""Run a git command and return stdout, or None on failure."""
28+
try:
29+
result = subprocess.run(
30+
['git'] + list(args),
31+
capture_output=True, text=True, timeout=10
32+
)
33+
if result.returncode == 0:
34+
return result.stdout.strip()
35+
except (subprocess.TimeoutExpired, FileNotFoundError):
36+
pass
37+
return None
38+
39+
40+
def parse_changelog_md(changelog_path):
41+
"""Parse docs/CHANGELOG.md and extract version entries."""
42+
if not changelog_path.exists():
43+
return {}
44+
45+
with open(changelog_path, 'r') as f:
46+
content = f.read()
47+
48+
version_pattern = r'- \*\*\[(\d{4})/(\d{2})\]\*\* Released \[v([^\]]+)\]'
49+
versions = {}
50+
51+
lines = content.split('\n')
52+
i = 0
53+
54+
while i < len(lines):
55+
line = lines[i]
56+
match = re.search(version_pattern, line)
57+
58+
if match:
59+
year, month, version = match.group(1), match.group(2), match.group(3)
60+
date = f"{year}-{month}-01"
61+
entries = []
62+
i += 1
63+
64+
while i < len(lines) and lines[i].strip() == '':
65+
i += 1
66+
67+
while i < len(lines):
68+
line = lines[i]
69+
if re.search(version_pattern, line):
70+
break
71+
72+
if line.strip().startswith('- '):
73+
entry = line.strip()[2:].strip()
74+
i += 1
75+
while i < len(lines):
76+
next_line = lines[i]
77+
if next_line.startswith(' ') and not next_line.strip().startswith('-'):
78+
entry += ' ' + next_line.strip()
79+
i += 1
80+
else:
81+
break
82+
# Remove markdown formatting
83+
entry = re.sub(r'\*([^*]+)\*', r'\1', entry)
84+
entry = re.sub(r'_([^_]+)_', r'\1', entry)
85+
entry = re.sub(r'`([^`]+)`', r'\1', entry)
86+
entries.append(entry)
87+
elif line.strip() == '':
88+
i += 1
89+
peek = i
90+
while peek < len(lines) and lines[peek].strip() == '':
91+
peek += 1
92+
if peek < len(lines) and re.search(version_pattern, lines[peek]):
93+
break
94+
else:
95+
i += 1
96+
97+
norm_ver = normalize_version(version)
98+
versions[norm_ver] = {
99+
'version': norm_ver,
100+
'date': date,
101+
'entries': entries,
102+
'source': 'changelog.md'
103+
}
104+
else:
105+
i += 1
106+
107+
return versions
108+
109+
110+
def get_git_tags():
111+
"""Get all version tags sorted by version (newest first)."""
112+
output = run_git('tag', '-l', 'v*', '--sort=-v:refname',
113+
'--format=%(refname:short) %(creatordate:short)')
114+
if not output:
115+
return []
116+
117+
tags = []
118+
for line in output.strip().split('\n'):
119+
parts = line.split(' ', 1)
120+
if len(parts) == 2:
121+
tag, date = parts
122+
version = tag.lstrip('v')
123+
tags.append({'tag': tag, 'version': version, 'date': date})
124+
return tags
125+
126+
127+
def get_commits_between_tags(tag_old, tag_new):
128+
"""Get commit summaries between two tags."""
129+
range_spec = f"{tag_old}..{tag_new}" if tag_old else tag_new
130+
output = run_git('log', range_spec, '--oneline', '--no-merges',
131+
'--format=%s')
132+
if not output:
133+
return []
134+
135+
commits = []
136+
for line in output.strip().split('\n'):
137+
line = line.strip()
138+
if not line:
139+
continue
140+
# Skip Dependabot bumps and trivial commits
141+
if line.startswith('Bump ') or line.startswith('Merge '):
142+
continue
143+
# Clean up [TAG] prefixes for readability
144+
clean = re.sub(r'^\[(CRL|PAL|UIL|CICD|CI|Others)\]\s*', '', line)
145+
# Remove PR number suffix
146+
clean = re.sub(r'\s*\(#\d+\)$', '', clean)
147+
if clean:
148+
commits.append(clean)
149+
return commits
150+
151+
152+
def build_version_list(changelog_md_path):
153+
"""Build complete version list from CHANGELOG.md + git tags."""
154+
# Source 1: CHANGELOG.md
155+
md_versions = parse_changelog_md(changelog_md_path)
156+
print(f" CHANGELOG.md: {len(md_versions)} version(s)")
157+
158+
# Source 2: git tags
159+
tags = get_git_tags()
160+
print(f" git tags: {len(tags)} tag(s)")
161+
162+
# Merge: use CHANGELOG.md entries when available, git tag fallback otherwise
163+
all_versions = []
164+
seen = set()
165+
166+
for i, tag_info in enumerate(tags):
167+
version = normalize_version(tag_info['version'])
168+
if version in seen:
169+
continue
170+
seen.add(version)
171+
172+
if version in md_versions:
173+
all_versions.append(md_versions[version])
174+
else:
175+
# Fallback: generate from git
176+
prev_tag = tags[i + 1]['tag'] if i + 1 < len(tags) else None
177+
commits = get_commits_between_tags(prev_tag, tag_info['tag'])
178+
179+
entries = commits if commits else [f"New upstream release v{version}"]
180+
181+
all_versions.append({
182+
'version': version,
183+
'date': tag_info['date'],
184+
'entries': entries,
185+
'source': 'git-tag'
186+
})
187+
188+
# Also include CHANGELOG.md versions that have no git tag
189+
for version, data in md_versions.items():
190+
if version not in seen:
191+
all_versions.append(data)
192+
seen.add(version)
193+
194+
# Sort by version descending
195+
def version_key(v):
196+
parts = v['version'].split('.')
197+
return tuple(int(p) for p in parts if p.isdigit())
198+
199+
all_versions.sort(key=version_key, reverse=True)
200+
return all_versions
201+
202+
203+
def generate_debian_changelog(versions, output_path):
204+
"""Generate Debian changelog format."""
205+
lines = []
206+
207+
for v in versions:
208+
date_obj = datetime.strptime(v['date'], '%Y-%m-%d')
209+
deb_date = date_obj.strftime('%a, %d %b %Y 10:00:00 +0800')
210+
211+
lines.append(f"flagcx ({v['version']}-1) unstable; urgency=medium")
212+
lines.append("")
213+
214+
for entry in v['entries']:
215+
lines.append(f" * {entry}")
216+
217+
lines.append("")
218+
lines.append(f" -- FlagOS Contributors <contact@flagos.io> {deb_date}")
219+
lines.append("")
220+
221+
with open(output_path, 'w') as f:
222+
f.write('\n'.join(lines))
223+
224+
print(f" Generated Debian changelog: {output_path}")
225+
226+
227+
def generate_rpm_changelog(versions):
228+
"""Generate RPM %changelog format."""
229+
lines = []
230+
231+
for v in versions:
232+
date_obj = datetime.strptime(v['date'], '%Y-%m-%d')
233+
rpm_date = date_obj.strftime('%a %b %d %Y')
234+
235+
lines.append(f"* {rpm_date} FlagOS Contributors <contact@flagos.io> - {v['version']}-1")
236+
237+
for entry in v['entries']:
238+
lines.append(f"- {entry}")
239+
240+
lines.append("")
241+
242+
return '\n'.join(lines)
243+
244+
245+
def update_rpm_spec(spec_path, changelog_content):
246+
"""Update %changelog section in RPM spec file."""
247+
if not spec_path.exists():
248+
print(f" Warning: RPM spec not found: {spec_path}")
249+
return
250+
251+
with open(spec_path, 'r') as f:
252+
spec_content = f.read()
253+
254+
changelog_pattern = r'%changelog.*$'
255+
new_spec = re.sub(changelog_pattern, f'%changelog\n{changelog_content}',
256+
spec_content, flags=re.DOTALL)
257+
258+
with open(spec_path, 'w') as f:
259+
f.write(new_spec)
260+
261+
print(f" Updated RPM spec changelog: {spec_path}")
262+
263+
264+
def main():
265+
project_root = Path(__file__).parent.parent
266+
changelog_md = project_root / 'docs' / 'CHANGELOG.md'
267+
debian_changelog = project_root / 'packaging' / 'debian' / 'changelog'
268+
rpm_spec = project_root / 'packaging' / 'rpm' / 'specs' / 'flagcx.spec'
269+
270+
print("Collecting version data...")
271+
versions = build_version_list(changelog_md)
272+
273+
if not versions:
274+
print("Error: No versions found from any source")
275+
sys.exit(1)
276+
277+
print(f"\nTotal: {len(versions)} version(s)")
278+
for v in versions:
279+
src = v.get('source', '?')
280+
print(f" v{v['version']} ({v['date']}) [{src}] - {len(v['entries'])} entries")
281+
282+
print("\nGenerating changelogs...")
283+
generate_debian_changelog(versions, debian_changelog)
284+
285+
rpm_changelog = generate_rpm_changelog(versions)
286+
update_rpm_spec(rpm_spec, rpm_changelog)
287+
288+
print("\nDone!")
289+
290+
291+
if __name__ == '__main__':
292+
main()

0 commit comments

Comments
 (0)