Files
Obtainium-Emulation-Pack/scripts/release.py
Richard Macias e539043aad spring cleaning
2026-03-07 22:34:36 -06:00

586 lines
18 KiB
Python

"""Create a GitHub release with tagged JSON artifacts.
Expects `just build` to have already been run. This script only handles
the publish side: tagging, pushing, and creating the GitHub release.
Workflow:
1. Prompt for version (suggests next based on latest tag)
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:
just build # build artifacts first
just release # then publish
Requires: gh (GitHub CLI), git, python3
"""
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Any
from constants import GITHUB_NOREPLY_SUFFIX
from help_formatter import StyledHelpFormatter
from utils import get_additional_settings, get_application_url, get_display_name, load_dotenv, make_obtainium_link, should_include_app
REPO_ROOT = Path(__file__).resolve().parent.parent
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"
RELEASES_URL = "https://github.com/RJNY/Obtainium-Emulation-Pack/releases/tag"
SEMVER_PATTERN = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$")
def load_owner_emails() -> set[str]:
raw = os.environ.get("OWNER_EMAILS", "")
if not raw.strip():
return set()
return {email.strip().lower() for email in raw.split(",") if email.strip()}
def run(
cmd: list[str],
capture: bool = False,
check: bool = True,
env: dict[str, str] | None = None,
) -> subprocess.CompletedProcess:
return subprocess.run(
cmd,
cwd=REPO_ROOT,
capture_output=capture,
text=True,
check=check,
env=env,
)
def check_prerequisites() -> None:
for tool in ("gh", "git"):
if shutil.which(tool) is None:
print(f"Error: '{tool}' is not installed. Install it first.")
sys.exit(1)
result = run(["gh", "auth", "status"], capture=True, check=False, env=_gh_env())
if result.returncode != 0:
print("Error: gh is not authenticated. Run `gh auth login` first.")
sys.exit(1)
def get_latest_tag() -> str | None:
result = run(["git", "tag", "--sort=-v:refname"], capture=True, check=False)
if result.returncode != 0:
return None
for line in result.stdout.strip().splitlines():
tag = line.strip()
if SEMVER_PATTERN.match(tag):
return tag
return None
def parse_semver(tag: str) -> tuple[int, int, int]:
match = SEMVER_PATTERN.match(tag)
if not match:
raise ValueError(f"Invalid semver tag: {tag}")
return int(match.group(1)), int(match.group(2)), int(match.group(3))
def suggest_versions(latest: str | None) -> dict[str, str]:
if latest is None:
return {"patch": "v0.0.1", "minor": "v0.1.0", "major": "v1.0.0"}
major, minor, patch = parse_semver(latest)
return {
"patch": f"v{major}.{minor}.{patch + 1}",
"minor": f"v{major}.{minor + 1}.0",
"major": f"v{major + 1}.0.0",
}
def prompt_version(latest: str | None) -> str:
suggestions = suggest_versions(latest)
print()
if latest:
print(f"Latest tag: {latest}")
else:
print("No existing tags found.")
print()
print("Suggested versions:")
print(f" [1] patch - {suggestions['patch']}")
print(f" [2] minor - {suggestions['minor']}")
print(f" [3] major - {suggestions['major']}")
print(f" [4] custom")
print()
while True:
choice = input("Select version [1/2/3/4]: ").strip()
if choice == "1":
return suggestions["patch"]
elif choice == "2":
return suggestions["minor"]
elif choice == "3":
return suggestions["major"]
elif choice == "4":
custom = input("Enter version (e.g. v1.2.3): ").strip()
if not custom.startswith("v"):
custom = f"v{custom}"
if not SEMVER_PATTERN.match(custom):
print(f"Invalid semver format: {custom}")
continue
return custom
else:
print("Invalid choice. Enter 1, 2, 3, or 4.")
def _app_key(app: dict[str, Any]) -> str:
"""Compound key to distinguish apps that share an Android package ID (e.g. variant forks)."""
return f"{app['id']}::{app['url']}"
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 {}
data = json.loads(result.stdout)
return {_app_key(app): app for app in data.get("apps", [])}
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_key(app): app for app in data.get("apps", [])}
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"}
normalized["additionalSettings"] = get_additional_settings(normalized)
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_keys = set(old_apps.keys())
new_keys = set(new_apps.keys())
added = [new_apps[k] for k in sorted(new_keys - old_keys)]
removed = [old_apps[k] for k in sorted(old_keys - new_keys)]
changed = []
for k in sorted(old_keys & new_keys):
old_norm = normalize_app_for_comparison(old_apps[k])
new_norm = normalize_app_for_comparison(new_apps[k])
if json.dumps(old_norm, sort_keys=True) != json.dumps(new_norm, sort_keys=True):
changed.append(new_apps[k])
return added, changed, removed
def _make_ref_key(app: dict[str, Any]) -> str:
return get_display_name(app).lower().replace(" ", "-").replace("!", "").replace("(", "").replace(")", "")
def make_app_table_row(app: dict[str, Any], change: str) -> str:
name = get_display_name(app)
ref_key = _make_ref_key(app)
app_link = f"[{name}]({get_application_url(app)})"
if change == "Removed":
install = "-"
else:
install = f"[Add to Obtainium!][{ref_key}]"
std = "" if should_include_app(app, "standard") else ""
ds = "" if should_include_app(app, "dual-screen") else ""
return f"| {app_link} | {install} | {change} | {std} | {ds} |"
def make_app_reference_link(app: dict[str, Any]) -> str:
ref_key = _make_ref_key(app)
obtainium_link = make_obtainium_link(app)
return f"[{ref_key}]: {obtainium_link}"
CHANGES_TABLE_HEADER = (
"| Application | Add to Obtainium | Change | Standard | Dual-Screen |\n"
"|-------------|------------------|--------|----------|-------------|"
)
def generate_changes_table(
added: list[dict[str, Any]],
changed: list[dict[str, Any]],
removed: list[dict[str, Any]],
version: str,
) -> str:
rows: list[tuple[str, dict[str, Any]]] = []
for app in added:
rows.append(("Added", app))
for app in changed:
rows.append(("Updated", app))
for app in removed:
rows.append(("Removed", app))
if not rows:
return ""
rows.sort(key=lambda r: get_display_name(r[1]).lower())
lines = [CHANGES_TABLE_HEADER]
for change, app in rows:
lines.append(make_app_table_row(app, change))
lines.append("")
lines.append("Links appear broken? [click here][release]")
lines.append("")
lines.append(f"[release]: {RELEASES_URL}/{version}")
for change, app in rows:
if change != "Removed":
lines.append(make_app_reference_link(app))
return "\n".join(lines)
def _git_log_lines(since_tag: str | None, pretty_format: str) -> list[str]:
cmd = ["git", "log"]
if since_tag:
cmd.append(f"{since_tag}..HEAD")
cmd += ["--pretty=format:" + pretty_format]
result = run(cmd, capture=True, check=False)
if result.returncode != 0 or not result.stdout.strip():
return []
return result.stdout.strip().splitlines()
def extract_github_username(email: str) -> str | None:
if not email.endswith(GITHUB_NOREPLY_SUFFIX):
return None
local_part = email[: -len(GITHUB_NOREPLY_SUFFIX)]
if "+" in local_part:
return local_part.split("+", 1)[1]
return local_part
def format_contributor(name: str, email: str) -> str:
username = extract_github_username(email)
if username:
return f"@{username}"
return name
def get_contributors(since_tag: str | None) -> list[str]:
owner_emails = load_owner_emails()
seen: set[str] = set()
contributors: list[str] = []
for line in _git_log_lines(since_tag, "%an%x00%ae"):
if "\x00" not in line:
continue
name, email = line.split("\x00", 1)
name, email = name.strip(), email.strip()
if email.lower() in owner_emails:
continue
formatted = format_contributor(name, email)
if formatted not in seen:
seen.add(formatted)
contributors.append(formatted)
return sorted(contributors, key=str.lower)
def get_commit_summaries(since_tag: str | None) -> list[str]:
return [line.strip() for line in _git_log_lines(since_tag, "%s") if line.strip()]
def generate_release_notes(
latest_tag: str | None,
added: list[dict[str, Any]],
changed: list[dict[str, Any]],
removed: list[dict[str, Any]],
version: str,
) -> str:
lines: list[str] = []
lines.append("## Summary\n")
commits = get_commit_summaries(latest_tag)
if commits:
for msg in commits:
if msg.startswith("Merge ") or msg.startswith("release:"):
continue
lines.append(f"- {msg}")
else:
lines.append("- ")
lines.append("")
contributors = get_contributors(latest_tag)
if contributors:
lines.append("## Contributors\n")
lines.append("Thanks to the following people for their contributions to this release:\n")
for contributor in contributors:
lines.append(f"- {contributor}")
lines.append("")
if added or changed or removed:
lines.append("## App Changes\n")
lines.append(generate_changes_table(added, changed, removed, version))
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
def check_working_tree_clean() -> bool:
result = run(["git", "status", "--porcelain"], capture=True)
return result.stdout.strip() == ""
def create_versioned_copies(version: str) -> list[Path]:
copies: list[Path] = []
standard_versioned = REPO_ROOT / f"obtainium-emulation-pack-{version}.json"
dual_versioned = REPO_ROOT / f"obtainium-emulation-pack-dual-screen-{version}.json"
shutil.copy2(STANDARD_JSON, standard_versioned)
shutil.copy2(DUAL_SCREEN_JSON, dual_versioned)
copies.append(standard_versioned)
copies.append(dual_versioned)
return copies
def create_tag(version: str) -> None:
print(f"Creating tag {version}...")
run(["git", "tag", version])
print(f"Pushing tag {version} to origin...")
run(["git", "push", "origin", version])
def _gh_env() -> dict[str, str] | None:
"""Build env for gh CLI, preferring GITHUB_TOKEN from .env over global gh auth."""
token = os.environ.get("GITHUB_TOKEN", "")
if not token:
return None
env = os.environ.copy()
env["GH_TOKEN"] = token
return env
def create_github_release(version: str, notes: str, assets: list[Path]) -> None:
cmd = ["gh", "release", "create", version]
if notes:
cmd += ["--notes", notes]
else:
cmd += ["--generate-notes"]
cmd += ["--title", version]
for asset in assets:
cmd.append(str(asset))
print(f"Creating GitHub release {version}...")
run(cmd, env=_gh_env())
def cleanup(files: list[Path]) -> None:
for f in files:
f.unlink(missing_ok=True)
def get_app_count(json_path: Path) -> int:
try:
with open(json_path, "r") as f:
data = json.load(f)
return len(data.get("apps", []))
except Exception:
return 0
def main() -> None:
parser = argparse.ArgumentParser(
description="Create a GitHub release for Obtainium Emulation Pack",
formatter_class=StyledHelpFormatter,
)
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(
"--since", "-s",
help="Override base tag for diff (e.g. v7.5.0). Defaults to latest tag.",
)
parser.add_argument(
"--dry-run", "--dryrun",
action="store_true",
help="Show what would happen without making changes.",
)
args = parser.parse_args()
load_dotenv()
check_prerequisites()
print("Fetching tags from remote...")
run(["git", "fetch", "--tags"])
latest = args.since or get_latest_tag()
if args.version:
version = args.version
if not version.startswith("v"):
version = f"v{version}"
if not SEMVER_PATTERN.match(version):
print(f"Error: Invalid semver format: {version}")
sys.exit(1)
else:
version = prompt_version(latest)
if not args.dry_run:
result = run(["git", "tag", "-l", version], capture=True)
if version in result.stdout.strip().splitlines():
print(f"Error: Tag {version} already exists.")
sys.exit(1)
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)}")
if args.notes_file:
notes = Path(args.notes_file).read_text().strip()
elif args.notes:
notes = args.notes
else:
notes = generate_release_notes(latest, added, changed, removed, version)
if args.dry_run:
tmp_dir = REPO_ROOT / "tmp"
tmp_dir.mkdir(exist_ok=True)
preview_path = tmp_dir / f"release-notes-{version}.md"
with open(preview_path, "w") as f:
f.write(notes)
print(f"\nRelease notes written to tmp/{preview_path.name}")
else:
notes = edit_release_notes(notes)
if not notes.strip():
print("Warning: Release notes are empty. Using auto-generated notes.")
notes = ""
if args.dry_run:
print()
print("=== DRY RUN ===")
print(f" Version: {version}")
print(f" Latest tag: {latest or '(none)'}")
print()
print(" Would run:")
print(f" 1. git tag {version}")
print(f" 2. git push origin {version}")
print(f" 3. gh release create {version} --title {version} <assets>")
print()
print(" Assets:")
print(f" - obtainium-emulation-pack-{version}.json")
print(f" - obtainium-emulation-pack-dual-screen-{version}.json")
print()
return
for f in (STANDARD_JSON, DUAL_SCREEN_JSON):
if not f.exists():
print(f"Error: Expected artifact not found: {f}")
print("Did you run `just build` first?")
sys.exit(1)
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:
preview = notes.split("\n")[0]
print(f"Notes preview: {preview[:80]}{'...' if len(preview) > 80 else ''}")
else:
print("Notes: (auto-generated from commits)")
print()
confirm = input("Proceed with release? [y/N]: ").strip().lower()
if confirm not in ("y", "yes"):
print("Aborted.")
sys.exit(0)
if not check_working_tree_clean():
print()
print("Working tree has changes. Committing...")
run(["git", "add", "-A"])
run(["git", "commit", "-m", f"release: {version}"])
run(["git", "push"])
versioned_copies = create_versioned_copies(version)
try:
create_tag(version)
create_github_release(version, notes, versioned_copies)
print()
print(f"Release {version} created successfully!")
print(f"{RELEASES_URL}/{version}")
finally:
cleanup(versioned_copies)
if __name__ == "__main__":
main()