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)。
This commit is contained in:
1 parent
a14b9e17c1
commit
24826c1a7c
18 files changed
+1133
-44
No files matched your search
@@ -128,8 +128,10 @@ pub fn topic(name: &str) -> Option<&'static str> {
|
||||
缺省替换本进程的 exe:**下次启动生效**(正在跑的这一份仍是旧版本)。
|
||||
--dest 钉到别的路径(取证 / 自定义安装位置)。不写 = 本进程 exe。
|
||||
|
||||
环境变量 ULL_UPDATE_DIR=<dir>:改走本地目录源(`<dir>/stable.json` + 产物),
|
||||
用于内网手工分发与「发现新版本」这条分支的整环取证;不设就走 GitHub。
|
||||
环境变量 ULL_UPDATE_DIR=<dir> + ULL_UPDATE_TEST_SOURCE=1:改走本地目录源(`<dir>/stable.json`
|
||||
+ 产物),用于内网手工分发与更新链路的整环取证。**两者都只在带 `update-test-source` feature
|
||||
编出来的验收构建里生效**(`cargo build --features update-test-source`);官方发布构建不含这条
|
||||
路径,设了环境变量也照旧走 GitHub。
|
||||
--json 输出 {\"data\":{\"status\":…,\"current\":…,\"version\":…}},脚本按 status 分流。",
|
||||
),
|
||||
"install" => Some(
|
||||
|
||||
@@ -16,6 +16,22 @@
|
||||
//! 同样不许说成「已是最新」。
|
||||
//!
|
||||
//! `apply` 默认替换**本进程的 exe**(`Job::for_process`);`--dest` 可以钉到别的路径(取证用)。
|
||||
//!
|
||||
//! # 测试通道(M169)
|
||||
//!
|
||||
//! 想在没有稳定 Release 的情况下整环验一遍,得用**专门编出来的验收二进制**:
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo build --release --features update-test-source # 官方发布构建不带这个 feature
|
||||
//! ULL_UPDATE_DIR=<root> ULL_UPDATE_TEST_SOURCE=1 ./ull update check
|
||||
//! ```
|
||||
//!
|
||||
//! 三档全给才生效(feature 默认关 = 官方二进制里没有这条路径;带 feature 时还要两档环境变量,
|
||||
//! 判据见 `core::update::job`)。这时 `--json` 的 `source` 是 `file:<root>`、`test_source` 是
|
||||
//! `true`,人读输出另有一行点名。它不是第二个更新源:没有设置键、也没有命令行参数。
|
||||
//!
|
||||
//! ⚠ **验收时必须给 `--dest`**:不给就是替换本进程的 exe。自动化用例里的「本进程」是测试二进制,
|
||||
//! 去掉 `--dest` 会把它真改名换掉(`crates/cli/tests/update_e2e_test.rs` 的注释写了这件事)。
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
@@ -77,6 +93,21 @@ fn failure(code: &'static str, action: &str, error: &UpdateError) -> CliError {
|
||||
.with_hint(hint)
|
||||
}
|
||||
|
||||
/// 这个源是不是测试通道(判据派生自**实际用了哪个源**,不是回读环境变量:
|
||||
/// `DirSource` 的 id 是 `file:<root>`,GitHub 源是 `github:<owner>/<repo>`)。
|
||||
fn is_test_source(source_id: &str) -> bool {
|
||||
source_id.starts_with("file:")
|
||||
}
|
||||
|
||||
/// 测试通道的点名行(只在真走了本地目录源时出现)。文案里点明**这是验收构建**:官方发布二进制
|
||||
/// 没有这条路径(`update-test-source` 默认关),所以出现这一行就说明手上这份不是官方产物。
|
||||
fn test_channel_note(source: &str) -> String {
|
||||
format!(
|
||||
"测试通道生效:源 = {source}(本地目录,不是 GitHub Releases;本二进制带 \
|
||||
`update-test-source` feature 编出来,官方发布构建不含这条路径)"
|
||||
)
|
||||
}
|
||||
|
||||
/// `ull update check`:只查、不动任何文件。
|
||||
///
|
||||
/// # Errors
|
||||
@@ -109,6 +140,8 @@ pub fn apply(ctx: &Ctx, args: &UpdateApplyArgs) -> Result<Outcome, CliError> {
|
||||
.backup
|
||||
.as_ref()
|
||||
.map(|path| path.display().to_string());
|
||||
let source = job.source_id();
|
||||
let test_source = is_test_source(source);
|
||||
let mut human = vec![
|
||||
format!("已下载并校验 v{version}({} 字节)", receipt.bytes),
|
||||
format!("已替换:{dest}"),
|
||||
@@ -118,10 +151,15 @@ pub fn apply(ctx: &Ctx, args: &UpdateApplyArgs) -> Result<Outcome, CliError> {
|
||||
None => "原本没有旧文件(首次落盘)".to_string(),
|
||||
});
|
||||
human.push("下次启动生效(正在运行的进程仍是旧版本)。".to_string());
|
||||
if test_source {
|
||||
human.push(test_channel_note(source));
|
||||
}
|
||||
Ok(Outcome::new(Report::new(
|
||||
json!({
|
||||
"current": current.to_string(),
|
||||
"channel": CHANNEL.as_str(),
|
||||
"source": source,
|
||||
"test_source": test_source,
|
||||
"status": "installed",
|
||||
"version": version,
|
||||
"path": dest,
|
||||
@@ -147,16 +185,21 @@ pub fn apply(ctx: &Ctx, args: &UpdateApplyArgs) -> Result<Outcome, CliError> {
|
||||
}
|
||||
|
||||
/// 把检查结论排成「人读几行 + `--json` 一份」。
|
||||
///
|
||||
/// `test_source` 逐条写进 JSON(不只在高亮时写):脚本据此判断「这一份结论是不是本地夹具给的」,
|
||||
/// 而判据来自 `source`(实际用了哪个源),所以不会出现「环境变量说关了、输出说开着」。
|
||||
fn report_of(current: &Version, source: &str, outcome: &CheckOutcome) -> Report {
|
||||
let test_source = is_test_source(source);
|
||||
let base = |status: &str| {
|
||||
json!({
|
||||
"current": current.to_string(),
|
||||
"channel": CHANNEL.as_str(),
|
||||
"source": source,
|
||||
"test_source": test_source,
|
||||
"status": status,
|
||||
})
|
||||
};
|
||||
match outcome {
|
||||
let mut report = match outcome {
|
||||
CheckOutcome::UpToDate { latest, .. } => {
|
||||
let latest = latest.as_ref().map(ToString::to_string);
|
||||
let mut data = base("up_to_date");
|
||||
@@ -187,6 +230,7 @@ fn report_of(current: &Version, source: &str, outcome: &CheckOutcome) -> Report
|
||||
"current": current.to_string(),
|
||||
"channel": CHANNEL.as_str(),
|
||||
"source": source,
|
||||
"test_source": test_source,
|
||||
"status": "available",
|
||||
"version": version,
|
||||
"size": candidate.artifact.size,
|
||||
@@ -204,6 +248,7 @@ fn report_of(current: &Version, source: &str, outcome: &CheckOutcome) -> Report
|
||||
"current": current.to_string(),
|
||||
"channel": CHANNEL.as_str(),
|
||||
"source": source,
|
||||
"test_source": test_source,
|
||||
"status": "missing_artifact",
|
||||
"version": version,
|
||||
"target": unlucky_core::update::Target::current().as_str(),
|
||||
@@ -214,5 +259,9 @@ fn report_of(current: &Version, source: &str, outcome: &CheckOutcome) -> Report
|
||||
)],
|
||||
)
|
||||
}
|
||||
};
|
||||
if test_source {
|
||||
report.human.push(test_channel_note(source));
|
||||
}
|
||||
report
|
||||
}
|
||||
Reference in new issue
Block a user