blob: 6f6fc4d1dfd060d0d1a9de951b67dad5424eb506 [file]
#!/usr/bin/env python3
# Copyright 2026 The Emscripten Authors. All rights reserved.
# Emscripten is available under two separate licenses, the MIT license and the
# University of Illinois/NCSA Open Source License. Both these licenses can be
# found in the LICENSE file.
"""Helper script to generate GitHub release notes using ChangeLog.md.
It uses the `gh` CLI to fetch default release notes (to get the contributor list
and changelog link), parses ChangeLog.md for the actual release description,
and combines them into a final release notes draft.
"""
import argparse
import json
import os
import re
import subprocess
import sys
from collections import Counter
script_dir = os.path.dirname(os.path.abspath(__file__))
root_dir = os.path.dirname(os.path.dirname(script_dir))
BOTS = {'dependabot[bot]', 'emscripten-bot', 'github-actions[bot]', 'web-flow'}
REPO = 'emscripten-core/emscripten'
def get_changelog_entry(changelog_path, version):
if not os.path.exists(changelog_path):
print(f'Error: Changelog not found at {changelog_path}', file=sys.stderr)
return None
with open(changelog_path, encoding='utf-8') as f:
lines = f.readlines()
start_idx = -1
end_idx = -1
for i, line in enumerate(lines):
line = line.strip()
# Match version followed by space, dash, or end of string
if re.match(rf'^{re.escape(version)}(?:\s|$|-)', line):
# A header is followed by a line of dashes
if i + 1 < len(lines) and re.match(r'^--+$', lines[i + 1].strip()):
start_idx = i + 2
break
if start_idx == -1:
return None
for i in range(start_idx, len(lines)):
line = lines[i].strip()
if i + 1 < len(lines) and re.match(r'^--+$', lines[i + 1].strip()):
end_idx = i
break
else:
end_idx = len(lines)
entry_lines = lines[start_idx:end_idx]
while entry_lines and not entry_lines[0].strip():
entry_lines.pop(0)
while entry_lines and not entry_lines[-1].strip():
entry_lines.pop()
return ''.join(entry_lines)
def get_latest_version(changelog_path):
if not os.path.exists(changelog_path):
return None
with open(changelog_path, encoding='utf-8') as f:
lines = f.readlines()
for i, line in enumerate(lines):
line = line.strip()
# A header is followed by a line of dashes
if i + 1 < len(lines) and re.match(r'^--+$', lines[i + 1].strip()):
# Skip "in development" versions
if '(in development)' in line:
continue
match = re.match(r'^([0-9.]+)', line)
if match:
return match.group(1)
return None
def get_previous_version(changelog_path, version):
if not os.path.exists(changelog_path):
return None
with open(changelog_path, encoding='utf-8') as f:
lines = f.readlines()
# Find the header for the target version
target_idx = -1
for i, line in enumerate(lines):
line = line.strip()
if re.match(rf'^{re.escape(version)}(?:\s|$|-)', line):
if i + 1 < len(lines) and re.match(r'^--+$', lines[i + 1].strip()):
target_idx = i
break
if target_idx == -1:
return None
# Find the next header after the target version
for i in range(target_idx + 2, len(lines)):
line = lines[i].strip()
if i + 1 < len(lines) and re.match(r'^--+$', lines[i + 1].strip()):
match = re.match(r'^([0-9.]+)', line)
if match:
return match.group(1)
return None
def generate_default_notes(tag, previous_tag):
cmd = [
'gh', 'api',
f'/repos/{REPO}/releases/generate-notes',
'-f', f'tag_name={tag}',
'-f', f'previous_tag_name={previous_tag}',
]
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
return json.loads(result.stdout)
def parse_default_notes(body):
sections = {}
current_header = None
current_content = []
# Parse the default notes into sections based on H2 headers (##)
for line in body.split('\n'):
if line.startswith('## '):
if current_header:
sections[current_header] = '\n'.join(current_content).strip()
current_header = line[3:].strip()
current_content = []
elif line.startswith('**Full Changelog**'):
if current_header:
sections[current_header] = '\n'.join(current_content).strip()
current_header = 'Full Changelog'
current_content = [line]
else:
current_content.append(line)
if current_header:
sections[current_header] = '\n'.join(current_content).strip()
return sections
def extract_pr_authors(whats_changed_content):
if not whats_changed_content:
return Counter()
# Match "by @username"
matches = re.findall(r'\sby\s@([a-zA-Z0-9-]+(?:\[bot\])?)', whats_changed_content)
return Counter(matches)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('version', nargs='?', help='Version to generate notes for (e.g. 6.0.0). Defaults to the latest entry in ChangeLog.md.')
args = parser.parse_args()
changelog_path = os.path.join(root_dir, 'ChangeLog.md')
version = args.version
if not version:
version = get_latest_version(changelog_path)
if not version:
print(f'Error: Could not detect latest version from {changelog_path}', file=sys.stderr)
return 1
# 1. Get Changelog Entry
changelog_entry = get_changelog_entry(changelog_path, version)
if changelog_entry is None:
print(f'Error: Could not find changelog entry for version {version} in {changelog_path}', file=sys.stderr)
return 1
# 2. Detect Previous Tag
previous_tag = get_previous_version(changelog_path, version)
if not previous_tag:
print(f'Error: Could not detect previous version for {version} from ChangeLog.md.', file=sys.stderr)
return 1
# 3. Generate Default Notes from GitHub
default_notes_json = generate_default_notes(version, previous_tag)
default_body = default_notes_json.get('body', '')
# 4. Parse Default Notes
default_sections = parse_default_notes(default_body)
# 5. Extract Contributors
whats_changed = default_sections.get('What\'s Changed', '')
new_contributors_content = default_sections.get('New Contributors', '')
pr_authors_counts = extract_pr_authors(whats_changed)
contributors = {user: count for user, count in pr_authors_counts.items() if user not in BOTS}
# 6. Assemble Final Notes
notes = []
notes.append('## What\'s Changed')
notes.append(changelog_entry)
notes.append('')
if contributors:
notes.append('## Contributors')
sorted_contributors = sorted(
contributors.items(),
key=lambda item: (-item[1], item[0].lower()),
)
mentions = ' '.join(f'@{user} ({count})' for user, count in sorted_contributors)
notes.append(f'Thanks to the following contributors: {mentions}')
notes.append('')
if new_contributors_content:
notes.append('## New Contributors')
notes.append(new_contributors_content)
notes.append('')
full_changelog = default_sections.get('Full Changelog')
if full_changelog:
notes.append(full_changelog)
# 7. Output Final Notes
print('\n'.join(notes))
return 0
if __name__ == '__main__':
sys.exit(main())