From 28b8e359190429a603c3395416a4714fef9707e6 Mon Sep 17 00:00:00 2001 From: TRIP <192432815+appergb@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:28:01 +0800 Subject: [PATCH] fix(ci): accept successful MSI reboot-required status and pin smoke tooling --- .github/workflows/release-tauri.yml | 19 +++++++- .../2.0.1-windows-ci-isolation-20261016.md | 8 ++++ .../app/scripts/windows-ime-install-smoke.ps1 | 12 +++-- .../windows-ime-install-smoke.test.ps1 | 47 +++++++++++++++++++ .../windows-msi-release-contract.test.mjs | 19 ++++++++ 5 files changed, 99 insertions(+), 6 deletions(-) create mode 100644 openless-all/app/scripts/windows-ime-install-smoke.test.ps1 diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml index 0fe0c9e7c..45761ea8b 100644 --- a/.github/workflows/release-tauri.yml +++ b/.github/workflows/release-tauri.yml @@ -129,6 +129,7 @@ jobs: uses: swatinem/rust-cache@v2 with: workspaces: 'openless-all/app/src-tauri -> target' + cache-on-failure: true - name: Cache macOS release dependencies if: startsWith(matrix.platform, 'macos') @@ -424,6 +425,19 @@ jobs: } Write-Host "[ok] MSI rebuilt at $msiPath" + # Tests are tooling, not application payload. A targeted repair checks out + # immutable app source above, but uses the reviewed workflow commit's smoke + # script so fixes to test semantics do not require moving a release tag. + - name: Checkout installer smoke tooling + if: matrix.platform == 'windows-latest' + uses: actions/checkout@v4 + with: + ref: ${{ github.workflow_sha }} + path: release-tooling + sparse-checkout: openless-all/app/scripts/windows-ime-install-smoke.ps1 + sparse-checkout-cone-mode: false + persist-credentials: false + - name: Verify Windows installers register TSF IME if: matrix.platform == 'windows-latest' shell: pwsh @@ -433,6 +447,7 @@ jobs: # after the NSIS install/uninstall has loaded the TSF DLLs. /norestart # suppresses an OS reboot, not Restart Manager application shutdown. # Keep both real installer/registry checks; restore the machine policy. + $smokeScript = Join-Path $env:GITHUB_WORKSPACE 'release-tooling\openless-all\app\scripts\windows-ime-install-smoke.ps1' $policy = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Installer' $policyName = 'DisableAutomaticApplicationShutdown' $previous = (Get-ItemProperty -LiteralPath $policy -Name $policyName -ErrorAction SilentlyContinue).$policyName @@ -441,7 +456,7 @@ jobs: New-ItemProperty -LiteralPath $policy -Name $policyName -PropertyType DWord -Value 1 -Force | Out-Null $nsis = Get-ChildItem "src-tauri\target\release\bundle\nsis\*.exe" -ErrorAction Stop | Select-Object -First 1 if (-not $nsis) { throw "NSIS installer not found." } - powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\windows-ime-install-smoke.ps1 -InstallerPath $nsis.FullName -InstallerKind nsis + powershell.exe -NoProfile -ExecutionPolicy Bypass -File $smokeScript -InstallerPath $nsis.FullName -InstallerKind nsis if ($LASTEXITCODE -ne 0) { throw "NSIS installer smoke failed with exit $LASTEXITCODE" } @@ -451,7 +466,7 @@ jobs: } $msi = Get-ChildItem "src-tauri\target\release\bundle\msi\*.msi" -ErrorAction Stop | Select-Object -First 1 if (-not $msi) { throw "MSI installer not found." } - powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\windows-ime-install-smoke.ps1 -InstallerPath $msi.FullName -InstallerKind msi + powershell.exe -NoProfile -ExecutionPolicy Bypass -File $smokeScript -InstallerPath $msi.FullName -InstallerKind msi if ($LASTEXITCODE -ne 0) { throw "MSI installer smoke failed with exit $LASTEXITCODE" } diff --git a/docs/releases/2.0.1-windows-ci-isolation-20261016.md b/docs/releases/2.0.1-windows-ci-isolation-20261016.md index 74ded0d64..17bf1da0d 100644 --- a/docs/releases/2.0.1-windows-ci-isolation-20261016.md +++ b/docs/releases/2.0.1-windows-ci-isolation-20261016.md @@ -9,3 +9,11 @@ Microsoft 官方定义 `/norestart` 不等于禁止 Restart Manager 关闭应用 来源:[DisableAutomaticApplicationShutdown](https://learn.microsoft.com/en-us/windows/win32/msi/disableautomaticapplicationshutdown)、[MSIRESTARTMANAGERCONTROL](https://learn.microsoft.com/en-us/windows/win32/msi/msirestartmanagercontrol)。官方 Markdown 已从 MicrosoftDocs/win32 对应 desktop-src/Msi 文件核对。 工作流与契约经 PR/CI 后,仅使用已存在的管理员/草稿/精确 tag 源码/七项 CI 守卫的 Windows-only repair 入口,对 `v2.0.1-tauri` 构建并验证。应用源码、版本与标签不变;最终仍须 Windows smoke 成功、全部资产与签名核验后才公开 Stable。 + +## 隔离后的安装器返回值 + +PR #1213 精确 CI `38888000367` 七项通过并合入 beta。Windows-only run `38889597557` 不再关闭 runner,MSI 安装返回 **3010**。Microsoft 官方 [Windows Installer error codes](https://learn.microsoft.com/en-us/windows/win32/msi/error-codes) 定义它为安装成功、需要重启,旧 smoke 却将所有非零返回视为失败,尚未执行后续注册验证。 + +修正通用进程 helper 的可接受返回值参数:默认仍仅 0,只有 MSI 安装/卸载显式允许 0 与 3010;NSIS 保持 0-only,1603 等错误及会实际重启 CI 的 1641 仍拒绝。接受 3010 后仍完整执行文件、DLL 路径和所有 COM/TSF/后端注册检查,不将成功返回码本身当成安装验收。新增 Windows 上执行实际 helper 的六个 mock 返回值回归,不启动安装器或修改注册表。 + +为保留不可变应用源码,工作流单独 sparse checkout **`github.workflow_sha`** 固定的 smoke 工具脚本;应用 checkout 仍指向原始 release tag。复制的只有测试脚本,编译/打包 payload 不使用工具分支;正常 tag 构建和定向草稿修复采用同一工具来源规则。Windows 编译缓存允许失败时保存,避免 smoke 问题每次重编所有依赖。后续实际 smoke 成功前仍不公开 Stable。 diff --git a/openless-all/app/scripts/windows-ime-install-smoke.ps1 b/openless-all/app/scripts/windows-ime-install-smoke.ps1 index 96a150ce9..6c0fcf230 100644 --- a/openless-all/app/scripts/windows-ime-install-smoke.ps1 +++ b/openless-all/app/scripts/windows-ime-install-smoke.ps1 @@ -58,15 +58,19 @@ function Invoke-CheckedProcess { [string]$FilePath, [string[]]$ArgumentList = @(), [Parameter(Mandatory = $true)] - [string]$Label + [string]$Label, + [int[]]$SuccessExitCodes = @(0) ) $commandLine = Join-ProcessArguments $ArgumentList Write-Host "[run] $Label`: $FilePath $commandLine" $process = Start-Process -FilePath $FilePath -ArgumentList $commandLine -Wait -PassThru - if ($process.ExitCode -ne 0) { + if ($process.ExitCode -notin $SuccessExitCodes) { throw "$Label failed with exit code $($process.ExitCode)" } + if ($process.ExitCode -eq 3010) { + Write-Host "[ok] $Label succeeded; Windows requested a reboot (3010). Continuing payload/registry verification without rebooting." + } } function Open-LocalMachineSubKey { @@ -178,7 +182,7 @@ function Uninstall-OpenLess { } Invoke-CheckedProcess -FilePath $uninstaller -ArgumentList @("/S") -Label "NSIS uninstall" } else { - Invoke-CheckedProcess -FilePath "msiexec.exe" -ArgumentList @("/x", $InstallerPath, "/qn", "/norestart") -Label "MSI uninstall" + Invoke-CheckedProcess -FilePath "msiexec.exe" -ArgumentList @("/x", $InstallerPath, "/qn", "/norestart") -Label "MSI uninstall" -SuccessExitCodes @(0, 3010) } } @@ -190,7 +194,7 @@ $InstallerPath = (Resolve-Path -LiteralPath $InstallerPath).Path if ($InstallerKind -eq "nsis") { Invoke-CheckedProcess -FilePath $InstallerPath -ArgumentList @("/S", "/AllUsers") -Label "NSIS install" } else { - Invoke-CheckedProcess -FilePath "msiexec.exe" -ArgumentList @("/i", $InstallerPath, "/qn", "/norestart") -Label "MSI install" + Invoke-CheckedProcess -FilePath "msiexec.exe" -ArgumentList @("/i", $InstallerPath, "/qn", "/norestart") -Label "MSI install" -SuccessExitCodes @(0, 3010) } $installRoot = Assert-OpenLessImeInstalled diff --git a/openless-all/app/scripts/windows-ime-install-smoke.test.ps1 b/openless-all/app/scripts/windows-ime-install-smoke.test.ps1 new file mode 100644 index 000000000..414f904f9 --- /dev/null +++ b/openless-all/app/scripts/windows-ime-install-smoke.test.ps1 @@ -0,0 +1,47 @@ +$ErrorActionPreference = 'Stop' + +# Load only the actual helper definitions; never run installation or registry code. +$tokens = $null +$parseErrors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile( + (Join-Path $PSScriptRoot 'windows-ime-install-smoke.ps1'), + [ref]$tokens, + [ref]$parseErrors +) +if ($parseErrors) { throw ($parseErrors | Out-String) } +foreach ($name in @('Join-ProcessArguments', 'Invoke-CheckedProcess')) { + $function = $ast.Find({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $name + }, $true) + if (-not $function) { throw "Missing helper: $name" } + . ([scriptblock]::Create($function.Extent.Text)) +} + +function Start-Process { + param([string]$FilePath, [string]$ArgumentList, [switch]$Wait, [switch]$PassThru) + if (-not $Wait -or -not $PassThru) { throw 'Helper must wait for the installer exit code' } + [pscustomobject]@{ ExitCode = $script:TestExitCode } +} + +$cases = @( + @{ Code = 0; Allowed = @(0); ShouldThrow = $false }, + @{ Code = 3010; Allowed = @(0); ShouldThrow = $true }, + @{ Code = 0; Allowed = @(0, 3010); ShouldThrow = $false }, + @{ Code = 3010; Allowed = @(0, 3010); ShouldThrow = $false }, + @{ Code = 1603; Allowed = @(0, 3010); ShouldThrow = $true }, + @{ Code = 1641; Allowed = @(0, 3010); ShouldThrow = $true } +) +foreach ($case in $cases) { + $script:TestExitCode = $case.Code + $threw = $false + try { + Invoke-CheckedProcess -FilePath 'test-only.exe' -Label 'mock installer' -SuccessExitCodes $case.Allowed + } catch { + $threw = $true + } + if ($threw -ne $case.ShouldThrow) { + throw "Wrong result for exit code $($case.Code), allowed $($case.Allowed -join ',')" + } +} +Write-Host 'windows-ime-install-smoke.test.ps1 passed: six real-helper exit-code cases, no installation' diff --git a/openless-all/app/scripts/windows-msi-release-contract.test.mjs b/openless-all/app/scripts/windows-msi-release-contract.test.mjs index ac8b911b4..52ddf1615 100644 --- a/openless-all/app/scripts/windows-msi-release-contract.test.mjs +++ b/openless-all/app/scripts/windows-msi-release-contract.test.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import vm from 'node:vm'; +import { fileURLToPath } from 'node:url'; import yaml from 'js-yaml'; const workflow = yaml.load( @@ -64,7 +65,13 @@ const publisher = steps.find((s) => s.uses?.startsWith('softprops/action-gh-rele assert.equal(publisher.with.tag_name, '${{ env.OPENLESS_RELEASE_TAG }}'); assert.equal(publisher.with.draft, true); assert.ok(steps.find((s) => s.name === 'Confirm repair target remains draft')); +const smokeTooling = steps.find((s) => s.name === 'Checkout installer smoke tooling'); +assert.equal(smokeTooling.with.ref, '${{ github.workflow_sha }}'); +assert.equal(smokeTooling.with['sparse-checkout'], 'openless-all/app/scripts/windows-ime-install-smoke.ps1'); +assert.equal(smokeTooling.with['persist-credentials'], false); const smoke = steps.find((s) => s.name === 'Verify Windows installers register TSF IME'); +assert.match(smoke.run, /-File \$smokeScript/); +assert.ok(steps.indexOf(smokeTooling) < steps.indexOf(smoke)); assert.equal(smoke.if, "matrix.platform == 'windows-latest'"); assert.match(smoke.run, /DisableAutomaticApplicationShutdown/); assert.match(smoke.run, /-PropertyType DWord -Value 1 -Force/); @@ -75,6 +82,18 @@ for (const kind of ['nsis', 'msi']) { assert.match(smoke.run, new RegExp(kind.toUpperCase() + ' installer smoke failed')); } assert.doesNotMatch(smoke.run, /continue-on-error|SkipUninstall|exit 0/); +const smokeSource = readFileSync(new URL('windows-ime-install-smoke.ps1', import.meta.url), 'utf8'); +assert.match(smokeSource, /\[int\[\]\]\$SuccessExitCodes = @\(0\)/); +assert.match(smokeSource, /\$process.ExitCode -notin \$SuccessExitCodes/); +assert.match(smokeSource, /-Label "MSI install" -SuccessExitCodes @\(0, 3010\)/); +assert.match(smokeSource, /-Label "MSI uninstall" -SuccessExitCodes @\(0, 3010\)/); +assert.doesNotMatch(smokeSource, /-Label "NSIS (?:install|uninstall)" -SuccessExitCodes/); +assert.ok(smokeSource.indexOf('$installRoot = Assert-OpenLessImeInstalled') > smokeSource.indexOf('-Label "MSI install"')); +if (process.platform === 'win32') { + const runtime = spawnSync('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', fileURLToPath(new URL('windows-ime-install-smoke.test.ps1', import.meta.url))], { encoding: 'utf8' }); + assert.equal(runtime.status, 0, runtime.stdout + runtime.stderr); + console.log(runtime.stdout.trim()); +} const schema = JSON.parse( readFileSync( new URL('../node_modules/@tauri-apps/cli/config.schema.json', import.meta.url),