Merge branch 'feat/m151-relmanif-generate-update-manifest-i'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
This commit is contained in:
commit
5c96227881
6 files changed
+503
-3
No files matched your search
@@ -94,6 +94,27 @@ jobs:
|
||||
sha256sum -c SHA256SUMS.txt
|
||||
cat SHA256SUMS.txt
|
||||
|
||||
- name: 生成更新清单 manifest.json(D50:固定名挂 Release 资产)
|
||||
shell: bash
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
RELEASE_BASE: ${{ github.server_url }}/${{ github.repository }}/releases/download
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# 先自验再产文件:golden 对拍 + 「DTO 严格解析」镜像(未知字段 / 摘要长度 / size=0 / 版本重复
|
||||
# 这些在发布步当场炸,而不是变成用户机器上的「查不到更新」)。零第三方依赖,毫秒级。
|
||||
python3 packaging/make_manifest.py --self-test
|
||||
# 事实来源是上一步**重算**出来的 dist/SHA256SUMS.txt:清单不另算一份摘要,两者不可能不一致。
|
||||
# url 钉死 tag(不是 latest):清单里每条都指向与它自报版本相符的字节。
|
||||
# min_supported / notes 刻意先不写:前者是「收回老版本支持」的发布策略(未拍板,写错会强制升级),
|
||||
# 后者在 Release 正文里已有,两处各写一份迟早漂。
|
||||
python3 packaging/make_manifest.py \
|
||||
--dist dist \
|
||||
--version "$TAG" \
|
||||
--channel stable \
|
||||
--asset-base-url "$RELEASE_BASE/$TAG" \
|
||||
--released-at "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
|
||||
- name: 生成 Release 说明(支持矩阵模板)
|
||||
shell: bash
|
||||
env:
|
||||
@@ -141,7 +162,12 @@ jobs:
|
||||
;;
|
||||
esac
|
||||
# Windows 单 exe 装不下 LICENSE/NOTICE/README.md,故三份与产物并列挂 Release(条款随包);`ull` 是独立资产。
|
||||
assets=(dist/unlucky-launcher_* dist/ull_* dist/SHA256SUMS.txt LICENSE NOTICE README.md)
|
||||
# manifest.json 是更新清单(D50):固定文件名,客户端问 `releases/latest/download/manifest.json`。
|
||||
# D51 定案(2026-11-06,方案甲):GitHub 的 `releases/latest/download/*` 只指向**非
|
||||
# prerelease** 的最新 Release。稳定版发布时显式 dispatch `prerelease=false`(latest 随之生效,
|
||||
# 客户端地址不变);push tag 默认仍 prerelease(视为预发布观察,不进稳定通道)。
|
||||
# 发版 checklist 第六件 = 确认 prerelease 标记与通道一致;v0.2.2 起首个适用。
|
||||
assets=(dist/unlucky-launcher_* dist/ull_* dist/manifest.json dist/SHA256SUMS.txt LICENSE NOTICE README.md)
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||
# 同 tag 重跑(补丁/重编):Release 已存在 → 覆盖资产 + 刷新说明,再 `create` 会因已存在而失败。
|
||||
gh release upload "$TAG" "${assets[@]}" --clobber
|
||||
|
||||
+28
-2
@@ -55,7 +55,7 @@ Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一)
|
||||
|
||||
| 文件 | 作用 | 行数上限 |
|
||||
|------|------|---------|
|
||||
| `packaging/README.md` | 本文:索引 + §11 / §12(契约入口;§1–§10 见 `docs/`) | ≤400 |
|
||||
| `packaging/README.md` | 本文:索引 + §11 / §12 / §13(契约入口;§1–§10 见 `docs/`) | ≤400 |
|
||||
| `packaging/docs/artifacts-and-version.md` | §1 产物矩阵 + §2 版本来源 | ≤400 |
|
||||
| `packaging/docs/build-and-ci.md` | §3 怎么构建(本地 / CI) | ≤400 |
|
||||
| `packaging/docs/runtime-deps.md` | §4 每平台运行时依赖 + §5 软渲染兜底 | ≤400 |
|
||||
@@ -69,4 +69,30 @@ Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一)
|
||||
| `packaging/linux-check-docker.sh` | 本地复跑 `ci.yml` 的两条 ubuntu 门禁(clippy / test,Docker) | ≤400 |
|
||||
| `.github/workflows/release.yml` | tag 触发 → 选槽位(`plan`)→ 调下面两个可复用工作流 | ≤400 |
|
||||
| `.github/workflows/release-build.yml` | `workflow_call`:单槽位构建(体检 / cargo / 打包 / 上传 artifact) | ≤400 |
|
||||
| `.github/workflows/release-publish.yml` | `workflow_call`:发布(`PUBLISH_SLOTS` 裁剪 / SHA256SUMS / Release) | ≤400 |
|
||||
| `packaging/make_manifest.py` | 更新清单 `manifest.json` 的生成与自验(D50,见 §13) | ≤400 |
|
||||
| `packaging/tests/manifest.golden.json` | 清单 golden 样例:`make_manifest.py --self-test` 逐字节对拍 | ≤400 |
|
||||
| `.github/workflows/release-publish.yml` | `workflow_call`:发布(`PUBLISH_SLOTS` 裁剪 / SHA256SUMS / 清单 / Release) | ≤400 |
|
||||
|
||||
## 13. 更新清单 `manifest.json`(D50)
|
||||
|
||||
清单作为**固定文件名**的 Release 资产挂着;客户端按 `releases/latest/download/manifest.json` 取(D50)。
|
||||
字段形状的唯一权威是 `crates/core/src/update/manifest.rs` 的 `ReleaseManifest`(`deny_unknown_fields` +
|
||||
逐字段校验:semver、`size > 0`、摘要长度/字符集、`target`/`format` 取值表)——生成器只负责把那套形状填对。
|
||||
|
||||
- **生成器**:`packaging/make_manifest.py`(零第三方依赖)。CI 在 `release-publish.yml` 里**重算
|
||||
`SHA256SUMS.txt` 之后**调它,所以清单描述的是「真正要发的资产」,不是「构建出来的全部」。
|
||||
- **同源,不双写**:每个 `sha256` 直接读 `SHA256SUMS.txt`,`size` 取自同一批文件的 `stat()`;产物名里的版本段
|
||||
必须等于 `--version`(tag 与 `Cargo.toml` 漂了就没有产物可对,当场失败)。`--self-test` 用
|
||||
`packaging/tests/manifest.golden.json` 逐字节对拍,并跑 14 条负例证明严格解析不是摆设。
|
||||
- **只放启动器产物**:`ull` 不进清单。DTO 的 `Artifact` 没有角色字段,`Release::artifact_for()` 取「第一个
|
||||
target 命中的产物」,把 CLI 并列写进去会让「该覆盖哪个文件」取决于数组顺序(`ull` 照旧进 `SHA256SUMS.txt`)。
|
||||
- **`url` 两种形态**:默认裸文件名(相对源根,`DirSource` 口径,本地/内网用);CI 传 `--asset-base-url`
|
||||
拼绝对地址,且**钉 tag 不钉 latest**——清单里每条都指向与它自报版本相符的字节。
|
||||
- `min_supported` / `notes` 只是**可填而不默认填**:前者是「收回老版本支持」的发布策略(未拍板,写错会强制升级),
|
||||
后者在 Release 正文里已有,两处各写一份迟早漂。
|
||||
- 本地复跑:`python3 packaging/make_artifacts.py --manifest` → 与 CI 同一段代码,产出 `dist/manifest.json`。
|
||||
|
||||
**D51 定案(2026-11-06,方案甲)**:GitHub 的 `releases/latest/download/*` 只指向**非 prerelease** 的最新
|
||||
Release。稳定版发布时显式 dispatch `prerelease=false` 让 latest 生效(客户端地址不变);push tag 默认仍
|
||||
`prerelease=true`,视为预发布观察、不进稳定通道。发版 checklist 第六件 = 确认 prerelease 标记与通道一致;
|
||||
v0.2.2 起首个适用。
|
||||
@@ -16,6 +16,7 @@
|
||||
| `macos-x64` | ❌ 不构建(未验证) | `unlucky-launcher_<ver>_macos_x64.tar.gz` | 单二进制归档 | 同上 |
|
||||
| 每槽位 | 随所属槽位 | `ull_<ver>_<平台>_<架构>[.exe]` | **裸 CLI 文件**(独立资产,不进 tar.gz) | 零 UI 依赖的 `ull` 本体,进同一份 `SHA256SUMS.txt` |
|
||||
| 全平台 | 仅已发布资产 | `SHA256SUMS.txt` | 校验清单 | 每行 `<sha256>␠␠<文件名>`(`sha256sum -c` 可直吃) |
|
||||
| 全平台 | 仅已发布资产 | `manifest.json` | **更新清单**(D50,见 `README.md` §13) | 渠道 + 版本 + 各平台产物(`target`/`format`/`url`/`size`/`sha256`);生成器 `make_manifest.py` |
|
||||
|
||||
约定细节:
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ Release matrix (see `packaging/README.md` for the full contract):
|
||||
macOS x64 unlucky-launcher_<ver>_macos_x64.tar.gz
|
||||
every slot ull_<ver>_<platform>_<arch>[.exe] (bare CLI binary, no archive)
|
||||
all SHA256SUMS.txt
|
||||
all manifest.json (only with --manifest, see below)
|
||||
|
||||
Naming rule (user's call, 2026-11-05): `<name>_<version>_<platform>_<arch>`, underscore
|
||||
separated, and **no implementation detail in the file name** — the asset never says which UI
|
||||
@@ -31,6 +32,14 @@ carries LICENSE / NOTICE / README.md anyway, so a bare `ull` loses nothing.
|
||||
The launcher binary also answers every CLI subcommand, so `ull` is not a second
|
||||
interface — it is the version of it that links no graphics stack at all.
|
||||
|
||||
`--manifest` additionally writes `manifest.json` (the update manifest, D50) into the same
|
||||
output directory. The generation itself lives in `packaging/make_manifest.py`, invoked as a
|
||||
subprocess: this file stays the single source of truth for **naming** (`asset_stem()`), that
|
||||
one is the single writer of the **manifest**, and it derives every sha256/size from the
|
||||
`SHA256SUMS.txt` this script just wrote — one set of facts, no second bookkeeping. CI does not
|
||||
use this flag: `release-publish.yml` recomputes `SHA256SUMS.txt` after pruning the publish
|
||||
whitelist and generates the manifest from *that*, so the manifest always describes what ships.
|
||||
|
||||
Slot and build directory are overridable because a cross build's binaries live in
|
||||
`target/<triple>/release` and the build host (x64) is not the artifact (arm64): CI
|
||||
passes both from its matrix, and the executable header is read back so a binary can
|
||||
@@ -209,6 +218,15 @@ def write_sums(out_dir: Path, assets: list[Path]) -> Path:
|
||||
return sums
|
||||
|
||||
|
||||
def write_manifest(out_dir: Path, version: str) -> Path:
|
||||
"""`manifest.json`(D50):跑的是 CI 发布侧同一份脚本,事实全部来自刚写完的 SHA256SUMS.txt。"""
|
||||
script = REPO_ROOT / "packaging" / "make_manifest.py"
|
||||
proc = subprocess.run([sys.executable, str(script), "--dist", str(out_dir), "--version", version])
|
||||
if proc.returncode != 0:
|
||||
fail(f"manifest generation failed ({script.name} exited {proc.returncode})")
|
||||
return out_dir / "manifest.json"
|
||||
|
||||
|
||||
def asset_stem(name: str, version: str, slot: str) -> str:
|
||||
"""`<name>_<version>_<platform>_<arch>` — 产物命名的唯一真相源。
|
||||
|
||||
@@ -267,6 +285,7 @@ def main() -> int:
|
||||
parser.add_argument("--slot", choices=list(SLOTS), help="matrix slot; defaults to the one derived from the host")
|
||||
parser.add_argument("--build-dir", default=DEFAULT_BUILD_DIR, help=f"directory holding the built binaries (default: {DEFAULT_BUILD_DIR})")
|
||||
parser.add_argument("--no-smoke", action="store_true", help="skip the `ull --version` smoke run")
|
||||
parser.add_argument("--manifest", action="store_true", help="also write manifest.json (update manifest, D50)")
|
||||
args = parser.parse_args()
|
||||
|
||||
version = workspace_version()
|
||||
@@ -310,6 +329,9 @@ def main() -> int:
|
||||
size_mb = item.stat().st_size / (1024 * 1024)
|
||||
print(f"artifact: {item.name} {size_mb:.2f} MiB sha256={sha256(item)}")
|
||||
print(f"sums: {sums.name}")
|
||||
if args.manifest:
|
||||
manifest = write_manifest(out_dir, version)
|
||||
print(f"manifest: {manifest.name}")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate `manifest.json` — the update manifest the launcher reads (D50).
|
||||
|
||||
Contract and rationale live in `packaging/README.md` §13; this docstring only states what the
|
||||
code must not get wrong.
|
||||
|
||||
* **Shape authority** = `crates/core/src/update/manifest.rs` (`ReleaseManifest`,
|
||||
`deny_unknown_fields` + per-field validation). This script only writes that shape.
|
||||
* **Facts come from one place**: every `sha256` is read out of the Release's own
|
||||
`SHA256SUMS.txt`, every `size` is `stat()` of the same file — the two can never disagree.
|
||||
The version token inside each asset name is asserted to equal `--version`, so a tag that
|
||||
drifted from `Cargo.toml` cannot yield a manifest pointing at files that do not exist.
|
||||
* **Only launcher artifacts go in.** `ull` does not: the DTO has no role field and
|
||||
`Release::artifact_for(target)` returns the *first* match, so listing the CLI beside the GUI
|
||||
would make "which file do I overwrite" depend on array order.
|
||||
* **`url`**: bare asset name by default (the `DirSource` convention in
|
||||
`crates/core/src/update/source.rs`); with `--asset-base-url` a tag-pinned absolute URL,
|
||||
which is what CI uses.
|
||||
|
||||
Usage:
|
||||
python3 packaging/make_manifest.py --dist dist --version v0.2.1 --channel stable \\
|
||||
--asset-base-url https://github.com/o/r/releases/download/v0.2.1
|
||||
python3 packaging/make_manifest.py --self-test # golden + strict-parse self check
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import NoReturn
|
||||
|
||||
# 本机 Python 可能是 isolated 模式(`safe_path=True`,脚本目录不进 `sys.path`),CI 的标准
|
||||
# python3 则会自动加入。显式补一次,两种环境下 `import make_artifacts` 都成立。
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
# 只借常量:产物命名(SLOTS / LAUNCHER_ASSET)、CLI 名与 SHA256SUMS 文件名都是 make_artifacts
|
||||
# 的单一口径,这里不复制一份表(复制就会漂);反向依赖不存在(make_artifacts 只用子进程调本脚本)。
|
||||
import make_artifacts
|
||||
|
||||
MANIFEST_NAME = "manifest.json"
|
||||
CHANNELS = ("stable", "beta", "alpha", "nightly")
|
||||
# 文件名里的平台段 -> 清单 target 的系统段(`windows_x64` -> `win-x64`)。
|
||||
PLATFORM_TARGET = {"windows": "win", "macos": "mac", "linux": "linux"}
|
||||
# 产物扩展名 -> 清单 format。归档(tar.gz)在 DTO 里只有「解压即用」这一档 `portable-zip`:
|
||||
# 名字带 zip,语义是「非安装器、可原地替换」(PackageFormat::needs_installer() == false)。
|
||||
EXT_FORMAT = {".exe": "portable-exe", ".tar.gz": "portable-zip"}
|
||||
GOLDEN = Path(__file__).resolve().parent / "tests" / "manifest.golden.json"
|
||||
# DTO 侧的取值表镜像(crates/core/src/update/target.rs::parse_os / Arch::parse / PackageFormat::parse)。
|
||||
TARGET_OS = {"win", "windows", "mac", "macos", "osx", "darwin", "linux"}
|
||||
TARGET_ARCH = {
|
||||
"x64", "x86_64", "x86-64", "amd64", "arm64", "aarch64", "arm64e", "x86", "i686", "i386", "x86_32",
|
||||
}
|
||||
FORMATS = {"msi", "dmg", "appimage", "deb", "rpm", "portable-zip", "zip", "portable-exe", "exe"}
|
||||
SEMVER = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$")
|
||||
SUMS_LINE = re.compile(r"^([0-9A-Fa-f]{64})[ \t]+\*?(.+)$")
|
||||
|
||||
|
||||
def fail(message: str) -> NoReturn:
|
||||
print(f"error: {message}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def normalize_version(text: str) -> str:
|
||||
"""`v0.2.1` -> `0.2.1`(tag 带 `v`,产物名与清单都不带)。"""
|
||||
version = text.strip()
|
||||
version = version[1:] if version.startswith("v") else version
|
||||
if not semver_ok(version):
|
||||
fail(f"version `{text}` is not a semver")
|
||||
return version
|
||||
|
||||
|
||||
def semver_ok(text: str) -> bool:
|
||||
"""Mirror of `crates/core/src/update/version.rs::Version::parse` on the accepted shapes."""
|
||||
match = SEMVER.match(text)
|
||||
if not match:
|
||||
return False
|
||||
if any(len(number) > 1 and number.startswith("0") for number in match.group(1, 2, 3)):
|
||||
return False
|
||||
pre, build = match.group(4), match.group(5)
|
||||
if pre is not None:
|
||||
for ident in pre.split("."):
|
||||
if not ident or (ident.isdigit() and len(ident) > 1 and ident.startswith("0")):
|
||||
return False
|
||||
if build is not None and any(not ident for ident in build.split(".")):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def read_sums(dist: Path) -> dict[str, str]:
|
||||
"""`SHA256SUMS.txt` -> {file name: lowercase sha256}. Missing/odd entries are fatal."""
|
||||
sums_path = dist / make_artifacts.SUMS_NAME
|
||||
if not sums_path.is_file():
|
||||
fail(f"missing {sums_path}(清单的 sha256 只从这里取,不另算一份)")
|
||||
sums: dict[str, str] = {}
|
||||
for lineno, line in enumerate(sums_path.read_text(encoding="utf-8").splitlines(), 1):
|
||||
if not line.strip():
|
||||
continue
|
||||
match = SUMS_LINE.match(line)
|
||||
if not match:
|
||||
fail(f"{sums_path.name}:{lineno} 不是 `<sha256> <文件名>` 形态:{line!r}")
|
||||
digest, name = match.group(1).lower(), match.group(2)
|
||||
if name in sums:
|
||||
fail(f"{sums_path.name} 里 {name} 出现两次(无法确定哪个 sha256 是对的)")
|
||||
sums[name] = digest
|
||||
if not sums:
|
||||
fail(f"{sums_path} 是空的:没有产物就没有清单")
|
||||
return sums
|
||||
|
||||
|
||||
def split_asset(name: str, version: str) -> tuple[str, str, str, str]:
|
||||
"""`<name>_<version>_<platform>_<arch>[.exe|.tar.gz]` -> (name, platform, arch, ext)."""
|
||||
stem, ext = name, ""
|
||||
for candidate in EXT_FORMAT:
|
||||
if stem.endswith(candidate):
|
||||
stem, ext = stem[: -len(candidate)], candidate
|
||||
break
|
||||
parts = stem.rsplit("_", 3)
|
||||
if len(parts) != 4:
|
||||
fail(f"`{name}` 不符合 `<名字>_<版本>_<平台>_<架构>` 命名(见 make_artifacts.asset_stem)")
|
||||
product, tag_version, platform_name, arch_name = parts
|
||||
if tag_version != version:
|
||||
fail(f"`{name}` 里的版本是 {tag_version},清单要的是 {version}(tag 与 Cargo.toml 漂了?)")
|
||||
if (platform_name, arch_name) not in {
|
||||
(slot_os, slot_arch) for slot_os, slot_arch, _kind in make_artifacts.SLOTS.values()
|
||||
}:
|
||||
fail(f"`{name}` 的平台/架构 {platform_name}/{arch_name} 不在发布矩阵里")
|
||||
return product, platform_name, arch_name, ext
|
||||
|
||||
|
||||
def build_artifacts(dist: Path, version: str, base_url: str) -> list[dict[str, object]]:
|
||||
"""Launcher artifacts from `SHA256SUMS.txt`, in matrix order (= priority order)."""
|
||||
sums = read_sums(dist)
|
||||
by_slot: dict[tuple[str, str], dict[str, object]] = {}
|
||||
for name, digest in sums.items():
|
||||
product, platform_name, arch_name, ext = split_asset(name, version)
|
||||
if product != make_artifacts.LAUNCHER_ASSET:
|
||||
continue # `ull` 不进清单,理由见模块注释
|
||||
fmt = EXT_FORMAT.get(ext)
|
||||
if fmt is None:
|
||||
fail(f"`{name}` 没有可识别的扩展名,无法判定包形态(format)")
|
||||
path = dist / name
|
||||
if not path.is_file():
|
||||
fail(f"{make_artifacts.SUMS_NAME} 提到 {name},但 {path} 不存在")
|
||||
size = path.stat().st_size
|
||||
if size == 0:
|
||||
fail(f"{path} 是 0 字节:这类产物只可能是打包漏了")
|
||||
url = f"{base_url.rstrip('/')}/{name}" if base_url else name
|
||||
if (platform_name, arch_name) in by_slot:
|
||||
fail(f"{platform_name}/{arch_name} 出现两个启动器产物,清单无法确定该用哪一个")
|
||||
by_slot[(platform_name, arch_name)] = {
|
||||
"target": f"{PLATFORM_TARGET[platform_name]}-{arch_name}",
|
||||
"format": fmt,
|
||||
"url": url,
|
||||
"size": size,
|
||||
"sha256": digest,
|
||||
}
|
||||
order = [(slot_os, slot_arch) for slot_os, slot_arch, _kind in make_artifacts.SLOTS.values()]
|
||||
artifacts_out = [by_slot[key] for key in order if key in by_slot]
|
||||
if not artifacts_out:
|
||||
fail(f"{make_artifacts.SUMS_NAME} 里没有任何 `{make_artifacts.LAUNCHER_ASSET}_*` 产物,清单会是空的")
|
||||
return artifacts_out
|
||||
|
||||
|
||||
def build_manifest(
|
||||
dist: Path,
|
||||
version: str,
|
||||
channel: str,
|
||||
*,
|
||||
base_url: str = "",
|
||||
min_supported: str | None = None,
|
||||
notes: str | None = None,
|
||||
released_at: str | None = None,
|
||||
force: bool = False,
|
||||
) -> dict[str, object]:
|
||||
"""Assemble the manifest document (the shape `ReleaseManifest` deserializes)."""
|
||||
if channel not in CHANNELS:
|
||||
fail(f"未知渠道 `{channel}`(只认 {' / '.join(CHANNELS)})")
|
||||
release: dict[str, object] = {"version": version, "channel": channel}
|
||||
if released_at:
|
||||
release["released_at"] = released_at
|
||||
if notes:
|
||||
release["notes"] = notes
|
||||
release["force"] = force
|
||||
if min_supported:
|
||||
release["min_supported"] = normalize_version(min_supported)
|
||||
release["artifacts"] = build_artifacts(dist, version, base_url)
|
||||
return {"channel": channel, "releases": [release]}
|
||||
|
||||
|
||||
def render(doc: dict[str, object]) -> str:
|
||||
"""Deterministic JSON: same facts + same args ⇒ byte-identical file (golden-testable)."""
|
||||
return json.dumps(doc, ensure_ascii=False, indent=2) + "\n"
|
||||
|
||||
|
||||
# ── 严格解析镜像(DTO 的看门规则)───────────────────────────────────────────────────────
|
||||
# `manifest.rs` 用 `deny_unknown_fields` + 逐字段校验;这里是它的 Python 镜像,只为了在没有
|
||||
# Rust 的机器上(本地打包、CI 发布步)也能当场炸掉拼错的字段。字段/取值的权威永远是 .rs。
|
||||
|
||||
|
||||
def check(condition: bool, message: str) -> None:
|
||||
if not condition:
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def check_keys(where: str, obj: object, allowed: set[str], required: set[str]) -> dict:
|
||||
check(isinstance(obj, dict), f"{where} 必须是对象")
|
||||
unknown = set(obj) - allowed
|
||||
check(not unknown, f"{where} 出现未知字段 {sorted(unknown)}(DTO 是 deny_unknown_fields)")
|
||||
missing = required - set(obj)
|
||||
check(not missing, f"{where} 缺字段 {sorted(missing)}")
|
||||
return obj
|
||||
|
||||
|
||||
def strict_parse(doc: object) -> None:
|
||||
"""Raise `ValueError` on anything `ReleaseManifest::from_json` would reject."""
|
||||
top = check_keys("清单", doc, {"channel", "releases"}, {"channel"})
|
||||
check(top["channel"] in CHANNELS, f"清单渠道 `{top['channel']}` 无法识别")
|
||||
releases = top.get("releases", [])
|
||||
check(isinstance(releases, list), "releases 必须是数组")
|
||||
seen: list[str] = []
|
||||
for index, raw in enumerate(releases):
|
||||
where = f"releases[{index}]"
|
||||
release = check_keys(
|
||||
where,
|
||||
raw,
|
||||
{"version", "channel", "released_at", "notes", "force", "min_supported", "artifacts"},
|
||||
{"version"},
|
||||
)
|
||||
version = release["version"]
|
||||
check(isinstance(version, str) and semver_ok(version), f"{where}.version 不是合法 semver")
|
||||
check(version not in seen, f"{where}.version {version} 在清单里重复出现")
|
||||
seen.append(version)
|
||||
if "channel" in release:
|
||||
check(release["channel"] in CHANNELS, f"{where}.channel 无法识别")
|
||||
for key in ("released_at", "notes"):
|
||||
check(release.get(key) is None or isinstance(release.get(key), str), f"{where}.{key} 必须是字符串")
|
||||
check(isinstance(release.get("force", False), bool), f"{where}.force 必须是布尔")
|
||||
if release.get("min_supported") is not None:
|
||||
check(semver_ok(str(release["min_supported"])), f"{where}.min_supported 不是合法 semver")
|
||||
artifacts_list = release.get("artifacts", [])
|
||||
check(isinstance(artifacts_list, list), f"{where}.artifacts 必须是数组")
|
||||
for art_index, raw_art in enumerate(artifacts_list):
|
||||
art_where = f"{where}.artifacts[{art_index}]"
|
||||
art = check_keys(
|
||||
art_where,
|
||||
raw_art,
|
||||
{"target", "format", "url", "size", "sha256", "sha1"},
|
||||
{"target", "format", "url", "size"},
|
||||
)
|
||||
os_part, _, arch_part = str(art["target"]).strip().partition("-")
|
||||
check(
|
||||
bool(arch_part) and os_part.lower() in TARGET_OS and arch_part.lower() in TARGET_ARCH,
|
||||
f"{art_where}.target `{art['target']}` 无法识别(形如 win-x64)",
|
||||
)
|
||||
check(str(art["format"]).lower() in FORMATS, f"{art_where}.format 无法识别")
|
||||
check(isinstance(art["url"], str) and art["url"].strip(), f"{art_where}.url 为空")
|
||||
size = art["size"]
|
||||
check(isinstance(size, int) and not isinstance(size, bool) and size > 0, f"{art_where}.size 必须 > 0")
|
||||
sha256, sha1 = art.get("sha256"), art.get("sha1")
|
||||
check(sha256 is not None or sha1 is not None, f"{art_where} 既没有 sha256 也没有 sha1")
|
||||
digest_hex = str(sha256 if sha256 is not None else sha1)
|
||||
expect = 64 if sha256 is not None else 40
|
||||
check(
|
||||
len(digest_hex) == expect and all(c in "0123456789abcdefABCDEF" for c in digest_hex),
|
||||
f"{art_where} 的摘要应为 {expect} 位十六进制",
|
||||
)
|
||||
|
||||
|
||||
def self_test() -> int:
|
||||
"""Golden 对拍(`.gitattributes` 的 `eol=lf` 保证跨平台同字节)+ 严格解析正负例:证明写出来的东西能被 DTO 吃下。"""
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
dist = Path(tmp)
|
||||
version = "9.9.9"
|
||||
# 两种启动器形态(裸 exe / 归档)+ 一个 CLI 资产:既覆盖 target/format 映射,
|
||||
# 也钉住「`ull` 不进清单」这条决定。
|
||||
payloads = {
|
||||
f"{make_artifacts.LAUNCHER_ASSET}_{version}_windows_x64.exe": b"gui payload\n",
|
||||
f"{make_artifacts.LAUNCHER_ASSET}_{version}_linux_x64.tar.gz": b"tar payload\n",
|
||||
f"{make_artifacts.CLI_BIN}_{version}_windows_x64.exe": b"cli payload\n",
|
||||
}
|
||||
for name, payload in payloads.items():
|
||||
(dist / name).write_bytes(payload)
|
||||
(dist / make_artifacts.SUMS_NAME).write_text(
|
||||
"".join(f"{make_artifacts.sha256(dist / name)} {name}\n" for name in payloads),
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
doc = build_manifest(
|
||||
dist,
|
||||
version,
|
||||
"stable",
|
||||
min_supported="9.0.0",
|
||||
notes="golden 样例(自验用,不是真实发布)",
|
||||
released_at="2026-01-02T03:04:05Z",
|
||||
)
|
||||
text = render(doc)
|
||||
|
||||
if "--write-golden" in sys.argv:
|
||||
GOLDEN.parent.mkdir(parents=True, exist_ok=True)
|
||||
GOLDEN.write_text(text, encoding="utf-8", newline="\n")
|
||||
print(f"golden: {GOLDEN} 已重写")
|
||||
expected = GOLDEN.read_text(encoding="utf-8")
|
||||
if text != expected:
|
||||
print("--- generated ---", text, "--- golden ---", expected, sep="\n", file=sys.stderr)
|
||||
fail("生成的清单与 golden 不一致(改了口径就 --write-golden 重出并对 diff)")
|
||||
print(f"golden: 与 {GOLDEN.name} 逐字节一致")
|
||||
|
||||
strict_parse(doc)
|
||||
print("strict: 生成物通过严格解析镜像(deny_unknown_fields + 逐字段校验)")
|
||||
# 负例:只测「顺利通过」等于没测——每条篡改都必须被拒。
|
||||
for label, mutate in (
|
||||
("未知字段", lambda d: d.update(extra=1)),
|
||||
("未知版本字段", lambda d: d["releases"][0].update(extra=1)),
|
||||
("未知产物字段", lambda d: d["releases"][0]["artifacts"][0].update(extra=1)),
|
||||
("缺 sha256", lambda d: d["releases"][0]["artifacts"][0].pop("sha256")),
|
||||
("摘要长度错", lambda d: d["releases"][0]["artifacts"][0].update(sha256="deadbeef")),
|
||||
("size 为 0", lambda d: d["releases"][0]["artifacts"][0].update(size=0)),
|
||||
("size 是字符串", lambda d: d["releases"][0]["artifacts"][0].update(size="12")),
|
||||
("未知 target", lambda d: d["releases"][0]["artifacts"][0].update(target="solaris-x64")),
|
||||
("未知 format", lambda d: d["releases"][0]["artifacts"][0].update(format="msix")),
|
||||
("空 url", lambda d: d["releases"][0]["artifacts"][0].update(url=" ")),
|
||||
("版本重复", lambda d: d["releases"].append(copy.deepcopy(d["releases"][0]))),
|
||||
("版本号不合法", lambda d: d["releases"][0].update(version="1.0")),
|
||||
("渠道不认识", lambda d: d.update(channel="canary")),
|
||||
("缺顶层 channel", lambda d: d.pop("channel")),
|
||||
):
|
||||
tampered = copy.deepcopy(doc)
|
||||
mutate(tampered)
|
||||
try:
|
||||
strict_parse(tampered)
|
||||
except ValueError:
|
||||
print(f"strict: 负例「{label}」被正确拒绝")
|
||||
continue
|
||||
fail(f"严格解析放过了负例「{label}」——这条校验形同虚设")
|
||||
print("self-test: ok")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--dist", default="dist", help="directory holding the assets + SHA256SUMS.txt (default: dist/)")
|
||||
parser.add_argument("--version", help="release version, e.g. v0.2.1 (must match the asset file names)")
|
||||
parser.add_argument("--channel", default="stable", choices=CHANNELS, help="manifest channel (D50: stable only for now)")
|
||||
parser.add_argument("--out", help=f"output path (default: <dist>/{MANIFEST_NAME})")
|
||||
parser.add_argument("--asset-base-url", default="", help="prefix for artifact urls; omit for bare file names")
|
||||
parser.add_argument("--min-supported", help="lowest version still supported (omitted when not given)")
|
||||
parser.add_argument("--notes", help="plain-text release notes for the update prompt")
|
||||
parser.add_argument("--notes-file", help="read --notes from this file")
|
||||
parser.add_argument("--released-at", help="RFC3339 timestamp (display only)")
|
||||
parser.add_argument("--force", action="store_true", help="mark this release as force-update")
|
||||
parser.add_argument("--self-test", action="store_true", help="golden + strict-parse self check (no files written)")
|
||||
parser.add_argument("--write-golden", action="store_true", help=argparse.SUPPRESS)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.self_test:
|
||||
return self_test()
|
||||
if not args.version:
|
||||
parser.error("--version is required (it must match the asset file names)")
|
||||
dist = Path(args.dist)
|
||||
if not dist.is_absolute():
|
||||
dist = make_artifacts.REPO_ROOT / dist
|
||||
notes = args.notes
|
||||
if args.notes_file:
|
||||
notes = (make_artifacts.REPO_ROOT / args.notes_file).read_text(encoding="utf-8").strip()
|
||||
doc = build_manifest(
|
||||
dist,
|
||||
normalize_version(args.version),
|
||||
args.channel,
|
||||
base_url=args.asset_base_url,
|
||||
min_supported=args.min_supported,
|
||||
notes=notes,
|
||||
released_at=args.released_at,
|
||||
force=args.force,
|
||||
)
|
||||
text = render(doc)
|
||||
strict_parse(doc)
|
||||
out = Path(args.out) if args.out else dist / MANIFEST_NAME
|
||||
if not out.is_absolute():
|
||||
out = make_artifacts.REPO_ROOT / out
|
||||
out.write_text(text, encoding="utf-8", newline="\n")
|
||||
release = doc["releases"][0]
|
||||
artifacts_list = release["artifacts"]
|
||||
print(f"manifest: {out.name} channel={doc['channel']} version={release['version']} artifacts={len(artifacts_list)}")
|
||||
for item in artifacts_list:
|
||||
print(f" {item['target']:<14} {item['format']:<13} {item['size']:>10} B sha256={str(item['sha256'])[:12]} {item['url']}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"channel": "stable",
|
||||
"releases": [
|
||||
{
|
||||
"version": "9.9.9",
|
||||
"channel": "stable",
|
||||
"released_at": "2026-01-02T03:04:05Z",
|
||||
"notes": "golden 样例(自验用,不是真实发布)",
|
||||
"force": false,
|
||||
"min_supported": "9.0.0",
|
||||
"artifacts": [
|
||||
{
|
||||
"target": "win-x64",
|
||||
"format": "portable-exe",
|
||||
"url": "unlucky-launcher_9.9.9_windows_x64.exe",
|
||||
"size": 12,
|
||||
"sha256": "8ac2dbaad20a7b318a75485359b0302d6d2550093b69296cea39db18d4836ef0"
|
||||
},
|
||||
{
|
||||
"target": "linux-x64",
|
||||
"format": "portable-zip",
|
||||
"url": "unlucky-launcher_9.9.9_linux_x64.tar.gz",
|
||||
"size": 12,
|
||||
"sha256": "dcd549d0571bb74eaee60af5c0bc702714fb0de052094336ab10e1e0c7faea0e"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user