#!/usr/bin/env python3 """Build and inspect the household assembly without creating a signing identity.""" import argparse import datetime import hashlib import json import os import re import shutil import subprocess import zipfile from pathlib import Path PACKAGE = "org.justinvisser.wolfskin.tv.opt" SIGNER = "182bac72e292b9ea4d3d4e6383f92eb2161fa28ba5afcff0337cb7d5a9d5341d" ABIS = ("armeabi-v7a", "arm64-v8a", "x86_64", "universal") ROOT = Path(__file__).resolve().parent.parent def run(*arguments: str) -> str: """Run a checked command whose arguments contain no credentials.""" return subprocess.check_output(arguments, cwd=ROOT, text=True).strip() def digest(path: Path) -> str: """Hash final artifact bytes without loading an APK into memory.""" with path.open("rb") as stream: return hashlib.file_digest(stream, "sha256").hexdigest() def validate_inputs(version: str, code: int) -> str: """Require a clean immutable source and a code newer than the tracked history.""" if not re.fullmatch(r"\d+\.\d+\.\d+", version) or not 1 <= code <= 2100000000: raise ValueError("Supply a plain VERSION and a positive Android VERSION_CODE") if run("git", "status", "--porcelain"): raise ValueError( "Release source must be clean, including the version-code ledger" ) commit = run("git", "rev-parse", "HEAD") run( "git", "fetch", "--no-tags", "origin", "refs/heads/main:refs/remotes/origin/main", ) used = [] for reference in (commit, "refs/remotes/origin/main"): if run("git", "ls-tree", "--name-only", reference, "docker/tv-version-codes"): ledger = run("git", "show", f"{reference}:docker/tv-version-codes") used.extend( int(line.split()[0]) for line in ledger.splitlines() if line and not line.startswith("#") ) reservations = run( "git", "for-each-ref", "--format=%(refname:strip=3)", "refs/wolfskin-tv/version-codes/", ) used.extend(int(value) for value in reservations.splitlines()) if code <= max(used): raise ValueError(f"VERSION_CODE must exceed reserved maximum {max(used)}") if list((ROOT / "app/libs").glob("*.aar")): raise ValueError("Local AAR overrides are forbidden for household releases") return commit def reserve_code(code: int, commit: str) -> None: """Keep a consumed code reserved even if its working-tree ledger append is discarded.""" run("git", "update-ref", f"refs/wolfskin-tv/version-codes/{code}", commit, "") def inspect_apk(path: Path, version: str, code: int, abi: str, proof: Path) -> int: """Verify identity, final signature, optimization and playback-complete native inventory.""" image = os.environ.get("WOLFSKIN_BUILD_IMAGE", "wolfskin-build:sdk37-jdk21") mount = f"type=bind,src={path.parent},dst=/artifacts,readonly" base = ( "docker", "run", "--rm", "--platform", "linux/amd64", "--mount", mount, image, ) badging = run(*base, "aapt2", "dump", "badging", f"/artifacts/{path.name}") certificate = run( *base, "apksigner", "verify", "--verbose", "--print-certs", f"/artifacts/{path.name}", ) proof.write_text(badging + "\n" + certificate + "\n") package = re.search( r"package: name='([^']+)' versionCode='(\d+)' versionName='([^']+)'", badging ) if package is None or package.groups() != (PACKAGE, str(code), version): raise ValueError(f"Wrong APK identity: {path.name}") fingerprints = re.findall( r"Signer #\d+ certificate SHA-256 digest: ([0-9a-fA-F]+)", certificate ) if fingerprints != [SIGNER]: raise ValueError(f"Wrong household signer: {path.name}") if "application-debuggable" in badging: raise ValueError("Household APK must not be debuggable") minimum = re.search(r"sdkVersion:'(\d+)'", badging) if minimum is None: raise ValueError("APK minimum SDK is missing") with zipfile.ZipFile(path) as archive: names = archive.namelist() for profile in ("assets/dexopt/baseline.prof", "assets/dexopt/baseline.profm"): if profile not in names: raise ValueError(f"Missing optimized profile: {profile}") if any( name.startswith(("assets/wolfskin-", "assets/library/")) for name in names ): raise ValueError("Debug fixtures leaked into the household assembly") for target in ABIS[:-1] if abi == "universal" else (abi,): libraries = [name for name in names if name.startswith(f"lib/{target}/")] for component in ("mpv", "ffmpeg", "av1"): if not any(component in name.lower() for name in libraries): raise ValueError(f"Missing native {component} for {target}") proof.with_suffix(".inventory").write_text("\n".join(names) + "\n") return int(minimum.group(1)) def release(version: str, code: int, output: Path, dry_run: bool) -> None: """Produce named APKs, inspected metadata, checksums and source-linked release notes.""" commit = validate_inputs(version, code) tag = f"pwolfskin-opt-v{version}" key = Path( os.environ.get( "WOLFSKIN_DEBUG_KEYSTORE", "/root/.treehouse/firstmate-7bab20/2/firstmate/data/signing/debug.keystore", ) ) if not key.is_file() or key.stat().st_size == 0: raise ValueError( "Existing household signing key required; never generate a replacement" ) if output.exists(): raise ValueError("Release output must not exist; preserve previous proof") if dry_run: print( f"Dry run: source {commit}; defaultOptimized {version} code {code}; four APKs, manifest, notes and SHA256SUMS; no build, tag or publication" ) return existing = run("git", "tag", "--list", tag) if existing and run("git", "rev-parse", f"{tag}^{{commit}}") != commit: raise ValueError("Source tag already identifies another commit") reserve_code(code, commit) output.mkdir(parents=True) private = output / "private" private.mkdir(mode=0o700) with (private / "build.log").open("w") as log: subprocess.run( [ "bash", "docker/gradle.sh", "household", f"-PtvVersion={version}", f"-PtvVersionCode={code}", "assembleDefaultOptimized", ":app:dependencies", "--configuration", "defaultOptimizedRuntimeClasspath", ], cwd=ROOT, check=True, stdout=log, stderr=subprocess.STDOUT, ) assets: list[dict[str, object]] = [] for abi in ABIS: suffix = "" if abi == "universal" else f"-{abi}" source = ( ROOT / f"app/build/outputs/apk/default/optimized/Wholphin-default-optimized-{version}-{code}{suffix}.apk" ) target = output / f"Wolfskin{suffix}.apk" shutil.copyfile(source, target) minimum = inspect_apk(target, version, code, abi, private / f"{abi}.inspection") assets.append( { "abi": abi, "name": target.name, "bytes": target.stat().st_size, "sha256": digest(target), "minSdk": minimum, } ) mapping = ROOT / "app/build/outputs/mapping/defaultOptimized" shutil.copytree(mapping, private / "mapping") manifest = { "schemaVersion": 1, "packageId": PACKAGE, "versionName": version, "versionCode": code, "publishedAt": datetime.datetime.now(datetime.UTC) .isoformat() .replace("+00:00", "Z"), "sourceCommit": commit, "sourceTag": tag, "signerSha256": SIGNER, "assets": assets, } (output / "wolfskin-update.json").write_text(json.dumps(manifest, indent=2) + "\n") source = f"https://git.justinvisser.org/justinzeus/wolfskin/src/tag/{tag}" (output / "RELEASE-NOTES.md").write_text( f"# Wolfskin TV {version}\n\nIn-app verified household updates with explicit Android confirmation.\n\n" f"Package `{PACKAGE}`, code {code}, signer SHA-256 `{SIGNER}`.\n" "Same-package, same-signer upgrades retain app data; never uninstall or clear data to update. " "An earlier ephemeral signer needs a separately authorized migration, not an automatic reinstall.\n\n" "Android 6 (API 23) or later. Standalone armeabi-v7a, arm64-v8a and x86_64 APKs; " "universal fallback. 32-bit x86 is not MPV-complete and is unsupported for playback. " "MPV, ffmpeg and AV1 extensions: pinned Wholphin extensions 0.2.4.\n\n" f"GPL-2.0-only; Wolfskin derives from Wholphin by damontecres and contributors. " f"Corresponding source: [{commit}]({source}); " f"[source archive](https://git.justinvisser.org/justinzeus/wolfskin/archive/{tag}.tar.gz).\n" "[Upstream Wholphin](https://github.com/damontecres/wholphin), " "[native extension source 0.2.4](https://github.com/damontecres/wholphin-extensions/tree/v0.2.4).\n" ) files = sorted(path for path in output.iterdir() if path.is_file()) (output / "SHA256SUMS").write_text( "".join(f"{digest(path)} {path.name}\n" for path in files) ) if not existing: run("git", "tag", tag, commit) with (ROOT / "docker/tv-version-codes").open("a") as ledger: ledger.write(f"{code} {version} {commit}\n") print( f"Release packaged at {output}; commit the code ledger and push source tag {tag} before distribution. Nothing published." ) def main() -> None: """Expose the make tv-release build contract.""" parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--version", required=True) parser.add_argument("--version-code", required=True, type=int) parser.add_argument("--output", type=Path, required=True) parser.add_argument("--dry-run", action="store_true") args = parser.parse_args() release(args.version, args.version_code, args.output.resolve(), args.dry_run) if __name__ == "__main__": main()