#!/usr/bin/env python3 import argparse import re import subprocess from pathlib import Path from datetime import date, datetime # Base directory of the project BASE_DIR = Path(__file__).parent.parent # Specific project files APPIMAGE_RECIPE = BASE_DIR / "build-aux" / "AppImageBuilder.yml" ARCH_PKGBUILD = BASE_DIR / "build-aux" / "PKGBUILD" FEDORA_SPEC = BASE_DIR / "build-aux" / "fedora.spec" PYPROJECT = BASE_DIR / "pyproject.toml" APP_PY = BASE_DIR / "portprotonqt" / "app.py" GITEA_WORKFLOW = BASE_DIR / ".gitea" / "workflows" / "build.yml" CHANGELOG = BASE_DIR / "CHANGELOG.md" DEBIAN_CHANGELOG = BASE_DIR / "debian" / "changelog" def bump_appimage(path: Path, old: str, new: str) -> bool: """ Update only the 'version' field under app_info in AppImageBuilder.yml """ if not path.exists(): return False text = path.read_text(encoding='utf-8') pattern = re.compile(r"(?m)^(\s*version:\s*)" + re.escape(old) + r"$") new_text, count = pattern.subn(lambda m: m.group(1) + new, text) if count: path.write_text(new_text, encoding='utf-8') return bool(count) def bump_arch(path: Path, old: str, new: str) -> bool: """ Update pkgver in PKGBUILD """ if not path.exists(): return False text = path.read_text(encoding='utf-8') pattern = re.compile(r"(?m)^(pkgver=)" + re.escape(old) + r"$") new_text, count = pattern.subn(lambda m: m.group(1) + new, text) if count: path.write_text(new_text, encoding='utf-8') return bool(count) def bump_fedora(path: Path, old: str, new: str) -> bool: """ Update only the '%global pypi_version' line in fedora.spec """ if not path.exists(): return False text = path.read_text(encoding='utf-8') pattern = re.compile(r"(?m)^(%global\s+pypi_version\s+)" + re.escape(old) + r"$") new_text, count = pattern.subn(lambda m: m.group(1) + new, text) if count: path.write_text(new_text, encoding='utf-8') return bool(count) def bump_pyproject(path: Path, old: str, new: str) -> bool: """ Update version in pyproject.toml under [project] """ if not path.exists(): return False text = path.read_text(encoding='utf-8') pattern = re.compile(r"(?m)^(version\s*=\s*)\"" + re.escape(old) + r"\"$") new_text, count = pattern.subn(lambda m: m.group(1) + f'"{new}"', text) if count: path.write_text(new_text, encoding='utf-8') return bool(count) def bump_app_py(path: Path, old: str, new: str) -> bool: """ Update __app_version__ in app.py """ if not path.exists(): return False text = path.read_text(encoding='utf-8') pattern = re.compile(r"(?m)^(\s*__app_version__\s*=\s*)\"" + re.escape(old) + r"\"$") new_text, count = pattern.subn(lambda m: m.group(1) + f'"{new}"', text) if count: path.write_text(new_text, encoding='utf-8') return bool(count) def bump_workflow(path: Path, old: str, new: str) -> bool: """ Update VERSION in Gitea Actions workflow """ if not path.exists(): return False text = path.read_text(encoding='utf-8') pattern = re.compile(r"(?m)^(\s*VERSION:\s*)" + re.escape(old) + r"$") new_text, count = pattern.subn(lambda m: m.group(1) + new, text) if count: path.write_text(new_text, encoding='utf-8') return bool(count) def bump_changelog(path: Path, old: str, new: str) -> bool: """ Update [Unreleased] to [new] - YYYY-MM-DD in CHANGELOG.md """ if not path.exists(): return False text = path.read_text(encoding='utf-8') pattern = re.compile(r"(?m)^##\s*\[Unreleased\]$") current_date = date.today().strftime('%Y-%m-%d') new_text, count = pattern.subn(f"## [{new}] - {current_date}", text) if count: path.write_text(new_text, encoding='utf-8') return bool(count) def bump_debian_changelog(path: Path, old: str, new: str) -> bool: """ Update debian/changelog with new version """ if not path.exists(): return False # Extract changelog entries from CHANGELOG.md for this version changelog_md_path = BASE_DIR / "CHANGELOG.md" changelog_entries = [] changelog_date = None if changelog_md_path.exists(): changelog_text = changelog_md_path.read_text(encoding='utf-8') lines = changelog_text.splitlines() # Find the section for the new version and extract the date start_reading = False end_reading = False in_contributors_section = False for line in lines: if line.startswith(f"## [{new}]"): # Extract date from line like "## [0.1.9] - 2025-12-08" date_match = re.search(r'\[.+\] - (\d{4}-\d{2}-\d{2})', line) if date_match: changelog_date_str = date_match.group(1) # Convert to the expected Debian format date_obj = datetime.strptime(changelog_date_str, '%Y-%m-%d') changelog_date = date_obj.strftime('%a, %d %b %Y') + " 00:00:00 +0000" start_reading = True in_contributors_section = False continue elif line.startswith("## [") and start_reading: end_reading = True break elif line.strip().lower() == "### contributors": # Start of contributors section - skip following lines until next section in_contributors_section = True continue # Skip section headers and contributor sections if start_reading and not end_reading and not in_contributors_section: stripped_line = line.strip() if stripped_line and not line.startswith("#") and not line.startswith("[") and not line.lower().startswith("###"): # Check if this line is a list item with changes if re.match(r'^\s*[*-]\s+', line): # Remove markdown list formatting and add proper Debian format clean_line = re.sub(r'^\s*[*-]\s+', ' * ', line.rstrip()) # Remove common markdown formatting like backticks clean_line = re.sub(r'`([^`]+)`', r'"\1"', clean_line) # Replace `code` with "code" changelog_entries.append(clean_line) # Also include lines that are sub-items (indented changes) elif line.startswith(" ") and re.match(r'^\s*[*-]\s+', line[4:]): clean_line = re.sub(r'^\s*[*-]\s+', ' * ', line[4:].rstrip()) clean_line = " " + clean_line # Add extra indentation # Remove common markdown formatting clean_line = re.sub(r'`([^`]+)`', r'"\1"', clean_line) changelog_entries.append(clean_line) # If no specific entries found for this version, use generic message if not changelog_entries: changelog_entries = [" * New upstream release"] # Use changelog date if available, otherwise use current time current_time = changelog_date if changelog_date else datetime.now().strftime('%a, %d %b %Y %H:%M:%S +0000') # Read the existing changelog to get maintainer info and other fields text = path.read_text(encoding='utf-8') # If the file is empty or doesn't contain proper maintainer info, use a default lines = text.splitlines() if not lines or not any(line.startswith(" -- ") for line in lines): # Create a default changelog entry with proper format package_name = "portprotonqt" new_version_line = f"{package_name} ({new}-1) unstable; urgency=medium" # Default maintainer info from the original file default_maintainer = "Boris Yumankulov " maintainer_line = f" -- {default_maintainer} {current_time}" new_content = new_version_line + "\n\n" + "\n".join(changelog_entries) + "\n\n" + maintainer_line + "\n" else: # Extract the header template from the current first entry header_parts = [] entry_end_index = 0 for i, line in enumerate(lines): header_parts.append(line) if line.startswith(" -- "): entry_end_index = i + 1 break # Construct new changelog entry new_entry_lines = [] if header_parts: # Parse the first line to extract package name (before the version) first_line = header_parts[0] # Extract package name by getting everything before the opening parenthesis if '(' in first_line: package_name = first_line.split('(')[0].strip() new_version_line = f"{package_name} ({new}-1) unstable; urgency=medium" else: # Fallback: if no parentheses found, use a default format new_version_line = f"portprotonqt ({new}-1) unstable; urgency=medium" new_entry_lines.append(new_version_line) # Add the changelog entries new_entry_lines.extend(changelog_entries) # Add the maintainer info and timestamp for j in range(1, len(header_parts)): if header_parts[j].startswith(" -- "): # Extract the maintainer information (everything after "-- ") maintainer_part = header_parts[j][4:] # Remove leading " -- " # Extract only the name and email, ignore timestamp maintainer_info = maintainer_part.split(' ')[0].strip() new_entry_lines.append(f" -- {maintainer_info} {current_time}") elif not header_parts[j].startswith(" *"): # Skip existing changes since we added new ones new_entry_lines.append(header_parts[j]) # Reconstruct the file with new entry at the top followed by the rest new_content = '\n'.join(new_entry_lines) + '\n' + '\n'.join(lines[entry_end_index:]) path.write_text(new_content, encoding='utf-8') return True def main(): parser = argparse.ArgumentParser(description='Bump project version in specific files') parser.add_argument('old', help='Old version string') parser.add_argument('new', help='New version string') args = parser.parse_args() old, new = args.old, args.new tasks = [ (APPIMAGE_RECIPE, bump_appimage), (ARCH_PKGBUILD, bump_arch), (FEDORA_SPEC, bump_fedora), (PYPROJECT, bump_pyproject), (APP_PY, bump_app_py), (GITEA_WORKFLOW, bump_workflow), (CHANGELOG, bump_changelog), (DEBIAN_CHANGELOG, bump_debian_changelog) ] updated = [] for path, func in tasks: if func(path, old, new): updated.append(path.relative_to(BASE_DIR)) if updated: print(f"Updated version from {old} to {new} in {len(updated)} files:") for p in sorted(updated): print(f" - {p}") try: subprocess.run(["uv", "lock"], check=True) print("Regenerated uv.lock") except subprocess.CalledProcessError as e: print(f"Failed to regenerate uv.lock: {e}") else: print(f"No occurrences of version {old} found in specified files.") if __name__ == '__main__': main()