Add validated OTA candidate publishing and fix 1.1.2 dependency failure

This commit is contained in:
2026-09-27 19:00:20 +08:00
parent 98eadc5c8b
commit f80c4d9938
26 changed files with 571 additions and 25 deletions
+1 -1
View File
@@ -1 +1 @@
2026-09-26T16:47+08:00
2026-09-27T17:51+08:00
+5 -2
View File
@@ -155,12 +155,15 @@ node --test tests/*.mjs
从 1.1.0 起,每个 minor 的 `.0` 是**软件安装包(必经升级版本)**。例如 `1.0.6 → 1.1.0 → 1.2.0 → 1.2.3`;不能跳过任何安装节点。同系列补丁可以跳过,例如 `1.1.0 → 1.1.3`。已预装 frp 的 1.0.6 调试设备也必须先完成 1.1.0,版本号与组件完整性分别检查。同版和降级仍不允许。
正式导出命令(项目根):
新 OTA 先导出到正式目录外的候选目录,完成真实设备试装及原版本恢复后,再按候选包 SHA-256 原字节晋升。以下命令从项目根执行;`<受保护临时目录>` 不得位于正式 OTA 发布目录内:
```powershell
python "核桃派软件源代码/scripts/export_release.py" ota --version 1.1.0 --notes "软件安装包:离线安装或修复 frpc,建立必经升级节点"
python "核桃派软件源代码/scripts/export_release.py" ota --candidate-output "<受保护临时目录>/ota-候选" --notes "更新说明"
python "核桃派软件源代码/scripts/export_release.py" ota --validated-candidate "<受保护临时目录>/ota-候选" --validation-report "<受保护临时目录>/实机验收.json" --notes "更新说明"
```
候选阶段不改 `VERSION` 或发布记录;正式目录包含候选原字节包、摘要、manifest 和 README。报告只含包摘要、目标/试装源/恢复版本、时间及离线安装、OTA 健康、运行目录、用户数据、FRP、蓝牙、事务清理、恢复原版本的布尔结果,不写凭据或设备标识。每个 minor 的 `.0` 仍需用 `--version` 明确指定登记的必经安装节点。直接打包脚本只用于非正式材料。
`UPGRADE_POLICY.json` 登记连续安装节点及固定依赖摘要;已发布节点不得改写。1.1.0 使用旧更新器支持的 v1 外层协议,frpc 放在 `software/system-dependencies/frpc/`。以后使用 v2,正式旧更新器会拒绝该格式;新更新器会在安装前提示必须先安装的版本。普通补丁不携带系统二进制,仍包含应用及 Python wheelhouse;系统组件缺失时先修复再更新。首装镜像仍携带完整离线依赖。
frpc 完整匹配时不替换二进制或重启服务;否则离线修复,保留 UUID 配置、选择项和启停状态。初次默认关闭。OTA 从现有 frp 非 root 账户、镜像生成的 `/etc/sudoers.d/90-matrix-screen-controller-account` 或唯一 sudo 普通账户确定运行账户,无法确定则在修改前失败。
+1 -1
View File
@@ -1 +1 @@
1.1.2
1.1.3
@@ -1,4 +1,3 @@
-r requirements.txt
pytest==8.4.1
httpx==0.28.1
paramiko==4.0.0
@@ -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)
@@ -0,0 +1,41 @@
{
"note": "Public interoperability fixture only; never use these scalars in real connections.",
"client_scalar": "1",
"server_scalar": "2",
"client_random": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
"server_random": "202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f",
"client_hello": "7b2270726f746f636f6c5f6d616a6f72223a312c2270726f746f636f6c5f6d696e6f72223a302c227075626c69635f6b6579223a224247735830664c684c454a482b4c7a6d35574f6b51504a33413332424c65737a6f5053684f5558596d4d4b57542b4e43347634616635754f352b744b66412b654669764f4d3164724d56374f79375a416144652f5566553d222c2272616e646f6d223a2241414543417751464267634943516f4c4441304f4478415245684d554652595847426b61477877644868383d227d",
"server_hello": "7b2270726f746f636f6c5f6d616a6f72223a312c2270726f746f636f6c5f6d696e6f72223a302c227075626c69635f6b6579223a2242487a796578694e4130392b696c4934417753314773504169576e69642f49624e61594c535078485a706c3442336456454e754f30454170505a72476e3351773237703972655938365949706e6753336e534a346339453d222c2272616e646f6d223a22494345694979516c4a69636f4b536f724c4330754c7a41784d6a4d304e5459334f446b364f7a7739506a383d227d",
"transcript": "9ca718b743dfe549aaba17180599eced0e2bcb680fd7c210e341ae74ccd17b76",
"request": "7b226964223a2231222c226d6574686f64223a2273657373696f6e2e6f70656e222c22706172616d73223a7b22636c69656e745f6e616d65223a22e6b58be8af95e6898be69cba227d7d",
"response": "7b226964223a2231222c226f6b223a747275652c22726573756c74223a7b22616c697665223a747275657d7d",
"client_record": "0000000000000000f2c854e062cd588b6de2066904d362400bf231bdeb046d786fca2a2bb6e1b539d0bd32d225461581655d7dc5066cf89d2d3bea12421e0d6e452f34ba4c39219b3e3efd3826c8461b67b8a74650976db8d47a0edd94e6cf39f526",
"server_record": "0000000000000000fd089bf6a0d3196c601f5dcbefad51b0f130e17cf38d2c76cbb9855a7da88480e53f3e89893dcc35fa175fcfbf01683f575244928462d07d13056a67",
"fragments_mtu23": [
"514d010300000000000000190000006200000000",
"514d010300000000000100190000006200000000",
"514d0103000000000002001900000062f2c854e0",
"514d010300000000000300190000006262cd588b",
"514d01030000000000040019000000626de20669",
"514d010300000000000500190000006204d36240",
"514d01030000000000060019000000620bf231bd",
"514d0103000000000007001900000062eb046d78",
"514d01030000000000080019000000626fca2a2b",
"514d0103000000000009001900000062b6e1b539",
"514d010300000000000a001900000062d0bd32d2",
"514d010300000000000b00190000006225461581",
"514d010300000000000c001900000062655d7dc5",
"514d010300000000000d001900000062066cf89d",
"514d010300000000000e0019000000622d3bea12",
"514d010300000000000f001900000062421e0d6e",
"514d0103000000000010001900000062452f34ba",
"514d01030000000000110019000000624c39219b",
"514d01030000000000120019000000623e3efd38",
"514d010300000000001300190000006226c8461b",
"514d010300000000001400190000006267b8a746",
"514d010300000000001500190000006250976db8",
"514d0103000000000016001900000062d47a0edd",
"514d010300000000001700190000006294e6cf39",
"514d0103000000000018001900000062f526"
]
}
@@ -71,7 +71,10 @@ def test_invalid_hello_and_json():
def test_shared_golden_vector():
path = Path(__file__).resolve().parents[2] / '移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/jvmTest/resources/protocol-v1.json'
path = Path(__file__).resolve().parent / 'fixtures/protocol-v1.json'
mobile_copy = Path(__file__).resolve().parents[2] / '移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/jvmTest/resources/protocol-v1.json'
if mobile_copy.is_file():
assert path.read_bytes() == mobile_copy.read_bytes()
vector = json.loads(path.read_text(encoding='utf-8'))
def raw(key):
return bytes.fromhex(vector[key])
@@ -241,6 +241,8 @@ def release_project(tmp_path, monkeypatch):
source.mkdir()
(source / 'VERSION').write_text('1.0.6\n', encoding='utf-8')
(source / 'FEATURE_UPDATED_AT').write_text('2026-09-07T20:00+08:00\n', encoding='utf-8')
(source / 'requirements.txt').write_text('example==1.0.0\n', encoding='utf-8')
(source / 'requirements-dev.txt').write_text('-r requirements.txt\n', encoding='utf-8')
deps = tmp_path / '发布更新相关/其他依赖'
binary = deps / 'frp/v/frpc'
binary.parent.mkdir(parents=True)
@@ -249,7 +251,9 @@ def release_project(tmp_path, monkeypatch):
(binary.parent/'SHA256SUMS').write_text(f'{digest} frpc\n', encoding='utf-8')
wheels = deps / 'aarch64-py311'
wheels.mkdir()
(wheels/'SHA256SUMS').write_text('fixture', encoding='utf-8')
wheel = wheels / 'example-1.0.0-py3-none-any.whl'
wheel.write_bytes(b'fixture wheel')
(wheels/'SHA256SUMS').write_text(f'{hashlib.sha256(wheel.read_bytes()).hexdigest()} {wheel.name}\n', encoding='ascii')
policy = {'schema_version': 1, 'checkpoints': [{'version': '1.1.0', 'components': {
'frpc': {'version': 'v', 'bundle': 'frp/v', 'sha256': digest}}}]}
(source/'UPGRADE_POLICY.json').write_text(json.dumps(policy), encoding='utf-8')
@@ -258,15 +262,102 @@ def release_project(tmp_path, monkeypatch):
return exporter, source
def test_export_checkpoint_then_patch_without_repeating_binary(tmp_path, monkeypatch):
def validated_ota_report(tmp_path, candidate, source_version, target_version):
artifact = candidate / f'matrix-screen-controller-{target_version}.ota'
report = tmp_path / f'validated-{target_version}.json'
report.write_text(json.dumps({
'schema_version': 1, 'artifact_type': 'ota',
'package_sha256': hashlib.sha256(artifact.read_bytes()).hexdigest(),
'software_version': target_version, 'source_version': source_version,
'restored_version': source_version, 'status': 'success',
'validated_at': '2026-09-27T18:00:00+08:00',
'offline_install_passed': True, 'ota_health_passed': True,
'runtime_lifecycle_passed': True, 'user_data_preserved': True,
'frp_state_preserved': True, 'bluetooth_state_preserved': True,
'transaction_cleanup_passed': True, 'baseline_restored': True,
}), encoding='utf-8')
return report
def publish_fixture_ota(tmp_path, monkeypatch, exporter, current, target, notes):
import sys
candidate = tmp_path / f'candidate-{target}'
args = ['export', 'ota', '--notes', notes, '--candidate-output', str(candidate)]
if target.endswith('.0'):
args.extend(['--version', target])
monkeypatch.setattr(sys, 'argv', args)
assert exporter.main() == 0
report = validated_ota_report(tmp_path, candidate, current, target)
monkeypatch.setattr(sys, 'argv', ['export', 'ota', '--notes', notes,
'--validated-candidate', str(candidate),
'--validation-report', str(report)])
assert exporter.main() == 0
return candidate
@pytest.mark.parametrize('failure', ['missing', 'version', 'digest'])
def test_ota_candidate_rejects_incomplete_offline_wheels(tmp_path, monkeypatch, failure):
import sys
exporter, source = release_project(tmp_path, monkeypatch)
wheels = tmp_path / '发布更新相关/其他依赖/aarch64-py311'
if failure == 'missing':
(source / 'requirements-dev.txt').write_text('-r requirements.txt\nparamiko==4.0.0\n', encoding='utf-8')
expected = 'offline wheel missing'
elif failure == 'version':
(source / 'requirements.txt').write_text('example==2.0.0\n', encoding='utf-8')
expected = 'offline wheel version missing'
else:
(wheels / 'example-1.0.0-py3-none-any.whl').write_bytes(b'changed')
expected = 'digest differs'
candidate = tmp_path / 'candidate-1.1.0'
monkeypatch.setattr(sys, 'argv', ['export', 'ota', '--version', '1.1.0',
'--candidate-output', str(candidate), '--notes', 'test'])
with pytest.raises(ValueError, match=expected):
exporter.main()
assert not candidate.exists()
assert (source / 'VERSION').read_text(encoding='utf-8').strip() == '1.0.6'
assert json.loads((tmp_path / '发布记录.json').read_text(encoding='utf-8'))['releases'] == []
assert not (tmp_path / '.release-export.lock').exists()
@pytest.mark.parametrize('failure', ['report', 'source', 'artifact'])
def test_ota_candidate_promotion_rejects_changed_evidence(tmp_path, monkeypatch, failure):
import sys
from scripts.promote_ota_release import promote
exporter, source = release_project(tmp_path, monkeypatch)
candidate = tmp_path / 'candidate-1.1.0'
monkeypatch.setattr(sys, 'argv', ['export', 'ota', '--version', '1.1.0',
'--candidate-output', str(candidate), '--notes', 'test'])
assert exporter.main() == 0
assert (source / 'VERSION').read_text(encoding='utf-8').strip() == '1.0.6'
assert not (tmp_path / '发布更新相关/OTA数据包/1.1.0').exists()
report = validated_ota_report(tmp_path, candidate, '1.0.6', '1.1.0')
if failure == 'report':
document = json.loads(report.read_text(encoding='utf-8'))
document['package_sha256'] = '0' * 64
report.write_text(json.dumps(document), encoding='utf-8')
expected = 'matching successful OTA'
elif failure == 'source':
(source / 'changed.py').write_text('CHANGED = True\n', encoding='utf-8')
expected = 'software file list differs'
else:
artifact = candidate / 'matrix-screen-controller-1.1.0.ota'
artifact.write_bytes(artifact.read_bytes() + b'tampered')
expected = 'manifest differs'
with pytest.raises(ValueError, match=expected):
promote(source, candidate, report, 'test')
assert (source / 'VERSION').read_text(encoding='utf-8').strip() == '1.0.6'
assert not (tmp_path / '发布更新相关/OTA数据包/1.1.0').exists()
assert json.loads((tmp_path / '发布记录.json').read_text(encoding='utf-8'))['releases'] == []
assert not (tmp_path / '.release-export.lock').exists()
def test_export_checkpoint_then_patch_without_repeating_binary(tmp_path, monkeypatch):
import tarfile
exporter, source = release_project(tmp_path, monkeypatch)
monkeypatch.setattr(sys, 'argv', ['export', 'ota', '--version', '1.1.0', '--notes', 'test'])
assert exporter.main() == 0
publish_fixture_ota(tmp_path, monkeypatch, exporter, '1.0.6', '1.1.0', 'test')
assert (source/'VERSION').read_text(encoding='utf-8').strip() == '1.1.0'
monkeypatch.setattr(sys, 'argv', ['export', 'ota', '--notes', 'patch'])
assert exporter.main() == 0
publish_fixture_ota(tmp_path, monkeypatch, exporter, '1.1.0', '1.1.1', 'patch')
artifact = next((tmp_path/'发布更新相关/OTA数据包/1.1.1').glob('*.ota'))
with zipfile.ZipFile(artifact) as z, z.open('payload.tar.gz') as f, tarfile.open(fileobj=f, mode='r|gz') as t:
assert not any('system-dependencies' in item.name for item in t)
@@ -282,7 +373,14 @@ def test_failed_export_does_not_consume_version(tmp_path, monkeypatch):
exporter, source = release_project(tmp_path, monkeypatch)
original = (tmp_path/'发布记录.json').read_bytes()
original_version = (source/'VERSION').read_bytes()
monkeypatch.setattr(sys, 'argv', ['export', 'ota', '--version', '1.1.0', '--notes', 'test'])
candidate = tmp_path / 'candidate-1.1.0'
monkeypatch.setattr(sys, 'argv', ['export', 'ota', '--version', '1.1.0',
'--candidate-output', str(candidate), '--notes', 'test'])
assert exporter.main() == 0
report = validated_ota_report(tmp_path, candidate, '1.0.6', '1.1.0')
monkeypatch.setattr(sys, 'argv', ['export', 'ota', '--notes', 'test',
'--validated-candidate', str(candidate),
'--validation-report', str(report)])
def fail(*args):
raise OSError('injected history write failure')
monkeypatch.setattr(exporter, '_atomic_json', fail)
@@ -319,11 +417,9 @@ def test_provisioned_account_does_not_need_sudo_group(host, monkeypatch):
@pytest.mark.parametrize('fail_commit', [False, True])
def test_same_version_repair_promotes_exact_candidate_and_preserves_old_artifact(tmp_path, monkeypatch, fail_commit):
import sys
from scripts.repair_ota_release import promote
exporter, source = release_project(tmp_path, monkeypatch)
monkeypatch.setattr(sys, 'argv', ['export', 'ota', '--version', '1.1.0', '--notes', 'original'])
exporter.main()
publish_fixture_ota(tmp_path, monkeypatch, exporter, '1.0.6', '1.1.0', 'original')
artifact=tmp_path/'发布更新相关/OTA数据包/1.1.0/matrix-screen-controller-1.1.0.ota'
old=artifact.read_bytes();old_history=(tmp_path/'发布记录.json').read_bytes()
(source/'fix.py').write_text('FIXED = True\n',encoding='utf-8')