469 lines
24 KiB
Python
469 lines
24 KiB
Python
#!/usr/bin/env python3
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
from datetime import datetime, timezone
|
||
import hashlib
|
||
import json
|
||
import os
|
||
from pathlib import Path
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import uuid
|
||
|
||
SOURCE_ROOT = Path(__file__).resolve().parents[1]
|
||
if str(SOURCE_ROOT) not in sys.path:
|
||
sys.path.insert(0, str(SOURCE_ROOT))
|
||
|
||
from app.ota.package import build_package, extract_payload
|
||
from app.ota.versioning import SoftwareVersion, read_software_version
|
||
from app.ota.policy import export_bundle, package_format, release_metadata, read_policy
|
||
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
|
||
|
||
|
||
def next_patch(version: SoftwareVersion) -> SoftwareVersion:
|
||
return SoftwareVersion(version.major, version.minor, version.patch + 1)
|
||
|
||
|
||
def _image_readme(version: SoftwareVersion, manifest: dict, notes: str, config: dict) -> str:
|
||
return (
|
||
f"# 奇妙小屏幕控制器 IMAGE {version}\n\n"
|
||
f"- 软件版本:`{version}`\n"
|
||
f"- 导出时间:`{manifest['created_at']}`\n"
|
||
f"- 说明:{notes.strip()}\n"
|
||
f"- 产物:`{manifest['artifact']}`\n"
|
||
f"- SHA-256:`{manifest['image_sha256']}`\n\n"
|
||
"## 未修改镜像的默认设置\n\n"
|
||
f"- Linux 用户名:`{config['account']['username']}`\n"
|
||
f"- Linux 密码:`{config['account']['password']}`\n"
|
||
f"- Wi-Fi SSID:`{config['wifi']['ssid']}`\n"
|
||
f"- Wi-Fi 密码:`{config['wifi']['password']}`\n"
|
||
"- IPv4:`DHCP`\n\n"
|
||
"> 这些是公开的默认凭据。建议刷写前使用镜像编辑器修改;直接使用未修改镜像时,"
|
||
"请按上述 SSID 和密码开启热点,并在首次登录后立即更换账户及 Wi-Fi 设置。\n\n"
|
||
"首次启动会离线安装全部软件并自动重启一次。请等待设备重新连接热点后再通过 SSH 或网页访问。\n\n"
|
||
"本目录是可删除的发布产物,不得被源码、脚本或后续版本引用。\n"
|
||
)
|
||
|
||
|
||
def _ota_readme(version: SoftwareVersion, manifest: dict, notes: str) -> str:
|
||
metadata = release_metadata(version)
|
||
return (
|
||
f"# 奇妙小屏幕控制器 OTA {version}\n\n"
|
||
f"- 软件版本:`{version}`\n"
|
||
f"- 导出时间:`{manifest['created_at']}`\n"
|
||
f"- 说明:{notes.strip()}\n"
|
||
f"- 产物:`{manifest['artifact']}`\n\n"
|
||
f"- 包类型:{'软件安装包(必经升级版本)' if metadata['is_checkpoint'] else '应用更新包'}\n"
|
||
f"- 前置系列基线:`{metadata['required_checkpoint']}`\n\n"
|
||
"在系统设置上传 OTA;每个 minor 的 .0 必须按顺序安装,同系列补丁允许跳过。"
|
||
"1.1.0 含 frpc 0.71.0 离线组件;已有完整组件会跳过,否则修复并保留配置和启停状态。"
|
||
"初次安装默认关闭。禁止跳过失败测试;失败自动恢复。\n\n"
|
||
"本目录是可删除的发布产物,不得被源码、脚本或后续版本引用。\n"
|
||
)
|
||
|
||
|
||
def _copy_source(source: Path, destination: Path, version: SoftwareVersion) -> None:
|
||
ignored = shutil.ignore_patterns(".venv", "__pycache__", ".pytest_cache", "node_modules", "data", "output", ".playwright-cli", "system-dependencies")
|
||
shutil.copytree(source, destination, ignore=ignored)
|
||
(destination / "VERSION").write_text(f"{version}\n", encoding="utf-8", newline="\n")
|
||
|
||
|
||
def _history(path: Path) -> dict:
|
||
if path.is_file():
|
||
document = json.loads(path.read_text(encoding="utf-8"))
|
||
if not isinstance(document, dict) or document.get("schema_version") != 1 or not isinstance(document.get("releases"), list):
|
||
raise ValueError("发布记录.json is invalid")
|
||
return document
|
||
return {
|
||
"schema_version": 1,
|
||
"releases": [
|
||
{
|
||
"version": "1.0.1",
|
||
"artifact_type": "ota",
|
||
"created_at": "2026-08-13T15:31:23+08:00",
|
||
"notes": "新增浏览器全量 OTA。",
|
||
"artifact_path": "发布更新相关/OTA数据包/1.0.1/matrix-screen-controller-1.0.1.ota",
|
||
"artifact_sha256": sha256_file(path.parent / "发布更新相关" / "OTA数据包" / "1.0.1" / "matrix-screen-controller-1.0.1.ota"),
|
||
}
|
||
],
|
||
}
|
||
|
||
|
||
def _atomic_json(path: Path, document: dict) -> None:
|
||
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
|
||
temporary.write_text(json.dumps(document, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", newline="\n")
|
||
os.replace(temporary, path)
|
||
|
||
|
||
def _atomic_bytes(path: Path, body: bytes) -> None:
|
||
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
|
||
temporary.write_bytes(body)
|
||
os.replace(temporary, path)
|
||
|
||
|
||
def _require_image_host() -> None:
|
||
if os.name != "posix" or not hasattr(os, "geteuid") or os.geteuid() != 0:
|
||
raise ValueError("image export must run as root on Linux")
|
||
missing = [name for name in ("bash", "losetup", "mount", "umount", "systemctl", "sha256sum") if shutil.which(name) is None]
|
||
if missing:
|
||
raise ValueError(f"image export host is missing required commands: {', '.join(missing)}")
|
||
|
||
|
||
def _validated_repair_input(
|
||
final_directory: Path,
|
||
history: dict,
|
||
version: SoftwareVersion,
|
||
) -> tuple[bytes, int]:
|
||
manifest_path = final_directory / "manifest.json"
|
||
if not manifest_path.is_file():
|
||
raise ValueError("current image release manifest is missing")
|
||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||
artifact_name = f"matrix-screen-controller-{version}.img"
|
||
artifact = final_directory / artifact_name
|
||
sidecar = final_directory / f"{artifact_name}.sha256"
|
||
if (
|
||
manifest.get("artifact_type") != "image"
|
||
or manifest.get("software_version") != str(version)
|
||
or manifest.get("artifact") != artifact_name
|
||
or not artifact.is_file()
|
||
or not sidecar.is_file()
|
||
):
|
||
raise ValueError("current image release files do not match the repair target")
|
||
digest = sha256_file(artifact)
|
||
expected_sidecar = f"{digest} {artifact_name}\n".encode("ascii")
|
||
if sidecar.read_bytes() != expected_sidecar or manifest.get("image_sha256") != digest:
|
||
raise ValueError("current image release digest is inconsistent")
|
||
matches = [
|
||
(index, item)
|
||
for index, item in enumerate(history["releases"])
|
||
if item.get("version") == str(version)
|
||
]
|
||
if len(matches) != 1:
|
||
raise ValueError("current version must have exactly one release record before repair")
|
||
index, record = matches[0]
|
||
expected_path = f"发布更新相关/导出包/{version}/{artifact_name}"
|
||
if (
|
||
record.get("artifact_type") != "image"
|
||
or record.get("artifact_path") != expected_path
|
||
or record.get("artifact_sha256") != digest
|
||
):
|
||
raise ValueError("current image release record is inconsistent")
|
||
with Fat16Image(artifact) as image:
|
||
config_bytes = image.read_file("MSCCFG.BIN")
|
||
config, _, _ = read_config_file(config_bytes)
|
||
if config["software_version"] != str(version):
|
||
raise ValueError("current image configuration version does not match VERSION")
|
||
return config_bytes, index
|
||
|
||
|
||
def _commit_repair(
|
||
final_directory: Path,
|
||
temporary_directory: Path,
|
||
history_path: Path,
|
||
history: dict,
|
||
) -> Path | None:
|
||
backup = final_directory.parent / f".{final_directory.name}.{uuid.uuid4().hex}.invalid-backup"
|
||
os.replace(final_directory, backup)
|
||
try:
|
||
os.replace(temporary_directory, final_directory)
|
||
_atomic_json(history_path, history)
|
||
except BaseException:
|
||
if final_directory.exists():
|
||
shutil.rmtree(final_directory, ignore_errors=True)
|
||
os.replace(backup, final_directory)
|
||
raise
|
||
shutil.rmtree(backup, ignore_errors=True)
|
||
return backup if backup.exists() else None
|
||
|
||
|
||
def main() -> int:
|
||
parser = argparse.ArgumentParser(description="Export one versioned OTA or SD image release")
|
||
parser.add_argument("kind", choices=("ota", "image"))
|
||
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("--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(
|
||
"--repair-current",
|
||
action="store_true",
|
||
help="rebuild and atomically replace the registered current-version image",
|
||
)
|
||
args = parser.parse_args()
|
||
if args.repair_current and args.version is not None:
|
||
parser.error("--repair-current cannot be combined with --version")
|
||
if args.kind == "ota" and args.repair_current:
|
||
if args.validated_candidate is None or args.validation_report is None:
|
||
parser.error("OTA repair requires --validated-candidate and --validation-report")
|
||
from scripts.repair_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:
|
||
parser.error("image candidate promotion requires --validation-report")
|
||
if args.config is not None or args.version is not None or args.candidate_output is not None or args.repair_current:
|
||
parser.error("image candidate promotion cannot use --config, --version, --candidate-output, or --repair-current")
|
||
try:
|
||
_require_image_host()
|
||
except ValueError as exc:
|
||
parser.error(str(exc))
|
||
from scripts.promote_image_release import promote
|
||
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")
|
||
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.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":
|
||
try:
|
||
_require_image_host()
|
||
except ValueError as exc:
|
||
parser.error(str(exc))
|
||
|
||
source = SOURCE_ROOT
|
||
project = source.parent
|
||
lock = project / ".release-export.lock"
|
||
try:
|
||
descriptor = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||
except FileExistsError:
|
||
parser.error("another release export is already active")
|
||
os.close(descriptor)
|
||
try:
|
||
current = read_software_version(source)
|
||
target = current if args.repair_current else (args.version or next_patch(current))
|
||
dependency_bundle = None
|
||
if not args.repair_current and args.kind == "ota":
|
||
dependency_bundle = export_bundle(source, project / "发布更新相关" / "其他依赖", current, target)
|
||
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 / "发布更新相关" / "导出包"
|
||
official_directory = output_root / str(target)
|
||
final_directory = args.candidate_output.resolve() if args.candidate_output is not None else official_directory
|
||
if args.candidate_output is not None:
|
||
try:
|
||
final_directory.relative_to(output_root.resolve())
|
||
except ValueError:
|
||
pass
|
||
else:
|
||
parser.error("candidate output must be outside the formal image 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}")
|
||
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)
|
||
history_path = project / "发布记录.json"
|
||
history = _history(history_path)
|
||
policy = read_policy(source)
|
||
for previous in history["releases"]:
|
||
recorded = previous.get("component_checkpoints", [])
|
||
if any(entry not in policy["checkpoints"] for entry in recorded):
|
||
parser.error("已发布的依赖节点不可改写;请追加新的 minor .0 节点")
|
||
history_original = history_path.read_bytes() if history_path.is_file() else None
|
||
version_original = (source / "VERSION").read_bytes()
|
||
repair_record_index: int | None = None
|
||
repair_config: bytes | None = None
|
||
if args.repair_current:
|
||
try:
|
||
repair_config, repair_record_index = _validated_repair_input(
|
||
final_directory, history, target
|
||
)
|
||
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
||
parser.error(str(exc))
|
||
build_parent = final_directory.parent if args.candidate_output is not None else output_root
|
||
build_parent.mkdir(parents=True, exist_ok=True)
|
||
temporary_directory = build_parent / f".{final_directory.name}.{uuid.uuid4().hex}.building"
|
||
temporary_directory.mkdir()
|
||
published = False
|
||
replaced_directory: Path | None = None
|
||
try:
|
||
with tempfile.TemporaryDirectory(prefix="matrix-release-source-") as staging_text:
|
||
staged_source = Path(staging_text) / "核桃派软件源代码"
|
||
_copy_source(source, staged_source, target)
|
||
created = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
|
||
if args.kind == "ota":
|
||
artifact = temporary_directory / f"matrix-screen-controller-{target}.ota"
|
||
info = build_package(
|
||
staged_source,
|
||
project / "发布更新相关" / "其他依赖" / "aarch64-py311",
|
||
artifact,
|
||
version=target,
|
||
release_notes=args.notes,
|
||
system_dependencies=dependency_bundle,
|
||
)
|
||
verified = Path(staging_text) / "verified-ota"
|
||
extract_payload(info, verified)
|
||
if (verified / "software/VERSION").read_text(encoding="utf-8").strip() != str(target):
|
||
raise ValueError("exported payload version mismatch")
|
||
if (verified / "software/FEATURE_UPDATED_AT").read_bytes() != (source / "FEATURE_UPDATED_AT").read_bytes():
|
||
raise ValueError("exported feature timestamp mismatch")
|
||
if read_policy(verified / "software") != policy:
|
||
raise ValueError("exported dependency policy mismatch")
|
||
manifest = {
|
||
"format_version": package_format(target),
|
||
"artifact_type": "ota",
|
||
"software_version": str(target),
|
||
"created_at": info.created_at,
|
||
"notes": args.notes.strip(),
|
||
"artifact": artifact.name,
|
||
"artifact_bytes": artifact.stat().st_size,
|
||
"artifact_sha256": sha256_file(artifact),
|
||
**release_metadata(target),
|
||
}
|
||
else:
|
||
if repair_config is not None:
|
||
config: dict | bytes = repair_config
|
||
else:
|
||
config = json.loads(args.config.read_text(encoding="utf-8"))
|
||
config["software_version"] = str(target)
|
||
artifact = temporary_directory / f"matrix-screen-controller-{target}.img"
|
||
bootstrap_bundle = Path(staging_text) / "MSCBOOT.TGZ"
|
||
kernel_dependency = (
|
||
project
|
||
/ "发布更新相关"
|
||
/ "其他依赖"
|
||
/ "aarch64-kernel"
|
||
/ "6.1.31-matrix-axp313a1"
|
||
)
|
||
kernel_source = (
|
||
project
|
||
/ "发布更新相关"
|
||
/ "其他依赖"
|
||
/ "kernel-source"
|
||
/ "walnutpi-linux-6.1.31-30ff3fd5"
|
||
)
|
||
manifest = build_image(
|
||
project / "发布更新相关" / "核桃派镜像" / "2025-3-6_V2.5.1_WalnutPi-1B_6.1.31_debian12_server.img",
|
||
staged_source,
|
||
project / "发布更新相关" / "其他依赖" / "aarch64-py311",
|
||
project / "发布更新相关" / "其他依赖" / "debian12-aarch64",
|
||
kernel_dependency,
|
||
kernel_source,
|
||
config,
|
||
artifact,
|
||
bootstrap_bundle,
|
||
project / "发布更新相关" / "其他依赖" / "frp" / "0.71.0" / "linux-arm64",
|
||
)
|
||
subprocess.run(
|
||
[
|
||
"bash",
|
||
str(staged_source / "scripts" / "install_image_bootstrap.sh"),
|
||
str(artifact),
|
||
str(staged_source / "systemd" / "matrix-image-firstboot.service"),
|
||
str(staged_source / "systemd" / "10-walnutpi-screen-hardening.conf"),
|
||
str(kernel_dependency),
|
||
str(bootstrap_bundle),
|
||
],
|
||
check=True,
|
||
)
|
||
subprocess.run(
|
||
[
|
||
"bash",
|
||
str(staged_source / "scripts" / "verify_image_bootstrap.sh"),
|
||
str(artifact),
|
||
str(staged_source / "systemd" / "matrix-image-firstboot.service"),
|
||
str(staged_source / "systemd" / "10-walnutpi-screen-hardening.conf"),
|
||
str(kernel_dependency),
|
||
str(bootstrap_bundle),
|
||
],
|
||
check=True,
|
||
)
|
||
manifest["created_at"] = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
|
||
manifest["image_bytes"] = artifact.stat().st_size
|
||
manifest["image_sha256"] = sha256_file(artifact)
|
||
manifest.update({"artifact_type": "image", "notes": args.notes.strip(), "artifact": artifact.name})
|
||
config_document, _generation, _slot = read_config_file(
|
||
repair_config if repair_config is not None else create_config_file(config)
|
||
)
|
||
manifest["config_sha256"] = hashlib.sha256(
|
||
repair_config if repair_config is not None else create_config_file(config_document)
|
||
).hexdigest()
|
||
(temporary_directory / f"{artifact.name}.sha256").write_text(
|
||
f"{manifest['image_sha256'] if args.kind == 'image' else manifest['artifact_sha256']} {artifact.name}\n",
|
||
encoding="ascii",
|
||
newline="\n",
|
||
)
|
||
(temporary_directory / "manifest.json").write_text(
|
||
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
|
||
encoding="utf-8",
|
||
newline="\n",
|
||
)
|
||
(temporary_directory / "README.md").write_text(
|
||
_image_readme(target, manifest, args.notes, config_document)
|
||
if args.kind == "image"
|
||
else _ota_readme(target, manifest, args.notes),
|
||
encoding="utf-8",
|
||
newline="\n",
|
||
)
|
||
if args.candidate_output is not None:
|
||
os.replace(temporary_directory, final_directory)
|
||
published = True
|
||
print(final_directory)
|
||
return 0
|
||
release_record = {
|
||
"version": str(target),
|
||
"artifact_type": args.kind,
|
||
"created_at": manifest["created_at"],
|
||
"notes": args.notes.strip(),
|
||
"artifact_path": f"{'发布更新相关/OTA数据包' if args.kind == 'ota' else '发布更新相关/导出包'}/{target}/{artifact.name}",
|
||
"artifact_sha256": manifest["image_sha256"] if args.kind == "image" else manifest["artifact_sha256"],
|
||
**(release_metadata(target) if args.kind == "ota" else {}),
|
||
**({"component_checkpoints": policy["checkpoints"]} if args.kind == "ota" else {}),
|
||
}
|
||
if args.repair_current:
|
||
assert repair_record_index is not None
|
||
history["releases"][repair_record_index] = release_record
|
||
replaced_directory = _commit_repair(
|
||
final_directory,
|
||
temporary_directory,
|
||
history_path,
|
||
history,
|
||
)
|
||
published = True
|
||
if replaced_directory is not None and replaced_directory.exists():
|
||
print(f"warning: obsolete image backup still requires cleanup: {replaced_directory}", file=sys.stderr)
|
||
replaced_directory = None
|
||
else:
|
||
os.replace(temporary_directory, final_directory)
|
||
published = True
|
||
history["releases"].append(release_record)
|
||
_atomic_json(history_path, history)
|
||
version_temp = source / ".VERSION.release.tmp"
|
||
version_temp.write_text(f"{target}\n", encoding="utf-8", newline="\n")
|
||
os.replace(version_temp, source / "VERSION")
|
||
print(final_directory)
|
||
except BaseException:
|
||
shutil.rmtree(temporary_directory, ignore_errors=True)
|
||
if published and not args.repair_current and args.candidate_output is None:
|
||
shutil.rmtree(final_directory, ignore_errors=True)
|
||
if history_original is None:
|
||
history_path.unlink(missing_ok=True)
|
||
else:
|
||
_atomic_bytes(history_path, history_original)
|
||
_atomic_bytes(source / "VERSION", version_original)
|
||
(source / ".VERSION.release.tmp").unlink(missing_ok=True)
|
||
if replaced_directory is not None and replaced_directory.exists() and not final_directory.exists():
|
||
os.replace(replaced_directory, final_directory)
|
||
raise
|
||
finally:
|
||
lock.unlink(missing_ok=True)
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|