diff --git a/.gitignore b/.gitignore index 0d25c05..da537e6 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .notes/ assets/ __pycache__ +release-notes-*.md diff --git a/Makefile b/Makefile index e254b2d..f2e02b4 100644 --- a/Makefile +++ b/Makefile @@ -1,23 +1,28 @@ -.PHONY: help all readme validate add-app normalize publish +.PHONY: help all readme validate add-app normalize publish publish/dry-run publish/from-file default: help help: # Show help for each of the makefile recipes. @grep -E '^[a-zA-Z0-9 -]+:.*#' Makefile | sort | while read -r l; \ do printf "\033[1;32m$$(echo $$l | cut -f 1 -d':')\033[00m:$$(echo $$l | cut -f 2- -d'#')\n"; done -release: validate normalize readme minify minify-dual-screen # Run all Make targets related to cutting a release. - -validate: # Validate applications.json for errors - @python scripts/validate-json.py src/applications.json +# --------------------------------------------------------------------------- +# Apps +# --------------------------------------------------------------------------- add-app: # Interactive CLI to add a new app @python scripts/add-app.py +validate: # Validate applications.json for errors + @python scripts/validate-json.py src/applications.json + normalize: # Normalize key order and add missing defaults in applications.json @python scripts/normalize-json.py src/applications.json -links: # Generate links for all obtainium packages - @python scripts/generate-obtainium-urls.py src/applications.json > scripts/links.md +# --------------------------------------------------------------------------- +# Build +# --------------------------------------------------------------------------- + +release: validate normalize readme minify minify-dual-screen # Run all Make targets related to cutting a release. minify: # Generate standard release JSON @python scripts/minify-json.py src/applications.json obtainium-emulation-pack-latest.json --variant standard @@ -25,8 +30,9 @@ minify: # Generate standard release JSON minify-dual-screen: # Generate dual screen release JSON @python scripts/minify-json.py src/applications.json obtainium-emulation-pack-dual-screen-latest.json --variant dual-screen -publish: # Tag, push, and create a GitHub release with versioned JSON assets (requires gh CLI) - @python scripts/release.py +# --------------------------------------------------------------------------- +# Docs +# --------------------------------------------------------------------------- table: # Generate a table of obtainium links for the README. @python scripts/generate-table.py src/applications.json ./pages/table.md @@ -37,3 +43,19 @@ readme: table # Generate the readme file. Why? Because editing that table every ./pages/table.md \ ./pages/faq.md \ ./pages/development.md + +links: # Generate links for all obtainium packages + @python scripts/generate-obtainium-urls.py src/applications.json > scripts/links.md + +# --------------------------------------------------------------------------- +# Publish +# --------------------------------------------------------------------------- + +publish: # Tag, push, and create a GitHub release (opens $EDITOR for notes) + @python scripts/release.py + +publish/dry-run: # Preview release notes as a markdown file without publishing + @python scripts/release.py --dry-run + +publish/from-file: # Publish using a previously edited release notes file (e.g. from publish-dry) + @python scripts/release.py --notes-file $(FILE) diff --git a/scripts/release.py b/scripts/release.py index c2a1d64..4b4725c 100644 --- a/scripts/release.py +++ b/scripts/release.py @@ -5,11 +5,11 @@ the publish side: tagging, pushing, and creating the GitHub release. Workflow: 1. Prompt for version (suggests next based on latest tag) - 2. Copy minified JSONs to versioned filenames - 3. Create git tag - 4. Push tag to origin - 5. Create GitHub release with `gh release create` - 6. Clean up versioned copies + 2. Detect changed apps since last tag + 3. Generate release notes with summary + app update table + 4. Open in $EDITOR for final edits + 5. Copy minified JSONs to versioned filenames + 6. Create git tag, push, and create GitHub release Usage: make release # build artifacts first @@ -20,24 +20,30 @@ Requires: gh (GitHub CLI), git, python3 import argparse import json +import os import re import shutil import subprocess import sys import tempfile +import urllib.parse +from collections import defaultdict from pathlib import Path +from typing import Any REPO_ROOT = Path(__file__).resolve().parent.parent # Release artifact paths (relative to repo root) STANDARD_JSON = REPO_ROOT / "obtainium-emulation-pack-latest.json" DUAL_SCREEN_JSON = REPO_ROOT / "obtainium-emulation-pack-dual-screen-latest.json" +APPLICATIONS_JSON = REPO_ROOT / "src" / "applications.json" SEMVER_PATTERN = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$") +# Helpers + def run(cmd: list[str], capture: bool = False, check: bool = True) -> subprocess.CompletedProcess: - """Run a command, optionally capturing output.""" return subprocess.run( cmd, cwd=REPO_ROOT, @@ -48,21 +54,20 @@ def run(cmd: list[str], capture: bool = False, check: bool = True) -> subprocess def check_prerequisites() -> None: - """Verify required tools are installed.""" for tool in ("gh", "git"): if shutil.which(tool) is None: print(f"Error: '{tool}' is not installed. Install it first.") sys.exit(1) - # Verify gh is authenticated result = run(["gh", "auth", "status"], capture=True, check=False) if result.returncode != 0: print("Error: gh is not authenticated. Run `gh auth login` first.") sys.exit(1) +# Version helpers + def get_latest_tag() -> str | None: - """Get the most recent semver tag from git.""" result = run(["git", "tag", "--sort=-v:refname"], capture=True, check=False) if result.returncode != 0: return None @@ -75,7 +80,6 @@ def get_latest_tag() -> str | None: def parse_semver(tag: str) -> tuple[int, int, int]: - """Parse a semver tag into (major, minor, patch).""" match = SEMVER_PATTERN.match(tag) if not match: raise ValueError(f"Invalid semver tag: {tag}") @@ -83,7 +87,6 @@ def parse_semver(tag: str) -> tuple[int, int, int]: def suggest_versions(latest: str | None) -> dict[str, str]: - """Suggest next patch, minor, and major versions.""" if latest is None: return {"patch": "v0.0.1", "minor": "v0.1.0", "major": "v1.0.0"} @@ -96,7 +99,6 @@ def suggest_versions(latest: str | None) -> dict[str, str]: def prompt_version(latest: str | None) -> str: - """Interactively prompt for the release version.""" suggestions = suggest_versions(latest) print() @@ -133,53 +135,231 @@ def prompt_version(latest: str | None) -> str: print("Invalid choice. Enter 1, 2, 3, or 4.") -def prompt_release_notes() -> str: - """Prompt user to write release notes, or open $EDITOR.""" - print() - print("Release notes options:") - print(" [1] Write inline (multi-line, end with EOF on empty line)") - print(" [2] Open in $EDITOR") - print(" [3] Skip (create release with auto-generated notes)") - print() +# App diff detection - choice = input("Select [1/2/3]: ").strip() +def load_apps_from_ref(ref: str) -> dict[str, dict[str, Any]]: + result = run( + ["git", "show", f"{ref}:src/applications.json"], + capture=True, + check=False, + ) + if result.returncode != 0: + return {} - if choice == "1": - print() - print("Enter release notes (type a blank line to finish):") - lines: list[str] = [] - while True: - line = input() - if line == "": - break - lines.append(line) - return "\n".join(lines) + data = json.loads(result.stdout) + return {app["id"]: app for app in data.get("apps", [])} - elif choice == "2": - import os - editor = os.environ.get("EDITOR", "vim") - with tempfile.NamedTemporaryFile(suffix=".md", mode="w", delete=False) as f: - f.write("## Summary\n\n- \n") - tmp_path = f.name - subprocess.run([editor, tmp_path], check=True) - with open(tmp_path, "r") as f: - notes = f.read().strip() - Path(tmp_path).unlink(missing_ok=True) - return notes +def load_apps_from_file() -> dict[str, dict[str, Any]]: + with open(APPLICATIONS_JSON, "r", encoding="utf-8") as f: + data = json.load(f) + return {app["id"]: app for app in data.get("apps", [])} - else: + +def normalize_app_for_comparison(app: dict[str, Any]) -> dict[str, Any]: + """Parse additionalSettings and strip meta so formatting-only changes are ignored.""" + normalized = {k: v for k, v in app.items() if k != "meta"} + + settings = normalized.get("additionalSettings") + if isinstance(settings, str): + try: + normalized["additionalSettings"] = json.loads(settings) + except json.JSONDecodeError: + pass + + return normalized + + +def diff_apps( + old_apps: dict[str, dict[str, Any]], + new_apps: dict[str, dict[str, Any]], +) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]: + """Returns (added, changed, removed) app lists. Removed entries use the old version.""" + old_ids = set(old_apps.keys()) + new_ids = set(new_apps.keys()) + + added = [new_apps[id] for id in sorted(new_ids - old_ids)] + + removed = [old_apps[id] for id in sorted(old_ids - new_ids)] + + changed = [] + for id in sorted(old_ids & new_ids): + old_norm = normalize_app_for_comparison(old_apps[id]) + new_norm = normalize_app_for_comparison(new_apps[id]) + if json.dumps(old_norm, sort_keys=True) != json.dumps(new_norm, sort_keys=True): + changed.append(new_apps[id]) + + return added, changed, removed + + +# Obtainium link generation (mirrors generate-table.py) + +def make_obtainium_link(app: dict[str, Any]) -> str: + payload = { + "id": app["id"], + "url": app["url"], + "author": app["author"], + "name": app["name"], + "otherAssetUrls": app.get("otherAssetUrls"), + "apkUrls": app.get("apkUrls"), + "preferredApkIndex": app.get("preferredApkIndex"), + "additionalSettings": app.get("additionalSettings"), + "categories": app.get("categories"), + "overrideSource": app.get("overrideSource"), + "allowIdChange": app.get("allowIdChange"), + } + encoded = urllib.parse.quote(json.dumps(payload, separators=(",", ":")), safe="") + return f"http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://app/{encoded}" + + +def should_include_app(app: dict[str, Any], variant: str) -> bool: + meta = app.get("meta", {}) + if meta.get("excludeFromExport", False): + return False + if variant == "standard": + return meta.get("includeInStandard", True) + elif variant == "dual-screen": + return meta.get("includeInDualScreen", True) + return True + + +def get_display_name(app: dict[str, Any]) -> str: + return app.get("meta", {}).get("nameOverride") or app.get("name", "") + + +def get_application_url(app: dict[str, Any]) -> str: + return app.get("meta", {}).get("urlOverride") or app.get("url", "") + + +def make_app_table_row(app: dict[str, Any]) -> str: + display_name = f'{get_display_name(app)}' + obtainium_link = make_obtainium_link(app) + badge = f'Add to Obtainium!' + std = "✅" if should_include_app(app, "standard") else "❌" + ds = "✅" if should_include_app(app, "dual-screen") else "❌" + return f"| {display_name} | {badge} | {std} | {ds} |" + + +TABLE_HEADER = ( + "| Application Name | Add to Obtainium | Included in export json? | Included in DS json? |\n" + "|------------------|------------------|---------------------------|----------------------|" +) + + +def generate_app_table(apps: list[dict[str, Any]], group_by_category: bool = False) -> str: + if not apps: return "" + if not group_by_category: + lines = [TABLE_HEADER] + for app in sorted(apps, key=lambda a: get_display_name(a).lower()): + lines.append(make_app_table_row(app)) + return "\n".join(lines) + + # Group by category + categorized: defaultdict[str, list[dict[str, Any]]] = defaultdict(list) + for app in apps: + for cat in app.get("categories", ["Other"]): + categorized[cat].append(app) + + sections: list[str] = [] + for category in sorted(categorized.keys()): + sections.append(f"### {category}\n") + sections.append(TABLE_HEADER) + for app in sorted(categorized[category], key=lambda a: get_display_name(a).lower()): + sections.append(make_app_table_row(app)) + sections.append("") + + return "\n".join(sections) + + +# Commit log + +def get_commit_summaries(since_tag: str | None) -> list[str]: + if since_tag: + cmd = ["git", "log", f"{since_tag}..HEAD", "--pretty=format:%s"] + else: + cmd = ["git", "log", "--pretty=format:%s"] + + result = run(cmd, capture=True, check=False) + if result.returncode != 0 or not result.stdout.strip(): + return [] + + return [line.strip() for line in result.stdout.strip().splitlines() if line.strip()] + + +# Release notes + +def generate_release_notes( + latest_tag: str | None, + added: list[dict[str, Any]], + changed: list[dict[str, Any]], + removed: list[dict[str, Any]], +) -> str: + lines: list[str] = [] + + # Summary section with commit messages as starting points + lines.append("## Summary\n") + commits = get_commit_summaries(latest_tag) + if commits: + for msg in commits: + # Skip merge commits and release commits + if msg.startswith("Merge ") or msg.startswith("release:"): + continue + lines.append(f"- {msg}") + else: + lines.append("- ") + lines.append("") + + # New apps section + if added: + lines.append("## New Apps\n") + lines.append(generate_app_table(added, group_by_category=True)) + lines.append("") + + # Updated apps section + if changed: + lines.append("## App Updates\n") + lines.append(generate_app_table(changed, group_by_category=False)) + lines.append("") + + # Removed apps section + if removed: + lines.append("## Removed Apps\n") + for app in sorted(removed, key=lambda a: get_display_name(a).lower()): + lines.append(f"- {get_display_name(app)}") + lines.append("") + + return "\n".join(lines) + + +def edit_release_notes(notes: str) -> str: + editor = os.environ.get("EDITOR", "vim") + + with tempfile.NamedTemporaryFile( + suffix="-release-notes.md", mode="w", delete=False, prefix="obtainium-" + ) as f: + f.write(notes) + tmp_path = f.name + + print(f"\nOpening release notes in {editor}...") + print("Edit the notes, save, and close to continue.\n") + subprocess.run([editor, tmp_path], check=True) + + with open(tmp_path, "r") as f: + edited = f.read().strip() + Path(tmp_path).unlink(missing_ok=True) + return edited + + +# Git / GitHub def check_working_tree_clean() -> bool: - """Check if the git working tree is clean.""" result = run(["git", "status", "--porcelain"], capture=True) return result.stdout.strip() == "" def create_versioned_copies(version: str) -> list[Path]: - """Copy release JSONs to versioned filenames for upload.""" copies: list[Path] = [] standard_versioned = REPO_ROOT / f"obtainium-emulation-pack-{version}.json" @@ -190,12 +370,10 @@ def create_versioned_copies(version: str) -> list[Path]: copies.append(standard_versioned) copies.append(dual_versioned) - return copies def create_tag(version: str) -> None: - """Create and push a git tag.""" print(f"Creating tag {version}...") run(["git", "tag", version]) print(f"Pushing tag {version} to origin...") @@ -203,7 +381,6 @@ def create_tag(version: str) -> None: def create_github_release(version: str, notes: str, assets: list[Path]) -> None: - """Create a GitHub release via gh CLI.""" cmd = ["gh", "release", "create", version] if notes: @@ -221,13 +398,11 @@ def create_github_release(version: str, notes: str, assets: list[Path]) -> None: def cleanup(files: list[Path]) -> None: - """Remove temporary versioned copies.""" for f in files: f.unlink(missing_ok=True) def get_app_count(json_path: Path) -> int: - """Count apps in a release JSON file.""" try: with open(json_path, "r") as f: data = json.load(f) @@ -236,12 +411,29 @@ def get_app_count(json_path: Path) -> int: return 0 +# Main + def main() -> None: - parser = argparse.ArgumentParser(description="Create a GitHub release for Obtainium Emulation Pack") - parser.add_argument("--version", "-v", help="Release version (e.g. v7.5.0). Prompts if not provided.") - parser.add_argument("--notes", "-n", help="Release notes (markdown string). Prompts if not provided.") - parser.add_argument("--notes-file", "-f", help="Path to a file containing release notes.") - parser.add_argument("--dry-run", action="store_true", help="Show what would happen without making changes.") + parser = argparse.ArgumentParser( + description="Create a GitHub release for Obtainium Emulation Pack" + ) + parser.add_argument( + "--version", "-v", + help="Release version (e.g. v7.5.0). Prompts if not provided.", + ) + parser.add_argument( + "--notes", "-n", + help="Release notes markdown string. Skips generation and editor.", + ) + parser.add_argument( + "--notes-file", "-f", + help="Path to a file containing release notes. Skips generation and editor.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would happen without making changes.", + ) args = parser.parse_args() check_prerequisites() @@ -265,14 +457,36 @@ def main() -> None: print(f"Error: Tag {version} already exists.") sys.exit(1) + # Detect changed apps + print("\nDetecting app changes...") + old_apps = load_apps_from_ref(latest) if latest else {} + new_apps = load_apps_from_file() + added, changed, removed = diff_apps(old_apps, new_apps) + + print(f" Added: {len(added)}") + print(f" Changed: {len(changed)}") + print(f" Removed: {len(removed)}") + # Determine release notes - notes = "" if args.notes_file: notes = Path(args.notes_file).read_text().strip() elif args.notes: notes = args.notes - elif not args.dry_run: - notes = prompt_release_notes() + else: + # Auto-generate and open in editor + notes = generate_release_notes(latest, added, changed, removed) + + if args.dry_run: + preview_path = REPO_ROOT / f"release-notes-{version}.md" + with open(preview_path, "w") as f: + f.write(notes) + print(f"\nRelease notes written to {preview_path.name}") + else: + notes = edit_release_notes(notes) + + if not notes.strip(): + print("Warning: Release notes are empty. Using auto-generated notes.") + notes = "" # Dry run summary if args.dry_run: @@ -280,7 +494,6 @@ def main() -> None: print("=== DRY RUN ===") print(f" Version: {version}") print(f" Latest tag: {latest or '(none)'}") - print(f" Notes: {'(auto-generated)' if not notes else notes[:80] + '...'}") print() print(" Would run:") print(f" 1. git tag {version}") @@ -297,17 +510,20 @@ def main() -> None: for f in (STANDARD_JSON, DUAL_SCREEN_JSON): if not f.exists(): print(f"Error: Expected artifact not found: {f}") + print("Did you run `make release` first?") sys.exit(1) # Show summary before proceeding std_count = get_app_count(STANDARD_JSON) ds_count = get_app_count(DUAL_SCREEN_JSON) + print() print(f"Version: {version}") print(f"Standard apps: {std_count}") print(f"Dual-screen: {ds_count}") if notes: - print(f"Notes preview: {notes[:80]}{'...' if len(notes) > 80 else ''}") + preview = notes.split("\n")[0] + print(f"Notes preview: {preview[:80]}{'...' if len(preview) > 80 else ''}") else: print("Notes: (auto-generated from commits)") print() @@ -317,10 +533,10 @@ def main() -> None: print("Aborted.") sys.exit(0) - # Stage, commit any build changes if working tree is dirty + # Commit any uncommitted changes (e.g. from `make release`) if not check_working_tree_clean(): print() - print("Working tree has changes (from build). Committing...") + print("Working tree has changes. Committing...") run(["git", "add", "-A"]) run(["git", "commit", "-m", f"release: {version}"]) run(["git", "push"]) @@ -329,7 +545,6 @@ def main() -> None: versioned_copies = create_versioned_copies(version) try: - # Tag and release create_tag(version) create_github_release(version, notes, versioned_copies) @@ -337,7 +552,6 @@ def main() -> None: print(f"Release {version} created successfully!") print(f"https://github.com/RJNY/Obtainium-Emulation-Pack/releases/tag/{version}") finally: - # Always clean up versioned copies cleanup(versioned_copies)