diff --git a/Makefile b/Makefile index 186aa78..e254b2d 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help all readme validate add-app normalize +.PHONY: help all readme validate add-app normalize publish default: help help: # Show help for each of the makefile recipes. @@ -25,6 +25,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 + table: # Generate a table of obtainium links for the README. @python scripts/generate-table.py src/applications.json ./pages/table.md diff --git a/scripts/release.py b/scripts/release.py new file mode 100644 index 0000000..c2a1d64 --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,345 @@ +"""Create a GitHub release with tagged JSON artifacts. + +Expects `make release` 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. 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 + +Usage: + make release # build artifacts first + make publish # then publish + +Requires: gh (GitHub CLI), git, python3 +""" + +import argparse +import json +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +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" + +SEMVER_PATTERN = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$") + + +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, + capture_output=capture, + text=True, + check=check, + ) + + +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) + + +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 + + 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]: + """Parse a semver tag into (major, minor, patch).""" + 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]: + """Suggest next patch, minor, and major versions.""" + 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: + """Interactively prompt for the release version.""" + 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 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() + + choice = input("Select [1/2/3]: ").strip() + + 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) + + 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 + + else: + return "" + + +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" + 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: + """Create and push a git tag.""" + print(f"Creating tag {version}...") + run(["git", "tag", version]) + print(f"Pushing tag {version} to origin...") + run(["git", "push", "origin", version]) + + +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: + 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) + + +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) + return len(data.get("apps", [])) + except Exception: + return 0 + + +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.") + args = parser.parse_args() + + check_prerequisites() + + latest = get_latest_tag() + + # Determine version + 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) + + # Check if tag already exists + 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) + + # 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() + + # Dry run summary + if args.dry_run: + print() + 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}") + print(f" 2. git push origin {version}") + print(f" 3. gh release create {version} --title {version} ") + print() + print(" Assets:") + print(f" - obtainium-emulation-pack-{version}.json") + print(f" - obtainium-emulation-pack-dual-screen-{version}.json") + print() + return + + # Verify artifacts exist + for f in (STANDARD_JSON, DUAL_SCREEN_JSON): + if not f.exists(): + print(f"Error: Expected artifact not found: {f}") + sys.exit(1) + + # Show summary before proceeding + std_count = get_app_count(STANDARD_JSON) + ds_count = get_app_count(DUAL_SCREEN_JSON) + + 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 ''}") + 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) + + # Stage, commit any build changes if working tree is dirty + if not check_working_tree_clean(): + print() + print("Working tree has changes (from build). Committing...") + run(["git", "add", "-A"]) + run(["git", "commit", "-m", f"release: {version}"]) + run(["git", "push"]) + + # Create versioned copies for upload + versioned_copies = create_versioned_copies(version) + + try: + # Tag and release + create_tag(version) + create_github_release(version, notes, versioned_copies) + + print() + 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) + + +if __name__ == "__main__": + main()