wolfskin/docker/tv_release.py

254 lines
10 KiB
Python
Executable file

#!/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()