Files
matrix-screen-controller/核桃派软件源代码/scripts/promote_ota_release.py
T

206 lines
9.7 KiB
Python

"""Publish the exact OTA candidate that passed a real-device rehearsal."""
from __future__ import annotations
from datetime import datetime, timezone
import json
import os
from pathlib import Path
import shutil
import tempfile
import uuid
from app.ota.package import _source_file_allowed, extract_payload, inspect_package
from app.ota.policy import export_bundle, package_format, read_policy, release_metadata, check_upgrade
from app.ota.versioning import SoftwareVersion, read_software_version
from scripts.build_sd_image import sha256_file
from scripts.offline_wheels import validate_offline_wheels
REPORT_FIELDS = {
"schema_version", "artifact_type", "package_sha256", "software_version",
"source_version", "restored_version", "status", "validated_at",
"offline_install_passed", "ota_health_passed", "runtime_lifecycle_passed",
"user_data_preserved", "frp_state_preserved", "bluetooth_state_preserved",
"transaction_cleanup_passed", "baseline_restored",
}
PASS_FIELDS = REPORT_FIELDS - {
"schema_version", "artifact_type", "package_sha256", "software_version",
"source_version", "restored_version", "status", "validated_at",
}
def _validate_report(path: Path, digest: str, version: SoftwareVersion) -> dict:
report = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(report, dict) or set(report) != REPORT_FIELDS:
raise ValueError("OTA validation report fields are invalid")
try:
timestamp = datetime.fromisoformat(report["validated_at"])
source_version = SoftwareVersion.parse(report["source_version"])
restored_version = SoftwareVersion.parse(report["restored_version"])
check_upgrade(source_version, version)
except (KeyError, TypeError, ValueError) as exc:
raise ValueError("OTA validation report version or timestamp is invalid") from exc
if (
report["schema_version"] != 1
or report["artifact_type"] != "ota"
or report["package_sha256"] != digest
or report["software_version"] != str(version)
or report["status"] != "success"
or timestamp.tzinfo is None
or restored_version != source_version
or any(report[name] is not True for name in PASS_FIELDS)
):
raise ValueError("candidate lacks matching successful OTA and baseline recovery validation")
return report
def _file_map(root: Path, *, source_root: bool = False) -> dict[str, Path]:
if source_root:
return {
path.relative_to(root).as_posix(): path
for path in root.rglob("*")
if path.is_file() and _source_file_allowed(path, root)
}
return {
path.relative_to(root).as_posix(): path
for path in root.rglob("*")
if path.is_file()
}
def _same_files(expected: dict[str, Path], actual: dict[str, Path], label: str) -> None:
if set(expected) != set(actual):
raise ValueError(f"validated candidate {label} file list differs from current inputs")
if any(sha256_file(expected[name]) != sha256_file(actual[name]) for name in expected):
raise ValueError(f"validated candidate {label} bytes differ from current inputs")
def promote(source: Path, candidate: Path, validation: Path, notes: str) -> Path:
from scripts.export_release import (
_atomic_bytes, _atomic_json, _copy_source, _history, _ota_readme, next_patch,
)
source = Path(source).resolve()
project = source.parent
candidate = Path(candidate).resolve()
validation = Path(validation).resolve()
lock = project / ".release-export.lock"
descriptor = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
os.close(descriptor)
history_path = project / "发布记录.json"
history_original = history_path.read_bytes()
version_original = (source / "VERSION").read_bytes()
archive: Path | None = None
staged: Path | None = None
final: Path | None = None
published = False
try:
if not candidate.is_dir():
raise ValueError("validated OTA candidate directory is missing")
manifest = json.loads((candidate / "manifest.json").read_text(encoding="utf-8"))
version = SoftwareVersion.parse(manifest.get("software_version", ""))
current = read_software_version(source)
next_checkpoint = SoftwareVersion(current.major, current.minor + 1, 0)
if version not in (next_patch(current), next_checkpoint):
raise ValueError("validated OTA candidate must be the next patch or registered checkpoint")
dependency_root = project / "发布更新相关" / "其他依赖"
wheels = dependency_root / "aarch64-py311"
validate_offline_wheels(source, wheels)
component_bundle = export_bundle(source, dependency_root, current, version)
name = f"matrix-screen-controller-{version}.ota"
if {path.name for path in candidate.iterdir()} != {
"README.md", "manifest.json", name, f"{name}.sha256",
}:
raise ValueError("OTA candidate directory contents are not exact")
artifact = candidate / name
info = inspect_package(artifact, current_version=current)
digest = sha256_file(artifact)
expected_manifest = {
"format_version": package_format(version),
"artifact_type": "ota",
"software_version": str(version),
"created_at": info.created_at,
"notes": notes.strip(),
"artifact": name,
"artifact_bytes": artifact.stat().st_size,
"artifact_sha256": digest,
**release_metadata(version),
}
if manifest != expected_manifest or info.release_notes != notes.strip():
raise ValueError("OTA candidate manifest differs from its package or release notes")
if (candidate / f"{name}.sha256").read_bytes() != f"{digest} {name}\n".encode("ascii"):
raise ValueError("OTA candidate sidecar checksum is invalid")
if (candidate / "README.md").read_text(encoding="utf-8") != _ota_readme(version, manifest, notes):
raise ValueError("OTA candidate README differs from release metadata")
_validate_report(validation, digest, version)
with tempfile.TemporaryDirectory(prefix="matrix-ota-promotion-") as temporary_text:
temporary = Path(temporary_text)
unpacked = temporary / "unpacked"
extract_payload(info, unpacked)
staged_source = temporary / "核桃派软件源代码"
_copy_source(source, staged_source, version)
_same_files(
_file_map(staged_source, source_root=True),
_file_map(unpacked / "software", source_root=True),
"software",
)
_same_files(_file_map(wheels), _file_map(unpacked / "wheelhouse"), "wheelhouse")
validate_offline_wheels(unpacked / "software", unpacked / "wheelhouse")
bundled = unpacked / "software/system-dependencies/frpc"
if component_bundle is None:
if bundled.exists():
raise ValueError("patch OTA candidate unexpectedly carries a system component")
else:
_same_files(_file_map(component_bundle), _file_map(bundled), "system component")
if read_policy(unpacked / "software") != read_policy(source):
raise ValueError("OTA candidate dependency policy changed")
history = _history(history_path)
if any(record.get("version") == str(version) for record in history["releases"]):
raise ValueError("validated OTA version is already registered")
policy = read_policy(source)
if any(entry not in policy["checkpoints"] for record in history["releases"]
for entry in record.get("component_checkpoints", [])):
raise ValueError("published dependency checkpoint changed")
final = project / "发布更新相关" / "OTA数据包" / str(version)
if final.exists():
raise ValueError("formal OTA release directory already exists")
stamp = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
archive = project / "各种归档" / f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_OTA{version}实机验收_{uuid.uuid4().hex[:6]}"
archive.mkdir(parents=True)
shutil.copy2(validation, archive / "实机验收.json")
staged = final.parent / f".{version}.{uuid.uuid4().hex}.publishing"
shutil.copytree(candidate, staged)
if sha256_file(staged / name) != digest:
raise ValueError("OTA candidate copy checksum mismatch")
history["releases"].append({
"version": str(version),
"artifact_type": "ota",
"created_at": info.created_at,
"notes": notes.strip(),
"artifact_path": f"发布更新相关/OTA数据包/{version}/{name}",
"artifact_sha256": digest,
**release_metadata(version),
"component_checkpoints": policy["checkpoints"],
"validation_path": f"{archive.relative_to(project).as_posix()}/实机验收.json",
"validated_at": stamp,
})
os.replace(staged, final)
staged = None
published = True
_atomic_json(history_path, history)
_atomic_bytes(source / "VERSION", f"{version}\n".encode("utf-8"))
return final
except BaseException:
if staged is not None:
shutil.rmtree(staged, ignore_errors=True)
if published and final is not None:
shutil.rmtree(final, ignore_errors=True)
_atomic_bytes(history_path, history_original)
_atomic_bytes(source / "VERSION", version_original)
if archive is not None:
shutil.rmtree(archive, ignore_errors=True)
raise
finally:
lock.unlink(missing_ok=True)