Files
Unlucky-Launcher/packaging/check_test_source_off.py
T
worker-updatee2e 24826c1a7c feat(m169): 更新通道端到端验收 —— 本地源改成编译期 feature,补一条可一键跑的 E2E + CI 守门
§6 欠账:更新链路的 GUI 端从来没验过,而 M167 发现它根本没法在本机验——`ULL_UPDATE_DIR` 只在
`debug_assertions` 下生效,发布产物却是 release;`apply` 又会替换本进程 exe,于是没人敢跑。

判据换档(tower 定案 B):`cfg!(debug_assertions)` → 编译期 cargo feature `update-test-source`
(core 定义,cli/presenter/app-iced 链式转发,**默认关**)。带 feature 的构建里还要两档环境变量
(`ULL_UPDATE_DIR` + `ULL_UPDATE_TEST_SOURCE=1`),只给路径什么都不会发生。官方发布二进制里那段
分支根本编不进来——README「release builds do not contain that path at all」这句因此继续为真。

- core: `job::default_source` 的本地源分支整段 `#[cfg(feature = "update-test-source")]`;判据抽成
  纯函数 `test_source_root_from` 并逐条单测(半开一律关、空路径、承认值只认 `1`);
  `the_test_channel_is_a_compile_time_opt_in` 在**两种构建下各断各的**(带 feature ⇒ 换源;
  不带 ⇒ 环境变量设满也走 GitHub)。
- cli: `update check/apply` 的 `--json` 增加 `test_source` 字段(判据派生自实际用了哪个源,不是回读
  环境变量),人读输出多一行点名;`help.rs` 的 `ULL_UPDATE_DIR` 说明按新判据重写。
- 验收:`crates/cli/tests/update_e2e_test.rs` + `update_e2e_tamper_test.rs` + 共用夹具
  `tests/support/update_e2e.rs`。临时数据根 + 本地源 + 真形状清单(与 `manifest.golden.json` 同
  字段)+ 真 PE 产物(现场构建的 ull.exe 前 1 MiB,理由写在 `PAYLOAD_LIMIT`)。断言覆盖:发现
  新版本(source=file:… / test_source=true / size / notes)、下载 → 双检 → 就位(dest 字节、旧字节
  进备份、暂存无残留)、重复 apply 的幂等终态、篡改 sha256(清单撒谎 / 包体被换)与截断都必须
  拒绝且不动现有安装、修复后能重装。**永远带 `--dest`**:不带会真的改名换掉测试二进制。
- 守门:`packaging/check_test_source_off.py`(stdlib-only)扫 `.github/workflows/**`、
  `packaging/*.py|*.sh` 与各 `Cargo.toml` 的 `default`,任何一处打开 feature(或 `--all-features`)
  就红;`--self-test` 注入 5 条真实回归证明每条都会红。CI 挂在 release-guard.yml 两步。

验证(本机):core update 单测 80 绿 / 带 feature 6 绿;E2E debug 6 绿;
`cargo test --release --features update-test-source` 6 绿(tower 要求 release 档验收);
`cargo test -p unlucky-cli -p unlucky-presenter` 全绿;守门 self-test + 正式扫描绿(真注入后红)。

已知没验:真 GitHub Release 下载、GUI 点击与重启替换(人工);跨卷替换(另开 finding)。
2026-11-08 11:12:10 +08:00

