#!/usr/bin/env python3 """ Generate release notes for Citron releases """ import os import sys import subprocess import json from datetime import datetime def run_git_command(cmd): """Run a git command and return its output""" try: result = subprocess.run(cmd, shell=True, capture_output=True, text=True, check=True) return result.stdout.strip() except subprocess.CalledProcessError: return None def get_commits_since_last_tag(): """Get commits since the last tag""" try: # Get the last tag last_tag = run_git_command("git describe --tags --abbrev=0") if last_tag: # Get commits since last tag commits = run_git_command(f"git log {last_tag}..HEAD --oneline --pretty=format:'%s'") return commits.split('\n') if commits else [] else: # No tags, get last 20 commits commits = run_git_command("git log --oneline --max-count=20 --pretty=format:'%s'") return commits.split('\n') if commits else [] except: return [] def categorize_commits(commits): """Categorize commits by type""" categories = { 'Features': [], 'Bug Fixes': [], 'Performance': [], 'UI/UX': [], 'Audio': [], 'Graphics': [], 'Input': [], 'Network': [], 'Build/CI': [], 'Documentation': [], 'Other': [] } for commit in commits: commit_lower = commit.lower() # Categorize based on commit message content if any(word in commit_lower for word in ['feat', 'feature', 'add', 'implement']): categories['Features'].append(commit) elif any(word in commit_lower for word in ['fix', 'bug', 'issue', 'crash', 'error']): categories['Bug Fixes'].append(commit) elif any(word in commit_lower for word in ['perf', 'optimize', 'speed', 'fast']): categories['Performance'].append(commit) elif any(word in commit_lower for word in ['ui', 'gui', 'interface', 'dialog', 'window']): categories['UI/UX'].append(commit) elif any(word in commit_lower for word in ['audio', 'sound', 'music']): categories['Audio'].append(commit) elif any(word in commit_lower for word in ['graphics', 'render', 'vulkan', 'opengl', 'gpu']): categories['Graphics'].append(commit) elif any(word in commit_lower for word in ['input', 'controller', 'keyboard', 'mouse']): categories['Input'].append(commit) elif any(word in commit_lower for word in ['network', 'multiplayer', 'online']): categories['Network'].append(commit) elif any(word in commit_lower for word in ['build', 'ci', 'cmake', 'docker']): categories['Build/CI'].append(commit) elif any(word in commit_lower for word in ['doc', 'readme', 'comment']): categories['Documentation'].append(commit) else: categories['Other'].append(commit) # Remove empty categories return {k: v for k, v in categories.items() if v} def generate_release_notes(version_info, output_format='markdown'): """Generate release notes""" # Get version information version_name = version_info['version_name'] git_commit_short = version_info['git_commit_short'] git_commit_full = version_info['git_commit_full'] git_branch = version_info['git_branch'] build_date = version_info['build_date'] build_time = version_info['build_time'] # Get project info from environment or defaults project_url = os.environ.get('CI_PROJECT_URL', 'https://git.citron-emu.org/citron/emulator') package_registry_url = os.environ.get('PACKAGE_REGISTRY_URL', f'{project_url}/-/packages') package_dir = os.environ.get('PACKAGE_DIR', f'Citron-Canary/{version_name}') # Get commits commits = get_commits_since_last_tag() categorized_commits = categorize_commits(commits) if output_format == 'markdown': return generate_markdown_release_notes( version_name, git_commit_short, git_commit_full, git_branch, build_date, build_time, project_url, package_registry_url, package_dir, categorized_commits ) elif output_format == 'json': return generate_json_release_notes( version_name, git_commit_short, git_commit_full, git_branch, build_date, build_time, project_url, package_registry_url, package_dir, categorized_commits ) else: raise ValueError(f"Unknown output format: {output_format}") def generate_markdown_release_notes(version_name, git_commit_short, git_commit_full, git_branch, build_date, build_time, project_url, package_registry_url, package_dir, categorized_commits): """Generate markdown release notes""" notes = [] notes.append(f"# Citron {version_name}") notes.append("") notes.append(f"**Build Date:** {build_date} {build_time} UTC ") notes.append(f"**Commit:** [{git_commit_short}]({project_url}/-/commit/{git_commit_full}) ") notes.append(f"**Branch:** `{git_branch}`") notes.append("") # Downloads section notes.append("## Downloads") notes.append("") notes.append("### Linux (AppImage)") notes.append(f"- **x86_64 (Generic):** [Citron-{version_name}-anylinux-x86_64.AppImage]({package_registry_url}/{package_dir}/Citron-{version_name}-anylinux-x86_64.AppImage)") notes.append(f"- **x86_64 (SteamDeck):** [Citron-{version_name}-steamdeck-x86_64.AppImage]({package_registry_url}/{package_dir}/Citron-{version_name}-steamdeck-x86_64.AppImage)") notes.append(f"- **x86_64 (Compatibility):** [Citron-{version_name}-compat-x86_64.AppImage]({package_registry_url}/{package_dir}/Citron-{version_name}-compat-x86_64.AppImage)") notes.append("") notes.append("### Android") notes.append(f"- **ARM64:** [Citron-{version_name}-android-arm64.apk]({package_registry_url}/{package_dir}/Citron-{version_name}-android-arm64.apk)") notes.append("") notes.append("### Source Code") notes.append(f"- **Source Archive:** [citron-{version_name}-source.tar.gz]({package_registry_url}/{package_dir}/citron-{version_name}-source.tar.gz)") notes.append("") # Installation instructions notes.append("## Installation Instructions") notes.append("") notes.append("### Linux") notes.append("1. Download the appropriate AppImage for your system") notes.append("2. Make it executable: `chmod +x Citron-*.AppImage`") notes.append("3. Run: `./Citron-*.AppImage`") notes.append("") notes.append("### Android") notes.append("1. Enable \"Install from Unknown Sources\" in your device settings") notes.append("2. Download and install the APK file") notes.append("") # Changes section if categorized_commits: notes.append("## Changes") notes.append("") for category, commits in categorized_commits.items(): if commits: notes.append(f"### {category}") for commit in commits[:10]: # Limit to 10 commits per category notes.append(f"- {commit}") notes.append("") # Footer notes.append("---") notes.append(f"*This is an automated build from commit [{git_commit_short}]({project_url}/-/commit/{git_commit_full})*") return '\n'.join(notes) def generate_json_release_notes(version_name, git_commit_short, git_commit_full, git_branch, build_date, build_time, project_url, package_registry_url, package_dir, categorized_commits): """Generate JSON release notes""" return json.dumps({ 'version': version_name, 'build_date': build_date, 'build_time': build_time, 'git_commit_short': git_commit_short, 'git_commit_full': git_commit_full, 'git_branch': git_branch, 'project_url': project_url, 'package_registry_url': package_registry_url, 'package_dir': package_dir, 'changes': categorized_commits, 'downloads': { 'linux': { 'generic': f'Citron-{version_name}-anylinux-x86_64.AppImage', 'steamdeck': f'Citron-{version_name}-steamdeck-x86_64.AppImage', 'compatibility': f'Citron-{version_name}-compat-x86_64.AppImage' }, 'android': { 'arm64': f'Citron-{version_name}-android-arm64.apk' }, 'source': f'citron-{version_name}-source.tar.gz' } }, indent=2) def main(): """Main function""" import argparse parser = argparse.ArgumentParser(description='Generate release notes for Citron') parser.add_argument('--format', choices=['markdown', 'json'], default='markdown', help='Output format (default: markdown)') parser.add_argument('--version-info', help='JSON file with version information') parser.add_argument('--output', help='Output file (default: stdout)') args = parser.parse_args() # Get version information if args.version_info and os.path.exists(args.version_info): with open(args.version_info, 'r') as f: version_info = json.load(f) else: # Import version script sys.path.insert(0, os.path.dirname(__file__)) from get_version import get_version_info version_info = get_version_info() # Generate release notes notes = generate_release_notes(version_info, args.format) # Output if args.output: with open(args.output, 'w') as f: f.write(notes) else: print(notes) if __name__ == '__main__': main()