Add validated OTA candidate publishing and fix 1.1.2 dependency failure
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
# 奇妙小屏幕控制器 OTA 1.1.2
|
# 奇妙小屏幕控制器 OTA 1.1.2
|
||||||
|
|
||||||
|
> **不可用,请勿安装。** 本包要求 `paramiko==4.0.0`,但内含的离线 wheelhouse 缺少该 wheel,设备会在安装依赖阶段失败并回滚。请改用修复后的 `1.1.3`;已安装 `1.1.1` 的设备可直接升级到 `1.1.3`,无需经过本包。本文件只标记历史坏包,原 `.ota`、manifest 和 SHA-256 保持不变。
|
||||||
|
|
||||||
- 软件版本:`1.1.2`
|
- 软件版本:`1.1.2`
|
||||||
- 导出时间:`2026-09-26T16:59:03+08:00`
|
- 导出时间:`2026-09-26T16:59:03+08:00`
|
||||||
- 说明:发布当前工作区设备端更新:BLE 控制与网络配置改进、OTA 内核恢复和运行期保护;包含新增离线 Python 依赖。
|
- 说明:发布当前工作区设备端更新:BLE 控制与网络配置改进、OTA 内核恢复和运行期保护;包含新增离线 Python 依赖。
|
||||||
@@ -10,4 +12,4 @@
|
|||||||
|
|
||||||
在系统设置上传 OTA;每个 minor 的 .0 必须按顺序安装,同系列补丁允许跳过。1.1.0 含 frpc 0.71.0 离线组件;已有完整组件会跳过,否则修复并保留配置和启停状态。初次安装默认关闭。禁止跳过失败测试;失败自动恢复。
|
在系统设置上传 OTA;每个 minor 的 .0 必须按顺序安装,同系列补丁允许跳过。1.1.0 含 frpc 0.71.0 离线组件;已有完整组件会跳过,否则修复并保留配置和启停状态。初次安装默认关闭。禁止跳过失败测试;失败自动恢复。
|
||||||
|
|
||||||
本目录是可删除的发布产物,不得被源码、脚本或后续版本引用。
|
本目录作为历史 OTA 长期保留,不得被源码、脚本或后续版本当作构建输入。
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# 奇妙小屏幕控制器 OTA 1.1.3
|
||||||
|
|
||||||
|
- 软件版本:`1.1.3`
|
||||||
|
- 导出时间:`2026-09-27T18:03:25+08:00`
|
||||||
|
- 说明:修复 1.1.2 OTA 将电脑端 Paramiko 误列入设备依赖导致离线安装失败;1.1.1 可直接升级至 1.1.3,包含 1.1.2 的设备端功能。
|
||||||
|
- 产物:`matrix-screen-controller-1.1.3.ota`
|
||||||
|
|
||||||
|
- 包类型:应用更新包
|
||||||
|
- 前置系列基线:`1.1.0`
|
||||||
|
|
||||||
|
在系统设置上传 OTA;每个 minor 的 .0 必须按顺序安装,同系列补丁允许跳过。1.1.0 含 frpc 0.71.0 离线组件;已有完整组件会跳过,否则修复并保留配置和启停状态。初次安装默认关闭。禁止跳过失败测试;失败自动恢复。
|
||||||
|
|
||||||
|
本目录作为 OTA 发布产物长期保留,不得被源码、脚本或后续版本当作构建输入。
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"format_version": 2,
|
||||||
|
"artifact_type": "ota",
|
||||||
|
"software_version": "1.1.3",
|
||||||
|
"created_at": "2026-09-27T18:03:25+08:00",
|
||||||
|
"notes": "修复 1.1.2 OTA 将电脑端 Paramiko 误列入设备依赖导致离线安装失败;1.1.1 可直接升级至 1.1.3,包含 1.1.2 的设备端功能。",
|
||||||
|
"artifact": "matrix-screen-controller-1.1.3.ota",
|
||||||
|
"artifact_bytes": 27010021,
|
||||||
|
"artifact_sha256": "db44e0b404a664d6d4f0495dea72106e7f7229aca00326f044ab229c6dd67d86",
|
||||||
|
"package_kind": "application",
|
||||||
|
"required_checkpoint": "1.1.0",
|
||||||
|
"is_checkpoint": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:db44e0b404a664d6d4f0495dea72106e7f7229aca00326f044ab229c6dd67d86
|
||||||
|
size 27010021
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
db44e0b404a664d6d4f0495dea72106e7f7229aca00326f044ab229c6dd67d86 matrix-screen-controller-1.1.3.ota
|
||||||
@@ -20,4 +20,6 @@
|
|||||||
|
|
||||||
正式镜像必须通过 `核桃派软件源代码/scripts/export_release.py image` 构建,禁止直接调用旧的 FAT 原地刷新脚本。新版本先用 `--candidate-output` 在正式目录外构建;同一字节候选通过摘要绑定的真实 TF 卡正向验收后,再用 `--validated-candidate` 和 `--validation-report` 晋升,晋升前会重建期望 bundle 并重新只读验证。候选阶段不得修改 `VERSION`、发布记录或正式目录。当前版本坏包的授权修复继续使用 `--repair-current`。构建主机只需 Python 标准库与已检查的 Linux 镜像工具,不得用安装 Pillow/FontTools 的临时 venv 掩盖导出器导入错误;构建不访问网络。
|
正式镜像必须通过 `核桃派软件源代码/scripts/export_release.py image` 构建,禁止直接调用旧的 FAT 原地刷新脚本。新版本先用 `--candidate-output` 在正式目录外构建;同一字节候选通过摘要绑定的真实 TF 卡正向验收后,再用 `--validated-candidate` 和 `--validation-report` 晋升,晋升前会重建期望 bundle 并重新只读验证。候选阶段不得修改 `VERSION`、发布记录或正式目录。当前版本坏包的授权修复继续使用 `--repair-current`。构建主机只需 Python 标准库与已检查的 Linux 镜像工具,不得用安装 Pillow/FontTools 的临时 venv 掩盖导出器导入错误;构建不访问网络。
|
||||||
|
|
||||||
|
正式 OTA 使用同一入口的 `ota --candidate-output` 先在正式目录外构建;导出前核对设备端直接 Python 依赖、固定版本及 wheel 摘要清单。对候选包实际载荷做目标架构无网络解析和板端隔离安装,再按摘要执行真实 OTA、恢复试装前设备状态,最后使用 `ota --validated-candidate --validation-report` 原字节晋升。候选和验收失败均不推进 `VERSION` 或发布记录;1.1.2 是保留原字节并在其 README 标记的不可用历史包,1.1.1 可直接安装 1.1.3。
|
||||||
|
|
||||||
Windows 与 Linux 构建机之间传输时,先在 Codex 临时目录生成不含凭据的项目快照及 IMG 压缩副本,并在解压前后分别校验 SHA-256。PuTTY SCP 对中文远端路径不可靠时,只在明确的远端临时根建立 ASCII 下载目录;同文件系统可使用硬链接避免复制第二份大型 IMG,启用 `protected_hardlinks` 时仅用 sudo 创建这些明确硬链接。官方 `SHA256SUMS` 使用相对文件名,必须在清单所在目录校验。传输、解压、loop、挂载和 ASCII 下载目录在产物回传并复核后按明确绝对路径清理。
|
Windows 与 Linux 构建机之间传输时,先在 Codex 临时目录生成不含凭据的项目快照及 IMG 压缩副本,并在解压前后分别校验 SHA-256。PuTTY SCP 对中文远端路径不可靠时,只在明确的远端临时根建立 ASCII 下载目录;同文件系统可使用硬链接避免复制第二份大型 IMG,启用 `protected_hardlinks` 时仅用 sudo 创建这些明确硬链接。官方 `SHA256SUMS` 使用相对文件名,必须在清单所在目录校验。传输、解压、loop、挂载和 ASCII 下载目录在产物回传并复核后按明确绝对路径清理。
|
||||||
|
|||||||
@@ -62,5 +62,11 @@
|
|||||||
"introduced_in": "Android 首版开发,未推进设备发布版本"
|
"introduced_in": "Android 首版开发,未推进设备发布版本"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"retired": []
|
"retired": [
|
||||||
|
{
|
||||||
|
"id": "paramiko-device-dev-requirement",
|
||||||
|
"retired_in": "1.1.3",
|
||||||
|
"reason": "1.1.2 将仅供电脑端移动测试的 Paramiko 误列为设备端开发依赖,却未提供对应 AArch64 wheel;1.1.3 移出设备安装清单。该 wheel 从未进入离线材料,无二进制或 SHA-256 条目可删除。"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,31 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.1.3",
|
||||||
|
"artifact_type": "ota",
|
||||||
|
"created_at": "2026-09-27T18:03:25+08:00",
|
||||||
|
"notes": "修复 1.1.2 OTA 将电脑端 Paramiko 误列入设备依赖导致离线安装失败;1.1.1 可直接升级至 1.1.3,包含 1.1.2 的设备端功能。",
|
||||||
|
"artifact_path": "发布更新相关/OTA数据包/1.1.3/matrix-screen-controller-1.1.3.ota",
|
||||||
|
"artifact_sha256": "db44e0b404a664d6d4f0495dea72106e7f7229aca00326f044ab229c6dd67d86",
|
||||||
|
"package_kind": "application",
|
||||||
|
"required_checkpoint": "1.1.0",
|
||||||
|
"is_checkpoint": false,
|
||||||
|
"component_checkpoints": [
|
||||||
|
{
|
||||||
|
"version": "1.1.0",
|
||||||
|
"components": {
|
||||||
|
"frpc": {
|
||||||
|
"version": "0.71.0",
|
||||||
|
"sha256": "6e8e45fd0c7514b636fd8d049212f8a5715e8b33c412cf968988252e7a8a00f2",
|
||||||
|
"bundle": "frp/0.71.0/linux-arm64"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"validation_path": "各种归档/20260927_183858_OTA1.1.3实机验收_769eca/实机验收.json",
|
||||||
|
"validated_at": "2026-09-27T18:38:58+08:00"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# OTA 1.1.3 实机验收与恢复记录
|
||||||
|
|
||||||
|
- 1.1.2 在设备 1.1.1 上安装时,离线 `requirements-dev.txt` 要求 `paramiko==4.0.0`,而包内无对应 wheel,故在切换程序前失败并自动回滚。历史 1.1.2 `.ota`、manifest 与摘要保持原样,已在其 README 标为不可用。
|
||||||
|
- 候选 1.1.3 SHA-256:`db44e0b404a664d6d4f0495dea72106e7f7229aca00326f044ab229c6dd67d86`。在核桃派 AArch64 / Python 3.11 上,从该候选的实际载荷校验 wheel 清单,在独立 venv 无网络安装依赖,并完成原生驱动构建测试及完整 pytest。未在此阶段切换生产服务。
|
||||||
|
- 先备份并校验 1.1.1 的程序、持久数据、相关 systemd 文件、FRP 与蓝牙组件文件和服务状态。随后通过系统设置使用的 `/api/ota/update` 上传接口提交同一候选包;正式 OTA 事务完成,1.1.3 服务报告真实 H618 驱动、PI bank 映射和 PWM4 OE 健康,组件事务及运行期保护均已清理。此次使用上传接口,未单独操作浏览器文件选择界面。
|
||||||
|
- 试装后核对原有 423 个持久文件:422 个内容不变,只有运行元数据 `kernel-recovery.json` 更新了启动时间和启动标识;用户配置与资源保留。FRP、蓝牙启停状态与备份一致。
|
||||||
|
- 试装完成后恢复 1.1.1。程序、持久数据、相关 systemd 与组件文件均与备份字节一致,主服务及 FRP、蓝牙状态一致,1.1.1 服务和真实驱动健康。先前 1.1.2 失败留下的 OTA 一次性 unit 从 `failed` 变为禁用的 `inactive`。候选包、试装版本、数据副本和设备临时备份已清理。
|
||||||
|
- 正式 1.1.3 `.ota` 与已试装候选包 SHA-256 一致;报告见同目录 `实机验收.json`。本次未改驱动或底层输出实现,实屏视觉检查不适用。设备最终仍运行 1.1.1,正式升级由用户自行上传。
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"artifact_type": "ota",
|
||||||
|
"package_sha256": "db44e0b404a664d6d4f0495dea72106e7f7229aca00326f044ab229c6dd67d86",
|
||||||
|
"software_version": "1.1.3",
|
||||||
|
"source_version": "1.1.1",
|
||||||
|
"restored_version": "1.1.1",
|
||||||
|
"status": "success",
|
||||||
|
"validated_at": "2026-09-27T18:38:35+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
|
||||||
|
}
|
||||||
@@ -281,6 +281,8 @@ WantedBy=multi-user.target
|
|||||||
- `DEPLOY-OTA-RUNTIME-LIFECYCLE`:主服务必须使用 `RuntimeDirectoryPreserve=yes`;`restart` 不能保护 OTA 的单独 stop。旧更新器升级时,候选迁移入口必须在停旧服务前安装、重载并核验运行期保护 drop-in,保护请求、日志和进度直到成功或回滚结束。运行数据仍由整机重启清空;临时 drop-in 按事务登记清理,不覆盖用户配置。必须使用真实 systemd unit 复现和验证生命周期,mock 不能代替验收。
|
- `DEPLOY-OTA-RUNTIME-LIFECYCLE`:主服务必须使用 `RuntimeDirectoryPreserve=yes`;`restart` 不能保护 OTA 的单独 stop。旧更新器升级时,候选迁移入口必须在停旧服务前安装、重载并核验运行期保护 drop-in,保护请求、日志和进度直到成功或回滚结束。运行数据仍由整机重启清空;临时 drop-in 按事务登记清理,不覆盖用户配置。必须使用真实 systemd unit 复现和验证生命周期,mock 不能代替验收。
|
||||||
- `TEST-OTA-LOG-RESILIENCE`:诊断目录意外丢失时尝试恢复日志;写入失败保留有限缓冲并输出不含配置或凭据的降级提示,不能阻止回滚或覆盖原始异常。恢复完成不代表升级成功,失败状态与原因必须保留。
|
- `TEST-OTA-LOG-RESILIENCE`:诊断目录意外丢失时尝试恢复日志;写入失败保留有限缓冲并输出不含配置或凭据的降级提示,不能阻止回滚或覆盖原始异常。恢复完成不代表升级成功,失败状态与原因必须保留。
|
||||||
- OTA 当前版本修复必须经用户明确授权,归档旧产物和记录;实际验收的候选包按摘要原样晋升,原子替换当前产物与记录,失败不改写原件、不推进版本。本次 1.1.0 必经节点修复适用此流程。
|
- OTA 当前版本修复必须经用户明确授权,归档旧产物和记录;实际验收的候选包按摘要原样晋升,原子替换当前产物与记录,失败不改写原件、不推进版本。本次 1.1.0 必经节点修复适用此流程。
|
||||||
|
- `DEPLOY-OTA-OFFLINE-CLOSURE`:导出 OTA 前必须核对设备端直接 Python 依赖的离线 wheel、固定版本及 SHA-256 清单;候选包的实际载荷还必须通过 Debian 12/AArch64/Python 3.11 无网络依赖解析和隔离安装。电脑端测试工具依赖不得误入设备端 `requirements-dev.txt`;板端完整 pytest 所需夹具必须随设备源码携带,不能引用 OTA 载荷外的移动工程。缺件或解析失败时不得发布或推进版本。
|
||||||
|
- `DEPLOY-OTA-CANDIDATE`:新 OTA 先导出到正式目录外,候选阶段不修改 `VERSION` 或发布记录。真实设备试装报告绑定候选 SHA-256、目标版本、安装健康与恢复结果;晋升时逐字节复核候选、当前源码、离线依赖和报告,再把同一字节包原子发布。任何失败不得占用版本或留下半成品;已登记的坏包保留原字节并在其 README 明确警示。
|
||||||
|
|
||||||
- `DEPLOY-OTA-CHECKPOINT`:从 1.1.0 起,新增或变更系统软件依赖须增加 minor 并归零 patch;每个 `.0` 是软件安装包且逐个必经。同系列补丁允许跳跃;跨多个系列必须先安装下一个 `.0`,即使调试设备已装依赖也不能跳过版本节点。升级策略与依赖摘要保存在源码,不能引用历史导出目录。
|
- `DEPLOY-OTA-CHECKPOINT`:从 1.1.0 起,新增或变更系统软件依赖须增加 minor 并归零 patch;每个 `.0` 是软件安装包且逐个必经。同系列补丁允许跳跃;跨多个系列必须先安装下一个 `.0`,即使调试设备已装依赖也不能跳过版本节点。升级策略与依赖摘要保存在源码,不能引用历史导出目录。
|
||||||
- `DEPLOY-OTA-COMPONENT-TRANSACTION`:1.1.0 兼容旧 v1 包协议,依赖放在 software 内;后续包用 v2 阻止正式旧更新器越级。旧 worker 的候选迁移入口在实际 pytest 通过后执行组件事务,独立监护与开机恢复处理失败/中断。frpc 完整匹配则跳过,否则离线修复;保留配置、选择项、权限和启停状态,首次默认关闭。普通补丁不携带系统二进制,缺失依赖时拒绝更新并提示修复。维护账户必须可靠确定,不依赖 OTA 环境的 SUDO_USER。
|
- `DEPLOY-OTA-COMPONENT-TRANSACTION`:1.1.0 兼容旧 v1 包协议,依赖放在 software 内;后续包用 v2 阻止正式旧更新器越级。旧 worker 的候选迁移入口在实际 pytest 通过后执行组件事务,独立监护与开机恢复处理失败/中断。frpc 完整匹配则跳过,否则离线修复;保留配置、选择项、权限和启停状态,首次默认关闭。普通补丁不携带系统二进制,缺失依赖时拒绝更新并提示修复。维护账户必须可靠确定,不依赖 OTA 环境的 SUDO_USER。
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
2026-09-26T16:47+08:00
|
2026-09-27T17:51+08:00
|
||||||
|
|||||||
+5
-2
@@ -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,版本号与组件完整性分别检查。同版和降级仍不允许。
|
从 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
|
```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;系统组件缺失时先修复再更新。首装镜像仍携带完整离线依赖。
|
`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 普通账户确定运行账户,无法确定则在修改前失败。
|
frpc 完整匹配时不替换二进制或重启服务;否则离线修复,保留 UUID 配置、选择项和启停状态。初次默认关闭。OTA 从现有 frp 非 root 账户、镜像生成的 `/etc/sudoers.d/90-matrix-screen-controller-account` 或唯一 sudo 普通账户确定运行账户,无法确定则在修改前失败。
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
1.1.2
|
1.1.3
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
-r requirements.txt
|
-r requirements.txt
|
||||||
pytest==8.4.1
|
pytest==8.4.1
|
||||||
httpx==0.28.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.package import build_package
|
||||||
from app.ota.versioning import read_software_version
|
from app.ota.versioning import read_software_version
|
||||||
from app.ota.policy import read_policy
|
from app.ota.policy import read_policy
|
||||||
|
from scripts.offline_wheels import validate_offline_wheels
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
@@ -26,6 +27,7 @@ def main() -> int:
|
|||||||
source_root = Path(__file__).resolve().parents[1]
|
source_root = Path(__file__).resolve().parents[1]
|
||||||
project_root = source_root.parent
|
project_root = source_root.parent
|
||||||
wheelhouse = project_root / "发布更新相关" / "其他依赖" / "aarch64-py311"
|
wheelhouse = project_root / "发布更新相关" / "其他依赖" / "aarch64-py311"
|
||||||
|
validate_offline_wheels(source_root, wheelhouse)
|
||||||
frpc_bundle = project_root / "发布更新相关" / "其他依赖" / "frp" / "0.71.0" / "linux-arm64"
|
frpc_bundle = project_root / "发布更新相关" / "其他依赖" / "frp" / "0.71.0" / "linux-arm64"
|
||||||
version = read_software_version(source_root)
|
version = read_software_version(source_root)
|
||||||
policy = read_policy(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.build_sd_image import build_image, sha256_file
|
||||||
from scripts.fat16_image import Fat16Image
|
from scripts.fat16_image import Fat16Image
|
||||||
from scripts.image_config import create_config_file, read_config_file
|
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:
|
def next_patch(version: SoftwareVersion) -> SoftwareVersion:
|
||||||
@@ -63,7 +64,7 @@ def _ota_readme(version: SoftwareVersion, manifest: dict, notes: str) -> str:
|
|||||||
"在系统设置上传 OTA;每个 minor 的 .0 必须按顺序安装,同系列补丁允许跳过。"
|
"在系统设置上传 OTA;每个 minor 的 .0 必须按顺序安装,同系列补丁允许跳过。"
|
||||||
"1.1.0 含 frpc 0.71.0 离线组件;已有完整组件会跳过,否则修复并保留配置和启停状态。"
|
"1.1.0 含 frpc 0.71.0 离线组件;已有完整组件会跳过,否则修复并保留配置和启停状态。"
|
||||||
"初次安装默认关闭。禁止跳过失败测试;失败自动恢复。\n\n"
|
"初次安装默认关闭。禁止跳过失败测试;失败自动恢复。\n\n"
|
||||||
"本目录是可删除的发布产物,不得被源码、脚本或后续版本引用。\n"
|
"本目录作为 OTA 发布产物长期保留,不得被源码、脚本或后续版本当作构建输入。\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -187,7 +188,7 @@ def main() -> int:
|
|||||||
parser.add_argument("--notes", required=True)
|
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("--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("--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("--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("--validation-report", type=Path, help="matching real-device validation JSON")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -204,6 +205,15 @@ def main() -> int:
|
|||||||
from scripts.repair_ota_release import promote
|
from scripts.repair_ota_release import promote
|
||||||
print(promote(SOURCE_ROOT, args.validated_candidate, args.validation_report, args.notes))
|
print(promote(SOURCE_ROOT, args.validated_candidate, args.validation_report, args.notes))
|
||||||
return 0
|
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
|
image_promotion = args.kind == "image" and args.validated_candidate is not None
|
||||||
if image_promotion:
|
if image_promotion:
|
||||||
if args.validation_report is None:
|
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))
|
print(promote(SOURCE_ROOT, args.validated_candidate, args.validation_report, args.notes))
|
||||||
return 0
|
return 0
|
||||||
if args.validated_candidate is not None or args.validation_report is not None:
|
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:
|
if args.kind == "image" and args.config is None and not args.repair_current:
|
||||||
parser.error("--config is required for image candidate export")
|
parser.error("--config is required for image candidate export")
|
||||||
if args.kind == "image" and not args.repair_current and args.candidate_output is None:
|
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")
|
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:
|
if args.kind == "ota" and args.config is not None:
|
||||||
parser.error("--candidate-output is only supported for image export")
|
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:
|
if args.repair_current and args.config is not None:
|
||||||
parser.error("--repair-current reuses the registered image configuration; do not pass --config")
|
parser.error("--repair-current reuses the registered image configuration; do not pass --config")
|
||||||
if args.kind == "image":
|
if args.kind == "image":
|
||||||
@@ -247,6 +259,7 @@ def main() -> int:
|
|||||||
dependency_bundle = None
|
dependency_bundle = None
|
||||||
if not args.repair_current and args.kind == "ota":
|
if not args.repair_current and args.kind == "ota":
|
||||||
dependency_bundle = export_bundle(source, project / "发布更新相关" / "其他依赖", current, target)
|
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:
|
if args.kind == "image" and not args.repair_current and target <= current:
|
||||||
parser.error("image candidate version must be newer than the current version")
|
parser.error("image candidate version must be newer than the current version")
|
||||||
output_root = project / "发布更新相关" / "OTA数据包" if args.kind == "ota" else project / "发布更新相关" / "导出包"
|
output_root = project / "发布更新相关" / "OTA数据包" if args.kind == "ota" else project / "发布更新相关" / "导出包"
|
||||||
@@ -258,11 +271,11 @@ def main() -> int:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
else:
|
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:
|
if final_directory.exists() and not args.repair_current:
|
||||||
parser.error(f"release directory already exists: {final_directory}")
|
parser.error(f"release directory already exists: {final_directory}")
|
||||||
if official_directory.exists() and args.candidate_output is not None:
|
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():
|
if args.repair_current and not final_directory.is_dir():
|
||||||
parser.error(f"current image release directory is missing: {final_directory}")
|
parser.error(f"current image release directory is missing: {final_directory}")
|
||||||
output_root.mkdir(parents=True, exist_ok=True)
|
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)
|
||||||
+41
@@ -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():
|
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'))
|
vector = json.loads(path.read_text(encoding='utf-8'))
|
||||||
def raw(key):
|
def raw(key):
|
||||||
return bytes.fromhex(vector[key])
|
return bytes.fromhex(vector[key])
|
||||||
|
|||||||
@@ -241,6 +241,8 @@ def release_project(tmp_path, monkeypatch):
|
|||||||
source.mkdir()
|
source.mkdir()
|
||||||
(source / 'VERSION').write_text('1.0.6\n', encoding='utf-8')
|
(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 / '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 / '发布更新相关/其他依赖'
|
deps = tmp_path / '发布更新相关/其他依赖'
|
||||||
binary = deps / 'frp/v/frpc'
|
binary = deps / 'frp/v/frpc'
|
||||||
binary.parent.mkdir(parents=True)
|
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')
|
(binary.parent/'SHA256SUMS').write_text(f'{digest} frpc\n', encoding='utf-8')
|
||||||
wheels = deps / 'aarch64-py311'
|
wheels = deps / 'aarch64-py311'
|
||||||
wheels.mkdir()
|
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': {
|
policy = {'schema_version': 1, 'checkpoints': [{'version': '1.1.0', 'components': {
|
||||||
'frpc': {'version': 'v', 'bundle': 'frp/v', 'sha256': digest}}}]}
|
'frpc': {'version': 'v', 'bundle': 'frp/v', 'sha256': digest}}}]}
|
||||||
(source/'UPGRADE_POLICY.json').write_text(json.dumps(policy), encoding='utf-8')
|
(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
|
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
|
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
|
import tarfile
|
||||||
exporter, source = release_project(tmp_path, monkeypatch)
|
exporter, source = release_project(tmp_path, monkeypatch)
|
||||||
monkeypatch.setattr(sys, 'argv', ['export', 'ota', '--version', '1.1.0', '--notes', 'test'])
|
publish_fixture_ota(tmp_path, monkeypatch, exporter, '1.0.6', '1.1.0', 'test')
|
||||||
assert exporter.main() == 0
|
|
||||||
assert (source/'VERSION').read_text(encoding='utf-8').strip() == '1.1.0'
|
assert (source/'VERSION').read_text(encoding='utf-8').strip() == '1.1.0'
|
||||||
monkeypatch.setattr(sys, 'argv', ['export', 'ota', '--notes', 'patch'])
|
publish_fixture_ota(tmp_path, monkeypatch, exporter, '1.1.0', '1.1.1', 'patch')
|
||||||
assert exporter.main() == 0
|
|
||||||
artifact = next((tmp_path/'发布更新相关/OTA数据包/1.1.1').glob('*.ota'))
|
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:
|
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)
|
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)
|
exporter, source = release_project(tmp_path, monkeypatch)
|
||||||
original = (tmp_path/'发布记录.json').read_bytes()
|
original = (tmp_path/'发布记录.json').read_bytes()
|
||||||
original_version = (source/'VERSION').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):
|
def fail(*args):
|
||||||
raise OSError('injected history write failure')
|
raise OSError('injected history write failure')
|
||||||
monkeypatch.setattr(exporter, '_atomic_json', fail)
|
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])
|
@pytest.mark.parametrize('fail_commit', [False, True])
|
||||||
def test_same_version_repair_promotes_exact_candidate_and_preserves_old_artifact(tmp_path, monkeypatch, fail_commit):
|
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
|
from scripts.repair_ota_release import promote
|
||||||
exporter, source = release_project(tmp_path, monkeypatch)
|
exporter, source = release_project(tmp_path, monkeypatch)
|
||||||
monkeypatch.setattr(sys, 'argv', ['export', 'ota', '--version', '1.1.0', '--notes', 'original'])
|
publish_fixture_ota(tmp_path, monkeypatch, exporter, '1.0.6', '1.1.0', 'original')
|
||||||
exporter.main()
|
|
||||||
artifact=tmp_path/'发布更新相关/OTA数据包/1.1.0/matrix-screen-controller-1.1.0.ota'
|
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()
|
old=artifact.read_bytes();old_history=(tmp_path/'发布记录.json').read_bytes()
|
||||||
(source/'fix.py').write_text('FIXED = True\n',encoding='utf-8')
|
(source/'fix.py').write_text('FIXED = True\n',encoding='utf-8')
|
||||||
|
|||||||
@@ -102,6 +102,12 @@ node --test @($tests.FullName)
|
|||||||
|
|
||||||
`TEST-RELEASE-VERSION` 交替模拟 OTA、镜像、同一代码连续导出两种产物、构建失败、已有目录、并发锁和历史导出 IMG 被删除。OTA 按已登记节点推进;IMG 不使用 OTA 跳转门禁,但新候选必须严格高于当前版本。`image --candidate-output` 只能写正式目录外且不得改变 `VERSION`、发布记录或正式目录;`image --validated-candidate --validation-report` 只接受下一补丁、摘要一致、字段严格且全部门槛为真的无秘密实卡报告,重建期望 bundle 并复核真实 IMG 后才原字节晋升。候选后源码变化、报告多余字段、摘要错误、已有版本、并发、复制或记录写入失败均须保持版本、记录和正式目录字节不变。另行覆盖用户明确授权的 `image --repair-current` 原有同版本修复语义。
|
`TEST-RELEASE-VERSION` 交替模拟 OTA、镜像、同一代码连续导出两种产物、构建失败、已有目录、并发锁和历史导出 IMG 被删除。OTA 按已登记节点推进;IMG 不使用 OTA 跳转门禁,但新候选必须严格高于当前版本。`image --candidate-output` 只能写正式目录外且不得改变 `VERSION`、发布记录或正式目录;`image --validated-candidate --validation-report` 只接受下一补丁、摘要一致、字段严格且全部门槛为真的无秘密实卡报告,重建期望 bundle 并复核真实 IMG 后才原字节晋升。候选后源码变化、报告多余字段、摘要错误、已有版本、并发、复制或记录写入失败均须保持版本、记录和正式目录字节不变。另行覆盖用户明确授权的 `image --repair-current` 原有同版本修复语义。
|
||||||
|
|
||||||
|
`TEST-OTA-LOCAL`/`TEST-RELEASE-VERSION` 增加 `DEPLOY-OTA-OFFLINE-CLOSURE` 与 `DEPLOY-OTA-CANDIDATE`:缺少直接依赖、固定版本不符、wheel 未列入摘要清单时,候选导出须在发布前拒绝;从候选包实际载荷解出软件与 wheelhouse,用目标 Python/AArch64 标签执行 `pip --no-index --dry-run --ignore-installed --only-binary=:all:`,并在板端隔离 venv 完成无网络安装。新 OTA 候选不占版本;晋升仅接受下一补丁、包字节与报告摘要相同、源码与离线材料未变化、真实安装及恢复均通过的报告。拒绝篡改包、过期源码、报告字段错误和发布中断,核对正式包与试装候选逐字节一致。1.1.1 可直接升级到 1.1.3,1.1.2 标记不可用但原包保留。
|
||||||
|
|
||||||
|
隔离复制验收须从候选包的 `software/` 单独运行完整 pytest;设备侧共享协议黄金向量应在 `tests/fixtures/` 随包携带。本机有移动工程时再对照其向量逐字节核验,OTA/板端不得因移动工程目录缺席而跳过设备端协议测试。
|
||||||
|
|
||||||
|
历史排障经验:电脑端测试工具的依赖只写入其独立环境,不写入设备 `requirements-dev.txt`;发布前从候选包实际载荷在板端隔离 venv 执行 `pip install --no-index`,并确认完整 pytest 通过。实机 OTA 后 `kernel-recovery.json` 的启动时间和启动标识可能更新,用户配置与资源应分别核对;恢复原版本时程序、持久数据和组件文件按备份字节复核。旧失败留下的 OTA 一次性 unit 可由 `failed` 变为禁用的 `inactive`,主服务及 FRP、蓝牙的启停状态仍须与备份一致。
|
||||||
|
|
||||||
`TEST-IMAGE-LOCAL` 校验官方 IMG 摘要在导出前后不变;独立读取 MBR、FAT16、rootfs 离线 bundle、软件版本和全部 SHA-256。镜像元数据必须严格为 v3,记录应用载荷 rootfs 路径、压缩字节数和摘要,以及已登记内核 release、压缩/展开字节数、摘要、FAT 安装预算和 `32 MiB` 安全余量;FAT 必须不存在 `MSCBOOT.TGZ`。预编译载荷逐项覆盖外层/内部摘要、3,048 个模块普通文件、五个 boot 文件、固定 release/commit、确定性 gzip、路径穿越、符号链接、特殊文件和损坏元数据。隔离假根验证应用与内核载荷共同写入、读回、既有目标拒绝及“两类压缩载荷 + 256 MiB”空间门槛;合成 FAT 分别覆盖空间通过、边界和不足,预算按簇计入五个 boot 文件、受管/原始/临时启动脚本与状态文件,并确认不足发生在任何候选 boot 文件写入前。Linux 上再只读挂载真实复制件根分区,逐字节比对两类载荷和 FAT 元数据,并确认 `matrix-image-firstboot.service` 与 `ssh.service` 均已启用、受管 sshd 配置逐字匹配、一次性程序指向 FAT 分区且排序早于 SSH 和普通启动脚本。根分区还必须包含 `sshd`、`sudo` 和 `visudo`,SSH 不得被 mask。以 `BASE_IMAGE_PACKAGES.tsv` 为基准解析 FFmpeg、libheif 和 Python venv 的全部直接/传递 Debian 依赖;删除任意仅由闭包提供的包、根包、基准清单、内核依赖或摘要条目时,构建必须在复制正式镜像前失败。导出入口必须在 `python3 -S` 下完成参数解析,不得因 Pillow 或 FontTools 未安装而在发布锁前退出。静态回归必须确认首启写入网络配置并重启 NetworkManager 后直接进入部署,不得循环调用 `nmcli connection up`、等待 SSID/DHCP 或以默认路由缺失退出;`DEFER_SERVICE_START=1` 的全新安装和同版本恢复均使用不要求默认路由的 `dedicated_host.py check --service`,普通部署的严格检查仍要求默认路由。离线完成状态必须固定、无凭据并明确安装成功和 WiFi 未连接。首启只启用控制服务,SSH 也只能 `enable`、不能在具有 `Before=` 排序的 oneshot 内 `start` 或 `enable --now`;账户必须显式加入 `sudo` 组,sudoers 临时文件先经 `visudo` 校验再原子安装,实际 sshd 策略必须经 `sshd -t/-T` 校验。同版本中断恢复必须拒绝版本或安装文件不完整的 `/opt`。破坏活动配置槽必须回退旧槽,两个槽都损坏、产品错误、未知 schema、非法账户/WiFi/IPv4 均拒绝。编辑器覆盖打开、密码遮挡、修改原包确认、另存为不改原包和另存后自动重开;中文、空格及长路径必须生成无 BOM UTF-8 规范摘要并由独立 `Get-FileHash` 复核,摘要长度、十六进制、分隔符、多行、文件名或内容错误以及旧版中文名变成 `?` 的摘要均拒绝。保留名、错误扩展名、同路径、既有 IMG、孤立同名摘要和不可写目录必须在修改前拒绝且不留半成品。主窗口、密码输入框和全部应用内提示在 100%、150%、200% 缩放及约 1280×720、1920×1080、4K 工作区完整可达,跨不同 DPI 显示器或改变分辨率后不得裁切;低分辨率允许滚动。镜像扫描不得包含项目凭据、当前设备数据、SSH 主机密钥、NetworkManager 连接、缓存或编译输出。
|
`TEST-IMAGE-LOCAL` 校验官方 IMG 摘要在导出前后不变;独立读取 MBR、FAT16、rootfs 离线 bundle、软件版本和全部 SHA-256。镜像元数据必须严格为 v3,记录应用载荷 rootfs 路径、压缩字节数和摘要,以及已登记内核 release、压缩/展开字节数、摘要、FAT 安装预算和 `32 MiB` 安全余量;FAT 必须不存在 `MSCBOOT.TGZ`。预编译载荷逐项覆盖外层/内部摘要、3,048 个模块普通文件、五个 boot 文件、固定 release/commit、确定性 gzip、路径穿越、符号链接、特殊文件和损坏元数据。隔离假根验证应用与内核载荷共同写入、读回、既有目标拒绝及“两类压缩载荷 + 256 MiB”空间门槛;合成 FAT 分别覆盖空间通过、边界和不足,预算按簇计入五个 boot 文件、受管/原始/临时启动脚本与状态文件,并确认不足发生在任何候选 boot 文件写入前。Linux 上再只读挂载真实复制件根分区,逐字节比对两类载荷和 FAT 元数据,并确认 `matrix-image-firstboot.service` 与 `ssh.service` 均已启用、受管 sshd 配置逐字匹配、一次性程序指向 FAT 分区且排序早于 SSH 和普通启动脚本。根分区还必须包含 `sshd`、`sudo` 和 `visudo`,SSH 不得被 mask。以 `BASE_IMAGE_PACKAGES.tsv` 为基准解析 FFmpeg、libheif 和 Python venv 的全部直接/传递 Debian 依赖;删除任意仅由闭包提供的包、根包、基准清单、内核依赖或摘要条目时,构建必须在复制正式镜像前失败。导出入口必须在 `python3 -S` 下完成参数解析,不得因 Pillow 或 FontTools 未安装而在发布锁前退出。静态回归必须确认首启写入网络配置并重启 NetworkManager 后直接进入部署,不得循环调用 `nmcli connection up`、等待 SSID/DHCP 或以默认路由缺失退出;`DEFER_SERVICE_START=1` 的全新安装和同版本恢复均使用不要求默认路由的 `dedicated_host.py check --service`,普通部署的严格检查仍要求默认路由。离线完成状态必须固定、无凭据并明确安装成功和 WiFi 未连接。首启只启用控制服务,SSH 也只能 `enable`、不能在具有 `Before=` 排序的 oneshot 内 `start` 或 `enable --now`;账户必须显式加入 `sudo` 组,sudoers 临时文件先经 `visudo` 校验再原子安装,实际 sshd 策略必须经 `sshd -t/-T` 校验。同版本中断恢复必须拒绝版本或安装文件不完整的 `/opt`。破坏活动配置槽必须回退旧槽,两个槽都损坏、产品错误、未知 schema、非法账户/WiFi/IPv4 均拒绝。编辑器覆盖打开、密码遮挡、修改原包确认、另存为不改原包和另存后自动重开;中文、空格及长路径必须生成无 BOM UTF-8 规范摘要并由独立 `Get-FileHash` 复核,摘要长度、十六进制、分隔符、多行、文件名或内容错误以及旧版中文名变成 `?` 的摘要均拒绝。保留名、错误扩展名、同路径、既有 IMG、孤立同名摘要和不可写目录必须在修改前拒绝且不留半成品。主窗口、密码输入框和全部应用内提示在 100%、150%、200% 缩放及约 1280×720、1920×1080、4K 工作区完整可达,跨不同 DPI 显示器或改变分辨率后不得裁切;低分辨率允许滚动。镜像扫描不得包含项目凭据、当前设备数据、SSH 主机密钥、NetworkManager 连接、缓存或编译输出。
|
||||||
|
|
||||||
`TEST-IMAGE-LOCAL` 的编辑器回归必须直接运行 Python/PySide6 源码单元测试,并将最终 Windows 10/11 x64 单 EXE 单独复制到隔离临时目录,在不依赖源码目录、Python、.NET、Qt 或外部 DLL 的环境中重复 `--validate` 与 `--apply`。同一合成 FAT16 镜像还必须由主工程 `image_config.py` 交叉读取,并覆盖非活动槽更新、活动槽损坏回退和失败不留新 IMG/摘要/临时文件。GUI 人工验收继续覆盖单 IMG 拖放、密码遮挡、确认流程、另存后自动重开、进度响应以及 100%/150%/200% DPI、1280×720、1080p、4K 和跨显示器变化。macOS 只验证源码和构建说明完整,未在 macOS 实际构建时不得写成 macOS 产物通过。
|
`TEST-IMAGE-LOCAL` 的编辑器回归必须直接运行 Python/PySide6 源码单元测试,并将最终 Windows 10/11 x64 单 EXE 单独复制到隔离临时目录,在不依赖源码目录、Python、.NET、Qt 或外部 DLL 的环境中重复 `--validate` 与 `--apply`。同一合成 FAT16 镜像还必须由主工程 `image_config.py` 交叉读取,并覆盖非活动槽更新、活动槽损坏回退和失败不留新 IMG/摘要/临时文件。GUI 人工验收继续覆盖单 IMG 拖放、密码遮挡、确认流程、另存后自动重开、进度响应以及 100%/150%/200% DPI、1280×720、1080p、4K 和跨显示器变化。macOS 只验证源码和构建说明完整,未在 macOS 实际构建时不得写成 macOS 产物通过。
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
本文件维护步骤与合格标准,不写一次性通过结果。每轮测试前阅读 [常见问题与排障](常见问题与排障.md),优先沿用已验证方法。结果记录到 `../测试结果归档/`。真机先检查 `测试设备登记/prepare_test_device.py`,多机精确选择,日志仅使用代号。源码与 UI 尚未实现的测试不得写成通过。
|
本文件维护步骤与合格标准,不写一次性通过结果。每轮测试前阅读 [常见问题与排障](常见问题与排障.md),优先沿用已验证方法。结果记录到 `../测试结果归档/`。真机先检查 `测试设备登记/prepare_test_device.py`,多机精确选择,日志仅使用代号。源码与 UI 尚未实现的测试不得写成通过。
|
||||||
|
|
||||||
|
远端联调脚本在电脑端使用 `requirements-host.txt` 的 Paramiko;它不属于核桃派离线安装依赖,不能加入设备端 `requirements-dev.txt` 或 OTA wheelhouse。
|
||||||
|
|
||||||
## 1. 门禁
|
## 1. 门禁
|
||||||
|
|
||||||
- 用户授权主测试机可安装覆盖本 App、清理 App 测试数据、操作权限及蓝牙;自动测试点击使用 root,也不操作其他应用数据。
|
- 用户授权主测试机可安装覆盖本 App、清理 App 测试数据、操作权限及蓝牙;自动测试点击使用 root,也不操作其他应用数据。
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
paramiko==4.0.0
|
||||||
@@ -18,6 +18,8 @@
|
|||||||
|
|
||||||
## 后续登记模板
|
## 后续登记模板
|
||||||
|
|
||||||
|
2026-09-27,DEPLOY-OTA-OFFLINE-CLOSURE/DEPLOY-OTA-CANDIDATE:设备端移除误加入的电脑测试专用 Paramiko 依赖,改为检查 OTA 离线依赖闭包并按实机验证的原字节候选发布 1.1.3。REST、BLE、驱动及移动端协议均不变;无需 App 更新或重新签名。实机试装和恢复结果以本轮验收记录为准。
|
||||||
|
|
||||||
2026-09-26,WEB-PERFORMANCE-MODE/DEPLOY-OTA-KERNEL-RECOVERY:设备 REST 的 `/api/ota/status` 增加可选 `kernel_recovery` 状态,`/api/status.system.performance_mode.last_error` 对缺失 cpufreq 给出具体原因;已有字段和 BLE 协议不变。旧 App 忽略新增 REST 字段,无需 App 更新。本轮仅改设备端;本机和实机验证结果以本次交付记录为准。
|
2026-09-26,WEB-PERFORMANCE-MODE/DEPLOY-OTA-KERNEL-RECOVERY:设备 REST 的 `/api/ota/status` 增加可选 `kernel_recovery` 状态,`/api/status.system.performance_mode.last_error` 对缺失 cpufreq 给出具体原因;已有字段和 BLE 协议不变。旧 App 忽略新增 REST 字段,无需 App 更新。本轮仅改设备端;本机和实机验证结果以本次交付记录为准。
|
||||||
|
|
||||||
2026-09-25,MOBILE-RECONNECT/MOBILE-ERRORS:App 增加连接/扫描代次隔离,主动断开取消未完成命令,清理失效画面与状态缓存;轮询收到业务拒绝时保留连接。模拟平台仅编入 commonTest,通过真实分片/RPC 层测试旧回调、传输失败、未完成操作取消后的恢复。协议不变、设备不需更新;本轮首版授权内实施,真机结果另行归档。
|
2026-09-25,MOBILE-RECONNECT/MOBILE-ERRORS:App 增加连接/扫描代次隔离,主动断开取消未完成命令,清理失效画面与状态缓存;轮询收到业务拒绝时保留连接。模拟平台仅编入 commonTest,通过真实分片/RPC 层测试旧回调、传输失败、未完成操作取消后的恢复。协议不变、设备不需更新;本轮首版授权内实施,真机结果另行归档。
|
||||||
|
|||||||
Reference in New Issue
Block a user