feat(releases): publish signed per-user MSI installers #47

Merged
KofTwentyTwo merged 1 commits from feat/signed-msi into main 2026-10-13 13:05:39 +00:00
+116 -1
View File
@@ -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: