254 lines
10 KiB
Python
Executable file
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()
|