586 lines
18 KiB
Python
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()
|