gh cli release script
This commit is contained in:
5
Makefile
5
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
|
||||
|
||||
|
||||
345
scripts/release.py
Normal file
345
scripts/release.py
Normal file
@@ -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} <assets>")
|
||||
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()
|
||||
Reference in New Issue
Block a user