Add validated OTA candidate publishing and fix 1.1.2 dependency failure
This commit is contained in:
@@ -16,6 +16,7 @@ if str(SOURCE_ROOT) not in sys.path:
|
||||
from app.ota.package import build_package
|
||||
from app.ota.versioning import read_software_version
|
||||
from app.ota.policy import read_policy
|
||||
from scripts.offline_wheels import validate_offline_wheels
|
||||
|
||||
|
||||
def main() -> int:
|
||||
@@ -26,6 +27,7 @@ def main() -> int:
|
||||
source_root = Path(__file__).resolve().parents[1]
|
||||
project_root = source_root.parent
|
||||
wheelhouse = project_root / "发布更新相关" / "其他依赖" / "aarch64-py311"
|
||||
validate_offline_wheels(source_root, wheelhouse)
|
||||
frpc_bundle = project_root / "发布更新相关" / "其他依赖" / "frp" / "0.71.0" / "linux-arm64"
|
||||
version = read_software_version(source_root)
|
||||
policy = read_policy(source_root)
|
||||
|
||||
@@ -23,6 +23,7 @@ from app.ota.policy import export_bundle, package_format, release_metadata, read
|
||||
from scripts.build_sd_image import build_image, sha256_file
|
||||
from scripts.fat16_image import Fat16Image
|
||||
from scripts.image_config import create_config_file, read_config_file
|
||||
from scripts.offline_wheels import validate_offline_wheels
|
||||
|
||||
|
||||
def next_patch(version: SoftwareVersion) -> SoftwareVersion:
|
||||
@@ -63,7 +64,7 @@ def _ota_readme(version: SoftwareVersion, manifest: dict, notes: str) -> str:
|
||||
"在系统设置上传 OTA;每个 minor 的 .0 必须按顺序安装,同系列补丁允许跳过。"
|
||||
"1.1.0 含 frpc 0.71.0 离线组件;已有完整组件会跳过,否则修复并保留配置和启停状态。"
|
||||
"初次安装默认关闭。禁止跳过失败测试;失败自动恢复。\n\n"
|
||||
"本目录是可删除的发布产物,不得被源码、脚本或后续版本引用。\n"
|
||||
"本目录作为 OTA 发布产物长期保留,不得被源码、脚本或后续版本当作构建输入。\n"
|
||||
)
|
||||
|
||||
|
||||
@@ -187,7 +188,7 @@ def main() -> int:
|
||||
parser.add_argument("--notes", required=True)
|
||||
parser.add_argument("--version", type=SoftwareVersion.parse, help="explicit release version, e.g. 1.1.0")
|
||||
parser.add_argument("--config", type=Path, help="required JSON provisioning config for image export")
|
||||
parser.add_argument("--candidate-output", type=Path, help="build a verified image candidate directory without publishing")
|
||||
parser.add_argument("--candidate-output", type=Path, help="build an OTA or image candidate directory without publishing")
|
||||
parser.add_argument("--validated-candidate", type=Path, help="exact real-device validated OTA file or image candidate directory")
|
||||
parser.add_argument("--validation-report", type=Path, help="matching real-device validation JSON")
|
||||
parser.add_argument(
|
||||
@@ -204,6 +205,15 @@ def main() -> int:
|
||||
from scripts.repair_ota_release import promote
|
||||
print(promote(SOURCE_ROOT, args.validated_candidate, args.validation_report, args.notes))
|
||||
return 0
|
||||
ota_promotion = args.kind == "ota" and args.validated_candidate is not None
|
||||
if ota_promotion:
|
||||
if args.validation_report is None:
|
||||
parser.error("OTA candidate promotion requires --validation-report")
|
||||
if args.config is not None or args.version is not None or args.candidate_output is not None:
|
||||
parser.error("OTA candidate promotion cannot use --config, --version, or --candidate-output")
|
||||
from scripts.promote_ota_release import promote
|
||||
print(promote(SOURCE_ROOT, args.validated_candidate, args.validation_report, args.notes))
|
||||
return 0
|
||||
image_promotion = args.kind == "image" and args.validated_candidate is not None
|
||||
if image_promotion:
|
||||
if args.validation_report is None:
|
||||
@@ -218,13 +228,15 @@ def main() -> int:
|
||||
print(promote(SOURCE_ROOT, args.validated_candidate, args.validation_report, args.notes))
|
||||
return 0
|
||||
if args.validated_candidate is not None or args.validation_report is not None:
|
||||
parser.error("candidate validation options require OTA --repair-current or image promotion")
|
||||
parser.error("candidate validation options require OTA or image promotion")
|
||||
if args.kind == "image" and args.config is None and not args.repair_current:
|
||||
parser.error("--config is required for image candidate export")
|
||||
if args.kind == "image" and not args.repair_current and args.candidate_output is None:
|
||||
parser.error("new image releases must use --candidate-output and pass real-card validation before publishing")
|
||||
if args.kind == "ota" and args.candidate_output is not None:
|
||||
parser.error("--candidate-output is only supported for image export")
|
||||
if args.kind == "ota" and args.config is not None:
|
||||
parser.error("--config is only supported for image export")
|
||||
if args.kind == "ota" and args.candidate_output is None:
|
||||
parser.error("new OTA releases must use --candidate-output and real-device validation before publishing")
|
||||
if args.repair_current and args.config is not None:
|
||||
parser.error("--repair-current reuses the registered image configuration; do not pass --config")
|
||||
if args.kind == "image":
|
||||
@@ -247,6 +259,7 @@ def main() -> int:
|
||||
dependency_bundle = None
|
||||
if not args.repair_current and args.kind == "ota":
|
||||
dependency_bundle = export_bundle(source, project / "发布更新相关" / "其他依赖", current, target)
|
||||
validate_offline_wheels(source, project / "发布更新相关" / "其他依赖" / "aarch64-py311")
|
||||
if args.kind == "image" and not args.repair_current and target <= current:
|
||||
parser.error("image candidate version must be newer than the current version")
|
||||
output_root = project / "发布更新相关" / "OTA数据包" if args.kind == "ota" else project / "发布更新相关" / "导出包"
|
||||
@@ -258,11 +271,11 @@ def main() -> int:
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
parser.error("candidate output must be outside the formal image release directory")
|
||||
parser.error("candidate output must be outside the formal release directory")
|
||||
if final_directory.exists() and not args.repair_current:
|
||||
parser.error(f"release directory already exists: {final_directory}")
|
||||
if official_directory.exists() and args.candidate_output is not None:
|
||||
parser.error(f"formal image release directory already exists: {official_directory}")
|
||||
parser.error(f"formal release directory already exists: {official_directory}")
|
||||
if args.repair_current and not final_directory.is_dir():
|
||||
parser.error(f"current image release directory is missing: {final_directory}")
|
||||
output_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Standard-library preflight for the device's offline Python requirements."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
|
||||
_REQUIREMENT = re.compile(r"^([A-Za-z0-9_.-]+)(?:\[[A-Za-z0-9_,.-]+\])?(.*)$")
|
||||
_DIGEST_LINE = re.compile(r"^([0-9a-f]{64}) ([^/\\]+\.whl)$")
|
||||
|
||||
|
||||
def _normalized(name: str) -> str:
|
||||
return re.sub(r"[-_.]+", "-", name).lower()
|
||||
|
||||
|
||||
def _requirements(source: Path, path: Path, visited: set[Path]) -> list[tuple[str, str]]:
|
||||
path = path.resolve()
|
||||
try:
|
||||
path.relative_to(source.resolve())
|
||||
except ValueError as exc:
|
||||
raise ValueError("requirement include escapes the source directory") from exc
|
||||
if path in visited:
|
||||
return []
|
||||
visited.add(path)
|
||||
result: list[tuple[str, str]] = []
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
item = line.split("#", 1)[0].strip()
|
||||
if not item:
|
||||
continue
|
||||
if item.startswith("-r "):
|
||||
result.extend(_requirements(source, path.parent / item[3:].strip(), visited))
|
||||
continue
|
||||
match = _REQUIREMENT.fullmatch(item)
|
||||
if match is None:
|
||||
raise ValueError(f"unsupported device requirement in {path.name}")
|
||||
name, specifier = match.groups()
|
||||
if specifier and not specifier.startswith(("==", ">=", "<=", ">", "<", "!=", "~=")):
|
||||
raise ValueError(f"unsupported device requirement in {path.name}")
|
||||
result.append((_normalized(name), specifier))
|
||||
return result
|
||||
|
||||
|
||||
def validate_offline_wheels(source: Path, wheelhouse: Path) -> None:
|
||||
"""Reject a missing direct dependency or inconsistent wheel SHA-256 list.
|
||||
|
||||
Full transitive and platform resolution remains a separate offline pip gate
|
||||
against the extracted candidate, followed by the board's actual venv install.
|
||||
"""
|
||||
source = Path(source).resolve()
|
||||
wheelhouse = Path(wheelhouse).resolve()
|
||||
requirements = _requirements(source, source / "requirements-dev.txt", set())
|
||||
listed: dict[str, str] = {}
|
||||
for line in (wheelhouse / "SHA256SUMS").read_text(encoding="ascii").splitlines():
|
||||
match = _DIGEST_LINE.fullmatch(line)
|
||||
if match is None or match[2] in listed:
|
||||
raise ValueError("offline wheel SHA256SUMS contains an invalid or repeated entry")
|
||||
listed[match[2]] = match[1]
|
||||
actual = {path.name for path in wheelhouse.iterdir() if path.is_file() and path.suffix == ".whl"}
|
||||
if not listed or actual != set(listed):
|
||||
raise ValueError("offline wheel files and SHA256SUMS do not match")
|
||||
packages: dict[str, set[str]] = {}
|
||||
for filename, expected in listed.items():
|
||||
parts = filename.removesuffix(".whl").split("-")
|
||||
if len(parts) < 5:
|
||||
raise ValueError(f"invalid offline wheel filename: {filename}")
|
||||
name, version = _normalized(parts[0]), parts[1]
|
||||
packages.setdefault(name, set()).add(version)
|
||||
digest = hashlib.sha256()
|
||||
with (wheelhouse / filename).open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
if digest.hexdigest() != expected:
|
||||
raise ValueError(f"offline wheel digest differs from SHA256SUMS: {filename}")
|
||||
for name, specifier in requirements:
|
||||
versions = packages.get(name, set())
|
||||
if not versions:
|
||||
raise ValueError(f"offline wheel missing for direct requirement: {name}")
|
||||
if specifier.startswith("==") and specifier[2:] not in versions:
|
||||
raise ValueError(f"offline wheel version missing for direct requirement: {name}{specifier}")
|
||||
@@ -0,0 +1,205 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user