feat: 统一 Client 发布产物命名 (#102)

This commit is contained in:
chengma
2026-08-10 14:55:17 +08:00
parent 6fd25984d2
commit 240626f945
12 changed files with 119 additions and 25 deletions
+14 -1
View File
@@ -5,11 +5,24 @@ from __future__ import annotations
import argparse import argparse
import hashlib import hashlib
import json import json
import re
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
MANIFEST_FILE_NAME = "autobuy——manifest.json" MANIFEST_FILE_NAME = "autobuy_manifest.json"
_VERSION_PATTERN = re.compile(
r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?$"
)
def versioned_manifest_file_name(version: str) -> str:
"""返回版本化清单文件名,例如 ``autobuy_manifest_0.2.2.json``。"""
normalized = version.strip()
if _VERSION_PATTERN.fullmatch(normalized) is None:
raise ValueError("版本号必须是三段或四段非负整数")
return f"autobuy_manifest_{normalized}.json"
def file_description(path: Path) -> dict[str, Any]: def file_description(path: Path) -> dict[str, Any]:
+13 -4
View File
@@ -160,23 +160,32 @@ VSVersionInfo(
[Text.UTF8Encoding]::new($false) [Text.UTF8Encoding]::new($false)
) )
$portableZip = Join-Path $releaseRoot "$packageName-portable.zip" # Windows PowerShell 5 may read a UTF-8 script without BOM as the system code page.
# Build the Chinese product name from Unicode code points so the release name is stable.
$portableProductName = -join @(
[char]0x81EA, [char]0x52A8, [char]0x91C7, [char]0x96C6,
[char]0x91C7, [char]0x8D2D, [char]0x5DE5, [char]0x5177
)
$portableZip = Join-Path $releaseRoot "$($portableProductName)_$version.zip"
Compress-Archive -Path (Join-Path $packageRoot "*") -DestinationPath $portableZip -CompressionLevel Optimal Compress-Archive -Path (Join-Path $packageRoot "*") -DestinationPath $portableZip -CompressionLevel Optimal
$portableLatestZip = Join-Path $releaseRoot "$portableProductName.zip"
Copy-Item -LiteralPath $portableZip -Destination $portableLatestZip
$updateStage = Join-Path $buildRoot "update-stage" $updateStage = Join-Path $buildRoot "update-stage"
New-Item -ItemType Directory -Force -Path $updateStage | Out-Null New-Item -ItemType Directory -Force -Path $updateStage | Out-Null
Copy-Item -LiteralPath $appRoot -Destination (Join-Path $updateStage "app") -Recurse Copy-Item -LiteralPath $appRoot -Destination (Join-Path $updateStage "app") -Recurse
$updateZip = Join-Path $releaseRoot "$packageName-update.zip" $updateZip = Join-Path $releaseRoot "CMAutoBuy_$version.zip"
Compress-Archive -Path (Join-Path $updateStage "*") -DestinationPath $updateZip -CompressionLevel Optimal Compress-Archive -Path (Join-Path $updateStage "*") -DestinationPath $updateZip -CompressionLevel Optimal
Write-Host "[7/7] Generating the release manifest..." Write-Host "[7/7] Generating the release manifest..."
$emDash = [char]0x2014 $manifestPath = Join-Path $releaseRoot "autobuy_manifest.json"
$manifestPath = Join-Path $releaseRoot ("autobuy{0}{0}manifest.json" -f $emDash)
Invoke-Checked -FailureMessage "Failed to generate the release manifest" -Command { Invoke-Checked -FailureMessage "Failed to generate the release manifest" -Command {
& $buildPython (Join-Path $clientRoot "build_tools\release_manifest.py") ` & $buildPython (Join-Path $clientRoot "build_tools\release_manifest.py") `
--version $version --update $updateZip --portable $portableZip ` --version $version --update $updateZip --portable $portableZip `
--output $manifestPath --output $manifestPath
} }
$versionedManifestPath = Join-Path $releaseRoot "autobuy_manifest_$version.json"
Copy-Item -LiteralPath $manifestPath -Destination $versionedManifestPath
Write-Host "Build complete: $releaseRoot" Write-Host "Build complete: $releaseRoot"
Write-Host "Release manifest: $manifestPath" Write-Host "Release manifest: $manifestPath"
+1 -1
View File
@@ -300,7 +300,7 @@ class SettingsPage(QWidget):
self.currentVersionLabel.setAccessibleName("当前软件版本") self.currentVersionLabel.setAccessibleName("当前软件版本")
self.updateManifestUrlInput = LineEdit(self) self.updateManifestUrlInput = LineEdit(self)
self.updateManifestUrlInput.setPlaceholderText( self.updateManifestUrlInput.setPlaceholderText(
"https://updates.example.com/autobuy——manifest.json" "https://updates.example.com/autobuy_manifest.json"
) )
self.updateManifestUrlInput.setClearButtonEnabled(True) self.updateManifestUrlInput.setClearButtonEnabled(True)
self.updateManifestUrlInput.setAccessibleName("在线更新清单地址") self.updateManifestUrlInput.setAccessibleName("在线更新清单地址")
+2 -1
View File
@@ -30,7 +30,8 @@ MAX_UPDATE_BYTES = 500 * 1024 * 1024
MAX_EXTRACTED_BYTES = 1024 * 1024 * 1024 MAX_EXTRACTED_BYTES = 1024 * 1024 * 1024
UPDATE_MANIFEST_SETTING = "update.manifest_url" UPDATE_MANIFEST_SETTING = "update.manifest_url"
UPDATE_USERNAME_SETTING = "update.username" UPDATE_USERNAME_SETTING = "update.username"
DEFAULT_UPDATE_MANIFEST_URL = "http://cm.xiapi.com/autobuy——manifest.json" DEFAULT_UPDATE_MANIFEST_URL = "http://cm.xiapi.com/autobuy_manifest.json"
LEGACY_DEFAULT_UPDATE_MANIFEST_URL = "http://cm.xiapi.com/autobuy——manifest.json"
DEFAULT_UPDATE_USERNAME = "admin" DEFAULT_UPDATE_USERNAME = "admin"
_ALLOWED_HTTP_UPDATE_HOST = "cm.xiapi.com" _ALLOWED_HTTP_UPDATE_HOST = "cm.xiapi.com"
_VERSION_PATTERN = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?$") _VERSION_PATTERN = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?$")
+4
View File
@@ -12,6 +12,7 @@ from .settings_repository import SettingsRepository
from .update_service import ( from .update_service import (
DEFAULT_UPDATE_MANIFEST_URL, DEFAULT_UPDATE_MANIFEST_URL,
DEFAULT_UPDATE_USERNAME, DEFAULT_UPDATE_USERNAME,
LEGACY_DEFAULT_UPDATE_MANIFEST_URL,
UPDATE_MANIFEST_SETTING, UPDATE_MANIFEST_SETTING,
UPDATE_USERNAME_SETTING, UPDATE_USERNAME_SETTING,
UpdateCancelled, UpdateCancelled,
@@ -142,6 +143,9 @@ class UpdateUiEventBinder(QObject):
if saved_url_record is not None and isinstance(saved_url_record.value, str) if saved_url_record is not None and isinstance(saved_url_record.value, str)
else DEFAULT_UPDATE_MANIFEST_URL else DEFAULT_UPDATE_MANIFEST_URL
) )
if saved_url == LEGACY_DEFAULT_UPDATE_MANIFEST_URL:
saved_url = DEFAULT_UPDATE_MANIFEST_URL
repository.set(UPDATE_MANIFEST_SETTING, saved_url)
saved_username = ( saved_username = (
saved_username_record.value saved_username_record.value
if saved_username_record is not None if saved_username_record is not None
+1 -1
View File
@@ -3,4 +3,4 @@
发布脚本和程序界面需要版本号时都从这里读取,避免多个文件各写一份。 发布脚本和程序界面需要版本号时都从这里读取,避免多个文件各写一份。
""" """
__version__ = "0.2.1" __version__ = "0.2.2"
+16 -4
View File
@@ -16,7 +16,11 @@ from build_tools.launcher import (
install_root, install_root,
is_main_program_running, is_main_program_running,
) )
from build_tools.release_manifest import MANIFEST_FILE_NAME, write_manifest from build_tools.release_manifest import (
MANIFEST_FILE_NAME,
versioned_manifest_file_name,
write_manifest,
)
from src.db import data_dir from src.db import data_dir
@@ -140,15 +144,15 @@ class ReleaseManifestTest(unittest.TestCase):
def test_manifest_name_and_hash_match_release_files(self): def test_manifest_name_and_hash_match_release_files(self):
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
root = Path(directory) root = Path(directory)
update_zip = root / "CMAutoBuy-0.1.0-update.zip" update_zip = root / "CMAutoBuy_0.1.0.zip"
portable_zip = root / "CMAutoBuy-0.1.0-portable.zip" portable_zip = root / "自动采集采购工具_0.1.0.zip"
update_zip.write_bytes(b"update") update_zip.write_bytes(b"update")
portable_zip.write_bytes(b"portable") portable_zip.write_bytes(b"portable")
output = root / MANIFEST_FILE_NAME output = root / MANIFEST_FILE_NAME
write_manifest("0.1.0", update_zip, portable_zip, output) write_manifest("0.1.0", update_zip, portable_zip, output)
self.assertEqual(output.name, "autobuy——manifest.json") self.assertEqual(output.name, "autobuy_manifest.json")
manifest = json.loads(output.read_text(encoding="utf-8")) manifest = json.loads(output.read_text(encoding="utf-8"))
self.assertEqual(manifest["version"], "0.1.0") self.assertEqual(manifest["version"], "0.1.0")
self.assertEqual(manifest["update"]["size"], len(b"update")) self.assertEqual(manifest["update"]["size"], len(b"update"))
@@ -158,6 +162,14 @@ class ReleaseManifestTest(unittest.TestCase):
) )
self.assertEqual(manifest["portable"]["file"], portable_zip.name) self.assertEqual(manifest["portable"]["file"], portable_zip.name)
def test_versioned_manifest_name_is_stable_and_validated(self):
self.assertEqual(
versioned_manifest_file_name("0.1.0"),
"autobuy_manifest_0.1.0.json",
)
with self.assertRaises(ValueError):
versioned_manifest_file_name("../latest")
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+4 -3
View File
@@ -12,6 +12,7 @@ from pathlib import Path
from src.update_service import ( from src.update_service import (
DEFAULT_UPDATE_MANIFEST_URL, DEFAULT_UPDATE_MANIFEST_URL,
LEGACY_DEFAULT_UPDATE_MANIFEST_URL,
UnsafeUpdateArchiveError, UnsafeUpdateArchiveError,
UpdateConfigurationError, UpdateConfigurationError,
UpdateIntegrityError, UpdateIntegrityError,
@@ -153,9 +154,9 @@ class UpdateServiceTest(unittest.TestCase):
) )
self.assertNotIn("unit-test-password", repr(credentials)) self.assertNotIn("unit-test-password", repr(credentials))
def test_default_unicode_manifest_path_is_encoded_for_http_request(self): def test_legacy_unicode_manifest_path_is_encoded_for_http_request(self):
request = UpdateService._make_request( request = UpdateService._make_request(
DEFAULT_UPDATE_MANIFEST_URL, LEGACY_DEFAULT_UPDATE_MANIFEST_URL,
UpdateCredentials("release-reader", "unit-test-password"), UpdateCredentials("release-reader", "unit-test-password"),
) )
@@ -257,7 +258,7 @@ class UpdateServiceTest(unittest.TestCase):
health = json.loads( health = json.loads(
(self.update_directory / "healthy.json").read_text(encoding="utf-8") (self.update_directory / "healthy.json").read_text(encoding="utf-8")
) )
self.assertEqual(health["version"], "0.2.1") self.assertEqual(health["version"], "0.2.2")
if __name__ == "__main__": if __name__ == "__main__":
+50 -4
View File
@@ -19,6 +19,7 @@ from src.settings_ui import SettingsPage
from src.update_service import ( from src.update_service import (
DEFAULT_UPDATE_MANIFEST_URL, DEFAULT_UPDATE_MANIFEST_URL,
DEFAULT_UPDATE_USERNAME, DEFAULT_UPDATE_USERNAME,
LEGACY_DEFAULT_UPDATE_MANIFEST_URL,
UPDATE_MANIFEST_SETTING, UPDATE_MANIFEST_SETTING,
UPDATE_USERNAME_SETTING, UPDATE_USERNAME_SETTING,
UpdateCheckResult, UpdateCheckResult,
@@ -46,7 +47,7 @@ class FakeCredentialStore:
class FakeUpdateService: class FakeUpdateService:
def __init__(self, result=None, delay=0.0, download_error=None): def __init__(self, result=None, delay=0.0, download_error=None):
self.result = result or UpdateCheckResult("0.2.1", "0.2.1", False) self.result = result or UpdateCheckResult("0.2.2", "0.2.2", False)
self.delay = delay self.delay = delay
self.download_error = download_error self.download_error = download_error
self.check_count = 0 self.check_count = 0
@@ -57,7 +58,7 @@ class FakeUpdateService:
def check( def check(
self, self,
manifest_url, manifest_url,
current_version="0.2.1", current_version="0.2.2",
is_cancelled=None, is_cancelled=None,
credentials=None, credentials=None,
): ):
@@ -141,7 +142,7 @@ class UpdateUiEventTest(unittest.TestCase):
def test_update_card_uses_safe_defaults_and_password_input(self): def test_update_card_uses_safe_defaults_and_password_input(self):
page = self._page(FakeUpdateService()) page = self._page(FakeUpdateService())
self.assertEqual(page.currentVersionLabel.text(), "0.2.1") self.assertEqual(page.currentVersionLabel.text(), "0.2.2")
self.assertEqual( self.assertEqual(
page.updateManifestUrlInput.text(), DEFAULT_UPDATE_MANIFEST_URL page.updateManifestUrlInput.text(), DEFAULT_UPDATE_MANIFEST_URL
) )
@@ -257,6 +258,51 @@ class UpdateUiEventTest(unittest.TestCase):
page.eventBinder.shutdown() page.eventBinder.shutdown()
page.deleteLater() page.deleteLater()
def test_legacy_default_manifest_url_is_migrated_before_auto_check(self):
self.repository.set_many(
{
UPDATE_MANIFEST_SETTING: LEGACY_DEFAULT_UPDATE_MANIFEST_URL,
UPDATE_USERNAME_SETTING: TEST_USERNAME,
}
)
store = FakeCredentialStore((TEST_USERNAME, TEST_PASSWORD))
service = FakeUpdateService()
page = self._page(service, store)
self._wait_until(lambda: service.check_count == 1)
self._wait_until(
lambda: page.eventBinder.updateEventBinder._check_thread is None
)
self.assertEqual(
self.repository.get(UPDATE_MANIFEST_SETTING),
DEFAULT_UPDATE_MANIFEST_URL,
)
self.assertEqual(service.manifest_url, DEFAULT_UPDATE_MANIFEST_URL)
page.eventBinder.shutdown()
page.deleteLater()
def test_custom_manifest_url_is_not_migrated(self):
self.repository.set_many(
{
UPDATE_MANIFEST_SETTING: MANIFEST_URL,
UPDATE_USERNAME_SETTING: TEST_USERNAME,
}
)
store = FakeCredentialStore((TEST_USERNAME, TEST_PASSWORD))
service = FakeUpdateService()
page = self._page(service, store)
self._wait_until(lambda: service.check_count == 1)
self._wait_until(
lambda: page.eventBinder.updateEventBinder._check_thread is None
)
self.assertEqual(self.repository.get(UPDATE_MANIFEST_SETTING), MANIFEST_URL)
self.assertEqual(service.manifest_url, MANIFEST_URL)
page.eventBinder.shutdown()
page.deleteLater()
def test_confirmed_new_version_downloads_with_saved_credentials(self): def test_confirmed_new_version_downloads_with_saved_credentials(self):
update = UpdateInfo( update = UpdateInfo(
version="0.3.0", version="0.3.0",
@@ -267,7 +313,7 @@ class UpdateUiEventTest(unittest.TestCase):
sha256="0" * 64, sha256="0" * 64,
) )
service = FakeUpdateService( service = FakeUpdateService(
UpdateCheckResult("0.2.1", "0.3.0", True, update) UpdateCheckResult("0.2.2", "0.3.0", True, update)
) )
page = self._page(service) page = self._page(service)
self._save_configuration(page) self._save_configuration(page)
+3 -2
View File
@@ -158,8 +158,9 @@ cd D:\chengma\cmautobuy\client
``` ```
第一次会在 `.build-venv/` 安装固定版本依赖,完成后产物位于 `release/`。发布时 第一次会在 `.build-venv/` 安装固定版本依赖,完成后产物位于 `release/`。发布时
使用完整便携压缩包;`autobuy——manifest.json` 和更新压缩包留给版本发布与后续 使用 `自动采集采购工具_<版本号>.zip` 完整便携压缩包;`autobuy_manifest.json`
更新功能使用。不要把本机已有的 `data/` 塞进发布包。 是在线更新固定入口,`autobuy_manifest_<版本号>.json` 用于归档,
`CMAutoBuy_<版本号>.zip` 是更新包。不要把本机已有的 `data/` 塞进发布包。
## 11. 接下来读什么 ## 11. 接下来读什么
+10 -3
View File
@@ -210,9 +210,16 @@ CMAutoBuy/ 整个文件夹拷到任何机器都能用
数据库靠 `PRAGMA user_version` 自动迁移,见 [03](03-data-model.md) §2.3。完整便携包 数据库靠 `PRAGMA user_version` 自动迁移,见 [03](03-data-model.md) §2.3。完整便携包
用于首次安装;更新包只包含 `app/`,不得包含 `data/`。 用于首次安装;更新包只包含 `app/`,不得包含 `data/`。
每次构建在 `client/release/` 生成更新包、完整便携包和 每次构建在 `client/release/` 生成以下发布文件:
`autobuy——manifest.json`。清单至少记录版本、文件名、字节大小和 SHA256;SHA256
用于发现下载损坏,不等同于发布者身份认证。 - `CMAutoBuy_<版本号>.zip`:在线更新包;
- `自动采集采购工具_<版本号>.zip`:带版本号的完整便携包;
- `自动采集采购工具.zip`:与带版本号便携包内容完全相同的下载别名;
- `autobuy_manifest.json`:Client 固定读取的在线更新清单;
- `autobuy_manifest_<版本号>.json`:与固定清单内容完全相同的归档副本。
清单中的更新包和便携包都引用带版本号的文件名,并记录版本、字节大小和 SHA256;
SHA256 用于发现下载损坏,不等同于发布者身份认证。
**在线更新:**设置页默认显示已确认的发布清单地址和账号,密码由操作人员首次 **在线更新:**设置页默认显示已确认的发布清单地址和账号,密码由操作人员首次
填写并保存到 Windows 凭据管理器;URL 和账号作为非敏感设置保存在 SQLite,密码 填写并保存到 Windows 凭据管理器;URL 和账号作为非敏感设置保存在 SQLite,密码
+1 -1
View File
@@ -226,7 +226,7 @@ Artifact 写入前应脱敏,数据库只保存引用。保留周期由设置
| 5 | Mock Admin 契约测试通过 | Mock 和 HTTP 两个实现跑同一套测试 | 开发者 | | 5 | Mock Admin 契约测试通过 | Mock 和 HTTP 两个实现跑同一套测试 | 开发者 |
| 6 | Windows 干净环境启动测试通过 | **未打包版本**:找一台没装过本项目的机器,照 [00 上手指南](00-getting-started.md) 从头走一遍。**已打包版本**:把整个文件夹拷到一台**没装 Python** 的机器上双击 `Launcher.exe` | 开发者 | | 6 | Windows 干净环境启动测试通过 | **未打包版本**:找一台没装过本项目的机器,照 [00 上手指南](00-getting-started.md) 从头走一遍。**已打包版本**:把整个文件夹拷到一台**没装 Python** 的机器上双击 `Launcher.exe` | 开发者 |
| 6b | 升级不丢数据(仅打包版本) | 关闭程序并只换掉 `app/`,保留 `Launcher.exe` 和 `data/`;启动后任务、日志、设置都还在 | 开发者 | | 6b | 升级不丢数据(仅打包版本) | 关闭程序并只换掉 `app/`,保留 `Launcher.exe` 和 `data/`;启动后任务、日志、设置都还在 | 开发者 |
| 6c | 发布清单与产物一致 | `autobuy——manifest.json` 中的版本、文件名、字节大小和 SHA256 与实际压缩包一致,更新包中没有 `data/` | 开发者 | | 6c | 发布清单与产物一致 | `autobuy_manifest.json` 与对应版本化清单内容相同;其中版本、文件名、字节大小和 SHA256 与实际压缩包一致,更新包中没有 `data/` | 开发者 |
| 6d | 在线更新和回退通过 | 使用上一版本目录检查新版本、下载、下次启动替换、健康标记、文件占用失败和自动回退;确认数据库未被覆盖 | 开发者 | | 6d | 在线更新和回退通过 | 使用上一版本目录检查新版本、下载、下次启动替换、健康标记、文件占用失败和自动回退;确认数据库未被覆盖 | 开发者 |
| 7 | 日志和产物无敏感信息 | 翻一遍日志和 `artifacts/`,确认没有 token、Cookie、密码、收货人信息 | 开发者 | | 7 | 日志和产物无敏感信息 | 翻一遍日志和 `artifacts/`,确认没有 token、Cookie、密码、收货人信息 | 开发者 |
| 8 | 开源和商业许可证已确认 | 新增依赖的许可证是否允许本项目的使用方式 | **项目负责人**(不是开发者自己判断) | | 8 | 开源和商业许可证已确认 | 新增依赖的许可证是否允许本项目的使用方式 | **项目负责人**(不是开发者自己判断) |