Implement verified household TV updates and release tooling
This commit is contained in:
parent
152dac97c0
commit
d171aaae9e
29 changed files with 2485 additions and 99 deletions
229
docker/tv_release.py
Executable file
229
docker/tv_release.py
Executable file
|
|
@ -0,0 +1,229 @@
|
|||
#!/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"
|
||||
)
|
||||
used = [
|
||||
int(line.split()[0])
|
||||
for line in (ROOT / "docker/tv-version-codes").read_text().splitlines()
|
||||
if line and not line.startswith("#")
|
||||
]
|
||||
if code <= max(used):
|
||||
raise ValueError(f"VERSION_CODE must exceed ledger maximum {max(used)}")
|
||||
if list((ROOT / "app/libs").glob("*.aar")):
|
||||
raise ValueError("Local AAR overrides are forbidden for household releases")
|
||||
return run("git", "rev-parse", "HEAD")
|
||||
|
||||
|
||||
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")
|
||||
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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue