gh cli release script
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,3 +2,4 @@
|
|||||||
.notes/
|
.notes/
|
||||||
assets/
|
assets/
|
||||||
__pycache__
|
__pycache__
|
||||||
|
release-notes-*.md
|
||||||
|
|||||||
40
Makefile
40
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
|
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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user