fix(release): finish v1.140.0 Windows cleanup

This commit is contained in:
coso
2026-09-04 11:33:44 +08:00
parent 9341eb297e
commit 81ba705fa5
6 changed files with 179 additions and 46 deletions
+7 -2
View File
@@ -46,7 +46,12 @@
- 失败复盘同时发现生命周期契约矛盾:Squirrel smoke 在 workflow 后续 CodeMode/native-host Gate B 前卸载候选,而 packaged evidence validator 又同时要求卸载成功和已安装 exe 存在。恢复补丁将卸载移动到全部 installed packaged Gate B 与身份校验之后;卸载仅容忍同时包含缺失子键文本、`RegistryKey.DeleteSubKeyTree` 与 `Squirrel.Update.Program.<Uninstall>` 的明确幂等错误,仍要求 Update.exe、候选目录、主程序与快捷方式全部消失,其他错误继续 fail closed。
- 恢复补丁本地验证:Windows Squirrel/packaged evidence/release workflow guard 定向 Vitest 3 文件 `66/66`;`npm run typecheck`、`npm run test:contracts`、`npm run verify:app-version`、`npm run governance:scripts`、release workflow guard、Prettier check 与 `git diff --check` 全部通过。
- Windows timeout 恢复补丁将测试负载改为不触发 shell 策略但仍可由 timeout 终止的 `.NET Thread.Sleep`,并给 Unix-only imports/helper 补齐 `cfg(unix)`,消除日志中的 4 个 `app-server` Windows 编译警告。本机 `npm run test:rust:related -- lime-rs/crates/app-server/src/command_exec/tests.rs lime-rs/crates/app-server/src/process/tests.rs` 通过 `1759/1759`,`cargo fmt --check` 通过;Windows 专属分支待恢复后的 Quality run 验证。
- 待执行:单独确认后提交并推送修复;通过 `workflow_dispatch version=v1.140.0` 从后续 `main` 修复提交重建资产,不移动已推送 tag;监控 GitHub Release 发布、CLI 资产与 R2 updater 完成。
- 恢复提交 `9341eb297e6baecdf453eccf2aed2465bad99e4f6747091bf` 已推送到 `origin/main`;`v1.140.0` tag 仍固定在原始 release commit `1fe2ad260bb080a94a8dbda9e0d954f6747091bf`,未改写。
- 第二次 Release run `33826403443`:macOS x64/arm64 构建通过;Windows Electron 构建、Squirrel 安装、N-1 更新、SHELL-01、CodeMode Gate B、native-host Gate B 与 packaged evidence identity 全部通过。最后的卸载步骤失败,artifact `windows-squirrel-rc-evidence-x86_64-pc-windows-msvc` 显示主程序和快捷方式已移除,但 `C:\Users\runneradmin\AppData\Local\lime\Update.exe` 与 `app-1.140.0` 在 60 秒后仍存在,因此 Electron/CLI 资产和 R2 发布被跳过。
- 第二次失败根因是门禁把 Squirrel.Windows 的有意保留行为误判成卸载失败:其 `FullUninstall` 删除产品文件后会重建带 `.dead` 的安装根,并保留正在参与卸载钩子的 `Update.exe`。本轮修复先 fail-closed 验证候选 `Lime.exe` 与快捷方式已由 Squirrel 移除,再等待同路径 updater 进程退出,最后仅清理经过安装布局校验的候选 app 目录与 `Update.exe`;证据分别记录产品卸载和 runner 残留清理结果,未知卸载错误仍不容忍。路径/顺序/fail-closed 定向 Vitest 与 packaged/release guard 共 `68/68` 通过。
- 第二次 Windows 日志同时暴露 `tool-runtime` 的 4 条告警:删除从未进入生产调用图的 `should_preserve_windows_job` 及其自证测试;将 Windows SDK 的 `NERR_Success` / `NERR_GroupExists` 仅在模式匹配处导入为 Rust 风格大写别名。使用仓库校验后的 sandboxed rusty_v8 资产后,`npm run test:rust:unit -- -p tool-runtime` 通过 `382/382`,`cargo fmt -p tool-runtime --check` 通过;`cargo clippy -D warnings` 完成依赖构建后被仓库内 23 条既有 Rust 1.95 lint 阻断,不属于本轮 Windows rustc 告警。Windows 无告警编译待下一次 CI 给出平台证据。
- 第二轮恢复补丁发布门禁:`npm run typecheck`、`npm run test:contracts`、`npm run verify:app-version`、`npm run governance:scripts`、Prettier 与 `git diff --check` 均通过。
- 待执行:复核发布门禁后单独确认、提交并推送第二轮修复;通过 `workflow_dispatch version=v1.140.0` 从后续 `main` 修复提交重建资产,不移动已推送 tag;监控 Windows/macOS 构建、GitHub Release、CLI 资产与 R2 updater 完成。
## 收尾分类
@@ -55,4 +60,4 @@
- `deprecated`:无新增。
- `dead / deleted`:旧 `lime-cli-npm` 包、旧 CLI skill/工具文档与其专用入口。
当前完成度:`85%`;版本 metadata、双语 release notes、原始 release commit/tag/push 已完成,正在修复 Windows 发布门禁并恢复远端资产发布。
当前完成度:`88%`;版本 metadata、双语 release notes、原始 release commit/tag/push 与第二轮 Windows 根因修复已完成,等待提交推送后重新验证并发布远端资产。
@@ -776,11 +776,6 @@ fn start_local_execution_process_with_inherited_environment(
})
}
#[cfg(any(test, target_os = "windows"))]
fn should_preserve_windows_job(wait_failed: bool, process_status: ExecutionProcessStatus) -> bool {
!wait_failed && !process_status.is_terminal()
}
#[allow(clippy::too_many_arguments)]
async fn supervise_local_process(
mut child: Child,
@@ -428,26 +428,6 @@ fn explicit_environment_overrides_can_restore_filtered_values() {
);
}
#[test]
fn windows_job_preserves_only_after_normal_root_exit() {
assert!(should_preserve_windows_job(
false,
ExecutionProcessStatus::Running
));
assert!(!should_preserve_windows_job(
true,
ExecutionProcessStatus::Running
));
assert!(!should_preserve_windows_job(
false,
ExecutionProcessStatus::Interrupted
));
assert!(!should_preserve_windows_job(
false,
ExecutionProcessStatus::Terminated
));
}
#[cfg(not(target_os = "windows"))]
#[test]
fn windows_world_writable_audit_is_clean_without_platform_commands() {
@@ -275,7 +275,8 @@ pub(super) fn ensure_local_account(username: &str, password: &str) -> io::Result
pub(super) fn ensure_local_group(name: &str, comment: &str) -> io::Result<()> {
use windows_sys::Win32::Foundation::ERROR_ALIAS_EXISTS;
use windows_sys::Win32::NetworkManagement::NetManagement::{
NERR_GroupExists, NERR_Success, NetLocalGroupAdd, LOCALGROUP_INFO_1,
NERR_GroupExists as NERR_GROUP_EXISTS, NERR_Success as NERR_SUCCESS, NetLocalGroupAdd,
LOCALGROUP_INFO_1,
};
let mut name_w = to_wide(name);
@@ -293,7 +294,10 @@ pub(super) fn ensure_local_group(name: &str, comment: &str) -> io::Result<()> {
&mut parameter_error,
)
};
if matches!(result, NERR_Success | ERROR_ALIAS_EXISTS | NERR_GroupExists) {
if matches!(
result,
NERR_SUCCESS | ERROR_ALIAS_EXISTS | NERR_GROUP_EXISTS
) {
Ok(())
} else {
Err(io::Error::other(format!(
@@ -305,7 +309,7 @@ pub(super) fn ensure_local_group(name: &str, comment: &str) -> io::Result<()> {
pub(super) fn ensure_local_group_member(group: &str, account: &str) -> io::Result<()> {
use windows_sys::Win32::Foundation::ERROR_MEMBER_IN_ALIAS;
use windows_sys::Win32::NetworkManagement::NetManagement::{
NERR_Success, NetLocalGroupAddMembers, LOCALGROUP_MEMBERS_INFO_3,
NERR_Success as NERR_SUCCESS, NetLocalGroupAddMembers, LOCALGROUP_MEMBERS_INFO_3,
};
let group_w = to_wide(group);
@@ -322,7 +326,7 @@ pub(super) fn ensure_local_group_member(group: &str, account: &str) -> io::Resul
1,
)
};
if matches!(result, NERR_Success | ERROR_MEMBER_IN_ALIAS) {
if matches!(result, NERR_SUCCESS | ERROR_MEMBER_IN_ALIAS) {
Ok(())
} else {
Err(io::Error::other(format!(
@@ -4,6 +4,7 @@ import {
existsSync,
mkdtempSync,
readFileSync,
rmSync,
statSync,
} from "node:fs";
import { createServer as createHttpServer } from "node:http";
@@ -244,7 +245,9 @@ export async function uninstallInstalledSquirrel({
timeoutMs = 60_000,
runProcessImpl = runProcess,
existsImpl = existsSync,
removeResidualPathsImpl = removeSquirrelResidualPaths,
waitForAbsentImpl = waitForAbsent,
waitForProcessExitImpl = waitForWindowsProcessExit,
} = {}) {
const requiredPaths = [updateExecutable, appDirectory, executable].filter(
(value) => typeof value === "string" && value.length > 0,
@@ -254,6 +257,11 @@ export async function uninstallInstalledSquirrel({
"Squirrel uninstall requires updateExecutable, appDirectory, and executable",
);
}
assertSquirrelInstallPathLayout({
appDirectory,
executable,
updateExecutable,
});
const result = await runProcessImpl(updateExecutable, ["--uninstall"], {
captureOutput: true,
env: process.env,
@@ -265,6 +273,20 @@ export async function uninstallInstalledSquirrel({
`Squirrel uninstall exited with ${result.exitCode} at ${updateExecutable}`,
);
}
// Legacy Squirrel retains updater files by design. Prove product removal first,
// then clean only the validated candidate paths left on the CI runner.
const productPaths = [executable, ...shortcutPaths].filter(
(value) => typeof value === "string" && value.length > 0,
);
await waitForAbsentImpl(productPaths, { existsImpl, timeoutMs });
const updaterQuiescence = await waitForProcessExitImpl(updateExecutable, {
timeoutMs,
});
const retainedBeforeCleanup = {
appDirectory: existsImpl(appDirectory),
updateExecutable: existsImpl(updateExecutable),
};
await removeResidualPathsImpl({ appDirectory, updateExecutable });
await waitForAbsentImpl(
[...requiredPaths, ...shortcutPaths].filter(
(value) => typeof value === "string" && value.length > 0,
@@ -279,6 +301,14 @@ export async function uninstallInstalledSquirrel({
commandAccepted: classification.accepted,
exitCode: result.exitCode,
warning: classification.warning,
productRemoval: {
executableAbsent: !existsImpl(executable),
shortcutsAbsent: shortcutPaths.every((shortcut) => !existsImpl(shortcut)),
},
retainedPathCleanup: {
retainedBeforeCleanup,
updaterQuiescence,
},
updateExecutableAbsent: !existsImpl(updateExecutable),
appDirectoryAbsent: !existsImpl(appDirectory),
executableAbsent: !existsImpl(executable),
@@ -286,6 +316,44 @@ export async function uninstallInstalledSquirrel({
};
}
function assertSquirrelInstallPathLayout({
appDirectory,
executable,
updateExecutable,
}) {
const normalizedAppDirectory = path.win32.resolve(appDirectory);
const normalizedExecutable = path.win32.resolve(executable);
const normalizedUpdateExecutable = path.win32.resolve(updateExecutable);
const packageRoot = path.win32.dirname(normalizedUpdateExecutable);
const equalsPath = (left, right) =>
left.toLowerCase() === right.toLowerCase();
const valid =
path.win32.basename(normalizedUpdateExecutable).toLowerCase() ===
"update.exe" &&
/^app-/i.test(path.win32.basename(normalizedAppDirectory)) &&
equalsPath(path.win32.dirname(normalizedAppDirectory), packageRoot) &&
equalsPath(
path.win32.dirname(normalizedExecutable),
normalizedAppDirectory,
) &&
path.win32.basename(normalizedExecutable).toLowerCase() === "lime.exe";
if (!valid) {
throw new Error(
"Squirrel uninstall paths must identify Lime.exe under one app-* directory beside Update.exe",
);
}
}
function removeSquirrelResidualPaths({ appDirectory, updateExecutable }) {
rmSync(appDirectory, {
force: true,
maxRetries: 10,
recursive: true,
retryDelay: 250,
});
rmSync(updateExecutable, { force: true });
}
async function waitForAbsent(
paths,
{ existsImpl = existsSync, timeoutMs = 60_000, intervalMs = 250 } = {},
@@ -147,34 +147,64 @@ describe("Windows Squirrel RC smoke", () => {
});
it("卸载只操作候选 Update.exe,并等待安装目录与快捷方式消失", async () => {
const updateExecutable =
"C:\\Users\\runner\\AppData\\Local\\lime\\Update.exe";
const appDirectory = "C:\\Users\\runner\\AppData\\Local\\lime\\app-1.2.3";
const executable = `${appDirectory}\\Lime.exe`;
const shortcut = "C:\\Users\\runner\\Desktop\\Lime.lnk";
const runProcessImpl = vi.fn().mockResolvedValue({ exitCode: 0 });
const exists = vi.fn().mockReturnValue(false);
const retainedPaths = new Set([updateExecutable, appDirectory]);
const exists = vi.fn((target) => retainedPaths.has(target));
const waitForAbsentImpl = vi.fn().mockResolvedValue(undefined);
const waitForProcessExitImpl = vi.fn().mockResolvedValue({
executable: updateExecutable,
exitCode: 0,
timeoutMs: 60_000,
});
const removeResidualPathsImpl = vi.fn((paths) => {
retainedPaths.delete(paths.appDirectory);
retainedPaths.delete(paths.updateExecutable);
});
const result = await uninstallInstalledSquirrel({
updateExecutable: "C:\\Users\\runner\\AppData\\Local\\lime\\Update.exe",
appDirectory: "C:\\Users\\runner\\AppData\\Local\\lime\\app-1.2.3",
executable:
"C:\\Users\\runner\\AppData\\Local\\lime\\app-1.2.3\\Lime.exe",
shortcutPaths: ["C:\\Users\\runner\\Desktop\\Lime.lnk"],
updateExecutable,
appDirectory,
executable,
shortcutPaths: [shortcut],
runProcessImpl,
existsImpl: exists,
removeResidualPathsImpl,
waitForAbsentImpl,
waitForProcessExitImpl,
});
expect(runProcessImpl).toHaveBeenCalledWith(
"C:\\Users\\runner\\AppData\\Local\\lime\\Update.exe",
updateExecutable,
["--uninstall"],
expect.objectContaining({ captureOutput: true, timeoutMs: 30_000 }),
);
expect(waitForAbsentImpl).toHaveBeenCalledWith(
[
"C:\\Users\\runner\\AppData\\Local\\lime\\Update.exe",
"C:\\Users\\runner\\AppData\\Local\\lime\\app-1.2.3",
"C:\\Users\\runner\\AppData\\Local\\lime\\app-1.2.3\\Lime.exe",
"C:\\Users\\runner\\Desktop\\Lime.lnk",
],
expect(waitForAbsentImpl).toHaveBeenNthCalledWith(
1,
[executable, shortcut],
expect.objectContaining({ timeoutMs: 60_000 }),
);
expect(waitForProcessExitImpl).toHaveBeenCalledWith(updateExecutable, {
timeoutMs: 60_000,
});
expect(removeResidualPathsImpl).toHaveBeenCalledWith({
appDirectory,
updateExecutable,
});
expect(waitForAbsentImpl).toHaveBeenNthCalledWith(
2,
[updateExecutable, appDirectory, executable, shortcut],
expect.objectContaining({ timeoutMs: 60_000 }),
);
expect(waitForAbsentImpl.mock.invocationCallOrder[0]).toBeLessThan(
waitForProcessExitImpl.mock.invocationCallOrder[0],
);
expect(waitForProcessExitImpl.mock.invocationCallOrder[0]).toBeLessThan(
removeResidualPathsImpl.mock.invocationCallOrder[0],
);
expect(result).toMatchObject({
commandAccepted: true,
exitCode: 0,
@@ -182,6 +212,16 @@ describe("Windows Squirrel RC smoke", () => {
executableAbsent: true,
shortcutsAbsent: true,
warning: null,
productRemoval: {
executableAbsent: true,
shortcutsAbsent: true,
},
retainedPathCleanup: {
retainedBeforeCleanup: {
appDirectory: true,
updateExecutable: true,
},
},
});
});
@@ -203,7 +243,9 @@ describe("Windows Squirrel RC smoke", () => {
.fn()
.mockResolvedValue({ exitCode: 1, stderr, stdout: "" }),
existsImpl: vi.fn().mockReturnValue(false),
removeResidualPathsImpl: vi.fn(),
waitForAbsentImpl,
waitForProcessExitImpl: vi.fn().mockResolvedValue({ exitCode: 0 }),
}),
).resolves.toMatchObject({
commandAccepted: true,
@@ -214,7 +256,46 @@ describe("Windows Squirrel RC smoke", () => {
shortcutsAbsent: true,
warning: "missing-registry-subkey",
});
expect(waitForAbsentImpl).toHaveBeenCalledOnce();
expect(waitForAbsentImpl).toHaveBeenCalledTimes(2);
});
it("拒绝清理不属于同一 Squirrel 安装布局的路径", async () => {
const runProcessImpl = vi.fn();
await expect(
uninstallInstalledSquirrel({
updateExecutable: "C:\\runner\\lime\\Update.exe",
appDirectory: "C:\\runner\\other\\app-1.2.3",
executable: "C:\\runner\\other\\app-1.2.3\\Lime.exe",
runProcessImpl,
}),
).rejects.toThrow(
"Squirrel uninstall paths must identify Lime.exe under one app-* directory beside Update.exe",
);
expect(runProcessImpl).not.toHaveBeenCalled();
});
it("产品文件未被 Squirrel 移除时不得用 runner 清理掩盖失败", async () => {
const removeResidualPathsImpl = vi.fn();
await expect(
uninstallInstalledSquirrel({
updateExecutable: "C:\\runner\\Update.exe",
appDirectory: "C:\\runner\\app-1.2.3",
executable: "C:\\runner\\app-1.2.3\\Lime.exe",
shortcutPaths: ["C:\\runner\\Desktop\\Lime.lnk"],
runProcessImpl: vi.fn().mockResolvedValue({ exitCode: 0 }),
removeResidualPathsImpl,
waitForAbsentImpl: vi
.fn()
.mockRejectedValue(
new Error(
"Squirrel uninstall did not remove paths: C:\\runner\\app-1.2.3\\Lime.exe",
),
),
}),
).rejects.toThrow("Squirrel uninstall did not remove paths");
expect(removeResidualPathsImpl).not.toHaveBeenCalled();
});
it("卸载失败时 fail closed,不标记候选生命周期成功", async () => {