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)