222 lines
9.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""测试通道守门(M169):**官方发布产物里不许有 `update-test-source` feature**。
为什么需要一条独立的守门:那条 feature 让 `core::update::job::default_source` 可以被两个环境变量
指向任意本地目录,而更新链路的终点是**替换用户盘上的 exe**。默认关 + 编译期不存在是唯一可靠的
边界,所以「谁也没在流水线里把它打开」必须是自动断言,不能靠注释和记性。
三条断言,任何一条不成立即 exit 1(CI 里红在 `.github/workflows/release-guard.yml`):
1. **工作流与打包脚本不许打开它**:`.github/workflows/*.yml|*.yaml` 与 `packaging/*.py|*.sh` 的
可执行内容里不许出现 `update-test-source` 字面量(本文件自己除外——它当然得写这个名字)。
注释里提它不算(`#` 之后一律剥掉),否则「这里不许加 X」这种说明反而会被守门判红。
2. **不许 `--all-features`**:它会把**所有** feature 打开,包括测试通道。两个目录里出现即红。
3. **不许设成默认**:任何 `Cargo.toml` 里 `update-test-source` 只许作为**自己的声明**
(`update-test-source = [...]`)出现;出现在 `default = [...]` 或别的 feature 的依赖数组里即红。
这一条专门兜「第 1 条扫不到」的那种开法:feature 一旦进 default,任何 `cargo build` 都是验收构建。
`--self-test` 是这条守门的变异自测:把每一条的**真实回归**注入临时副本(workflow 里加
`--features update-test-source`、打包脚本里加同一个字面量、workflow 里改 `--all-features`、
`Cargo.toml` 的 `default` 加进去),断言**每一项都红**、基线绿、撤回后重新变绿。守门自己抓不住
回归时它会先红,所以这条断言不会变成装饰品。
# 抓得住什么 / 抓不住什么
| 抓得住 | 抓不住 |
|---|---|
| 有人在 workflow / 打包脚本里加 `--features update-test-source` | 有人在自己机器上手工构建并**自己**分发(流水线管不到的范围) |
| 有人把 feature 塞进 `default`(第 3 条专治;第 1 条扫不到这种开法) | 有人改名后再接回发布:名字一变就脱离了本守门的扫描表 |
| 有人用 `--all-features` 一把梭 | `.github/actions/**` / 别的自定义 action 里间接打开:本守门只扫上面两个目录,扩扫描面时记得连它一起加 |
"""
from __future__ import annotations
import argparse
import re
import sys
import tempfile
from pathlib import Path
#: 被守的 feature 名(改名就得同步改本文件与 `crates/*/Cargo.toml`)。
FEATURE = "update-test-source"
#: 仓库根(本文件在 `packaging/` 下)。
ROOT = Path(__file__).resolve().parent.parent
#: 本文件自己(扫描时跳过:它必须写出 feature 名)。
GUARD = Path(__file__).resolve()
WORKFLOW_DIR = Path(".github/workflows")
SCRIPT_DIR = Path("packaging")
SCRIPT_SUFFIXES = (".py", ".sh")
MANIFEST_GLOBS = ("Cargo.toml", "crates/*/Cargo.toml")
#: Cargo.toml 里「自己的声明」长这样:行首 `update-test-source = [`。
DECLARATION = re.compile(rf"^\s*{re.escape(FEATURE)}\s*=\s*\[")
#: `#` 之后的注释(三种文件都用 `#`;fixture 里没有字符串内 `#`,够用)。
COMMENT = re.compile(r"#.*$")
#: 会把所有 feature 打开的开关。
ALL_FEATURES = re.compile(r"--all-features\b")
def _stripped(line: str) -> str:
"""去掉注释与行尾空白(注释里提 feature 不算违规)。"""
return COMMENT.sub("", line).strip()
def scan_enabling_text(path: Path, root: Path) -> list[str]:
"""扫「可执行内容」:命中的开关会被原样带进一次真实构建。"""
problems: list[str] = []
try:
text = path.read_text(encoding="utf-8")
except OSError as error:
return [f"::error file={path}::读不到 {path}:{error}"]
for number, line in enumerate(text.splitlines(), start=1):
code = _stripped(line)
if not code:
continue
where = path.relative_to(root).as_posix()
if FEATURE in code:
problems.append(
f"::error file={where}:{number}::{where} 打开了 `{FEATURE}`:"
"官方发布产物里不许有测试通道(它能把更新源指向任意本地目录)"
)
if ALL_FEATURES.search(code):
problems.append(
f"::error file={where}:{number}::{where} 用了 `--all-features`:"
f"它会把 `{FEATURE}` 一起打开"
)
return problems
def scan_manifest(path: Path, root: Path) -> list[str]:
"""扫 Cargo.toml:feature 只许出现在自己的声明行上(不许进 default / 别的 feature)。"""
problems: list[str] = []
try:
text = path.read_text(encoding="utf-8")
except OSError as error:
return [f"::error file={path}::读不到 {path}:{error}"]
for number, line in enumerate(text.splitlines(), start=1):
code = _stripped(line)
if FEATURE not in code or DECLARATION.match(code):
continue
where = path.relative_to(root).as_posix()
problems.append(
f"::error file={where}:{number}::{where} 把 `{FEATURE}` 写进了别的 feature 列表:"
"只许它是自己的声明(`update-test-source = [...]`),不许进 `default` 或别的 feature"
)
return problems
def scan(root: Path) -> list[str]:
"""扫整个仓库,返回所有问题(空 = 绿)。"""
problems: list[str] = []
for pattern in ("*.yml", "*.yaml"):
for path in sorted((root / WORKFLOW_DIR).glob(pattern)):
problems += scan_enabling_text(path, root)
for suffix in SCRIPT_SUFFIXES:
for path in sorted((root / SCRIPT_DIR).glob(f"*{suffix}")):
if path.resolve() == GUARD:
continue
problems += scan_enabling_text(path, root)
for pattern in MANIFEST_GLOBS:
for path in sorted(root.glob(pattern)):
problems += scan_manifest(path, root)
return problems
# ────────────────────────── 变异自测 ──────────────────────────
#: 自测基线:一份最小但结构完整的仓(路径与真实仓一致)。
BASELINE: dict[str, str] = {
".github/workflows/release.yml": "jobs:\n build:\n steps:\n - run: cargo build --release\n",
"packaging/make_artifacts.py": "def build():\n return 'cargo build --release'\n",
"Cargo.toml": "[workspace]\nmembers = []\n",
"crates/core/Cargo.toml": (
"[features]\n"
f"{FEATURE} = []\n"
"# 注释里提到 update-test-source 不算违规\n"
),
}
#: 每条变异 =(构造时改哪个文件、插了什么);改完必须红,撤回后必须绿。
MUTATIONS: list[tuple[str, str, str]] = [
(
"workflow 里加 --features update-test-source",
".github/workflows/release.yml",
" - run: cargo build --release --features update-test-source\n",
),
(
"打包脚本里加同一个 feature",
"packaging/make_artifacts.py",
"FLAGS = '--features update-test-source'\n",
),
(
"workflow 里改 --all-features(会一并打开测试通道)",
".github/workflows/release.yml",
" - run: cargo build --release --all-features\n",
),
(
"Cargo.toml 的 default 把 feature 带上",
"crates/core/Cargo.toml",
'default = ["update-test-source"]\n',
),
(
"别的 feature 依赖它",
"crates/core/Cargo.toml",
'release = ["update-test-source"]\n',
),
]
def self_test() -> int:
failures: list[str] = []
with tempfile.TemporaryDirectory(prefix="ull-test-source-guard-") as temp:
base = Path(temp)
for relative, text in BASELINE.items():
path = base / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
baseline = scan(base)
if baseline:
failures.append(f"基线应当在守门里是绿的,实际红了:{baseline}")
for label, relative, injected in MUTATIONS:
target = base / relative
original = target.read_text(encoding="utf-8")
target.write_text(original + injected, encoding="utf-8")
hits = scan(base)
if not hits:
failures.append(f"{label}:注入了真实回归,守门却没红(抓不住)")
target.write_text(original, encoding="utf-8")
if scan(base):
failures.append(f"{label}:撤回注入后没有回到绿")
if failures:
for failure in failures:
print(f"::error::{failure}", file=sys.stderr)
return 1
print(f"OK: 守门自测通过({len(MUTATIONS)} 条注入全部变红,基线绿)")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description=f"守门:官方发布产物里不许有 {FEATURE}")
parser.add_argument("--self-test", action="store_true", help="注入变异自测(每条都必须红)")
parser.add_argument("--root", type=Path, default=ROOT, help="仓根(自测用)")
args = parser.parse_args()
if args.self_test:
return self_test()
problems = scan(args.root)
if problems:
for problem in problems:
print(problem, file=sys.stderr)
return 1
print(f"OK: 流水线与打包脚本都没有打开 {FEATURE}(默认关,发布产物不含测试通道)")
return 0
if __name__ == "__main__":
raise SystemExit(main())