diff --git a/app/release_manifest.py b/app/release_manifest.py new file mode 100644 index 0000000..071dc79 --- /dev/null +++ b/app/release_manifest.py @@ -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()) diff --git a/docs/04-architecture.md b/docs/04-architecture.md index a5dee80..d76c318 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -498,6 +498,7 @@ cmshopee/ │ ├── cdp.py # CDP 底座(由根目录 cdp.py 迁入) │ ├── appconfig.py / db.py / excel.py / config.py / accounts.py / chrome.py │ ├── editor.py / ai.py / prompts.py / gui.py / workers.py +│ ├── release_manifest.py # 发布包文件清单、zip哈希与服务端元数据模板 ├── main.py # GUI 启动入口:from app.gui import main ├── shopee待处理任务模板.xlsx # 标准空 Excel 模板,可提交;业务填写后的副本不提交 ├── 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 交互事实变化同步第七节。 - 正式代码只放 `app/` 包;根目录只保留 `main.py`、配置/数据目录、文档和原型目录,不新增正式业务模块。 - `app` 包内模块优先用相对导入(如 `from .cdp import CDP`);根入口 `main.py` 用 `from app.gui import main`。 diff --git a/docs/packaging.md b/docs/packaging.md index 280f3bd..8eff000 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -1,6 +1,6 @@ # 打包与分发 -> T-524 目标:先把当前 Windows 桌面工具打成免安装 onedir `.exe`,方便第一版交付使用。暂不做自动更新器、安装器、增量补丁或在线升级。 +> 当前发布仍是免安装 onedir;T-615 已增加自动升级所需的机器可验证清单和发布元数据,但尚不下载或替换客户端程序。 ## 一、打包前提 @@ -53,7 +53,7 @@ cmshopee\ ## 三、版本号与发布目录 -参考 `D:\chengma\cmbot` 的打包方式,版本控制采用“单一版本源 + 发布目录带版本号”的轻量方案,暂不引入启动器、自动更新或 manifest。 +版本控制采用“单一版本源 + 发布目录带版本号 + 可验证 manifest”的方案。 ### 3.1 单一版本源 @@ -86,7 +86,9 @@ release\ version.txt README.txt + package-manifest.json 蝦皮圈優化助手0.1.0.zip + release-metadata.json ``` 说明: @@ -96,6 +98,8 @@ release\ - `release\蝦皮圈優化助手.zip` 是交付给用户的便携压缩包。 - `version.txt` 使用 ASCII/UTF-8 无 BOM 写入 `APP_VERSION`,用于人工排查和未来更新机制读取。 - `README.txt` 用中文写明启动方式、不要放入 `Program Files`、保留 `data\`、升级时覆盖程序文件但不覆盖 `data\`。 +- `package-manifest.json` 记录包格式、版本、入口、更新器协议、允许替换根项目,以及除清单自身外每个程序文件的规范化路径、字节数和 SHA-256。 +- `release-metadata.json` 记录整个 zip 的准确字节数和 SHA-256,并提供服务端发布字段模板;它不放进 zip。 当前 PyInstaller 6.11.1 的 onedir 为集中依赖结构,所以 `release\蝦皮圈優化助手\` 必须包含 `cmshopee.exe` 和 `_internal\`,不得只复制单个 exe。 @@ -104,7 +108,7 @@ release\ 1. 修改 `app/version.py` 中的 `APP_VERSION`。 2. 运行语法检查和单元测试。 3. 运行 `powershell -ExecutionPolicy Bypass -File scripts\build_exe.ps1`。 -4. 检查 `release\蝦皮圈優化助手\version.txt`、GUI 标题栏版本、压缩包文件名三者一致。 +4. 检查 `version.txt`、GUI 标题栏、`package-manifest.json`、压缩包文件名和 `release-metadata.json` 的版本一致。 5. 在无 Python 环境的 Windows 10/11 机器上解压 `蝦皮圈優化助手.zip` 并启动验证。 正式发布构建固定使用 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 依赖目录很容易出现文件占用、半覆盖失败、权限不足或杀毒拦截。第一版只做“强制提示 + 下载新版 + 用户手动覆盖”,和当前便携包更新方式一致。 -### 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 环境运行”的第一版交付路径。 -- `cmshopee` 刚完成 `data\` 数据隔离,先把手动发版和版本号一致性做稳。 -- 自动更新需要安装根可写、下载暂存、目录切换、回滚和安全校验,属于独立后续任务,不能混入当前打包版本号控制。 - -后续如果要做真正自动覆盖升级,再另起任务,参考 `cmbot` 的 `Launcher.exe + app\ + manifest.json + sha256 + version.txt` 设计。 +T-615 不改变客户端行为:T-544 仍只打开浏览器下载。客户端下载、解压校验、独立进程替换、回滚和重启分别由 T-616 至 T-619 实现。清单预留签名字段,但当前只验证完整性,不宣称已验证发布者身份。 ## 四、绝不打包的本地数据 diff --git a/docs/tasks/T-615.md b/docs/tasks/T-615.md index 59e0844..995fe39 100644 --- a/docs/tasks/T-615.md +++ b/docs/tasks/T-615.md @@ -3,7 +3,7 @@ id: T-615 title: 自动升级发布契约与可验证发布清单 phase: 8 deps: [T-540, T-544] -status: TODO +status: DONE 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`。 diff --git a/docs/update-check.md b/docs/update-check.md index b592067..d2697f4 100644 --- a/docs/update-check.md +++ b/docs/update-check.md @@ -1,7 +1,7 @@ # 版本检查接口契约(启动强制升级) > 客户端在启动时请求版本接口,判断是否需要**强制升级**。本文是**服务端响应格式的权威契约**——字段以 `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` 至少要有一个**,否则客户端判为"接口缺字段"、按失败放行 | | `force_update` | bool | 是否强制升级。`true`/`1`/`yes`/`y`/`on`(大小写不敏感)都识别为真 | | `min_supported_version` | string | 最低支持版本;本地低于它一律强制 | -| `download_url` | string(URL) | 「下载新版」按钮打开的地址(用系统浏览器打开)| -| `sha256` | string | 安装包校验值;**第一版不校验,可留空** | +| `download_url` | string(URL) | 下载地址;自动安装时必须是 HTTPS | +| `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` | 版本号比较用**语义化数字段比较**(`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` 高于要淘汰的客户端版本 | @@ -61,7 +67,11 @@ "release": { "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", - "sha256": "", + "sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "size_bytes": 123456789, + "package_format": "cmshopee-portable-v1", + "updater_protocol": 1, + "min_updater_protocol": 1, "release_notes": "优化了ai模块的生图的功能", "published_at": "2026-07-08T14:30:26.935549+08:00", "force_update": true @@ -104,3 +114,5 @@ - 服务端返回 `force_update`/`min_supported_version` 属**本仓库外的服务端配置**;客户端代码(`app/update_check.py`/`app/version.py`/启动流程)已按本契约实现,**发强制版不需要改客户端**。 - `download_url` 里的中文可用 URL 编码(如 `%E8%9D%A6...`),系统浏览器能正确打开。 - 若将来要"非强制也温和提示(可跳过、不阻断)",需在客户端加一个非强制分支,另立任务。 +- 空 `sha256` 只兼容 T-544 的人工下载引导,绝不能进入自动安装。自动安装还必须同时校验 HTTPS、`size_bytes`、包格式和更新器协议。 +- `manifest_signature` 与 `signature_algorithm` 是预留字段;当前未启用数字签名,不能将 SHA-256 描述为发布者身份认证。 diff --git a/scripts/build_exe.ps1 b/scripts/build_exe.ps1 index 8b217eb..a8dcdfb 100644 --- a/scripts/build_exe.ps1 +++ b/scripts/build_exe.ps1 @@ -153,8 +153,9 @@ $releaseRoot = Join-Path $repoRoot "release" $releaseName = "$appName$appVersion" $releaseDir = Join-Path $releaseRoot $releaseName $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 if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force @@ -181,9 +182,18 @@ $readme = @( Set-Content -LiteralPath (Join-Path $releaseDir "README.txt") -Value $readme -Encoding UTF8 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 +& $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 "5Y+R5biD55uu5b2VOg==") $releaseDir" Write-Host "$(Decode-Utf8Base64 "5L6/5pC65Y6L57yp5YyFOg==") $portableZip" +Write-Host "发布元数据: $releaseMetadata" Write-Host (Decode-Utf8Base64 "5LiN6KaB5oqK5pys5ZywIGRhdGHjgIHphY3nva7jgIHmlbDmja7lupPjgIHlm77niYfjgIHml6Xlv5fmiJbnmbvlvZXmgIHlpI3liLbov5vlj5HluIPljIXjgII=") diff --git a/tests/test_packaging.py b/tests/test_packaging.py index f527720..f35416d 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -1,6 +1,11 @@ +import hashlib +import json +import tempfile import unittest +from pathlib import Path from _helpers import REPO_ROOT +from app import release_manifest from app import version @@ -88,6 +93,59 @@ class PackagingTests(unittest.TestCase): self.assertIn('Compress-ArchiveWithRetry', script) self.assertIn('Start-Sleep -Seconds $DelaySeconds', 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__":