From 8463119884d58ab37eca29fc2d8189ee164dfe0b Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 13 Oct 2026 08:01:18 -0500 Subject: [PATCH] feat(releases): publish signed per-user MSI installers Enable optional native Velopack MSI with the existing signing hook. Verify extracted MSI payload bytes through the existing archive verifier, require actual MSI install/window launch/uninstall before publication, and attest its SBOM. Existing EXE callers keep their default behavior. Co-Authored-By: Codex GPT-6 Signed-off-by: James Maes --- .github/workflows/release-velopack.yml | 117 ++++++++++++++++++++++++- 1 file changed, 116 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-velopack.yml b/.github/workflows/release-velopack.yml index 7fbc39a..c2f7193 100644 --- a/.github/workflows/release-velopack.yml +++ b/.github/workflows/release-velopack.yml @@ -108,6 +108,10 @@ on: description: Newline-separated required owned file names or publish-relative patterns, including the optional CLI; required when sign is true. type: string default: "" + msi: + description: Generate a per-user MSI alongside Setup.exe and portable ZIP; requires the same signing and verification. + type: boolean + default: false outputs: version: description: The released version (tag without the leading v). @@ -494,6 +498,8 @@ jobs: - name: Velopack pack shell: pwsh + env: + VPK_MSI: ${{ inputs.msi }} run: | $sign = @() if ($env:K22_SIGN -eq 'true') @@ -505,7 +511,7 @@ jobs: $sign = @('--signTemplate', $command, '--signExclude', $exclude) } $env:VELOPACK_TEMP = Join-Path $env:RUNNER_TEMP 'k22-velopack' - vpk pack --runtime $env:K22_RUNTIME --packId $env:K22_APP_ID --packVersion $env:K22_VERSION --packDir publish/app --mainExe $env:K22_MAIN_EXE --channel $env:K22_CHANNEL --icon $env:K22_ICON --outputDir Releases @sign + vpk pack --runtime $env:K22_RUNTIME --packId $env:K22_APP_ID --packVersion $env:K22_VERSION --packDir publish/app --mainExe $env:K22_MAIN_EXE --channel $env:K22_CHANNEL --icon $env:K22_ICON --outputDir Releases --instLocation PerUser @sign if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } # Keeps only what this build produced, as `vpk upload` does: the files listed in @@ -534,6 +540,108 @@ jobs: Write-Output "Assets: $($names -join ', '); feed: $feedName; setup: $setup" "setup=$setup" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + - name: Extract required MSI payload + if: inputs.msi + id: msi + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $installers = @(Get-ChildItem -LiteralPath release -Filter '*.msi' -File) + if ($installers.Count -ne 1) { throw 'Exactly one MSI installer is required.' } + $root = Join-Path $env:RUNNER_TEMP ('k22-msi-' + [guid]::NewGuid().ToString('N')) + "root=$root" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + $image = Join-Path $root 'image' + $assets = Join-Path $root 'assets' + New-Item -ItemType Directory -Path $image, $assets -Force | Out-Null + $start = [Diagnostics.ProcessStartInfo]::new('msiexec.exe') + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + foreach ($argument in @('/a', $installers[0].FullName, '/qn', "TARGETDIR=$image", '/l*v', (Join-Path $root 'extract.log'))) + { + $start.ArgumentList.Add($argument) + } + $process = [Diagnostics.Process]::Start($start) + try + { + if (-not $process.WaitForExit(180000)) { $process.Kill(); throw 'MSI extraction timed out.' } + if ($process.ExitCode -ne 0) { throw "MSI extraction failed: $($process.ExitCode)." } + } + finally { $process.Dispose() } + $manifests = @(Get-ChildItem -LiteralPath $image -Recurse -File -Filter sq.version) + if ($manifests.Count -ne 1 -or $manifests[0].Directory.Name -ne 'current') { throw 'Expected one installed MSI application payload.' } + $payload = $manifests[0].Directory.Parent.FullName + [IO.Compression.ZipFile]::CreateFromDirectory($payload, (Join-Path $assets 'msi-Portable.zip')) + "directory=$assets" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + + # Reuse the existing archive verifier for MSI's administratively extracted + # payload: every owned/third-party byte and generated launcher is checked. + - name: Verify exact signed MSI payload + if: inputs.msi && inputs.sign + uses: KofTwentyTwo/standards/.github/actions/windows-signing@f896215ec96854b18883191759259fd9866ee9be # reviewed signing helpers on main + with: + operation: verify-distribution + directory: ${{ steps.msi.outputs.directory }} + manifest: ${{ steps.signing.outputs.manifest }} + expected-subject: ${{ vars.ARTIFACT_SIGNING_SUBJECT || vars.TRUSTED_SIGNING_SUBJECT }} + + - name: Verify MSI installs, launches and uninstalls + if: inputs.msi + shell: pwsh + env: + MSI_ROOT: ${{ steps.msi.outputs.root }} + run: | + $ErrorActionPreference = 'Stop' + $msi = @(Get-ChildItem -LiteralPath release -Filter '*.msi' -File) + if ($msi.Count -ne 1) { throw 'Exactly one MSI installer is required.' } + $installer = New-Object -ComObject WindowsInstaller.Installer + $database = $installer.OpenDatabase($msi[0].FullName, 0) + $view = $database.OpenView('SELECT `Value` FROM `Property` WHERE `Property` = ''ProductCode''') + $view.Execute() + $product = $view.Fetch().StringData(1) + if ($product -notmatch '^\{[0-9A-Fa-f-]{36}\}$') { throw 'Invalid MSI product identity.' } + $target = Join-Path $env:MSI_ROOT 'installed' + $application = $null + try + { + $install = Start-Process msiexec.exe -ArgumentList @('/i', ('"' + $msi[0].FullName + '"'), '/qn', ('VELOPACK_INSTALLDIR="' + $target + '"'), '/l*v', ('"' + (Join-Path $env:MSI_ROOT 'install.log') + '"')) -WindowStyle Hidden -PassThru + if (-not $install.WaitForExit(180000)) { $install.Kill(); throw 'MSI installation timed out.' } + if ($install.ExitCode -ne 0) { throw "MSI installation failed: $($install.ExitCode)." } + $executable = Join-Path $target "current/$env:K22_MAIN_EXE" + if (-not (Test-Path -LiteralPath $executable -PathType Leaf)) { throw 'MSI did not install the application.' } + $application = Start-Process -FilePath $executable -PassThru + $deadline = [DateTime]::UtcNow.AddSeconds(30) + do + { + $application.Refresh() + if ($application.HasExited) { throw 'MSI-installed application exited before opening its window.' } + if ($application.MainWindowHandle -ne [IntPtr]::Zero) { break } + Start-Sleep -Milliseconds 200 + } while ([DateTime]::UtcNow -lt $deadline) + if ($application.MainWindowHandle -eq [IntPtr]::Zero) { throw 'MSI-installed application did not open a window.' } + if (-not $application.CloseMainWindow() -or -not $application.WaitForExit(10000)) { $application.Kill(); throw 'MSI-installed application did not close normally.' } + } + finally + { + if ($null -ne $application -and -not $application.HasExited) { $application.Kill() } + $uninstall = Start-Process msiexec.exe -ArgumentList @('/x', $product, '/qn', '/l*v', ('"' + (Join-Path $env:MSI_ROOT 'uninstall.log') + '"')) -WindowStyle Hidden -PassThru + if (-not $uninstall.WaitForExit(180000)) { $uninstall.Kill(); throw 'MSI uninstall timed out.' } + if ($uninstall.ExitCode -notin 0, 1605) { throw "MSI uninstall failed: $($uninstall.ExitCode)." } + } + if (Test-Path -LiteralPath (Join-Path $target "current/$env:K22_MAIN_EXE")) { throw 'MSI uninstall left the application installed.' } + Write-Output "MSI $product installed, opened its window, closed and uninstalled successfully." + + - name: Remove temporary MSI extraction + if: always() && inputs.msi + shell: pwsh + env: + MSI_ROOT: ${{ steps.msi.outputs.root }} + run: | + if ([string]::IsNullOrWhiteSpace($env:MSI_ROOT)) { exit 0 } + $root = [IO.Path]::GetFullPath($env:MSI_ROOT) + $temporaryRoot = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($env:RUNNER_TEMP)) + if ([IO.Path]::GetDirectoryName($root) -ne $temporaryRoot -or [IO.Path]::GetFileName($root) -notmatch '^k22-msi-[0-9a-f]{32}$') { throw 'Unexpected MSI scratch directory.' } + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + - name: Zip CLI if: inputs.cli-project != '' shell: pwsh @@ -648,6 +756,13 @@ jobs: subject-path: release/*-cli-*.zip sbom-path: sbom/${{ inputs.app-id }}-cli-${{ needs.build.outputs.version }}.cdx.json + - name: Attest MSI SBOM + if: inputs.msi + uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0 + with: + subject-path: release/*.msi + sbom-path: sbom/${{ inputs.app-id }}-${{ needs.build.outputs.version }}.cdx.json + - name: Upload SBOMs uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: -- 2.54.0