NuvioTV/scripts/release_beta.py
2026-03-24 21:29:00 +05:30

664 lines
21 KiB
Python
Executable file

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
BUILD_FILE = ROOT / "app" / "build.gradle.kts"
RELEASE_OUTPUT_DIR = ROOT / "build" / "release"
APK_DIR = ROOT / "app" / "build" / "outputs" / "apk" / "release"
DEFAULT_BETA_NOTICE = (
"## This is a beta version intended for testing only. Expect breaking changes "
"in updates. Normal users are advised to wait for the stable release."
)
EXPECTED_ASSET_NAMES = [
"app-arm64-v8a-release.apk",
"app-armeabi-v7a-release.apk",
"app-x86_64-release.apk",
"app-x86-release.apk",
"app-universal-release.apk",
]
VERSION_NAME_RE = re.compile(r'(?m)^(\s*versionName\s*=\s*")([^"]+)(")')
VERSION_CODE_RE = re.compile(r"(?m)^(\s*versionCode\s*=\s*)(\d+)")
VERSION_PATTERN = re.compile(r"^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$")
PREFIX_RE = re.compile(
r"^(feat|fix|ref|refactor|perf|ui|ux|build|style|docs|test|ci|chore)"
r"(\([^)]+\))?:\s*",
re.IGNORECASE,
)
LEADING_ISSUE_RE = re.compile(
r"^(fixe?[sd]?|close[sd]?|resolve[sd]?|address(?:e[sd])?)\s*#\d+\s*,?\s*",
re.IGNORECASE,
)
DROP_PATTERNS = (
re.compile(r"^merge\b", re.IGNORECASE),
re.compile(r"^revert\b", re.IGNORECASE),
re.compile(r"^docs?[:\s]", re.IGNORECASE),
re.compile(r"^ci[:\s]", re.IGNORECASE),
re.compile(r"^test[:\s]", re.IGNORECASE),
re.compile(r"^chore[:\s]", re.IGNORECASE),
re.compile(r"^bump\b", re.IGNORECASE),
re.compile(r"^update .*translation", re.IGNORECASE),
re.compile(r"^merge .*translation", re.IGNORECASE),
re.compile(r"^close unlabeled issues workflow", re.IGNORECASE),
re.compile(r"^update issue reporting title rule", re.IGNORECASE),
re.compile(r"^merge conflict$", re.IGNORECASE),
re.compile(r"^update .* version to ", re.IGNORECASE),
re.compile(r"^update .*\.(kt|java|xml|gradle\.kts?)$", re.IGNORECASE),
re.compile(r"baselineprofile", re.IGNORECASE),
)
TRIM_TOKENS = ("cw", "wip")
WORD_REPLACEMENTS = (
(re.compile(r"\bcw\b", re.IGNORECASE), ""),
(re.compile(r"\bhomelayouts\b", re.IGNORECASE), "Home layouts"),
(re.compile(r"\bshowInHome param\b", re.IGNORECASE), "`showInHome` parameter"),
(re.compile(r"\bui\b"), "UI"),
(re.compile(r"\btrakt\b", re.IGNORECASE), "Trakt"),
(re.compile(r"\bnuvio\b", re.IGNORECASE), "Nuvio"),
)
ASSET_ORDER = {
"arm64-v8a": 0,
"armeabi-v7a": 1,
"x86_64": 2,
"x86": 3,
"universal": 4,
}
def run(
*args: str,
check: bool = True,
capture_output: bool = True,
cwd: Path = ROOT,
env: dict[str, str] | None = None,
) -> subprocess.CompletedProcess[str]:
return subprocess.run(
args,
cwd=cwd,
check=check,
text=True,
capture_output=capture_output,
env=env,
)
def git(*args: str, check: bool = True) -> str:
return run("git", *args, check=check).stdout.strip()
def require_tool(name: str) -> None:
if shutil.which(name) is None:
raise SystemExit(f"Missing required tool: {name}")
def read_build_file() -> str:
return BUILD_FILE.read_text(encoding="utf-8-sig")
def parse_versions(contents: str) -> tuple[str, int]:
version_match = VERSION_NAME_RE.search(contents)
code_match = VERSION_CODE_RE.search(contents)
if version_match is None or code_match is None:
raise SystemExit(f"Failed to find versionName/versionCode in {BUILD_FILE}")
return version_match.group(2), int(code_match.group(2))
def updated_build_file(contents: str, version_name: str, version_code: int) -> str:
contents, version_replacements = VERSION_NAME_RE.subn(
rf"\g<1>{version_name}\g<3>", contents, count=1
)
contents, code_replacements = VERSION_CODE_RE.subn(
rf"\g<1>{version_code}", contents, count=1
)
if version_replacements != 1 or code_replacements != 1:
raise SystemExit(f"Failed to update versionName/versionCode in {BUILD_FILE}")
return contents
def write_build_file(contents: str) -> None:
BUILD_FILE.write_text(contents, encoding="utf-8-sig")
def last_tag() -> str | None:
result = run("git", "describe", "--tags", "--abbrev=0", check=False)
tag = result.stdout.strip()
return tag or None
def release_range(previous_tag: str | None) -> str | None:
if previous_tag:
return f"{previous_tag}..HEAD"
return None
def commit_subjects(previous_tag: str | None) -> list[str]:
args = ["log", "--reverse", "--pretty=format:%s"]
revision_range = release_range(previous_tag)
if revision_range:
args.append(revision_range)
output = git(*args)
return [line.strip() for line in output.splitlines() if line.strip()]
def normalize_subject(subject: str) -> str | None:
if not subject:
return None
candidate = subject.strip()
for pattern in DROP_PATTERNS:
if pattern.search(candidate):
return None
candidate = PREFIX_RE.sub("", candidate)
while True:
updated = LEADING_ISSUE_RE.sub("", candidate)
if updated == candidate:
break
candidate = updated
candidate = candidate.strip(" .")
if not candidate:
return None
if candidate.lower().startswith(("with ", "one more ", "trying ")):
return None
words = candidate.split()
while words and words[-1].lower() in TRIM_TOKENS:
words.pop()
candidate = " ".join(words).strip()
if not candidate:
return None
for pattern, replacement in WORD_REPLACEMENTS:
candidate = pattern.sub(replacement, candidate)
candidate = re.sub(r"\s{2,}", " ", candidate).strip()
if not candidate:
return None
if candidate.islower():
candidate = candidate[:1].upper() + candidate[1:]
return candidate
def dedupe_keep_latest(items: list[str], limit: int) -> list[str]:
deduped: list[str] = []
seen: set[str] = set()
for item in items:
key = item.casefold()
if key in seen:
continue
seen.add(key)
deduped.append(item)
if limit > 0 and len(deduped) > limit:
deduped = deduped[-limit:]
return deduped
def parse_extra_notes(notes_text: str | None, extra_lines: list[str]) -> list[str]:
parsed: list[str] = []
if notes_text:
for raw_line in notes_text.splitlines():
line = raw_line.strip()
if not line:
continue
if line.startswith("- "):
line = line[2:].strip()
parsed.append(line)
parsed.extend(line.strip() for line in extra_lines if line.strip())
return parsed
def build_release_notes(
previous_tag: str | None,
max_items: int,
downloader_code: str | None,
extra_notes: str | None,
extra_lines: list[str],
custom_notes: str | None,
) -> str:
if custom_notes and custom_notes.strip():
return custom_notes.strip() + "\n"
normalized = [
note
for note in (normalize_subject(subject) for subject in commit_subjects(previous_tag))
if note
]
bullet_items = dedupe_keep_latest(normalized, max_items)
bullet_items.extend(parse_extra_notes(extra_notes, extra_lines))
if not bullet_items:
bullet_items = ["Beta maintenance update"]
lines = [DEFAULT_BETA_NOTICE, "", "### Improvements & Fixes"]
lines.extend(f"- {item}" for item in bullet_items)
if downloader_code:
lines.extend(["", f"### Downloader Code - {downloader_code.strip()}"])
return "\n".join(lines).strip() + "\n"
def release_notes_path(version_name: str) -> Path:
RELEASE_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
safe_name = version_name.replace("/", "-")
return RELEASE_OUTPUT_DIR / f"release-notes-{safe_name}.md"
def write_release_notes(version_name: str, notes: str) -> Path:
path = release_notes_path(version_name)
path.write_text(notes, encoding="utf-8")
return path
def append_job_summary(
*,
mode: str,
version_name: str,
release_tag: str,
release_title: str,
commit_message: str,
version_code: int,
previous_tag: str | None,
notes_path: Path,
assets: list[Path] | None,
) -> None:
summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
if not summary_path:
return
lines = [
"## Beta Release Preview",
"",
f"- Mode: `{mode}`",
f"- Version: `{version_name}`",
f"- Tag: `{release_tag}`",
f"- Title: `{release_title}`",
f"- Commit message: `{commit_message}`",
f"- versionCode: `{version_code}`",
f"- Previous tag: `{previous_tag or 'none'}`",
f"- Notes file: `{notes_path.relative_to(ROOT)}`",
"",
"### Release Notes",
"",
notes_path.read_text(encoding="utf-8").strip(),
"",
"### Assets",
]
if assets:
lines.extend(f"- `{path.name}`" for path in assets)
else:
lines.extend(f"- `{name}`" for name in EXPECTED_ASSET_NAMES)
lines.append("")
with open(summary_path, "a", encoding="utf-8") as handle:
handle.write("\n".join(lines))
def current_branch() -> str:
branch = git("rev-parse", "--abbrev-ref", "HEAD")
if branch == "HEAD":
branch = os.environ.get("GITHUB_REF_NAME", "").strip()
if not branch:
raise SystemExit("Unable to determine the current branch for publishing")
return branch
def ensure_clean_worktree() -> None:
status = git("status", "--short")
if status:
raise SystemExit(
"Refusing to publish from a dirty worktree. Commit or stash changes first."
)
def ensure_version_available(release_tag: str) -> None:
tag_check = run(
"git", "rev-parse", "-q", "--verify", f"refs/tags/{release_tag}", check=False
)
if tag_check.returncode == 0:
raise SystemExit(f"Tag already exists: {release_tag}")
def build_release() -> list[Path]:
subprocess.run(
["./gradlew", "app:assembleRelease"],
cwd=ROOT,
check=True,
text=True,
)
assets = sorted(
APK_DIR.glob("*.apk"),
key=lambda path: next(
(order for token, order in ASSET_ORDER.items() if token in path.name),
999,
),
)
if not assets:
raise SystemExit(f"No APK assets found in {APK_DIR}")
return assets
def commit_tag_push(
release_tag: str,
release_title: str,
commit_message: str,
branch_name: str,
) -> None:
subprocess.run(["git", "add", str(BUILD_FILE.relative_to(ROOT))], cwd=ROOT, check=True)
subprocess.run(
["git", "commit", "-m", commit_message],
cwd=ROOT,
check=True,
text=True,
)
subprocess.run(
["git", "tag", "-a", release_tag, "-m", f"Release {release_title}"],
cwd=ROOT,
check=True,
text=True,
)
subprocess.run(["git", "push", "origin", f"HEAD:{branch_name}"], cwd=ROOT, check=True)
subprocess.run(["git", "push", "origin", release_tag], cwd=ROOT, check=True)
def tag_push(
release_tag: str,
release_title: str,
branch_name: str,
) -> None:
subprocess.run(
["git", "tag", "-a", release_tag, "-m", f"Release {release_title}"],
cwd=ROOT,
check=True,
text=True,
)
subprocess.run(["git", "push", "origin", f"HEAD:{branch_name}"], cwd=ROOT, check=True)
subprocess.run(["git", "push", "origin", release_tag], cwd=ROOT, check=True)
def create_github_release(
release_tag: str,
release_title: str,
notes_path: Path,
assets: list[Path],
*,
draft: bool,
) -> None:
require_tool("gh")
command = [
"gh",
"release",
"create",
release_tag,
*[str(asset) for asset in assets],
"--title",
release_title,
"--notes-file",
str(notes_path),
]
if draft:
command.append("--draft")
else:
command.append("--latest")
subprocess.run(command, cwd=ROOT, check=True, text=True)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Preview, draft, or publish a beta GitHub release by either bumping the app "
"version or manually supplying the release tag/title, then building APKs and "
"generating release notes from recent commits."
)
)
parser.add_argument(
"version",
nargs="?",
help="Target versionName, for example 0.4.19-beta. Required unless --manual-release is set.",
)
parser.add_argument(
"--manual-release",
action="store_true",
help="Skip changing Gradle version metadata and publish using the provided tag/title.",
)
parser.add_argument(
"--version-code",
type=int,
help="Explicit versionCode to write. Defaults to current versionCode + 1. Ignored in manual mode.",
)
parser.add_argument(
"--release-tag",
help="Git tag to create for the release. Defaults to the target versionName.",
)
parser.add_argument(
"--release-title",
help="GitHub release title. Defaults to the release tag.",
)
parser.add_argument(
"--commit-message",
help="Git commit message for the version bump. Defaults to 'release: <release-tag>'.",
)
parser.add_argument(
"--custom-notes",
help="Full release notes markdown. When set, it replaces the generated notes entirely.",
)
parser.add_argument(
"--custom-notes-file",
help="Path to a markdown file whose contents replace the generated release notes entirely.",
)
parser.add_argument(
"--downloader-code",
help="Optional release-note footer value for the downloader code",
)
parser.add_argument(
"--extra-notes",
help="Optional multi-line text. Each non-empty line is appended as a bullet.",
)
parser.add_argument(
"--extra-line",
action="append",
default=[],
help="Append one extra bullet to the Improvements & Fixes list.",
)
parser.add_argument(
"--max-items",
type=int,
default=10,
help="Maximum number of generated commit bullets to keep.",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show the generated release notes without editing files, building, or publishing.",
)
parser.add_argument(
"--publish",
action="store_true",
help="Build all release APKs, commit the version bump, push tag/branch, and create the GitHub release.",
)
parser.add_argument(
"--draft",
action="store_true",
help="Build all release APKs, commit the version bump, push tag/branch, and create the GitHub release as a draft.",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
selected_modes = [args.dry_run, args.publish, args.draft]
if sum(1 for enabled in selected_modes if enabled) > 1:
raise SystemExit("Use only one of --dry-run, --draft, or --publish.")
if args.custom_notes and args.custom_notes_file:
raise SystemExit("Use either --custom-notes or --custom-notes-file, not both.")
original_contents = read_build_file()
current_version_name, current_version_code = parse_versions(original_contents)
previous_tag = last_tag()
if args.manual_release:
if args.version:
raise SystemExit("Do not provide version when --manual-release is set.")
if args.version_code is not None:
raise SystemExit("--version-code is not supported when --manual-release is set.")
if not args.release_tag:
raise SystemExit("--release-tag is required when --manual-release is set.")
if not args.release_title:
raise SystemExit("--release-title is required when --manual-release is set.")
target_version_name = current_version_name
next_version_code = current_version_code
release_tag = args.release_tag
release_title = args.release_title
commit_message = "manual release: no version bump"
notes_key = release_tag
else:
if not args.version:
raise SystemExit("version is required unless --manual-release is set.")
if not VERSION_PATTERN.match(args.version):
raise SystemExit(f"Invalid version format: {args.version}")
target_version_name = args.version
next_version_code = (
args.version_code if args.version_code is not None else current_version_code + 1
)
if next_version_code < 1:
raise SystemExit("versionCode must be a positive integer.")
release_tag = args.release_tag or target_version_name
release_title = args.release_title or release_tag
commit_message = args.commit_message or f"release: {release_tag}"
notes_key = target_version_name
custom_notes = args.custom_notes
if args.custom_notes_file:
custom_notes = Path(args.custom_notes_file).read_text(encoding="utf-8")
notes = build_release_notes(
previous_tag=previous_tag,
max_items=args.max_items,
downloader_code=args.downloader_code,
extra_notes=args.extra_notes,
extra_lines=args.extra_line,
custom_notes=custom_notes,
)
notes_path = write_release_notes(notes_key, notes)
print(f"Current versionName: {current_version_name}")
print(f"Current versionCode: {current_version_code}")
print(f"Target versionName: {target_version_name}")
print(f"Release tag: {release_tag}")
print(f"Release title: {release_title}")
print(f"Commit message: {commit_message}")
print(f"Target versionCode: {next_version_code}")
print(f"Previous tag: {previous_tag or 'none'}")
print(f"Release notes: {notes_path.relative_to(ROOT)}")
print()
print(notes.strip())
print()
mode = (
"dry-run" if args.dry_run
else "draft" if args.draft
else "publish" if args.publish
else "local-build"
)
if args.dry_run:
print("Dry run expected assets:")
for asset_name in EXPECTED_ASSET_NAMES:
print(f"- {asset_name}")
append_job_summary(
mode=mode,
version_name=target_version_name,
release_tag=release_tag,
release_title=release_title,
commit_message=commit_message,
version_code=next_version_code,
previous_tag=previous_tag,
notes_path=notes_path,
assets=None,
)
return 0
if args.publish or args.draft:
ensure_clean_worktree()
ensure_version_available(release_tag)
updated_contents = original_contents
if not args.manual_release:
updated_contents = updated_build_file(
original_contents,
version_name=target_version_name,
version_code=next_version_code,
)
write_build_file(updated_contents)
print(f"Updated {BUILD_FILE.relative_to(ROOT)}")
assets: list[Path] = []
try:
assets = build_release()
print("Built release assets:")
for asset in assets:
print(f"- {asset.relative_to(ROOT)}")
if args.publish or args.draft:
branch_name = current_branch()
if args.manual_release:
tag_push(release_tag, release_title, branch_name)
else:
commit_tag_push(
release_tag, release_title, commit_message, branch_name
)
create_github_release(
release_tag,
release_title,
notes_path,
assets,
draft=args.draft,
)
if args.draft:
print(
f"Created draft GitHub release {release_tag} "
f"({release_title}) from branch {branch_name}"
)
else:
print(
f"Published GitHub release {release_tag} "
f"({release_title}) from branch {branch_name}"
)
except Exception:
if not (args.publish or args.draft):
write_build_file(original_contents)
raise
append_job_summary(
mode=mode,
version_name=target_version_name,
release_tag=release_tag,
release_title=release_title,
commit_message=commit_message,
version_code=next_version_code,
previous_tag=previous_tag,
notes_path=notes_path,
assets=assets,
)
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except subprocess.CalledProcessError as exc:
if exc.stdout:
sys.stdout.write(exc.stdout)
if exc.stderr:
sys.stderr.write(exc.stderr)
raise SystemExit(exc.returncode) from exc