Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/actions/build-bundle/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: Build WiX Burn bundle
description: >-
Builds the single self-selecting installer .exe that embeds both MSIs. Exists for the same reason as the
publish and build-msi actions: build.yml and release.yml must package identically, and the only way to
guarantee that is for both to call one definition. It deliberately does NOT build the MSIs - it takes the
two that were just produced, so a bundle can never embed a stale package it happened to find on disk.

inputs:
x64-msi:
description: Path to the x64 MSI to embed, relative to the bundle project.
required: true
x86-msi:
description: Path to the x86 MSI to embed, relative to the bundle project.
required: true
version:
description: Bundle version, e.g. 1.0.0.
required: true
output:
description: Output directory for the built .exe.
required: true

runs:
using: composite
steps:
- name: Build bundle ${{ inputs.version }}
shell: pwsh
# Version-scoped intermediate directory, for the reason spelled out in build-msi: MSBuild's up-to-date
# check does not track property changes, so a shared obj/ turns a second build into a copy of the first.
run: >-
dotnet build src/JenkinsAsService.Bundle
--nologo
--configuration Release
-p:RestoreLockedMode=true
-p:X64Msi=${{ inputs.x64-msi }}
-p:X86Msi=${{ inputs.x86-msi }}
-p:Version=${{ inputs.version }}
-p:BaseIntermediateOutputPath=obj/bundle-${{ inputs.version }}/
--output ${{ inputs.output }}
165 changes: 149 additions & 16 deletions .github/scripts/MsiQuery.psm1
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
packages. It lives here once.

Everything goes through WindowsInstaller.Installer late binding: the MSI object model has no usable
primary interop assembly on a GitHub runner, so InvokeMember is the practical way in.
primary interop assembly on a GitHub runner, so InvokeMember is the practical way in. That interop is
itself written once, in Invoke-MsiQuery - the public functions below are a query string plus a projection.
#>

Set-StrictMode -Version Latest
Expand All @@ -17,8 +18,17 @@ Set-StrictMode -Version Latest
# and is the only authoritative statement of the architecture a package targets.
$script:TemplateSummaryProperty = 7

# The one piece of Windows Installer trivia nobody remembers: a 32-bit package's platform token is spelled
# "Intel", not "x86". Held here, next to the function that reads the field, so no caller has to know it.
$script:PlatformTokens = [ordered]@{
x64 = 'x64'
x86 = 'Intel'
}

# Named Get-, not New-: it opens a read-only handle and changes nothing. A New- verb would (correctly) draw
# PSUseShouldProcessForStateChangingFunctions, since that verb promises a mutation this does not perform.
# Every caller must pass the result to Close-MsiDatabase, or the package file stays open behind a live COM
# reference until GC - which matters here because callers hand the same file to msiexec straight afterwards.
function Get-MsiDatabase {
param([Parameter(Mandatory)][string]$Path)
$resolved = (Resolve-Path -LiteralPath $Path).ProviderPath
Expand All @@ -27,7 +37,60 @@ function Get-MsiDatabase {
Installer = $installer
Database = $installer.GetType().InvokeMember(
'OpenDatabase', 'InvokeMethod', $null, $installer, @($resolved, 0))
Path = $resolved
}
}

