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.
155 lines
7.2 KiB
Python
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()
|