Files
pebrel/scripts/android_release.py
Kuddev bf2ed9af9b feat(mobile): integrate remote terminal, SSH and reader workflows (#343)
Integrate the Android client with native desktop pairing, remote terminal and SSH/SFTP sessions, keyboard controls, file reading and Agent conversations. Preserve authenticated connection ownership and per-device input permissions.

Validation: all ten required GitHub checks passed, including five native test platforms and both macOS release workspace checks.
2026-09-29 20:09:00 +08:00

155 lines
7.2 KiB
Python

#!/usr/bin/env python3
"""Verify the existing Preview identity and the APK actually tested for a release."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import re
import shutil
import subprocess
import sys
import xml.etree.ElementTree as ET
if __package__:
from scripts.preview_release import sha256, write_atomic
from scripts.stable_release import expected_asset_names
else:
from preview_release import sha256, write_atomic
from stable_release import expected_asset_names
ROOT = Path(__file__).resolve().parents[1]
APPLICATION_ID = "io.github.kuddev.pebrel.mobile.preview"
# 固定当前预览包的证书,防止 CI 悄悄换成 debug、旧预览或新生成的签名。
CERTIFICATE_SHA256 = "93587953edfe9611601768833b5f008ff700d85c2486ddcfb7f7ea82b2de827b"
def asset_name(version: str) -> str:
names = [name for name in expected_asset_names(version) if name.endswith(".apk")]
if len(names) != 1:
raise ValueError("This release requires exactly one Android Preview APK")
return names[0]
def validate_identity(metadata: dict, badging: str, signature: str, version: str) -> int:
elements = metadata.get("elements", [])
if (metadata.get("applicationId") != APPLICATION_ID or metadata.get("variantName") != "preview"
or len(elements) != 1 or elements[0].get("filters") != []
or elements[0].get("outputFile") != "app-preview.apk"):
raise ValueError("Expected the universal Preview APK metadata")
element = elements[0]
code = element.get("versionCode")
# 当前安装基线为 17;本轮发布必须向前递增,不能靠更换文件名伪装升级。
if type(code) is not int or code <= 17 or element.get("versionName") != f"{version}-preview":
raise ValueError("Android version name/code does not advance the Preview release")
package = re.search(r"^package: name='([^']+)' versionCode='([0-9]+)' versionName='([^']+)'", badging, re.M)
if (not package or package.groups() != (APPLICATION_ID, str(code), f"{version}-preview")
or re.search(r"^application-debuggable(?:\s|$)", badging, re.M)):
raise ValueError("Compiled APK identity differs or is debuggable")
certificates = re.findall(r"^Signer #[0-9]+ certificate SHA-256 digest: ([0-9a-fA-F]{64})$", signature, re.M)
if [value.lower() for value in certificates] != [CERTIFICATE_SHA256]:
raise ValueError("APK signer differs from the current Preview certificate")
return code
def test_summary(directory: Path) -> dict:
files = sorted(directory.rglob("TEST-*.xml"))
if not files:
raise ValueError(f"Missing Android test results: {directory.name}")
passed = skipped = 0
for path in files:
root = ET.parse(path).getroot()
if root.findall(".//failure") or root.findall(".//error"):
raise ValueError(f"Android tests failed: {path.name}")
for suite in root.iter("testsuite"):
if int(suite.get("failures", "0")) or int(suite.get("errors", "0")):
raise ValueError(f"Android test suite failed: {path.name}")
for case in root.iter("testcase"):
if case.find("skipped") is not None:
skipped += 1
else:
passed += 1
if not passed:
raise ValueError("Android test report contains no executed passing tests")
return {"passed": passed, "skipped": skipped}
def run(*command: str | Path) -> str:
return subprocess.check_output([str(part) for part in command], text=True, encoding="utf-8", timeout=180)
def package(version: str, commit: str, sdk: Path, output: Path, report: Path) -> None:
if not re.fullmatch(r"[0-9a-f]{40}", commit) or run("git", "rev-parse", "HEAD").strip() != commit:
raise ValueError("Android package source must match the release commit")
app = ROOT / "mobile/android/app/build"
apk = app / "outputs/apk/preview/app-preview.apk"
metadata = json.loads(apk.with_name("output-metadata.json").read_text(encoding="utf-8"))
build_tools = sdk / "build-tools/35.0.0"
badging = run(build_tools / "aapt", "dump", "badging", apk)
signature = run(build_tools / "apksigner", "verify", "--verbose", "--print-certs", apk)
code = validate_identity(metadata, badging, signature, version)
run(build_tools / "zipalign", "-c", "-P", "16", "4", apk)
# 复用已有 ABI、JNI、许可证及内置中继校验,不另造第二套内容清单。
sys.path.insert(0, str(ROOT / "mobile/tools"))
from verify_ghostty_apk import verify as verify_native
from verify_native_relay_apk import verify as verify_relay
native = verify_native(apk)
verify_relay(apk, commit)
tests = {
"unit": test_summary(app / "test-results/testDebugUnitTest"),
"instrumented": test_summary(app / "outputs/androidTest-results/connected"),
}
output.mkdir(parents=True, exist_ok=True)
target = output / asset_name(version)
if target.exists():
raise ValueError("Android release output already exists")
shutil.copyfile(apk, target)
if sha256(target) != native["sha256"]:
raise ValueError("APK changed during release collection")
write_atomic(report, json.dumps({
"schema_version": 1, "status": "passed", "commit": commit,
"version": version, "application_id": APPLICATION_ID, "version_code": code,
"certificate_sha256": CERTIFICATE_SHA256, "apk": target.name,
"sha256": sha256(target), "tests": tests, "native": native,
}, indent=2) + "\n")
def validate_evidence(report: Path, directory: Path, version: str, commit: str) -> None:
data = json.loads(report.read_text(encoding="utf-8"))
name = asset_name(version)
expected = {"schema_version": 1, "status": "passed", "commit": commit,
"version": version, "application_id": APPLICATION_ID, "apk": name,
"certificate_sha256": CERTIFICATE_SHA256, "sha256": sha256(directory / name)}
if any(data.get(key) != value for key, value in expected.items()):
raise ValueError("Android evidence differs from the release APK or source")
code = data.get("version_code")
if type(code) is not int or code <= 17:
raise ValueError("Android evidence does not advance the installed Preview")
for kind in ("unit", "instrumented"):
count = data.get("tests", {}).get(kind, {}).get("passed")
if type(count) is not int or count < 1:
raise ValueError(f"Missing passed Android {kind} tests")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--version", required=True)
parser.add_argument("--commit", required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--report", type=Path, required=True)
parser.add_argument("--sdk", type=Path)
parser.add_argument("--verify-evidence", action="store_true")
args = parser.parse_args()
if args.verify_evidence:
validate_evidence(args.report, args.output, args.version, args.commit)
elif args.sdk:
package(args.version, args.commit, args.sdk, args.output, args.report)
else:
parser.error("--sdk is required when collecting an APK")
if __name__ == "__main__":
main()