function Close-MsiDatabase {
param([Parameter(Mandatory)][hashtable]$Msi)
foreach ($key in @('Database', 'Installer')) {
if ($Msi.ContainsKey($key) -and $null -ne $Msi[$key]) {
[void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($Msi[$key])
}
}
}

function Invoke-MsiQuery {
<#
.SYNOPSIS
Runs an MSI SQL query and returns each row as an object[] of its first $FieldCount string fields.
.DESCRIPTION
The whole late-bound OpenView/Execute/Fetch/StringData dance, in the only place it is written.
The view is closed and every record released as it goes, so a query does not leave the package open.
#>
[CmdletBinding()]
[OutputType([object[]])]
param(
[Parameter(Mandatory)]$Database,
[Parameter(Mandatory)][string]$Sql,
[Parameter(Mandatory)][int]$FieldCount
)

$view = $Database.GetType().InvokeMember(
'OpenView', 'InvokeMethod', $null, $Database, @($Sql))
try {
$view.GetType().InvokeMember('Execute', 'InvokeMethod', $null, $view, $null) | Out-Null

while ($true) {
$record = $view.GetType().InvokeMember('Fetch', 'InvokeMethod', $null, $view, $null)
if ($null -eq $record) { break }

try {
# A plain loop rather than a per-row scriptblock: the fields are read by index and nothing
# about the projection changes between rows.
$row = @()
foreach ($i in 1..$FieldCount) {
$row += $record.GetType().InvokeMember('StringData', 'GetProperty', $null, $record, $i)
}
# -NoEnumerate so each row reaches the caller as one object[] rather than being flattened
# into a single stream of fields.
Write-Output -NoEnumerate -InputObject $row
}
finally { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($record) }
}
}
finally {
$view.GetType().InvokeMember('Close', 'InvokeMethod', $null, $view, $null) | Out-Null
[void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($view)
}
}

Expand All @@ -45,23 +108,32 @@ function Get-MsiProperty {
[Parameter(Mandatory)][string]$Name
)

$msi = Get-MsiDatabase -Path $Path
$database = $msi.Database

# Parameterised through a WHERE on a quoted literal: Name is caller-supplied, and string-building a
# query around it is the SQL-injection shape even here. MSI SQL has no bound parameters for the SELECT
# list, so the value is validated instead - property names are identifiers, nothing else.
if ($Name -notmatch '^[A-Za-z_][A-Za-z0-9_.]*$') {
throw "Invalid MSI property name '$Name'."
}

$view = $database.GetType().InvokeMember(
'OpenView', 'InvokeMethod', $null, $database, @("SELECT Value FROM Property WHERE Property='$Name'"))
$view.GetType().InvokeMember('Execute', 'InvokeMethod', $null, $view, $null) | Out-Null
$record = $view.GetType().InvokeMember('Fetch', 'InvokeMethod', $null, $view, $null)
if ($null -eq $record) { return $null }
$msi = Get-MsiDatabase -Path $Path
try {
$rows = @(Invoke-MsiQuery -Database $msi.Database -FieldCount 1 `
-Sql "SELECT Value FROM Property WHERE Property='$Name'")
if ($rows.Count -eq 0) { return $null }
return $rows[0][0]
}
finally { Close-MsiDatabase -Msi $msi }
}

return $record.GetType().InvokeMember('StringData', 'GetProperty', $null, $record, 1)
function Get-MsiPlatformToken {
<#
.SYNOPSIS
The summary Template token an architecture is spelled with - x64 -> "x64", x86 -> "Intel".
#>
[CmdletBinding()]
[OutputType([string])]
param([Parameter(Mandatory)][ValidateSet('x64', 'x86')][string]$Platform)
return $script:PlatformTokens[$Platform]
}

function Get-MsiPlatform {
Expand All @@ -75,13 +147,74 @@ function Get-MsiPlatform {

$resolved = (Resolve-Path -LiteralPath $Path).ProviderPath
$installer = New-Object -ComObject WindowsInstaller.Installer
$summary = $installer.GetType().InvokeMember(
'SummaryInformation', 'GetProperty', $null, $installer, @($resolved, 0))
$template = $summary.GetType().InvokeMember(
'Property', 'GetProperty', $null, $summary, @($script:TemplateSummaryProperty))
try {
$summary = $installer.GetType().InvokeMember(
'SummaryInformation', 'GetProperty', $null, $installer, @($resolved, 0))
try {
$template = $summary.GetType().InvokeMember(
'Property', 'GetProperty', $null, $summary, @($script:TemplateSummaryProperty))
}
finally { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($summary) }
}
finally { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($installer) }

# "x64;1033" -> "x64". An empty language suffix is legal, so split rather than assume.
return ($template -split ';')[0]
}

Export-ModuleMember -Function Get-MsiProperty, Get-MsiPlatform
function Get-MsiArchitecture {
<#
.SYNOPSIS
The architecture a package targets, normalised to x64/x86 - or the raw token if it is neither.
.DESCRIPTION
What callers actually want to compare against an x64/x86 parameter, without each of them having to
know how a 32-bit package spells itself.
#>
[CmdletBinding()]
[OutputType([string])]
param([Parameter(Mandatory)][string]$Path)

$token = Get-MsiPlatform -Path $Path
foreach ($platform in $script:PlatformTokens.Keys) {
if ($script:PlatformTokens[$platform] -eq $token) { return $platform }
}
return $token
}

function Get-MsiControlEvent {
<#
.SYNOPSIS
Every row of the package's ControlEvent table - the wizard's navigation graph.
.DESCRIPTION
The UI sequence is authored, never executed by the /quiet lifecycle test, so the only automated way to
check that a dialog route is correctly gated is to read the routes back out of the built package. This
also catches the fragment being dropped by the linker altogether, which has happened here before: a
missing UIRef silently shipped an MSI with none of the custom pages in it.
.OUTPUTS
Objects with Dialog, Control, Event, Argument, Condition and Ordering.
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param([Parameter(Mandatory)][string]$Path)

$msi = Get-MsiDatabase -Path $Path
try {
# No caller input reaches this query - the whole table is read and filtered by the caller.
Invoke-MsiQuery -Database $msi.Database -FieldCount 6 `
-Sql 'SELECT Dialog_, Control_, Event, Argument, Condition, Ordering FROM ControlEvent' |
ForEach-Object {
[pscustomobject]@{
Dialog = $_[0]
Control = $_[1]
Event = $_[2]
Argument = $_[3]
Condition = $_[4]
Ordering = $_[5]
}
}
}
finally { Close-MsiDatabase -Msi $msi }
}

Export-ModuleMember -Function Get-MsiProperty, Get-MsiPlatform, Get-MsiPlatformToken,
Get-MsiArchitecture, Get-MsiControlEvent
Loading
Loading