gh cli release script

This commit is contained in:
Richard Macias
2026-02-14 11:19:02 -06:00
parent fc864f75f4
commit 134e19922b
3 changed files with 310 additions and 73 deletions

1
.gitignore vendored
View File

@@ -2,3 +2,4 @@
.notes/ .notes/
assets/ assets/
__pycache__ __pycache__
release-notes-*.md

View File

@@ -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 default: help
help: # Show help for each of the makefile recipes. help: # Show help for each of the makefile recipes.
@grep -E '^[a-zA-Z0-9 -]+:.*#' Makefile | sort | while read -r l; \ @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 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. # ---------------------------------------------------------------------------
# Apps
validate: # Validate applications.json for errors # ---------------------------------------------------------------------------
@python scripts/validate-json.py src/applications.json
add-app: # Interactive CLI to add a new app add-app: # Interactive CLI to add a new app
@python scripts/add-app.py @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 normalize: # Normalize key order and add missing defaults in applications.json
@python scripts/normalize-json.py src/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 minify: # Generate standard release JSON
@python scripts/minify-json.py src/applications.json obtainium-emulation-pack-latest.json --variant standard @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 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 @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. table: # Generate a table of obtainium links for the README.
@python scripts/generate-table.py src/applications.json ./pages/table.md @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/table.md \
./pages/faq.md \ ./pages/faq.md \
./pages/development.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)

View File

