feat: 统一 Client 发布产物命名 (#102)
This commit is contained in:
@@ -5,11 +5,24 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
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]:
|
||||
|
||||
@@ -160,23 +160,32 @@ VSVersionInfo(
|
||||
[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
|
||||
$portableLatestZip = Join-Path $releaseRoot "$portableProductName.zip"
|
||||
Copy-Item -LiteralPath $portableZip -Destination $portableLatestZip
|
||||
|
||||
$updateStage = Join-Path $buildRoot "update-stage"
|
||||
New-Item -ItemType Directory -Force -Path $updateStage | Out-Null
|
||||
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
|
||||
|
||||
Write-Host "[7/7] Generating the release manifest..."
|
||||
$emDash = [char]0x2014
|
||||
$manifestPath = Join-Path $releaseRoot ("autobuy{0}{0}manifest.json" -f $emDash)
|
||||
$manifestPath = Join-Path $releaseRoot "autobuy_manifest.json"
|
||||
Invoke-Checked -FailureMessage "Failed to generate the release manifest" -Command {
|
||||
& $buildPython (Join-Path $clientRoot "build_tools\release_manifest.py") `
|
||||
--version $version --update $updateZip --portable $portableZip `
|
||||
--output $manifestPath
|
||||
}
|
||||
$versionedManifestPath = Join-Path $releaseRoot "autobuy_manifest_$version.json"
|
||||
Copy-Item -LiteralPath $manifestPath -Destination $versionedManifestPath
|
||||
|
||||
Write-Host "Build complete: $releaseRoot"
|
||||
Write-Host "Release manifest: $manifestPath"
|
||||
|
||||
@@ -300,7 +300,7 @@ class SettingsPage(QWidget):
|
||||
self.currentVersionLabel.setAccessibleName("当前软件版本")
|
||||
self.updateManifestUrlInput = LineEdit(self)
|
||||
self.updateManifestUrlInput.setPlaceholderText(
|
||||
"https://updates.example.com/autobuy——manifest.json"
|
||||
"https://updates.example.com/autobuy_manifest.json"
|
||||
)
|
||||
self.updateManifestUrlInput.setClearButtonEnabled(True)
|
||||
self.updateManifestUrlInput.setAccessibleName("在线更新清单地址")
|
||||
|
||||
@@ -30,7 +30,8 @@ MAX_UPDATE_BYTES = 500 * 1024 * 1024
|
||||
MAX_EXTRACTED_BYTES = 1024 * 1024 * 1024
|
||||
UPDATE_MANIFEST_SETTING = "update.manifest_url"
|
||||
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"
|
||||
_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*))?$")
|
||||
|
||||
@@ -12,6 +12,7 @@ from .settings_repository import SettingsRepository
|
||||
from .update_service import (
|
||||
DEFAULT_UPDATE_MANIFEST_URL,
|
||||
DEFAULT_UPDATE_USERNAME,
|
||||
LEGACY_DEFAULT_UPDATE_MANIFEST_URL,
|
||||
UPDATE_MANIFEST_SETTING,
|
||||
UPDATE_USERNAME_SETTING,
|
||||
UpdateCancelled,
|
||||
@@ -142,6 +143,9 @@ class UpdateUiEventBinder(QObject):
|
||||
if saved_url_record is not None and isinstance(saved_url_record.value, str)
|
||||
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_record.value
|
||||
if saved_username_record is not None
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
发布脚本和程序界面需要版本号时都从这里读取,避免多个文件各写一份。
|
||||
"""
|
||||
|
||||
__version__ = "0.2.1"
|
||||
__version__ = "0.2.2"
|
||||
|
||||
@@ -16,7 +16,11 @@ from build_tools.launcher import (
|
||||
install_root,
|
||||
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
|
||||
|
||||
|
||||
@@ -140,15 +144,15 @@ class ReleaseManifestTest(unittest.TestCase):
|
||||
def test_manifest_name_and_hash_match_release_files(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
update_zip = root / "CMAutoBuy-0.1.0-update.zip"
|
||||
portable_zip = root / "CMAutoBuy-0.1.0-portable.zip"
|
||||
update_zip = root / "CMAutoBuy_0.1.0.zip"
|
||||
portable_zip = root / "自动采集采购工具_0.1.0.zip"
|
||||
update_zip.write_bytes(b"update")
|
||||
portable_zip.write_bytes(b"portable")
|
||||
output = root / MANIFEST_FILE_NAME
|
||||
|
||||
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"))
|
||||
self.assertEqual(manifest["version"], "0.1.0")
|
||||
self.assertEqual(manifest["update"]["size"], len(b"update"))
|
||||
@@ -158,6 +162,14 @@ class ReleaseManifestTest(unittest.TestCase):
|
||||
)
|
||||
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__":
|
||||
unittest.main()
|
||||
|
||||
@@ -12,6 +12,7 @@ from pathlib import Path
|
||||
|
||||
from src.update_service import (
|
||||
DEFAULT_UPDATE_MANIFEST_URL,
|
||||
LEGACY_DEFAULT_UPDATE_MANIFEST_URL,
|
||||
UnsafeUpdateArchiveError,
|
||||
UpdateConfigurationError,
|
||||
UpdateIntegrityError,
|
||||
@@ -153,9 +154,9 @@ class UpdateServiceTest(unittest.TestCase):
|
||||
)
|
||||
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(
|
||||
DEFAULT_UPDATE_MANIFEST_URL,
|
||||
LEGACY_DEFAULT_UPDATE_MANIFEST_URL,
|
||||
UpdateCredentials("release-reader", "unit-test-password"),
|
||||
)
|
||||
|
||||
@@ -257,7 +258,7 @@ class UpdateServiceTest(unittest.TestCase):
|
||||
health = json.loads(
|
||||
(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__":
|
||||
|
||||
@@ -19,6 +19,7 @@ from src.settings_ui import SettingsPage
|
||||
from src.update_service import (
|
||||
DEFAULT_UPDATE_MANIFEST_URL,
|
||||
DEFAULT_UPDATE_USERNAME,
|
||||
LEGACY_DEFAULT_UPDATE_MANIFEST_URL,
|
||||
UPDATE_MANIFEST_SETTING,
|
||||
UPDATE_USERNAME_SETTING,
|
||||
UpdateCheckResult,
|
||||
@@ -46,7 +47,7 @@ class FakeCredentialStore:
|
||||
|
||||
class FakeUpdateService:
|
||||
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.download_error = download_error
|
||||
self.check_count = 0
|
||||
@@ -57,7 +58,7 @@ class FakeUpdateService:
|
||||
def check(
|
||||
self,
|
||||
manifest_url,
|
||||
current_version="0.2.1",
|
||||
current_version="0.2.2",
|
||||
is_cancelled=None,
|
||||
credentials=None,
|
||||
):
|
||||
@@ -141,7 +142,7 @@ class UpdateUiEventTest(unittest.TestCase):
|
||||
def test_update_card_uses_safe_defaults_and_password_input(self):
|
||||
page = self._page(FakeUpdateService())
|
||||
|
||||
self.assertEqual(page.currentVersionLabel.text(), "0.2.1")
|
||||
self.assertEqual(page.currentVersionLabel.text(), "0.2.2")
|
||||
self.assertEqual(
|
||||
page.updateManifestUrlInput.text(), DEFAULT_UPDATE_MANIFEST_URL
|
||||
)
|
||||
@@ -257,6 +258,51 @@ class UpdateUiEventTest(unittest.TestCase):
|
||||
page.eventBinder.shutdown()
|
||||
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):
|
||||
update = UpdateInfo(
|
||||
version="0.3.0",
|
||||
@@ -267,7 +313,7 @@ class UpdateUiEventTest(unittest.TestCase):
|
||||
sha256="0" * 64,
|
||||
)
|
||||
service = FakeUpdateService(
|
||||
UpdateCheckResult("0.2.1", "0.3.0", True, update)
|
||||
UpdateCheckResult("0.2.2", "0.3.0", True, update)
|
||||
)
|
||||
page = self._page(service)
|
||||
self._save_configuration(page)
|
||||
|
||||
@@ -158,8 +158,9 @@ cd D:\chengma\cmautobuy\client
|
||||
```
|
||||
|
||||
第一次会在 `.build-venv/` 安装固定版本依赖,完成后产物位于 `release/`。发布时
|
||||
使用完整便携压缩包;`autobuy——manifest.json` 和更新压缩包留给版本发布与后续
|
||||
更新功能使用。不要把本机已有的 `data/` 塞进发布包。
|
||||
使用 `自动采集采购工具_<版本号>.zip` 完整便携压缩包;`autobuy_manifest.json`
|
||||
是在线更新固定入口,`autobuy_manifest_<版本号>.json` 用于归档,
|
||||
`CMAutoBuy_<版本号>.zip` 是更新包。不要把本机已有的 `data/` 塞进发布包。
|
||||
|
||||
## 11. 接下来读什么
|
||||
|
||||
|
||||
@@ -210,9 +210,16 @@ CMAutoBuy/ 整个文件夹拷到任何机器都能用
|
||||
数据库靠 `PRAGMA user_version` 自动迁移,见 [03](03-data-model.md) §2.3。完整便携包
|
||||
用于首次安装;更新包只包含 `app/`,不得包含 `data/`。
|
||||
|
||||
每次构建在 `client/release/` 生成更新包、完整便携包和
|
||||
`autobuy——manifest.json`。清单至少记录版本、文件名、字节大小和 SHA256;SHA256
|
||||
用于发现下载损坏,不等同于发布者身份认证。
|
||||
每次构建在 `client/release/` 生成以下发布文件:
|
||||
|
||||
- `CMAutoBuy_<版本号>.zip`:在线更新包;
|
||||
- `自动采集采购工具_<版本号>.zip`:带版本号的完整便携包;
|
||||
- `自动采集采购工具.zip`:与带版本号便携包内容完全相同的下载别名;
|
||||
- `autobuy_manifest.json`:Client 固定读取的在线更新清单;
|
||||
- `autobuy_manifest_<版本号>.json`:与固定清单内容完全相同的归档副本。
|
||||
|
||||
清单中的更新包和便携包都引用带版本号的文件名,并记录版本、字节大小和 SHA256;
|
||||
SHA256 用于发现下载损坏,不等同于发布者身份认证。
|
||||
|
||||
**在线更新:**设置页默认显示已确认的发布清单地址和账号,密码由操作人员首次
|
||||
填写并保存到 Windows 凭据管理器;URL 和账号作为非敏感设置保存在 SQLite,密码
|
||||
|
||||
@@ -226,7 +226,7 @@ Artifact 写入前应脱敏,数据库只保存引用。保留周期由设置
|
||||
| 5 | Mock Admin 契约测试通过 | Mock 和 HTTP 两个实现跑同一套测试 | 开发者 |
|
||||
| 6 | Windows 干净环境启动测试通过 | **未打包版本**:找一台没装过本项目的机器,照 [00 上手指南](00-getting-started.md) 从头走一遍。**已打包版本**:把整个文件夹拷到一台**没装 Python** 的机器上双击 `Launcher.exe` | 开发者 |
|
||||
| 6b | 升级不丢数据(仅打包版本) | 关闭程序并只换掉 `app/`,保留 `Launcher.exe` 和 `data/`;启动后任务、日志、设置都还在 | 开发者 |
|
||||
| 6c | 发布清单与产物一致 | `autobuy——manifest.json` 中的版本、文件名、字节大小和 SHA256 与实际压缩包一致,更新包中没有 `data/` | 开发者 |
|
||||
| 6c | 发布清单与产物一致 | `autobuy_manifest.json` 与对应版本化清单内容相同;其中版本、文件名、字节大小和 SHA256 与实际压缩包一致,更新包中没有 `data/` | 开发者 |
|
||||
| 6d | 在线更新和回退通过 | 使用上一版本目录检查新版本、下载、下次启动替换、健康标记、文件占用失败和自动回退;确认数据库未被覆盖 | 开发者 |
|
||||
| 7 | 日志和产物无敏感信息 | 翻一遍日志和 `artifacts/`,确认没有 token、Cookie、密码、收货人信息 | 开发者 |
|
||||
| 8 | 开源和商业许可证已确认 | 新增依赖的许可证是否允许本项目的使用方式 | **项目负责人**(不是开发者自己判断) |
|
||||
|
||||
Reference in New Issue
Block a user