"""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)