mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-10-16 15:34:08 +08:00
fix(release): finish v1.140.0 Windows cleanup
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user