feat(update): add verifiable release manifest

This commit is contained in:
chengma
2026-07-13 11:58:05 +08:00
parent d4a3df4df0
commit 567d4e7b68
7 changed files with 281 additions and 17 deletions
+174
View File
@@ -0,0 +1,174 @@
"""发布包清单与服务端发布元数据生成。"""
import argparse
import hashlib
import json
from pathlib import Path
PACKAGE_FORMAT = "cmshopee-portable-v1"
UPDATER_PROTOCOL = 1
ENTRY_POINT = "cmshopee.exe"
MANIFEST_FILENAME = "package-manifest.json"
ALLOWED_ROOTS = {
ENTRY_POINT,
"_internal",
"version.txt",
"README.txt",
MANIFEST_FILENAME,
"cmshopee-updater.exe",
}
FORBIDDEN_ROOTS = {"data", ".cmshopee-update"}
class ReleaseManifestError(ValueError):
"""发布目录不符合自动升级包契约。"""
def sha256_file(path):
digest = hashlib.sha256()
with Path(path).open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _normalized_relative(path, root):
relative = path.relative_to(root).as_posix()
if relative.startswith("/") or ".." in Path(relative).parts:
raise ReleaseManifestError("发布包包含不安全的文件路径:%s" % relative)
return relative
def build_package_manifest(release_dir, app_version):
release_dir = Path(release_dir).resolve()
if not release_dir.is_dir():
raise ReleaseManifestError("发布目录不存在:%s" % release_dir)
required = [ENTRY_POINT, "_internal", "version.txt", "README.txt"]
missing = [name for name in required if not (release_dir / name).exists()]
if missing:
raise ReleaseManifestError("发布目录缺少必要项目:%s" % "、".join(missing))
if not (release_dir / "_internal").is_dir():
raise ReleaseManifestError("_internal 必须是目录")
version_text = (release_dir / "version.txt").read_text(
encoding="utf-8-sig"
).strip()
if version_text != app_version:
raise ReleaseManifestError(
"version.txt 与应用版本不一致:%s != %s"
% (version_text, app_version)
)
root_names = {entry.name for entry in release_dir.iterdir()}
forbidden = sorted(root_names & FORBIDDEN_ROOTS)
if forbidden:
raise ReleaseManifestError("发布目录包含用户数据:%s" % "、".join(forbidden))
unknown = sorted(root_names - ALLOWED_ROOTS)
if unknown:
raise ReleaseManifestError("发布目录包含未授权根项目:%s" % "、".join(unknown))
files = []
seen = set()
for path in sorted(release_dir.rglob("*"), key=lambda value: value.as_posix().lower()):
if path.is_symlink():
raise ReleaseManifestError("发布包不允许符号链接:%s" % path)
if not path.is_file() or path.name == MANIFEST_FILENAME:
continue
relative = _normalized_relative(path, release_dir)
key = relative.casefold()
if key in seen:
raise ReleaseManifestError("发布包包含重复路径:%s" % relative)
seen.add(key)
files.append(
{
"path": relative,
"size_bytes": path.stat().st_size,
"sha256": sha256_file(path),
}
)
replace_roots = sorted(root_names | {MANIFEST_FILENAME}, key=str.casefold)
return {
"schema_version": 1,
"package_format": PACKAGE_FORMAT,
"app_version": app_version,
"entry_point": ENTRY_POINT,
"updater_protocol": UPDATER_PROTOCOL,
"min_updater_protocol": UPDATER_PROTOCOL,
"replace_roots": replace_roots,
"files": files,
"signature_algorithm": "",
"manifest_signature": "",
}
def write_package_manifest(release_dir, app_version):
release_dir = Path(release_dir)
manifest = build_package_manifest(release_dir, app_version)
output = release_dir / MANIFEST_FILENAME
output.write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
return output
def build_release_metadata(zip_path, app_version):
zip_path = Path(zip_path)
if not zip_path.is_file():
raise ReleaseManifestError("发布压缩包不存在:%s" % zip_path)
return {
"platform": "windows",
"release": {
"version": app_version,
"force_update": False,
"min_supported_version": "",
"download_url": "",
"download_filename": zip_path.name,
"sha256": sha256_file(zip_path),
"size_bytes": zip_path.stat().st_size,
"package_format": PACKAGE_FORMAT,
"updater_protocol": UPDATER_PROTOCOL,
"min_updater_protocol": UPDATER_PROTOCOL,
"release_notes": "",
"signature_algorithm": "",
"manifest_signature": "",
},
}
def write_release_metadata(zip_path, app_version, output_path):
metadata = build_release_metadata(zip_path, app_version)
output_path = Path(output_path)
output_path.write_text(
json.dumps(metadata, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
return output_path
def main(argv=None):
parser = argparse.ArgumentParser(description="生成蝦皮圈优化助手发布校验文件")
subparsers = parser.add_subparsers(dest="command", required=True)
manifest_parser = subparsers.add_parser("manifest")
manifest_parser.add_argument("--release-dir", required=True)
manifest_parser.add_argument("--version", required=True)
metadata_parser = subparsers.add_parser("metadata")
metadata_parser.add_argument("--zip", required=True)
metadata_parser.add_argument("--version", required=True)
metadata_parser.add_argument("--output", required=True)
args = parser.parse_args(argv)
if args.command == "manifest":
write_package_manifest(args.release_dir, args.version)
else:
write_release_metadata(args.zip, args.version, args.output)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+8
View File
@@ -498,6 +498,7 @@ cmshopee/
│ ├── cdp.py # CDP 底座(由根目录 cdp.py 迁入) │ ├── cdp.py # CDP 底座(由根目录 cdp.py 迁入)
│ ├── appconfig.py / db.py / excel.py / config.py / accounts.py / chrome.py │ ├── appconfig.py / db.py / excel.py / config.py / accounts.py / chrome.py
│ ├── editor.py / ai.py / prompts.py / gui.py / workers.py │ ├── editor.py / ai.py / prompts.py / gui.py / workers.py
│ ├── release_manifest.py # 发布包文件清单、zip哈希与服务端元数据模板
├── main.py # GUI 启动入口:from app.gui import main ├── main.py # GUI 启动入口:from app.gui import main
├── shopee待处理任务模板.xlsx # 标准空 Excel 模板,可提交;业务填写后的副本不提交 ├── shopee待处理任务模板.xlsx # 标准空 Excel 模板,可提交;业务填写后的副本不提交
├── data/ # 用户本地数据根(整体 gitignore;打包更新时保留) ├── data/ # 用户本地数据根(整体 gitignore;打包更新时保留)
@@ -520,6 +521,13 @@ cmshopee/
## 十、架构纪律 ## 十、架构纪律
### 10.1 发布与自动升级边界
- `app/version.py` 是应用版本唯一来源;`app/release_manifest.py` 生成 `package-manifest.json` 和 `release-metadata.json`,构建脚本不得自行维护另一套 hash 或路径规则。
- 发布包格式固定为 `cmshopee-portable-v1`,入口为 `cmshopee.exe`,程序依赖集中在 `_internal/`。manifest 覆盖除自身外的所有程序文件,并记录规范化相对路径、字节数和 SHA-256。
- 发布包只能包含程序根项目,`data/` 和 `.cmshopee-update/` 永远在替换边界之外。第一阶段预留签名字段,但 SHA-256 只负责传输完整性,不等同于发布者身份认证。
- T-615 只提供可验证发布契约;启动门禁仍保持 T-544 的人工下载行为,直到后续下载、独立更新器、事务替换和失败熔断任务全部接入。
- CDP 交互事实变化同步第七节。 - CDP 交互事实变化同步第七节。
- 正式代码只放 `app/` 包;根目录只保留 `main.py`、配置/数据目录、文档和原型目录,不新增正式业务模块。 - 正式代码只放 `app/` 包;根目录只保留 `main.py`、配置/数据目录、文档和原型目录,不新增正式业务模块。
- `app` 包内模块优先用相对导入(如 `from .cdp import CDP`);根入口 `main.py` 用 `from app.gui import main`。 - `app` 包内模块优先用相对导入(如 `from .cdp import CDP`);根入口 `main.py` 用 `from app.gui import main`。
+10 -10
View File
@@ -1,6 +1,6 @@
# 打包与分发 # 打包与分发
> T-524 目标:先把当前 Windows 桌面工具打成免安装 onedir `.exe`,方便第一版交付使用。暂不做自动更新器、安装器、增量补丁或在线升级。 > 当前发布仍是免安装 onedir;T-615 已增加自动升级所需的机器可验证清单和发布元数据,但尚不下载或替换客户端程序。
## 一、打包前提 ## 一、打包前提
@@ -53,7 +53,7 @@ cmshopee\
## 三、版本号与发布目录 ## 三、版本号与发布目录
参考 `D:\chengma\cmbot` 的打包方式,版本控制采用“单一版本源 + 发布目录带版本号”的轻量方案,暂不引入启动器、自动更新或 manifest。 版本控制采用“单一版本源 + 发布目录带版本号 + 可验证 manifest”的方案。
### 3.1 单一版本源 ### 3.1 单一版本源
@@ -86,7 +86,9 @@ release\
<PyInstaller 依赖文件和依赖目录> <PyInstaller 依赖文件和依赖目录>
version.txt version.txt
README.txt README.txt
package-manifest.json
蝦皮圈優化助手0.1.0.zip 蝦皮圈優化助手0.1.0.zip
release-metadata.json
``` ```
说明: 说明:
@@ -96,6 +98,8 @@ release\
- `release\蝦皮圈優化助手<APP_VERSION>.zip` 是交付给用户的便携压缩包。 - `release\蝦皮圈優化助手<APP_VERSION>.zip` 是交付给用户的便携压缩包。
- `version.txt` 使用 ASCII/UTF-8 无 BOM 写入 `APP_VERSION`,用于人工排查和未来更新机制读取。 - `version.txt` 使用 ASCII/UTF-8 无 BOM 写入 `APP_VERSION`,用于人工排查和未来更新机制读取。
- `README.txt` 用中文写明启动方式、不要放入 `Program Files`、保留 `data\`、升级时覆盖程序文件但不覆盖 `data\`。 - `README.txt` 用中文写明启动方式、不要放入 `Program Files`、保留 `data\`、升级时覆盖程序文件但不覆盖 `data\`。
- `package-manifest.json` 记录包格式、版本、入口、更新器协议、允许替换根项目,以及除清单自身外每个程序文件的规范化路径、字节数和 SHA-256。
- `release-metadata.json` 记录整个 zip 的准确字节数和 SHA-256,并提供服务端发布字段模板;它不放进 zip。
当前 PyInstaller 6.11.1 的 onedir 为集中依赖结构,所以 `release\蝦皮圈優化助手<APP_VERSION>\` 必须包含 `cmshopee.exe` 和 `_internal\`,不得只复制单个 exe。 当前 PyInstaller 6.11.1 的 onedir 为集中依赖结构,所以 `release\蝦皮圈優化助手<APP_VERSION>\` 必须包含 `cmshopee.exe` 和 `_internal\`,不得只复制单个 exe。
@@ -104,7 +108,7 @@ release\
1. 修改 `app/version.py` 中的 `APP_VERSION`。 1. 修改 `app/version.py` 中的 `APP_VERSION`。
2. 运行语法检查和单元测试。 2. 运行语法检查和单元测试。
3. 运行 `powershell -ExecutionPolicy Bypass -File scripts\build_exe.ps1`。 3. 运行 `powershell -ExecutionPolicy Bypass -File scripts\build_exe.ps1`。
4. 检查 `release\蝦皮圈優化助手<APP_VERSION>\version.txt`、GUI 标题栏版本、压缩包文件名三者一致。 4. 检查 `version.txt`、GUI 标题栏、`package-manifest.json`、压缩包文件名和 `release-metadata.json` 的版本一致。
5. 在无 Python 环境的 Windows 10/11 机器上解压 `蝦皮圈優化助手<APP_VERSION>.zip` 并启动验证。 5. 在无 Python 环境的 Windows 10/11 机器上解压 `蝦皮圈優化助手<APP_VERSION>.zip` 并启动验证。
正式发布构建固定使用 Python 3.10。源码在 Python 3.7.9 上曾验证可运行,但发布包不再以 3.7.9 作为打包基线;脚本会直接调用 `py -3.10` 并校验返回版本必须是 `3.10.x`,避免同一个 `APP_VERSION` 在不同 Python 版本下打出不可比对的包。CI 仍可继续使用 Python 3.11 做自动测试。 正式发布构建固定使用 Python 3.10。源码在 Python 3.7.9 上曾验证可运行,但发布包不再以 3.7.9 作为打包基线;脚本会直接调用 `py -3.10` 并校验返回版本必须是 `3.10.x`,避免同一个 `APP_VERSION` 在不同 Python 版本下打出不可比对的包。CI 仍可继续使用 Python 3.11 做自动测试。
@@ -166,15 +170,11 @@ T-544 第一版目标是**启动时检查是否必须升级**,但仍不做自
原因:Windows 下运行中的 `cmshopee.exe` 和 PyInstaller onedir 依赖目录很容易出现文件占用、半覆盖失败、权限不足或杀毒拦截。第一版只做“强制提示 + 下载新版 + 用户手动覆盖”,和当前便携包更新方式一致。 原因:Windows 下运行中的 `cmshopee.exe` 和 PyInstaller onedir 依赖目录很容易出现文件占用、半覆盖失败、权限不足或杀毒拦截。第一版只做“强制提示 + 下载新版 + 用户手动覆盖”,和当前便携包更新方式一致。
### 3.5 暂不引入自动覆盖升级 ### 3.5 自动升级发布基础(T-615)
本阶段不迁移 `cmbot` 的 `Launcher.exe`、`manifest.json`、SHA-256 下载校验、自动替换程序目录或增量补丁。原因: `app/release_manifest.py` 是发布清单与 zip 元数据的唯一生成逻辑。构建脚本按以下顺序执行:组装 release 目录、拒绝用户数据和未知根项目、生成 `package-manifest.json`、压缩 zip、计算 zip hash/大小、生成 `release-metadata.json`。允许的根项目仅为 `cmshopee.exe`、`_internal/`、`version.txt`、`README.txt`、清单和后续独立更新器;明确禁止 `data/` 与 `.cmshopee-update/`。
- 当前用户已验证“拷贝发布目录到无 Python 环境运行”的第一版交付路径。 T-615 不改变客户端行为:T-544 仍只打开浏览器下载。客户端下载、解压校验、独立进程替换、回滚和重启分别由 T-616 至 T-619 实现。清单预留签名字段,但当前只验证完整性,不宣称已验证发布者身份。
- `cmshopee` 刚完成 `data\` 数据隔离,先把手动发版和版本号一致性做稳。
- 自动更新需要安装根可写、下载暂存、目录切换、回滚和安全校验,属于独立后续任务,不能混入当前打包版本号控制。
后续如果要做真正自动覆盖升级,再另起任务,参考 `cmbot` 的 `Launcher.exe + app\ + manifest.json + sha256 + version.txt` 设计。
## 四、绝不打包的本地数据 ## 四、绝不打包的本地数据
+4 -2
View File
@@ -3,7 +3,7 @@ id: T-615
title: 自动升级发布契约与可验证发布清单 title: 自动升级发布契约与可验证发布清单
phase: 8 phase: 8
deps: [T-540, T-544] deps: [T-540, T-544]
status: TODO status: DONE
created: 2026-07-13 created: 2026-07-13
--- ---
@@ -43,4 +43,6 @@ created: 2026-07-13
## 执行记录 ## 执行记录
(完成后记录实现、验证命令与结果。) - 2026-07-13:新增 `app/release_manifest.py`,统一生成程序文件清单与zip发布元数据;构建脚本在压缩前生成manifest、压缩后生成准确hash和大小。
- 2026-07-13:发布目录仅允许程序根项目,拒绝 `data/`、`.cmshopee-update/`、未知根项目、版本不一致、重复路径和符号链接;同步更新发布契约、打包文档与架构边界。
- 2026-07-13:干净worktree验证通过:`python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`py -3.10 -m unittest discover -s tests`(394项)、`git diff --check`。
+16 -4
View File
@@ -1,7 +1,7 @@
# 版本检查接口契约(启动强制升级) # 版本检查接口契约(启动强制升级)
> 客户端在启动时请求版本接口,判断是否需要**强制升级**。本文是**服务端响应格式的权威契约**——字段以 `app/update_check.py` 的实际读取逻辑为准,服务端按此返回,避免格式漂移。 > 客户端在启动时请求版本接口,判断是否需要**强制升级**。本文是**服务端响应格式的权威契约**——字段以 `app/update_check.py` 的实际读取逻辑为准,服务端按此返回,避免格式漂移。
> 客户端逻辑已由 T-544 实现,本文不要求改客户端代码;发强制版时按「发版约定」配置服务端响应即可。 > T-544 已实现启动检查与浏览器下载引导;T-615 起增加自动安装所需的发布契约。T-615 只生成可验证发布包,客户端下载与替换由后续任务实现。
## 一、接口 ## 一、接口
@@ -22,8 +22,12 @@
| `version` / `latest_version` | string | 最新版本号。客户端取 `payload.latest_version` 或 `release.version`。**`latest_version` 与 `min_supported_version` 至少要有一个**,否则客户端判为"接口缺字段"、按失败放行 | | `version` / `latest_version` | string | 最新版本号。客户端取 `payload.latest_version` 或 `release.version`。**`latest_version` 与 `min_supported_version` 至少要有一个**,否则客户端判为"接口缺字段"、按失败放行 |
| `force_update` | bool | 是否强制升级。`true`/`1`/`yes`/`y`/`on`(大小写不敏感)都识别为真 | | `force_update` | bool | 是否强制升级。`true`/`1`/`yes`/`y`/`on`(大小写不敏感)都识别为真 |
| `min_supported_version` | string | 最低支持版本;本地低于它一律强制 | | `min_supported_version` | string | 最低支持版本;本地低于它一律强制 |
| `download_url` | string(URL) | 「下载新版」按钮打开的地址(用系统浏览器打开)| | `download_url` | string(URL) | 下载地址;自动安装时必须是 HTTPS |
| `sha256` | string | 安装包校验值;**第一版不校验,可留空** | | `sha256` | string | zip 的 SHA-256;自动安装时必须是 64 位十六进制且不可为空 |
| `size_bytes` | integer | zip 的准确字节数;自动安装时必须大于 0 |
| `package_format` | string | 自动安装固定为 `cmshopee-portable-v1` |
| `updater_protocol` | integer | 发布包要求的更新器协议版本,当前为 `1` |
| `min_updater_protocol` | integer | 可安装此包的最低更新器协议,当前为 `1` |
| `release_notes` / `message` | string | 升级说明,显示在强制升级弹窗里。客户端取 `message` 或 `release_notes` | | `release_notes` / `message` | string | 升级说明,显示在强制升级弹窗里。客户端取 `message` 或 `release_notes` |
版本号比较用**语义化数字段比较**(`compare_versions`,逐段比数字),不是字符串字典序:`0.1.10 > 0.1.9`。 版本号比较用**语义化数字段比较**(`compare_versions`,逐段比数字),不是字符串字典序:`0.1.10 > 0.1.9`。
@@ -45,6 +49,8 @@
## 五、发版约定(服务端据此控制) ## 五、发版约定(服务端据此控制)
自动安装使用的全部字段必须放在同一个 `release` 对象内,不得把版本取自一个对象、hash 取自另一个对象。构建脚本生成的 `release/release-metadata.json` 是服务端录入模板;发布人员只补 HTTPS `download_url`、强制策略和中文发布说明,不得手工改写 hash、大小、包格式或协议版本。
| 想要的效果 | 服务端返回 | | 想要的效果 | 服务端返回 |
| --- | --- | | --- | --- |
| **强制升到最新**(方案 A) | `release.force_update: true` + `release.version` 高于要淘汰的客户端版本 | | **强制升到最新**(方案 A) | `release.force_update: true` + `release.version` 高于要淘汰的客户端版本 |
@@ -61,7 +67,11 @@
"release": { "release": {
"version": "0.1.1", "version": "0.1.1",
"download_url": "https://cm.833729.com/media/downloads/%E8%9D%A6%E7%9A%AE%E5%9C%88%E5%84%AA%E5%8C%96%E5%8A%A9%E6%89%8B0.1.1.zip", "download_url": "https://cm.833729.com/media/downloads/%E8%9D%A6%E7%9A%AE%E5%9C%88%E5%84%AA%E5%8C%96%E5%8A%A9%E6%89%8B0.1.1.zip",
"sha256": "", "sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"size_bytes": 123456789,
"package_format": "cmshopee-portable-v1",
"updater_protocol": 1,
"min_updater_protocol": 1,
"release_notes": "优化了ai模块的生图的功能", "release_notes": "优化了ai模块的生图的功能",
"published_at": "2026-07-08T14:30:26.935549+08:00", "published_at": "2026-07-08T14:30:26.935549+08:00",
"force_update": true "force_update": true
@@ -104,3 +114,5 @@
- 服务端返回 `force_update`/`min_supported_version` 属**本仓库外的服务端配置**;客户端代码(`app/update_check.py`/`app/version.py`/启动流程)已按本契约实现,**发强制版不需要改客户端**。 - 服务端返回 `force_update`/`min_supported_version` 属**本仓库外的服务端配置**;客户端代码(`app/update_check.py`/`app/version.py`/启动流程)已按本契约实现,**发强制版不需要改客户端**。
- `download_url` 里的中文可用 URL 编码(如 `%E8%9D%A6...`),系统浏览器能正确打开。 - `download_url` 里的中文可用 URL 编码(如 `%E8%9D%A6...`),系统浏览器能正确打开。
- 若将来要"非强制也温和提示(可跳过、不阻断)",需在客户端加一个非强制分支,另立任务。 - 若将来要"非强制也温和提示(可跳过、不阻断)",需在客户端加一个非强制分支,另立任务。
- 空 `sha256` 只兼容 T-544 的人工下载引导,绝不能进入自动安装。自动安装还必须同时校验 HTTPS、`size_bytes`、包格式和更新器协议。
- `manifest_signature` 与 `signature_algorithm` 是预留字段;当前未启用数字签名,不能将 SHA-256 描述为发布者身份认证。
+11 -1
View File
@@ -153,8 +153,9 @@ $releaseRoot = Join-Path $repoRoot "release"
$releaseName = "$appName$appVersion" $releaseName = "$appName$appVersion"
$releaseDir = Join-Path $releaseRoot $releaseName $releaseDir = Join-Path $releaseRoot $releaseName
$portableZip = Join-Path $releaseRoot "$releaseName.zip" $portableZip = Join-Path $releaseRoot "$releaseName.zip"
$releaseMetadata = Join-Path $releaseRoot "release-metadata.json"
foreach ($path in @($releaseDir, $portableZip)) { foreach ($path in @($releaseDir, $portableZip, $releaseMetadata)) {
Assert-InProject -Path $path Assert-InProject -Path $path
if (Test-Path -LiteralPath $path) { if (Test-Path -LiteralPath $path) {
Remove-Item -LiteralPath $path -Recurse -Force Remove-Item -LiteralPath $path -Recurse -Force
@@ -181,9 +182,18 @@ $readme = @(
Set-Content -LiteralPath (Join-Path $releaseDir "README.txt") -Value $readme -Encoding UTF8 Set-Content -LiteralPath (Join-Path $releaseDir "README.txt") -Value $readme -Encoding UTF8
Assert-NoExcludedData -Root $releaseDir -Excluded $excluded Assert-NoExcludedData -Root $releaseDir -Excluded $excluded
& $pythonExe @pythonArgs -m app.release_manifest manifest --release-dir $releaseDir --version $appVersion
if ($LASTEXITCODE -ne 0) {
throw "生成 package-manifest.json 失败,退出码: $LASTEXITCODE"
}
Compress-ArchiveWithRetry -SourcePath (Join-Path $releaseDir "*") -DestinationPath $portableZip Compress-ArchiveWithRetry -SourcePath (Join-Path $releaseDir "*") -DestinationPath $portableZip
& $pythonExe @pythonArgs -m app.release_manifest metadata --zip $portableZip --version $appVersion --output $releaseMetadata
if ($LASTEXITCODE -ne 0) {
throw "生成 release-metadata.json 失败,退出码: $LASTEXITCODE"
}
Write-Host "$(Decode-Utf8Base64 "5p6E5bu65a6M5oiQOg==") $exePath" Write-Host "$(Decode-Utf8Base64 "5p6E5bu65a6M5oiQOg==") $exePath"
Write-Host "$(Decode-Utf8Base64 "5Y+R5biD55uu5b2VOg==") $releaseDir" Write-Host "$(Decode-Utf8Base64 "5Y+R5biD55uu5b2VOg==") $releaseDir"
Write-Host "$(Decode-Utf8Base64 "5L6/5pC65Y6L57yp5YyFOg==") $portableZip" Write-Host "$(Decode-Utf8Base64 "5L6/5pC65Y6L57yp5YyFOg==") $portableZip"
Write-Host "发布元数据: $releaseMetadata"
Write-Host (Decode-Utf8Base64 "5LiN6KaB5oqK5pys5ZywIGRhdGHjgIHphY3nva7jgIHmlbDmja7lupPjgIHlm77niYfjgIHml6Xlv5fmiJbnmbvlvZXmgIHlpI3liLbov5vlj5HluIPljIXjgII=") Write-Host (Decode-Utf8Base64 "5LiN6KaB5oqK5pys5ZywIGRhdGHjgIHphY3nva7jgIHmlbDmja7lupPjgIHlm77niYfjgIHml6Xlv5fmiJbnmbvlvZXmgIHlpI3liLbov5vlj5HluIPljIXjgII=")
+58
View File
@@ -1,6 +1,11 @@
import hashlib
import json
import tempfile
import unittest import unittest
from pathlib import Path
from _helpers import REPO_ROOT from _helpers import REPO_ROOT
from app import release_manifest
from app import version from app import version
@@ -88,6 +93,59 @@ class PackagingTests(unittest.TestCase):
self.assertIn('Compress-ArchiveWithRetry', script) self.assertIn('Compress-ArchiveWithRetry', script)
self.assertIn('Start-Sleep -Seconds $DelaySeconds', script) self.assertIn('Start-Sleep -Seconds $DelaySeconds', script)
self.assertIn('Assert-NoExcludedData -Root $releaseDir', script) self.assertIn('Assert-NoExcludedData -Root $releaseDir', script)
self.assertIn('-m app.release_manifest manifest', script)
self.assertIn('-m app.release_manifest metadata', script)
self.assertIn('release-metadata.json', script)
def make_release_dir(self, root, app_version="1.2.3"):
release_dir = Path(root) / "release"
(release_dir / "_internal").mkdir(parents=True)
(release_dir / "cmshopee.exe").write_bytes(b"exe")
(release_dir / "_internal" / "runtime.dll").write_bytes(b"runtime")
(release_dir / "version.txt").write_text(app_version, encoding="ascii")
(release_dir / "README.txt").write_text("说明", encoding="utf-8")
return release_dir
def test_release_manifest_covers_program_files_with_hashes(self):
with tempfile.TemporaryDirectory() as temp_dir:
release_dir = self.make_release_dir(temp_dir)
output = release_manifest.write_package_manifest(release_dir, "1.2.3")
manifest = json.loads(output.read_text(encoding="utf-8"))
self.assertEqual("cmshopee-portable-v1", manifest["package_format"])
self.assertEqual("1.2.3", manifest["app_version"])
self.assertEqual("cmshopee.exe", manifest["entry_point"])
paths = [item["path"] for item in manifest["files"]]
self.assertEqual(len(paths), len(set(path.casefold() for path in paths)))
self.assertNotIn("package-manifest.json", paths)
self.assertIn("_internal/runtime.dll", paths)
exe = next(item for item in manifest["files"] if item["path"] == "cmshopee.exe")
self.assertEqual(3, exe["size_bytes"])
self.assertEqual(hashlib.sha256(b"exe").hexdigest(), exe["sha256"])
def test_release_manifest_rejects_user_data_and_version_mismatch(self):
with tempfile.TemporaryDirectory() as temp_dir:
release_dir = self.make_release_dir(temp_dir)
(release_dir / "data").mkdir()
with self.assertRaises(release_manifest.ReleaseManifestError):
release_manifest.build_package_manifest(release_dir, "1.2.3")
(release_dir / "data").rmdir()
with self.assertRaises(release_manifest.ReleaseManifestError):
release_manifest.build_package_manifest(release_dir, "9.9.9")
def test_release_metadata_matches_zip_size_and_hash(self):
with tempfile.TemporaryDirectory() as temp_dir:
zip_path = Path(temp_dir) / "蝦皮圈優化助手1.2.3.zip"
zip_path.write_bytes(b"portable-zip")
metadata = release_manifest.build_release_metadata(zip_path, "1.2.3")
release = metadata["release"]
self.assertEqual(zip_path.stat().st_size, release["size_bytes"])
self.assertEqual(
hashlib.sha256(b"portable-zip").hexdigest(), release["sha256"]
)
self.assertEqual("cmshopee-portable-v1", release["package_format"])
if __name__ == "__main__": if __name__ == "__main__":