@@ -5,11 +5,11 @@ the publish side: tagging, pushing, and creating the GitHub release.
Workflow: Workflow:
1. Prompt for version (suggests next based on latest tag) 1. Prompt for version (suggests next based on latest tag)
2. Copy minified JSONs to versioned filenames 2. Detect changed apps since last tag
3. Create git tag 3. Generate release notes with summary + app update table
4. Push tag to origin 4. Open in $EDITOR for final edits
5. Create GitHub release with `gh release create` 5. Copy minified JSONs to versioned filenames
6. Clean up versioned copies 6. Create git tag, push, and create GitHub release
Usage: Usage:
make release # build artifacts first make release # build artifacts first
@@ -20,24 +20,30 @@ Requires: gh (GitHub CLI), git, python3
import argparse import argparse
import json import json
import os
import re import re
import shutil import shutil
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import urllib.parse
from collections import defaultdict
from pathlib import Path from pathlib import Path
from typing import Any
REPO_ROOT = Path(__file__).resolve().parent.parent REPO_ROOT = Path(__file__).resolve().parent.parent
# Release artifact paths (relative to repo root) # Release artifact paths (relative to repo root)
STANDARD_JSON = REPO_ROOT / "obtainium-emulation-pack-latest.json" STANDARD_JSON = REPO_ROOT / "obtainium-emulation-pack-latest.json"
DUAL_SCREEN_JSON = REPO_ROOT / "obtainium-emulation-pack-dual-screen-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+)$") SEMVER_PATTERN = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$")
# Helpers
def run(cmd: list[str], capture: bool = False, check: bool = True) -> subprocess.CompletedProcess: def run(cmd: list[str], capture: bool = False, check: bool = True) -> subprocess.CompletedProcess:
"""Run a command, optionally capturing output."""
return subprocess.run( return subprocess.run(
cmd, cmd,
cwd=REPO_ROOT, cwd=REPO_ROOT,
@@ -48,21 +54,20 @@ def run(cmd: list[str], capture: bool = False, check: bool = True) -> subprocess
def check_prerequisites() -> None: def check_prerequisites() -> None:
"""Verify required tools are installed."""
for tool in ("gh", "git"): for tool in ("gh", "git"):
if shutil.which(tool) is None: if shutil.which(tool) is None:
print(f"Error: '{tool}' is not installed. Install it first.") print(f"Error: '{tool}' is not installed. Install it first.")
sys.exit(1) sys.exit(1)
# Verify gh is authenticated
result = run(["gh", "auth", "status"], capture=True, check=False) result = run(["gh", "auth", "status"], capture=True, check=False)
if result.returncode != 0: if result.returncode != 0:
print("Error: gh is not authenticated. Run `gh auth login` first.") print("Error: gh is not authenticated. Run `gh auth login` first.")
sys.exit(1) sys.exit(1)
# Version helpers
def get_latest_tag() -> str | None: 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) result = run(["git", "tag", "--sort=-v:refname"], capture=True, check=False)
if result.returncode != 0: if result.returncode != 0:
return None return None
@@ -75,7 +80,6 @@ def get_latest_tag() -> str | None:
def parse_semver(tag: str) -> tuple[int, int, int]: def parse_semver(tag: str) -> tuple[int, int, int]:
"""Parse a semver tag into (major, minor, patch)."""
match = SEMVER_PATTERN.match(tag) match = SEMVER_PATTERN.match(tag)
if not match: if not match:
raise ValueError(f"Invalid semver tag: {tag}") 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]: def suggest_versions(latest: str | None) -> dict[str, str]:
"""Suggest next patch, minor, and major versions."""
if latest is None: if latest is None:
return {"patch": "v0.0.1", "minor": "v0.1.0", "major": "v1.0.0"} 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: def prompt_version(latest: str | None) -> str:
"""Interactively prompt for the release version."""
suggestions = suggest_versions(latest) suggestions = suggest_versions(latest)
print() print()
@@ -133,53 +135,231 @@ def prompt_version(latest: str | None) -> str:
print("Invalid choice. Enter 1, 2, 3, or 4.") print("Invalid choice. Enter 1, 2, 3, or 4.")
def prompt_release_notes() -> str: # App diff detection
"""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() 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": data = json.loads(result.stdout)
print() return {app["id"]: app for app in data.get("apps", [])}
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) def load_apps_from_file() -> dict[str, dict[str, Any]]:
with open(tmp_path, "r") as f: with open(APPLICATIONS_JSON, "r", encoding="utf-8") as f:
notes = f.read().strip() data = json.load(f)
Path(tmp_path).unlink(missing_ok=True) return {app["id"]: app for app in data.get("apps", [])}
return notes
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'<a href="{get_application_url(app)}">{get_display_name(app)}</a>'
obtainium_link = make_obtainium_link(app)
badge = f'<a href="{obtainium_link}">Add to Obtainium!</a>'
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 "" 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: def check_working_tree_clean() -> bool:
"""Check if the git working tree is clean."""
result = run(["git", "status", "--porcelain"], capture=True) result = run(["git", "status", "--porcelain"], capture=True)
return result.stdout.strip() == "" return result.stdout.strip() == ""
def create_versioned_copies(version: str) -> list[Path]: def create_versioned_copies(version: str) -> list[Path]:
"""Copy release JSONs to versioned filenames for upload."""
copies: list[Path] = [] copies: list[Path] = []
standard_versioned = REPO_ROOT / f"obtainium-emulation-pack-{version}.json" 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(standard_versioned)
copies.append(dual_versioned) copies.append(dual_versioned)
return copies return copies
def create_tag(version: str) -> None: def create_tag(version: str) -> None:
"""Create and push a git tag."""
print(f"Creating tag {version}...") print(f"Creating tag {version}...")
run(["git", "tag", version]) run(["git", "tag", version])
print(f"Pushing tag {version} to origin...") 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: def create_github_release(version: str, notes: str, assets: list[Path]) -> None:
"""Create a GitHub release via gh CLI."""
cmd = ["gh", "release", "create", version] cmd = ["gh", "release", "create", version]
if notes: if notes:
@@ -221,13 +398,11 @@ def create_github_release(version: str, notes: str, assets: list[Path]) -> None:
def cleanup(files: list[Path]) -> None: def cleanup(files: list[Path]) -> None:
"""Remove temporary versioned copies."""
for f in files: for f in files:
f.unlink(missing_ok=True) f.unlink(missing_ok=True)
def get_app_count(json_path: Path) -> int: def get_app_count(json_path: Path) -> int:
"""Count apps in a release JSON file."""
try: try:
with open(json_path, "r") as f: with open(json_path, "r") as f:
data = json.load(f) data = json.load(f)
@@ -236,12 +411,29 @@ def get_app_count(json_path: Path) -> int:
return 0 return 0
# Main
def main() -> None: def main() -> None:
parser = argparse.ArgumentParser(description="Create a GitHub release for Obtainium Emulation Pack") parser = argparse.ArgumentParser(
parser.add_argument("--version", "-v", help="Release version (e.g. v7.5.0). Prompts if not provided.") description="Create a GitHub release for Obtainium Emulation Pack"
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(
parser.add_argument("--dry-run", action="store_true", help="Show what would happen without making changes.") "--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() args = parser.parse_args()
check_prerequisites() check_prerequisites()
@@ -265,14 +457,36 @@ def main() -> None:
print(f"Error: Tag {version} already exists.") print(f"Error: Tag {version} already exists.")
sys.exit(1) 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 # Determine release notes
notes = ""
if args.notes_file: if args.notes_file:
notes = Path(args.notes_file).read_text().strip() notes = Path(args.notes_file).read_text().strip()
elif args.notes: elif args.notes:
notes = args.notes notes = args.notes
elif not args.dry_run: else:
notes = prompt_release_notes() # 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 # Dry run summary
if args.dry_run: if args.dry_run:
@@ -280,7 +494,6 @@ def main() -> None:
print("=== DRY RUN ===") print("=== DRY RUN ===")
print(f" Version: {version}") print(f" Version: {version}")
print(f" Latest tag: {latest or '(none)'}") print(f" Latest tag: {latest or '(none)'}")
print(f" Notes: {'(auto-generated)' if not notes else notes[:80] + '...'}")
print() print()
print(" Would run:") print(" Would run:")
print(f" 1. git tag {version}") print(f" 1. git tag {version}")
@@ -297,17 +510,20 @@ def main() -> None:
for f in (STANDARD_JSON, DUAL_SCREEN_JSON): for f in (STANDARD_JSON, DUAL_SCREEN_JSON):
if not f.exists(): if not f.exists():
print(f"Error: Expected artifact not found: {f}") print(f"Error: Expected artifact not found: {f}")
print("Did you run `make release` first?")
sys.exit(1) sys.exit(1)
# Show summary before proceeding # Show summary before proceeding
std_count = get_app_count(STANDARD_JSON) std_count = get_app_count(STANDARD_JSON)
ds_count = get_app_count(DUAL_SCREEN_JSON) ds_count = get_app_count(DUAL_SCREEN_JSON)
print()
print(f"Version: {version}") print(f"Version: {version}")
print(f"Standard apps: {std_count}") print(f"Standard apps: {std_count}")
print(f"Dual-screen: {ds_count}") print(f"Dual-screen: {ds_count}")
if notes: 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: else:
print("Notes: (auto-generated from commits)") print("Notes: (auto-generated from commits)")
print() print()
@@ -317,10 +533,10 @@ def main() -> None:
print("Aborted.") print("Aborted.")
sys.exit(0) 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(): if not check_working_tree_clean():
print() print()
print("Working tree has changes (from build). Committing...") print("Working tree has changes. Committing...")
run(["git", "add", "-A"]) run(["git", "add", "-A"])
run(["git", "commit", "-m", f"release: {version}"]) run(["git", "commit", "-m", f"release: {version}"])
run(["git", "push"]) run(["git", "push"])
@@ -329,7 +545,6 @@ def main() -> None:
versioned_copies = create_versioned_copies(version) versioned_copies = create_versioned_copies(version)
try: try:
# Tag and release
create_tag(version) create_tag(version)
create_github_release(version, notes, versioned_copies) create_github_release(version, notes, versioned_copies)
@@ -337,7 +552,6 @@ def main() -> None:
print(f"Release {version} created successfully!") print(f"Release {version} created successfully!")
print(f"https://github.com/RJNY/Obtainium-Emulation-Pack/releases/tag/{version}") print(f"https://github.com/RJNY/Obtainium-Emulation-Pack/releases/tag/{version}")
finally: finally:
# Always clean up versioned copies
cleanup(versioned_copies) cleanup(versioned_copies)