diff --git a/.github/actions/build-bundle/action.yml b/.github/actions/build-bundle/action.yml new file mode 100644 index 0000000..bce161c --- /dev/null +++ b/.github/actions/build-bundle/action.yml @@ -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 }} diff --git a/.github/scripts/MsiQuery.psm1 b/.github/scripts/MsiQuery.psm1 index 3a13216..e842989 100644 --- a/.github/scripts/MsiQuery.psm1 +++ b/.github/scripts/MsiQuery.psm1 @@ -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 @@ -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 @@ -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) } } @@ -45,9 +108,6 @@ 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. @@ -55,13 +115,25 @@ function Get-MsiProperty { 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 { @@ -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 diff --git a/.github/scripts/MsiTestHelpers.psm1 b/.github/scripts/MsiTestHelpers.psm1 new file mode 100644 index 0000000..89253b2 --- /dev/null +++ b/.github/scripts/MsiTestHelpers.psm1 @@ -0,0 +1,231 @@ +<# +.SYNOPSIS + Shared scaffolding for the MSI/bundle end-to-end suites. + +.DESCRIPTION + Test-MsiLifecycle, Test-MsiArchMigration and Test-Bundle all install a real package on a real runner and + then assert against the same three things: the PASS/FAIL transcript, the location keys the package records, + and the config it writes. Each of them grew its own copy of that scaffolding, and the copies had already + started to disagree - two different Get-RecordedLocation signatures, three different wrappers around + Start-Process, and three near-identical failure epilogues. + + The registry layout in particular is load-bearing: WHICH key holds a path is the package's statement of + which architecture is installed (see InstallLocation.wxs). Written out per-script, a renamed key makes + Get-InstalledPlatform return $null everywhere - and an assertion of the form "the other arch key is + absent" then passes harder than before. It is stated once, here. + + Write-Host throughout is deliberate: these produce a human-readable CI transcript, not a pipeline. The + alternatives are wrong - Write-Output would pollute the return value of the functions it is called from, + and Write-Verbose/Information are hidden by default, which is the opposite of what a test log needs. +#> + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidUsingWriteHost', '', Justification = 'Intentional CI transcript output')] +param() + +Set-StrictMode -Version Latest + +# Where each architecture records the locations it installed to. An x86 install is additionally redirected +# into WOW6432Node, since its component inherits the package's 32-bit-ness - so the arch is stated twice over. +$script:LocationKeys = [ordered]@{ + x64 = 'HKLM:\SOFTWARE\JenkinsAsService\x64' + x86 = 'HKLM:\SOFTWARE\WOW6432Node\JenkinsAsService\x86' +} + +# 0 = success, 3010 = success but a reboot was requested. Neither is a failure for this package. Held here so +# "succeeded" is defined once rather than restated as a bare @(0, 3010) at every call site. +$script:SuccessExitCodes = @(0, 3010) + +$script:Failures = @() + +function Get-JasLocationKeyPath { + <# + .SYNOPSIS + The registry path under which the given architecture records its install and data locations. + #> + [CmdletBinding()] + [OutputType([string])] + param([Parameter(Mandatory)][ValidateSet('x64', 'x86')][string]$Platform) + return $script:LocationKeys[$Platform] +} + +function Assert-That { + <# + .SYNOPSIS + Records a PASS/FAIL against the module's failure list. Never throws - a suite runs to the end so one + broken assertion does not hide the state of every later one. + #> + [CmdletBinding()] + param([Parameter(Mandatory)][bool]$Condition, [Parameter(Mandatory)][string]$Message) + if ($Condition) { Write-Host " [PASS] $Message" } + else { + Write-Host " [FAIL] $Message" + $script:Failures += $Message + } +} + +function Complete-AssertionReport { + <# + .SYNOPSIS + Reports the recorded failures and exits 1 if there were any. The last line of every suite. + #> + [CmdletBinding()] + param([Parameter(Mandatory)][string]$Subject) + Write-Host '' + if ($script:Failures.Count -gt 0) { + Write-Host "::error::$($script:Failures.Count) $Subject assertion(s) failed" + $script:Failures | ForEach-Object { Write-Host "::error:: $_" } + exit 1 + } + Write-Host "All $Subject assertions passed." +} + +function Test-InstallerSuccess { + <# + .SYNOPSIS + Whether an installer exit code means success - 0, or 3010 for "success, reboot requested". + #> + [CmdletBinding()] + [OutputType([bool])] + param([Parameter(Mandatory)][int]$ExitCode) + return $ExitCode -in $script:SuccessExitCodes +} + +function Invoke-InstallerProcess { + <# + .SYNOPSIS + Runs an installer to completion and returns its exit code, echoing the command line and the result. + .DESCRIPTION + Returns the code rather than throwing: one suite asserts on a NON-zero code as its main result, so a + failure here is data. Callers that do want a failure to be fatal pass the code to + Assert-InstallerSucceeded. + #> + [CmdletBinding()] + [OutputType([int])] + param( + [Parameter(Mandatory)][string]$FilePath, + [Parameter(Mandatory)][string[]]$Arguments + ) + Write-Host "$FilePath $($Arguments -join ' ')" + $process = Start-Process $FilePath -ArgumentList $Arguments -Wait -PassThru + Write-Host " exit code $($process.ExitCode)" + return $process.ExitCode +} + +function Invoke-Msiexec { + <# + .SYNOPSIS + Invoke-InstallerProcess against msiexec, with the /quiet /norestart /l*v tail every call needs. + #> + [CmdletBinding()] + [OutputType([int])] + param( + [Parameter(Mandatory)][string[]]$Arguments, + [Parameter(Mandatory)][string]$LogPath + ) + return Invoke-InstallerProcess -FilePath 'msiexec.exe' ` + -Arguments ($Arguments + @('/quiet', '/norestart', '/l*v', $LogPath)) +} + +function Assert-InstallerSucceeded { + <# + .SYNOPSIS + Throws on a failing exit code, after tailing the log into the CI transcript. + .DESCRIPTION + The tail is the whole point: a bare "exit code 1603" in a CI log is unactionable, and the verbose log + is an artifact nobody downloads for a run that failed on a typo. Fatal rather than an assertion - + every later step in these suites presupposes the install actually happened. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)][int]$ExitCode, + [Parameter(Mandatory)][string]$LogPath, + [Parameter(Mandatory)][string]$Activity, + [int]$TailLines = 40 + ) + if (Test-InstallerSuccess -ExitCode $ExitCode) { return } + + Write-Host "::error::$Activity failed with exit code $ExitCode - see artifact $(Split-Path -Leaf $LogPath)" + if (Test-Path $LogPath) { + Get-Content $LogPath -Tail $TailLines | ForEach-Object { Write-Host " $_" } + } + throw "$Activity exit code $ExitCode" +} + +function Get-RecordedLocation { + <# + .SYNOPSIS + A recorded value from one architecture's key, or $null when that architecture is not the installed one. + .DESCRIPTION + Guarded through PSObject.Properties because Set-StrictMode -Version Latest turns reading an absent + property into a terminating error rather than returning $null. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)][ValidateSet('x64', 'x86')][string]$Platform, + [Parameter(Mandatory)][string]$Name + ) + $key = Get-ItemProperty -Path $script:LocationKeys[$Platform] -ErrorAction SilentlyContinue + if ($null -eq $key -or -not $key.PSObject.Properties[$Name]) { return $null } + return $key.$Name +} + +function Get-InstalledPlatform { + <# + .SYNOPSIS + Which architecture the machine believes is installed - which is simply which key holds a path. + .DESCRIPTION + $null if neither does, and 'both' if somehow both do, so a broken state fails loudly rather than being + silently reported as whichever one happened to be checked first. + #> + [CmdletBinding()] + [OutputType([string])] + param() + $found = @($script:LocationKeys.Keys | + Where-Object { $null -ne (Get-RecordedLocation -Platform $_ -Name 'InstallPath') }) + if ($found.Count -eq 0) { return $null } + if ($found.Count -gt 1) { return 'both' } + return $found[0] +} + +function Get-InstalledConfig { + <# + .SYNOPSIS + The installed appsettings.json as an object, or $null when it is not there. + #> + [CmdletBinding()] + param([Parameter(Mandatory)][string]$Path) + if (-not (Test-Path $Path)) { return $null } + return Get-Content $Path -Raw | ConvertFrom-Json +} + +function Get-PeMachine { + <# + .SYNOPSIS + The architecture of a binary on disk, read out of its PE header. + .DESCRIPTION + The location key records what the package SAID it was; this is what it shipped. If those two ever + disagree, every other assertion in these suites is measuring the wrong thing - which is not + hypothetical here: a build-caching bug once shipped an x86 MSI that was a byte copy of the x64 one. + #> + [CmdletBinding()] + [OutputType([string])] + param([Parameter(Mandatory)][string]$Path) + $stream = [System.IO.File]::OpenRead($Path) + try { + $reader = New-Object System.IO.BinaryReader($stream) + $stream.Position = 0x3C # e_lfanew: offset of the PE signature + $peOffset = $reader.ReadInt32() + $stream.Position = $peOffset + 4 # skip "PE\0\0" to the COFF Machine field + switch ($reader.ReadUInt16()) { + 0x8664 { return 'x64' } + 0x014C { return 'x86' } + default { return 'unknown' } + } + } + finally { $stream.Dispose() } +} + +Export-ModuleMember -Function Get-JasLocationKeyPath, Assert-That, Complete-AssertionReport, + Test-InstallerSuccess, Invoke-InstallerProcess, Invoke-Msiexec, Assert-InstallerSucceeded, + Get-RecordedLocation, Get-InstalledPlatform, Get-InstalledConfig, Get-PeMachine diff --git a/.github/scripts/New-WingetManifest.ps1 b/.github/scripts/New-WingetManifest.ps1 new file mode 100644 index 0000000..e5c8049 --- /dev/null +++ b/.github/scripts/New-WingetManifest.ps1 @@ -0,0 +1,130 @@ +<# +.SYNOPSIS + Emits the three winget manifest files for a release. + +.DESCRIPTION + winget requires a multi-file manifest (version + installer + locale), and every field that changes per + release - the version, the download URL and the installer's SHA256 - has to agree across them. Generating + them from one place is the only way that stays true; a hand-edited manifest with a stale hash is rejected + by the community-repo pipeline long after the release has shipped. + + The manifest points at the BUNDLE, not the two MSIs, and lists it under BOTH architectures with the same + URL. That is deliberate. winget picks an installer by architecture, and the bundle is the component that + knows how to choose - it installs the machine's native architecture and migrates an install of the other + one. Listing the MSIs directly would move that decision into winget, which would then offer an x64 + package to a machine running the x86 install and produce a failed upgrade rather than a migration. + + The output is not submitted anywhere. It is attached to the release so the winget-pkgs pull request can be + opened from a known-good, hash-correct starting point. +#> +[CmdletBinding()] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidUsingWriteHost', '', Justification = 'Intentional CI transcript output')] +param( + [Parameter(Mandatory)][string]$Version, + # The bundle .exe being released - hashed here rather than passed in, so the manifest cannot disagree + # with the artifact. + [Parameter(Mandatory)][string]$BundlePath, + [Parameter(Mandatory)][string]$InstallerUrl, + [Parameter(Mandatory)][string]$OutputDirectory, + # The bundle's UpgradeCode, which is how winget matches an installed Burn bundle in ARP. Authored in + # Bundle.wxs; defaulted here rather than hardcoded inline so a rotation has one obvious place to land and + # a caller can pass the built bundle's own value. If the two ever disagree, winget stops recognising the + # installed package and every upgrade fails on a user's machine with nothing failing in CI. + [string]$BundleUpgradeCode = '{015B49BD-10B0-4FC7-802B-A248BD50A205}' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$packageId = 'EliorMachlev.JenkinsAsService' +# Pinned rather than tracking the newest: a schema bump can add required fields, and finding that out during +# a release is the wrong time. +$manifestVersion = '1.6.0' + +$hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $BundlePath).Hash.ToUpper() +New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null + +function Write-Manifest { + param([Parameter(Mandatory)][string]$Name, [Parameter(Mandatory)][string]$Content) + $path = Join-Path $OutputDirectory $Name + # UTF-8 with BOM is what the winget schema expects for manifest files. + Set-Content -LiteralPath $path -Value $Content -Encoding utf8BOM + Write-Host " wrote $path" +} + +Write-Manifest -Name "$packageId.yaml" -Content @" +# yaml-language-server: `$schema=https://aka.ms/winget-manifest.version.$manifestVersion.schema.json +PackageIdentifier: $packageId +PackageVersion: $Version +DefaultLocale: en-US +ManifestType: version +ManifestVersion: $manifestVersion +"@ + +# InstallerType: burn - a WiX bundle, which winget understands natively (it knows the -quiet/-norestart and +# -uninstall switches, and that ARP registration is the bundle's UpgradeCode rather than an MSI ProductCode). +# The same file is listed for both architectures because the bundle selects internally; see the note above. +Write-Manifest -Name "$packageId.installer.yaml" -Content @" +# yaml-language-server: `$schema=https://aka.ms/winget-manifest.installer.$manifestVersion.schema.json +PackageIdentifier: $packageId +PackageVersion: $Version +InstallerType: burn +Scope: machine +InstallModes: + - interactive + - silent + - silentWithProgress +UpgradeBehavior: install +ProductCode: '$BundleUpgradeCode' +ReleaseDate: $(Get-Date -Format 'yyyy-MM-dd') +Installers: + - Architecture: x64 + InstallerUrl: $InstallerUrl + InstallerSha256: $hash + - Architecture: x86 + InstallerUrl: $InstallerUrl + InstallerSha256: $hash +ManifestType: installer +ManifestVersion: $manifestVersion +"@ + +Write-Manifest -Name "$packageId.locale.en-US.yaml" -Content @" +# yaml-language-server: `$schema=https://aka.ms/winget-manifest.defaultLocale.$manifestVersion.schema.json +PackageIdentifier: $packageId +PackageVersion: $Version +PackageLocale: en-US +Publisher: EliorMachlev +PublisherUrl: https://github.com/EliorMachlev +PublisherSupportUrl: https://github.com/EliorMachlev/JenkinsAsService/issues +PackageName: Jenkins Agent Service +PackageUrl: https://github.com/EliorMachlev/JenkinsAsService +License: BSD-3-Clause +LicenseUrl: https://github.com/EliorMachlev/JenkinsAsService/blob/main/LICENSE +Copyright: Copyright (c) EliorMachlev +ShortDescription: Runs a Jenkins inbound (JNLP) agent as a hardened native Windows Service. +Description: |- + JenkinsAsService runs a Jenkins inbound (JNLP) agent as a native Windows Service - no login session, no + scheduled task, no manual restarts. It validates its configuration, resolves Java, tests connectivity, + downloads and integrity-checks agent.jar, then supervises the agent with an event-driven watchdog that + recovers automatically from crashes. + + The agent secret is protected at rest (TPM, DPAPI, Credential Manager or environment variable), kept off + the process table and redacted from logs. The service runs under a least-privilege virtual account, and + the install folder stays non-writable by the agent identity. +Moniker: jenkinsasservice +Tags: + - jenkins + - ci + - agent + - windows-service + - devops +ReleaseNotesUrl: https://github.com/EliorMachlev/JenkinsAsService/releases/tag/v$Version +Documentations: + - DocumentLabel: Documentation + DocumentUrl: https://jenkinsasservice.machlev.org +ManifestType: defaultLocale +ManifestVersion: $manifestVersion +"@ + +Write-Host "winget manifests for $packageId $Version written to $OutputDirectory" diff --git a/.github/scripts/Test-Bundle.ps1 b/.github/scripts/Test-Bundle.ps1 new file mode 100644 index 0000000..1c35e4b --- /dev/null +++ b/.github/scripts/Test-Bundle.ps1 @@ -0,0 +1,154 @@ +<# +.SYNOPSIS + End-to-end test of the single self-selecting installer .exe (the Burn bundle). + +.DESCRIPTION + The bundle's whole job is to install the architecture that matches the machine and to migrate an install + of the other one. The second half is the dangerous half, and it is dangerous in a way that reads as + success: the MSI's purge custom action deletes the config, the data folder and the secret, and it is + gated only on REMOVE="ALL" AND NOT UPGRADINGPRODUCTCODE. If Burn were ever to plan the old + architecture's product as a standalone UNINSTALL rather than letting the incoming MSI's MajorUpgrade + replace it, that purge would fire - and the migration would then "succeed" onto a machine whose + configuration and agent secret had just been destroyed. + + Reasoning says Burn treats it as an upgrade, because the two MSIs share an UpgradeCode and the chain's + own x86 package (a different ProductCode) is not detected as present. Reasoning is not evidence for + something with that failure mode, so this installs the x86 MSI directly, runs the bundle over it on a + 64-bit runner, and asserts the config, the operator's edit, the secret and the data folder all survived. + + Asserted here: + * the bundle installs the architecture matching the machine - checked against the PE header of the + binary that actually landed, not against what the package claimed + * it migrates an install of the other architecture, without being told to + * the config, an operator edit, the secret and the data folder survive that migration + * the install and data folders are the original ones, not defaults + * uninstalling through the bundle removes the service, both folders and the location keys + + No Jenkins controller is involved: the URL points at a closed port, so the agent never connects. +#> +[CmdletBinding()] +# Write-Host is deliberate - see the note in Test-MsiLifecycle.ps1; this is a CI transcript, not a pipeline. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidUsingWriteHost', '', Justification = 'Intentional CI transcript output')] +param( + # The x86 MSI to install first, at a LOWER version than the bundle. + [Parameter(Mandatory)][string]$BaselineX86Msi, + # The bundle .exe under test. + [Parameter(Mandatory)][string]$BundleExe +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +Import-Module (Join-Path $PSScriptRoot 'MsiQuery.psm1') -Force +Import-Module (Join-Path $PSScriptRoot 'MsiTestHelpers.psm1') -Force + +# The 32-bit Program Files, because the baseline installed here is the x86 package: ProgramFiles6432Folder +# resolves to "C:\Program Files (x86)" in a 32-bit package. The migration must PRESERVE this folder rather +# than relocate to the x64 default, so it stays the expected path throughout - before and after the bundle +# runs. Using $env:ProgramFiles here would have looked for the baseline in a folder it was never installed to. +$installFolder = Join-Path ${env:ProgramFiles(x86)} 'Jenkins' +$x64DefaultInstallFolder = Join-Path $env:ProgramFiles 'Jenkins' +$dataFolder = Join-Path $env:ProgramData 'JenkinsAsServiceBundleTest' +$configPath = Join-Path $installFolder 'appsettings.json' +$agentExe = Join-Path $installFolder 'JenkinsAsService.exe' +$serviceName = 'Jenkins' +$logDir = Join-Path (Get-Location) 'msi-logs' +New-Item -ItemType Directory -Path $logDir -Force | Out-Null + +function Get-BundleLogPath { + param([Parameter(Mandatory)][string]$Name) + return Join-Path $logDir "$Name.log" +} + +# -------------------------------------------------------------------------------------------------- +Write-Host "`n=== 0. Preconditions ===" +# The runner must be 64-bit or the migration under test cannot happen at all - the bundle would pick x86 on +# both runs and quietly assert nothing. A hard stop, not an assertion, for the same reason the lifecycle +# suite hard-stops on matching ProductVersions. +if (-not [Environment]::Is64BitOperatingSystem) { + throw 'This suite migrates x86 -> x64 and therefore requires a 64-bit runner.' +} +$baselineVersion = Get-MsiProperty -Path $BaselineX86Msi -Name 'ProductVersion' +Write-Host " baseline x86 package: $baselineVersion" +Assert-That ((Get-MsiArchitecture -Path $BaselineX86Msi) -eq 'x86') "the baseline package really targets x86" +Assert-That (Test-Path $BundleExe) "the bundle exe exists at $BundleExe" + +# -------------------------------------------------------------------------------------------------- +Write-Host "`n=== 1. Install the x86 MSI directly ===" +$log = Get-BundleLogPath -Name 'bundle-baseline' +$code = Invoke-Msiexec -LogPath $log -Arguments @( + '/i', $BaselineX86Msi, + "DATAFOLDER=$dataFolder", + 'JENKINS_URL=https://127.0.0.1:59999', + 'JENKINS_SECRET=bundle-smoke-secret', + 'JENKINS_SECRET_MODE=Unprotected', + 'JENKINS_AGENT_NAME=bundle-node') +Assert-InstallerSucceeded -ExitCode $code -LogPath $log -Activity 'baseline x86 install' + +Assert-That ((Get-InstalledPlatform) -eq 'x86') "the baseline is recorded as x86" +Assert-That ((Get-PeMachine -Path $agentExe) -eq 'x86') "the installed binary really is x86" + +# An operator edit and a data-folder sentinel: these are what prove the migration PRESERVED rather than +# recreated. Without them a purge-then-reinstall would look identical to a clean migration. +$raw = Get-Content $configPath -Raw | ConvertFrom-Json +$raw.Jenkins.Logging.RetainedLogs = 5 +$raw | ConvertTo-Json -Depth 8 | Set-Content $configPath -Encoding utf8 +$sentinel = Join-Path $dataFolder 'operator-data.txt' +Set-Content -Path $sentinel -Value 'must survive the architecture migration' -Encoding utf8 + +# -------------------------------------------------------------------------------------------------- +Write-Host "`n=== 2. Run the bundle - it must migrate x86 -> x64 unprompted ===" +# No properties at all, and no force flag: the bundle supplies FORCE_UPGRADE=1 itself, because installing the +# machine's native architecture is its policy rather than an option. Anything the install needs beyond that +# has to come from what is already on disk. +$log = Get-BundleLogPath -Name 'bundle-migrate' +$code = Invoke-InstallerProcess -FilePath $BundleExe -Arguments @('-quiet', '-norestart', '-log', $log) +Assert-InstallerSucceeded -ExitCode $code -LogPath $log -Activity 'bundle install' -TailLines 60 + +Assert-That ((Get-InstalledPlatform) -eq 'x64') "the bundle selected x64 for a 64-bit machine, and only x64" +Assert-That ((Get-PeMachine -Path $agentExe) -eq 'x64') "the binary on disk really is x64 now" + +# The purge check. If Burn planned the x86 product as a standalone uninstall instead of letting the x64 MSI's +# MajorUpgrade replace it, PurgeInstallation would have run and taken all four of these with it. +Assert-That (Test-Path $configPath) "appsettings.json survives the migration" +$cfg = Get-InstalledConfig -Path $configPath +Assert-That ($null -ne $cfg -and $cfg.Jenkins.Secret.Value -eq 'bundle-smoke-secret') ` + "the agent secret survives the migration" +Assert-That ($null -ne $cfg -and $cfg.Jenkins.Logging.RetainedLogs -eq 5) ` + "the operator's edit survives - the config was preserved, not rewritten" +Assert-That (Test-Path $sentinel) "the data folder survives the migration with its contents" + +Assert-That ((Get-RecordedLocation -Platform 'x64' -Name 'InstallPath') -eq "$installFolder\") ` + "the migrated install kept the original install folder, recovered rather than reset to the x64 default" +Assert-That (-not (Test-Path (Join-Path $x64DefaultInstallFolder 'JenkinsAsService.exe'))) ` + "nothing was installed into the x64 default folder - the recovered location was used" +Assert-That ((Get-RecordedLocation -Platform 'x64' -Name 'DataPath') -eq "$dataFolder\") ` + "the migrated install kept the original data folder, recovered rather than reset to the default" +Assert-That (-not (Test-Path (Join-Path $env:ProgramData 'JenkinsAsService'))) ` + "no stray default data folder was created" + +$service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue +Assert-That ($null -ne $service -and $service.Status -eq 'Running') "the service is Running after the migration" + +# -------------------------------------------------------------------------------------------------- +Write-Host "`n=== 3. Re-running the bundle on a matching architecture is a no-op, not a reinstall ===" +$code = Invoke-InstallerProcess -FilePath $BundleExe -Arguments @( + '-quiet', '-norestart', '-log', (Get-BundleLogPath -Name 'bundle-repeat')) +Assert-That (Test-InstallerSuccess -ExitCode $code) "re-running the bundle succeeds (exit code $code)" +$cfg = Get-InstalledConfig -Path $configPath +Assert-That ($null -ne $cfg -and $cfg.Jenkins.Secret.Value -eq 'bundle-smoke-secret') ` + "the secret survives re-running the bundle over an identical install" + +# -------------------------------------------------------------------------------------------------- +Write-Host "`n=== 4. Uninstall through the bundle ===" +$code = Invoke-InstallerProcess -FilePath $BundleExe -Arguments @( + '-uninstall', '-quiet', '-norestart', '-log', (Get-BundleLogPath -Name 'bundle-uninstall')) +Assert-That (Test-InstallerSuccess -ExitCode $code) "the bundle uninstalls cleanly (exit code $code)" +Assert-That ($null -eq (Get-Service -Name $serviceName -ErrorAction SilentlyContinue)) "service is removed" +Assert-That (-not (Test-Path $installFolder)) "the install folder is removed" +Assert-That (-not (Test-Path $dataFolder)) "the data folder is removed" +Assert-That ($null -eq (Get-InstalledPlatform)) "neither architecture key survives the uninstall" + +# -------------------------------------------------------------------------------------------------- +Complete-AssertionReport -Subject 'bundle' diff --git a/.github/scripts/Test-MsiArchMigration.ps1 b/.github/scripts/Test-MsiArchMigration.ps1 new file mode 100644 index 0000000..fb26610 --- /dev/null +++ b/.github/scripts/Test-MsiArchMigration.ps1 @@ -0,0 +1,225 @@ +<# +.SYNOPSIS + End-to-end test of the x64 <-> x86 migration policy, in both directions. + +.DESCRIPTION + The two packages share an UpgradeCode, so Windows Installer will happily let one replace the other and + FindRelatedProducts cannot tell that the architecture changed. Left alone that is a foot-gun with no undo: + MajorUpgrade is scheduled afterInstallInitialize, so by the time anyone notices, the old product is gone. + + The policy is deliberately ASYMMETRIC, and this asserts both halves of it: + + x86 install -> x64 package SUPPORTED, opt-in via FORCE_UPGRADE=1. The install folder, data folder, + config and secret are all carried across. + x64 install -> x86 package REFUSED, and FORCE_UPGRADE does NOT override it. A 32-bit package cannot + recover a 64-bit install folder: the registry search works, but Windows + Installer's WIN64DUALFOLDERS substitution rewrites the result's + `C:\Program Files\` prefix to `C:\Program Files (x86)\`. The migration + would install beside the real one and strand appsettings.json - the only + copy of the secret - at the original path. CI caught exactly that, as a + 1603 from UpgradeConfig running an exe with no config beside it. + + Nothing is lost by refusing: the bundle's policy is the machine's NATIVE architecture, so it only ever + needs the supported direction (x64 on a 64-bit machine; on a 32-bit machine no x64 install can exist). + + Asserting only one half would be worthless. Asserting only the forced half would let a gate that never + blocks anything pass; asserting only the blocked half would let a gate that blocks everything - including + the migration it is supposed to permit - pass just as easily. + + No Jenkins controller is involved: the URL points at a closed port, so the agent never connects. +#> +[CmdletBinding()] +# Write-Host is deliberate - see the note in Test-MsiLifecycle.ps1; this is a CI transcript, not a pipeline. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidUsingWriteHost', '', Justification = 'Intentional CI transcript output')] +param( + # x64, and the LOWEST version of the three: the refused direction is attempted as an upgrade of it. + [Parameter(Mandatory)][string]$X64BaselineMsi, + # x86, at a HIGHER version than the baseline - so the refused attempt is a genuine upgrade candidate and + # is refused on architecture rather than on a version rule. + [Parameter(Mandatory)][string]$X86Msi, + # x64, at a HIGHER version than the x86 package: the supported migration's target. + [Parameter(Mandatory)][string]$X64TargetMsi +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +Import-Module (Join-Path $PSScriptRoot 'MsiQuery.psm1') -Force +Import-Module (Join-Path $PSScriptRoot 'MsiTestHelpers.psm1') -Force + +# The x64 and x86 packages install to DIFFERENT default folders - ProgramFiles6432Folder resolves to +# "C:\Program Files" in a 64-bit package and "C:\Program Files (x86)" in a 32-bit one. Which folder the +# migrated install ends up in is the whole point of the supported half, so both are named here. +$x64InstallFolder = Join-Path $env:ProgramFiles 'Jenkins' +$x86InstallFolder = Join-Path ${env:ProgramFiles(x86)} 'Jenkins' +$dataFolder = Join-Path $env:ProgramData 'JenkinsAsServiceArchTest' +$serviceName = 'Jenkins' +$logDir = Join-Path (Get-Location) 'msi-logs' +New-Item -ItemType Directory -Path $logDir -Force | Out-Null + +function Get-ArchLogPath { + param([Parameter(Mandatory)][string]$Name) + return Join-Path $logDir "$Name.log" +} + +$freshInstallProperties = @( + "DATAFOLDER=$dataFolder", + 'JENKINS_URL=https://127.0.0.1:59999', + 'JENKINS_SECRET=arch-smoke-secret', + 'JENKINS_SECRET_MODE=Unprotected', + 'JENKINS_AGENT_NAME=arch-node' +) + +# Asserts a refused install was also INERT. BlockArchMigration is sequenced at 58/59, far ahead of +# InstallInitialize (1500) and RemoveExistingProducts (1501), so nothing should have been removed, moved or +# reconfigured - a gate that blocks but damages the installed product on its way out is not a gate. +function Assert-RefusalWasInert { + param( + [Parameter(Mandatory)][ValidateSet('x64', 'x86')][string]$ExpectedPlatform, + [Parameter(Mandatory)][string]$ConfigPath, + [Parameter(Mandatory)][string]$What + ) + Assert-That ((Get-InstalledPlatform) -eq $ExpectedPlatform) ` + "$What : the installed product is still $ExpectedPlatform" + $service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue + Assert-That ($null -ne $service -and $service.Status -eq 'Running') ` + "$What : the service is still Running - a blocked install must not disturb the installed one" + $cfg = Get-InstalledConfig -Path $ConfigPath + Assert-That ($null -ne $cfg -and $cfg.Jenkins.Secret.Value -eq 'arch-smoke-secret') ` + "$What : the config and secret are untouched" +} + +# -------------------------------------------------------------------------------------------------- +Write-Host "`n=== 0. Package preconditions ===" +$baselineVersion = Get-MsiProperty -Path $X64BaselineMsi -Name 'ProductVersion' +$x86Version = Get-MsiProperty -Path $X86Msi -Name 'ProductVersion' +$targetVersion = Get-MsiProperty -Path $X64TargetMsi -Name 'ProductVersion' +Write-Host " x64 baseline: $baselineVersion" +Write-Host " x86 : $x86Version" +Write-Host " x64 target : $targetVersion" + +# Hard stops, not assertions. Matching ProductVersions turn a second /i into a maintenance reconfigure that +# touches nothing and passes everything, and a lower version makes MajorUpgrade refuse on a version rule +# while the failure appears to say something about architecture. +foreach ($pair in @( + @{ Lower = $baselineVersion; Higher = $x86Version; What = 'x86 package vs x64 baseline' }, + @{ Lower = $x86Version; Higher = $targetVersion; What = 'x64 target vs x86 package' })) { + if ([version]$pair.Higher -le [version]$pair.Lower) { + Write-Host "::error::$($pair.What): $($pair.Higher) must be strictly greater than $($pair.Lower)." + throw "Version ladder is wrong for $($pair.What)." + } +} + +Assert-That ((Get-MsiArchitecture -Path $X64BaselineMsi) -eq 'x64') 'the baseline package really targets x64' +Assert-That ((Get-MsiArchitecture -Path $X86Msi) -eq 'x86') 'the x86 package really targets x86' +Assert-That ((Get-MsiArchitecture -Path $X64TargetMsi) -eq 'x64') 'the target package really targets x64' + +# ================================================================================================== +# PART ONE - the refused direction: x64 installed, x86 package must not take over. +# ================================================================================================== +Write-Host "`n=== 1. Install x64 $baselineVersion ===" +$log = Get-ArchLogPath -Name 'arch-x64-install' +$code = Invoke-Msiexec -LogPath $log -Arguments (@('/i', $X64BaselineMsi) + $freshInstallProperties) +Assert-InstallerSucceeded -ExitCode $code -LogPath $log -Activity 'x64 baseline install' + +$x64ConfigPath = Join-Path $x64InstallFolder 'appsettings.json' +Assert-That ((Get-InstalledPlatform) -eq 'x64') 'installed architecture recorded as x64, and under that key only' +Assert-That (Test-Path $x64ConfigPath) "appsettings.json written to $x64InstallFolder" +Assert-That ((Get-RecordedLocation -Platform 'x64' -Name 'InstallPath') -eq "$x64InstallFolder\") ` + 'the x64 install recorded its real install folder' + +Write-Host "`n=== 2. x86 $x86Version must be refused - with AND without FORCE_UPGRADE ===" +# Without the flag first, then with it. The second is the one that matters: this direction is refused +# outright, so FORCE_UPGRADE must NOT be a way through. If it ever becomes one, the migration silently +# installs to C:\Program Files (x86) and strands the secret. +foreach ($attempt in @( + @{ Name = 'arch-x86-blocked'; Args = @('/i', $X86Msi); What = 'x86 over x64 without FORCE_UPGRADE' }, + @{ Name = 'arch-x86-forced'; Args = @('/i', $X86Msi, 'FORCE_UPGRADE=1'); What = 'x86 over x64 WITH FORCE_UPGRADE' })) { + $code = Invoke-Msiexec -LogPath (Get-ArchLogPath -Name $attempt.Name) -Arguments $attempt.Args + Assert-That (-not (Test-InstallerSuccess -ExitCode $code)) ` + "$($attempt.What) is refused (msiexec exit code $code)" + Assert-RefusalWasInert -ExpectedPlatform 'x64' -ConfigPath $x64ConfigPath -What $attempt.What +} + +Assert-That (-not (Test-Path (Join-Path $x86InstallFolder 'JenkinsAsService.exe'))) ` + 'the refused x86 install left nothing in the 32-bit Program Files - it never got as far as installing' + +Write-Host "`n=== 3. Remove the x64 install ===" +Invoke-Msiexec -LogPath (Get-ArchLogPath -Name 'arch-x64-uninstall') -Arguments @('/x', $X64BaselineMsi) | Out-Null +Assert-That ($null -eq (Get-InstalledPlatform)) 'no architecture key survives the x64 uninstall' +Assert-That (-not (Test-Path $dataFolder)) 'the data folder is removed by the x64 uninstall' + +# ================================================================================================== +# PART TWO - the supported direction: x86 installed, x64 package migrates it on FORCE_UPGRADE=1. +# ================================================================================================== +Write-Host "`n=== 4. Install x86 $x86Version ===" +$log = Get-ArchLogPath -Name 'arch-x86-install' +$code = Invoke-Msiexec -LogPath $log -Arguments (@('/i', $X86Msi) + $freshInstallProperties) +Assert-InstallerSucceeded -ExitCode $code -LogPath $log -Activity 'x86 install' + +$x86ConfigPath = Join-Path $x86InstallFolder 'appsettings.json' +Assert-That ((Get-InstalledPlatform) -eq 'x86') 'installed architecture recorded as x86, and under that key only' +Assert-That ((Get-PeMachine -Path (Join-Path $x86InstallFolder 'JenkinsAsService.exe')) -eq 'x86') ` + 'the installed binary really is x86' +Assert-That ((Get-RecordedLocation -Platform 'x86' -Name 'InstallPath') -eq "$x86InstallFolder\") ` + 'the x86 install recorded its real install folder' + +# An operator edit, so "the config survived" means the real file survived rather than an identical one having +# been rewritten from the properties below (which are deliberately not supplied to either attempt). +$raw = Get-Content $x86ConfigPath -Raw | ConvertFrom-Json +$raw.Jenkins.Logging.RetainedLogs = 7 +$raw | ConvertTo-Json -Depth 8 | Set-Content $x86ConfigPath -Encoding utf8 + +Write-Host "`n=== 5. x64 $targetVersion WITHOUT FORCE_UPGRADE must be refused ===" +$code = Invoke-Msiexec -LogPath (Get-ArchLogPath -Name 'arch-x64-blocked') -Arguments @('/i', $X64TargetMsi) +Assert-That (-not (Test-InstallerSuccess -ExitCode $code)) ` + "x64 over x86 without FORCE_UPGRADE is refused (msiexec exit code $code)" +Assert-RefusalWasInert -ExpectedPlatform 'x86' -ConfigPath $x86ConfigPath -What 'x64 over x86 without FORCE_UPGRADE' + +Write-Host "`n=== 6. x64 $targetVersion WITH FORCE_UPGRADE=1 must succeed and preserve everything ===" +# No DATAFOLDER, no URL, no secret: a migration must recover all of that from what is already recorded on the +# machine. Supplying any of it would hide a recovery that never happened. +$log = Get-ArchLogPath -Name 'arch-x64-forced' +$code = Invoke-Msiexec -LogPath $log -Arguments @('/i', $X64TargetMsi, 'FORCE_UPGRADE=1') +Assert-InstallerSucceeded -ExitCode $code -LogPath $log -Activity 'forced x86 -> x64 migration' + +# Not just "the new key exists" - the OLD one has to be gone, or the next package sees two architectures +# installed at once and the gate starts firing against a product that no longer exists. +Assert-That ((Get-InstalledPlatform) -eq 'x64') ` + 'the recorded architecture is now x64, and only x64 - the x86 key is gone' + +# The load-bearing assertion of this half: the migrated x64 install stayed in the folder the x86 install used, +# recovered from the registry rather than reset to the x64 default. If this ever fails, appsettings.json and +# the secret have been stranded at the old path - which is the failure the refused direction suffers from. +Assert-That ((Get-RecordedLocation -Platform 'x64' -Name 'InstallPath') -eq "$x86InstallFolder\") ` + "the migrated install kept the original install folder ($x86InstallFolder), not the x64 default" +Assert-That ((Get-RecordedLocation -Platform 'x64' -Name 'DataPath') -eq "$dataFolder\") ` + 'the migrated install kept the original data folder, recovered rather than reset to the default' +Assert-That (-not (Test-Path (Join-Path $env:ProgramData 'JenkinsAsService'))) ` + 'no stray default data folder was created by the migration' +Assert-That (-not (Test-Path (Join-Path $x64InstallFolder 'JenkinsAsService.exe'))) ` + 'nothing was installed into the x64 default folder - the recovered location was used' + +Assert-That ((Get-PeMachine -Path (Join-Path $x86InstallFolder 'JenkinsAsService.exe')) -eq 'x64') ` + 'the binary at the original path really is x64 now - the architecture actually changed' + +$cfg = Get-InstalledConfig -Path $x86ConfigPath +Assert-That ($null -ne $cfg) 'appsettings.json still exists after the migration' +Assert-That ($cfg.Jenkins.Secret.Value -eq 'arch-smoke-secret') ` + 'the secret survives a migration that was never given JENKINS_SECRET' +Assert-That ($cfg.Jenkins.Logging.RetainedLogs -eq 7) "the operator's edit survives the migration" +Assert-That ($cfg.Jenkins.Connection.AgentName -eq 'arch-node') 'an unrelated value survives the migration' + +$service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue +Assert-That ($null -ne $service -and $service.Status -eq 'Running') 'the service is Running after the migration' + +# -------------------------------------------------------------------------------------------------- +Write-Host "`n=== 7. Clean up ===" +Invoke-Msiexec -LogPath (Get-ArchLogPath -Name 'arch-final-uninstall') -Arguments @('/x', $X64TargetMsi) | Out-Null +Assert-That ($null -eq (Get-Service -Name $serviceName -ErrorAction SilentlyContinue)) 'service is removed' +Assert-That (-not (Test-Path $dataFolder)) 'the data folder is removed' +Assert-That ($null -eq (Get-InstalledPlatform)) 'neither architecture key survives the uninstall' + +# -------------------------------------------------------------------------------------------------- +Complete-AssertionReport -Subject 'architecture-migration' diff --git a/.github/scripts/Test-MsiLifecycle.ps1 b/.github/scripts/Test-MsiLifecycle.ps1 index 78eb128..cfaec55 100644 --- a/.github/scripts/Test-MsiLifecycle.ps1 +++ b/.github/scripts/Test-MsiLifecycle.ps1 @@ -35,6 +35,7 @@ $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest Import-Module (Join-Path $PSScriptRoot 'MsiQuery.psm1') -Force +Import-Module (Join-Path $PSScriptRoot 'MsiTestHelpers.psm1') -Force $installFolder = Join-Path $env:ProgramFiles 'Jenkins' $dataFolder = Join-Path $env:ProgramData 'JenkinsAsServiceTest' @@ -44,46 +45,27 @@ $productName = 'Jenkins Agent Service' $logDir = Join-Path (Get-Location) 'msi-logs' New-Item -ItemType Directory -Path $logDir -Force | Out-Null -# The install and the upgrade must be given the SAME properties: the point of the upgrade assertions is that -# the config survives, so any difference here would make a preserved value indistinguishable from a re-written -# one. Declared once for exactly that reason. -$commonProperties = @( +# Fresh-install properties. The upgrade deliberately supplies NONE of these - see step 2. +$installProperties = @( "DATAFOLDER=$dataFolder", 'JENKINS_URL=https://127.0.0.1:59999', 'JENKINS_SECRET=ci-smoke-secret', 'JENKINS_SECRET_MODE=Unprotected' ) -$script:Failures = @() - -function Assert-That { - param([Parameter(Mandatory)][bool]$Condition, [Parameter(Mandatory)][string]$Message) - if ($Condition) { - Write-Host " [PASS] $Message" - } - else { - Write-Host " [FAIL] $Message" - $script:Failures += $Message - } -} +# Where the package records the locations it installed to, so the NEXT package can find them instead of +# resetting both to their defaults. Each architecture owns a named subkey, and which one exists IS the +# statement of what is installed. This job installs x64, which writes the native 64-bit view. The paths +# themselves live in MsiTestHelpers, which is what Get-RecordedLocation -Platform reads. +$locationKey = Get-JasLocationKeyPath -Platform 'x64' +$otherArchKey = Get-JasLocationKeyPath -Platform 'x86' +# Every install here must succeed, so the exit code is checked rather than returned. function Invoke-Msi { param([Parameter(Mandatory)][string[]]$Arguments, [Parameter(Mandatory)][string]$LogName) $log = Join-Path $logDir "$LogName.log" - $all = $Arguments + @('/quiet', '/norestart', '/l*v', $log) - Write-Host "msiexec $($all -join ' ')" - $p = Start-Process msiexec.exe -ArgumentList $all -Wait -PassThru - # 3010 = success, reboot requested. Not an error for this package, but worth surfacing. - if ($p.ExitCode -notin @(0, 3010)) { - Write-Host "::error::msiexec failed with exit code $($p.ExitCode) - see artifact $LogName.log" - Get-Content $log -Tail 40 | ForEach-Object { Write-Host " $_" } - throw "msiexec exit code $($p.ExitCode)" - } -} - -function Get-Config { - if (-not (Test-Path $configPath)) { return $null } - return Get-Content $configPath -Raw | ConvertFrom-Json + $code = Invoke-Msiexec -Arguments $Arguments -LogPath $log + Assert-InstallerSucceeded -ExitCode $code -LogPath $log -Activity 'msiexec' } # The installed product's version, from the uninstall registry keys. @@ -128,16 +110,43 @@ if ($v1Version -eq $v2Version) { throw "MSI ProductVersion must differ between the two packages (both are $v1Version)" } +# -------------------------------------------------------------------------------------------------- +# The wizard is never shown by this job - every msiexec call below is /quiet - so the upgrade UI gating is +# checked by reading the navigation graph back out of the package instead. +# +# The invariant: on an upgrade the operator must not be routed into the configuration pages. Those pages +# collect properties that WriteConfig / WriteAdvanced1-3 / SetDataDir consume, and all five are gated +# NOT WIX_UPGRADE_DETECTED - so an upgrade would demand the controller URL and the AGENT SECRET (blocking +# Next until both are filled in) and then discard both. Every edge INTO the config flow from outside it must +# therefore carry that same gate; edges between the config pages are the flow's own Back/Next and are +# unreachable once the entry points are gated. +# +# Asserting the routes exist at all is load-bearing too: this fragment is pulled in by a UIRef, and when that +# reference was missing the linker dropped the whole thing and shipped an MSI with no custom pages. +Write-Host "`n=== 0b. Upgrade skips the configuration pages ===" +$configDialogs = @('JenkinsConfigDlg', 'SecurityOptionsDlg', 'AdvancedOptionsDlg') +# @() so an empty result is an empty array rather than $null: Set-StrictMode turns .Count on $null into a +# terminating error, which would abort the run instead of failing the assertion it is meant to fail. +$intoConfigFlow = @(Get-MsiControlEvent -Path $V2Msi | + Where-Object { $_.Event -eq 'NewDialog' -and $_.Argument -in $configDialogs -and $_.Dialog -notin $configDialogs }) + +Assert-That ($intoConfigFlow.Count -gt 0) ` + "the custom configuration pages are present in the package (the UIRef still pulls the fragment in)" +foreach ($edge in $intoConfigFlow) { + Assert-That ($edge.Condition -match 'NOT\s+WIX_UPGRADE_DETECTED') ` + "$($edge.Dialog)/$($edge.Control) -> $($edge.Argument) is gated off on upgrade (condition: '$($edge.Condition)')" +} + # -------------------------------------------------------------------------------------------------- Write-Host "`n=== 1. Fresh install ($v1Version) ===" -Invoke-Msi -LogName 'install-v1' -Arguments (@('/i', $V1Msi) + $commonProperties + 'JENKINS_AGENT_NAME=ci-node') +Invoke-Msi -LogName 'install-v1' -Arguments (@('/i', $V1Msi) + $installProperties + 'JENKINS_AGENT_NAME=ci-node') $service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue Assert-That ($null -ne $service) "service '$serviceName' is registered" Assert-That (Test-Path $configPath) "appsettings.json written to the install folder" Assert-That (Test-Path $dataFolder) "data folder created at $dataFolder" -$cfg = Get-Config +$cfg = Get-InstalledConfig -Path $configPath Assert-That ($cfg.Jenkins.Connection.Url -eq 'https://127.0.0.1:59999') "Connection:Url persisted from the property" Assert-That ($cfg.Jenkins.Connection.AgentName -eq 'ci-node') "Connection:AgentName persisted from the property" Assert-That ($cfg.Jenkins.Secret.Value -eq 'ci-smoke-secret') "Secret:Value persisted" @@ -150,6 +159,20 @@ Assert-That ($cfg.Jenkins.Agent.DataDirectory -eq $dataFolder) "Agent:DataDirect $service.Refresh() Assert-That ($service.Status -eq 'Running') "service is Running after install (bring-up retries, it must not exit)" +# The locations are recorded so the next package can recover them. Without this the upgrade below would reset +# INSTALLFOLDER and DATAFOLDER to their defaults - and since appsettings.json is written by a custom action +# rather than installed as a tracked file, a relocated install folder would strand the only copy of the +# secret at the old path. DATAFOLDER here is deliberately NOT the default, so a value that merely looks +# plausible cannot pass. +Assert-That ((Get-RecordedLocation -Platform 'x64' -Name 'InstallPath') -eq "$installFolder\") ` + "install location recorded at $locationKey\InstallPath" +Assert-That ((Get-RecordedLocation -Platform 'x64' -Name 'DataPath') -eq "$dataFolder\") ` + "data location recorded at $locationKey\DataPath (the non-default DATAFOLDER, not the default)" +# Which key holds the paths is how a later package tells a same-arch upgrade from a cross-arch migration, so +# an x64 install writing anything under the x86 key would break that distinction in the quietest way possible. +Assert-That (-not (Test-Path $otherArchKey)) ` + "the x64 install recorded itself under the x64 key only - nothing under $otherArchKey" + # -------------------------------------------------------------------------------------------------- Write-Host "`n=== 2. Operator edits the config, then upgrades to $v2Version ===" @@ -169,13 +192,23 @@ $raw | ConvertTo-Json -Depth 8 | Set-Content $configPath -Encoding utf8 $sentinel = Join-Path $dataFolder 'operator-data.txt' Set-Content -Path $sentinel -Value 'operator data that must survive an upgrade and die on uninstall' -Encoding utf8 -Invoke-Msi -LogName 'upgrade-v2' -Arguments (@('/i', $V2Msi) + $commonProperties) +# NO properties. An upgrade must not need to be told the URL, the data folder or - above all - the agent +# secret: the wizard no longer asks for them, so a scripted upgrade must not have to supply them either. +# This is also the stronger assertion. Passing the same values on both runs made a preserved value +# indistinguishable from a re-written one; withholding them means every check below can only pass if +# UpgradeConfig really did reconcile the config that was already on disk. +Invoke-Msi -LogName 'upgrade-v2' -Arguments @('/i', $V2Msi) -$cfg = Get-Config +$cfg = Get-InstalledConfig -Path $configPath Assert-That ($null -ne $cfg) "appsettings.json still exists after the upgrade" Assert-That ($cfg.Jenkins.Logging.RetainedLogs -eq 9) "an operator's edited value survives the upgrade" Assert-That ($cfg.Jenkins.Connection.AgentName -eq 'ci-node') "an unrelated value survives the upgrade" -Assert-That ($cfg.Jenkins.Secret.Value -eq 'ci-smoke-secret') "the secret survives the upgrade" +Assert-That ($cfg.Jenkins.Connection.Url -eq 'https://127.0.0.1:59999') ` + "Connection:Url survives an upgrade that was never given JENKINS_URL" +Assert-That ($cfg.Jenkins.Agent.DataDirectory -eq $dataFolder) ` + "Agent:DataDirectory survives an upgrade that was never given DATAFOLDER" +Assert-That ($cfg.Jenkins.Secret.Value -eq 'ci-smoke-secret') ` + "the secret survives an upgrade that was never given JENKINS_SECRET" Assert-That ($null -ne $cfg.Jenkins.Connection.PSObject.Properties['ControllerCertThumbprint']) ` "a schema key missing from the old config is re-added by the reconcile" Assert-That ($null -eq $cfg.Jenkins.Logging.PSObject.Properties['NoSuchSetting']) ` @@ -183,6 +216,18 @@ Assert-That ($null -eq $cfg.Jenkins.Logging.PSObject.Properties['NoSuchSetting'] Assert-That (Test-Path $sentinel) "the data folder SURVIVES an upgrade (the purge must be gated on NOT UPGRADINGPRODUCTCODE)" +# The upgrade was given no DATAFOLDER, so it had to recover the recorded one. If it fell back to the default +# instead, the DataFolderAcl component would create and ACL a stray %ProgramData%\JenkinsAsService - granting +# the service account write on a folder nothing uses, which uninstall's purge (it reads DataDirectory out of +# the config) would then leave behind for good. Nothing here should ever create the default path. +$strayDataFolder = Join-Path $env:ProgramData 'JenkinsAsService' +Assert-That (-not (Test-Path $strayDataFolder)) ` + "no stray default data folder at $strayDataFolder - the recorded DATAFOLDER was recovered" +Assert-That ((Get-RecordedLocation -Platform 'x64' -Name 'InstallPath') -eq "$installFolder\") ` + "the recorded install location survives the upgrade and still points at the real folder" +Assert-That ((Get-RecordedLocation -Platform 'x64' -Name 'DataPath') -eq "$dataFolder\") ` + "the recorded data location survives the upgrade" + $service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue Assert-That ($null -ne $service -and $service.Status -eq 'Running') "service is Running after the upgrade" @@ -207,12 +252,10 @@ Assert-That (-not (Test-Path $configPath)) "appsettings.json is removed from the Assert-That (-not (Test-Path $installFolder)) "the install folder is removed" Assert-That (-not (Test-Path $dataFolder)) "the data folder is removed, with the logs, agent.jar and work tree" -# -------------------------------------------------------------------------------------------------- -Write-Host '' -if ($script:Failures.Count -gt 0) { - Write-Host "::error::$($script:Failures.Count) MSI lifecycle assertion(s) failed" - $script:Failures | ForEach-Object { Write-Host "::error:: $_" } - exit 1 -} +# The location key is a tracked component, so a genuine uninstall takes it with everything else. Leaving it +# would point the next fresh install at a folder that no longer exists. +Assert-That ($null -eq (Get-RecordedLocation -Platform 'x64' -Name 'InstallPath')) ` + "the recorded install location is removed - a later fresh install must not inherit a dead path" -Write-Host "All MSI lifecycle assertions passed." +# -------------------------------------------------------------------------------------------------- +Complete-AssertionReport -Subject 'MSI lifecycle' diff --git a/.github/scripts/Test-MsiPlatform.ps1 b/.github/scripts/Test-MsiPlatform.ps1 index 0d0d299..2a1e290 100644 --- a/.github/scripts/Test-MsiPlatform.ps1 +++ b/.github/scripts/Test-MsiPlatform.ps1 @@ -29,11 +29,12 @@ Set-StrictMode -Version Latest Import-Module (Join-Path $PSScriptRoot 'MsiQuery.psm1') -Force -# Platform token in the summary Template, by architecture. 32-bit packages say "Intel" for historical -# reasons - it is not a vendor name here, it is the Windows Installer token for x86. +# The expected platform token in each package's summary Template. Get-MsiPlatformToken owns the translation +# (32-bit packages say "Intel" - the Windows Installer token for x86, not a vendor name), so this states only +# which package is meant to be which architecture. $expectedPlatforms = [ordered]@{ - $X64Msi = 'x64' - $X86Msi = 'Intel' + $X64Msi = Get-MsiPlatformToken -Platform 'x64' + $X86Msi = Get-MsiPlatformToken -Platform 'x86' } $failures = @() diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8f968ca..faad51c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -76,8 +76,22 @@ jobs: # WIX_UPGRADE_DETECTED only happens during an actual in-place upgrade — previously a manual-verify step, # which is why the upgrade path stayed on the "not verified" list release after release. msi-lifecycle: - name: MSI Install / Upgrade / Uninstall + name: MSI Install / Upgrade / Uninstall / Arch Migration runs-on: windows-latest + # The version ladder, in one place. The ORDER is the load-bearing part and every rung depends on the one + # below it, so the values are named rather than repeated as literals across the steps and their titles: + # BASELINE -> installed first by the lifecycle suite, and the "from" side of the migration gate + # UPGRADE -> higher than BASELINE, or msiexec reconfigures instead of upgrading and every assertion + # below still passes without an upgrade having happened + # X86 -> higher than BASELINE, so the cross-architecture migration is an upgrade, not a downgrade + # BUNDLE -> higher than X86, since the bundle test installs the X86 package as its baseline and then + # migrates it; a lower value makes MajorUpgrade refuse the run on a version rule while the + # failure appears to say something about architecture + env: + BASELINE_VERSION: 1.0.0 + UPGRADE_VERSION: 1.0.1 + X86_VERSION: 1.0.2 + BUNDLE_VERSION: 1.0.3 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -97,26 +111,97 @@ jobs: # Two versions from one publish: the upgrade needs a higher ProductVersion, nothing else differs. The # build-msi action derives a per-version intermediate directory, which is what keeps these two builds # from collapsing into one copied package — see the action for why that matters. - - name: Build MSI 1.0.0 + - name: Build MSI x64 ${{ env.BASELINE_VERSION }} uses: ./.github/actions/build-msi with: publish-dir: ../../publish/x64/ platform: x64 - version: 1.0.0 + version: ${{ env.BASELINE_VERSION }} output: msi/v1/ - - name: Build MSI 1.0.1 + - name: Build MSI x64 ${{ env.UPGRADE_VERSION }} uses: ./.github/actions/build-msi with: publish-dir: ../../publish/x64/ platform: x64 - version: 1.0.1 + version: ${{ env.UPGRADE_VERSION }} output: msi/v2/ + # The x64 <-> x86 migration gate needs a real package of the OTHER architecture: the two share an + # UpgradeCode, so nothing short of actually installing one over the other exercises it. + - name: Publish x86 + uses: ./.github/actions/publish + with: + runtime: win-x86 + output: publish/x86/ + + # Higher than the baseline the migration test installs, so it is an upgrade (matching versions would make + # msiexec reconfigure instead, and every assertion would pass without a migration happening). + - name: Build MSI x86 ${{ env.X86_VERSION }} + uses: ./.github/actions/build-msi + with: + publish-dir: ../../publish/x86/ + platform: x86 + version: ${{ env.X86_VERSION }} + output: msi/x86/ + + # The bundle embeds BOTH architectures at the SAME version — a dedicated pair rather than reusing an + # earlier x64 build, because that version has to outrank the x86 baseline the bundle test installs (see + # the ladder above). This is the rung whose ordering matters most and is easiest to get wrong. + - name: Build MSI x64 ${{ env.BUNDLE_VERSION }} + uses: ./.github/actions/build-msi + with: + publish-dir: ../../publish/x64/ + platform: x64 + version: ${{ env.BUNDLE_VERSION }} + output: msi/bundle-x64/ + + - name: Build MSI x86 ${{ env.BUNDLE_VERSION }} + uses: ./.github/actions/build-msi + with: + publish-dir: ../../publish/x86/ + platform: x86 + version: ${{ env.BUNDLE_VERSION }} + output: msi/bundle-x86/ + + - name: Build bundle ${{ env.BUNDLE_VERSION }} + uses: ./.github/actions/build-bundle + with: + x64-msi: ../../msi/bundle-x64/JenkinsAsService.Installer.msi + x86-msi: ../../msi/bundle-x86/JenkinsAsService.Installer.msi + version: ${{ env.BUNDLE_VERSION }} + output: bundle/ + + # Every package exists by this point, so the three real-install suites run back to back. They install + # the SAME product, so they must never overlap - each ends by uninstalling, and the order is fixed. - name: Install, upgrade, uninstall shell: pwsh run: .github/scripts/Test-MsiLifecycle.ps1 -V1Msi (Get-Item msi/v1/*.msi).FullName -V2Msi (Get-Item msi/v2/*.msi).FullName + # Runs after the lifecycle suite, which ends with an uninstall - both suites install the same product, + # so they must not overlap. + # + # Both halves of the asymmetric policy, so it needs three packages in a strict version ladder: the x64 + # baseline that the x86 package must be REFUSED over (even with FORCE_UPGRADE, since a 32-bit package + # cannot recover a 64-bit install folder), then the x86 install that the higher x64 package migrates on + # FORCE_UPGRADE=1. The x64 target is the bundle's package, reused rather than built a fourth time. + - name: Cross-architecture migration policy + shell: pwsh + run: >- + .github/scripts/Test-MsiArchMigration.ps1 + -X64BaselineMsi (Get-Item msi/v1/*.msi).FullName + -X86Msi (Get-Item msi/x86/*.msi).FullName + -X64TargetMsi (Get-Item msi/bundle-x64/*.msi).FullName + + # Runs last: like the two suites above it installs the same product, so they must not overlap. This is + # the one that proves an architecture migration does not trip the MSI's purge and destroy the config. + - name: Bundle install / migrate / uninstall + shell: pwsh + run: >- + .github/scripts/Test-Bundle.ps1 + -BaselineX86Msi (Get-Item msi/x86/*.msi).FullName + -BundleExe (Get-Item bundle/*.exe).FullName + - name: Upload MSI logs on failure if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.github/workflows/powershell.yml b/.github/workflows/powershell.yml index 62749af..86e8065 100644 --- a/.github/workflows/powershell.yml +++ b/.github/workflows/powershell.yml @@ -23,8 +23,53 @@ jobs: - name: Run PSScriptAnalyzer shell: pwsh run: | - $root = $env:GITHUB_WORKSPACE ?? $PWD.Path - $results = Invoke-ScriptAnalyzer -Path $root -Recurse + $root = $env:GITHUB_WORKSPACE ?? $PWD.Path + + # Explicit file list rather than `-Path $root -Recurse`, and a bounded retry. + # + # Recursing a tree makes PSScriptAnalyzer walk directories and probe for modules, and that path + # throws an intermittent "Object reference not set to an instance of an object" - reproduced here + # as 1 crash in 3 runs over an UNCHANGED tree, on 1.22.0 and 1.25.0 alike. It is a flake in the + # analyzer, not a finding: the same files analyzed individually are always clean. Passing paths + # directly skips the traversal that trips it. + # + # The retry covers the residue. Findings never fail this job (they are uploaded as SARIF below), + # so the only way it can fail is the analyzer itself throwing - which must still fail loudly after + # a few attempts rather than be swallowed into a green check. + # -Force is the load-bearing switch, and the reason two attempts at this found zero files on the + # runner while working locally: every script in this repo lives under .github, a DOT-PREFIXED + # directory. POSIX treats that as hidden, and Get-ChildItem -Recurse does not descend into hidden + # directories without -Force. On Windows .github carries no hidden attribute, so it is enumerated + # either way - the platform difference is in the filesystem, not in PowerShell. + # + # -Filter rather than -Include for the extensions: with a directory -Path, -Include matches the + # path leaf and needs a trailing wildcard to behave. -Filter is applied by the provider. + $files = @('*.ps1', '*.psm1', '*.psd1' | ForEach-Object { + Get-ChildItem -Path $root -Filter $_ -Recurse -File -Force + } | + Where-Object { $_.FullName -notmatch '[\\/](obj|bin|node_modules|\.git)[\\/]' } | + Select-Object -ExpandProperty FullName -Unique | Sort-Object) + Write-Host "Analyzing $($files.Count) PowerShell file(s)." + # A filter that matches nothing must fail, not report a clean run: "0 findings" and "analyzed + # nothing" are indistinguishable in the SARIF upload, and the second one is how a scan quietly + # stops scanning. + if ($files.Count -eq 0) { throw 'No PowerShell files found - the file filter is wrong.' } + + # -Path takes one path, so this is a loop rather than an array argument - which also scopes each + # retry to the single file that flaked instead of redoing the whole set. + $results = @(foreach ($file in $files) { + foreach ($attempt in 1..3) { + try { + Invoke-ScriptAnalyzer -Path $file -ErrorAction Stop + break + } + catch { + Write-Host "::warning::PSScriptAnalyzer attempt $attempt on $file failed: $($_.Exception.Message)" + if ($attempt -eq 3) { throw } + } + } + }) + Write-Host "$($results.Count) finding(s)." $sarifResults = @($results | ForEach-Object { $rel = $_.ScriptPath.Replace($root, '').TrimStart('/\').Replace('\', '/') diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9da95ea..fe5906a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -105,11 +105,22 @@ jobs: shell: pwsh run: .github/scripts/Test-MsiPlatform.ps1 -X64Msi (Get-Item msi/x64/*.msi).FullName -X86Msi (Get-Item msi/x86/*.msi).FullName - - name: Rename MSI files + # The single self-selecting installer, built from the two packages just verified above. Built BEFORE the + # rename, because the bundle project takes the paths the build-msi action produced. + - name: Build bundle + uses: ./.github/actions/build-bundle + with: + x64-msi: ../../msi/x64/JenkinsAsService.Installer.msi + x86-msi: ../../msi/x86/JenkinsAsService.Installer.msi + version: ${{ steps.version.outputs.VERSION }} + output: bundle/ + + - name: Rename installer files run: | $v = "${{ steps.version.outputs.VERSION }}" Move-Item msi/x64/JenkinsAsService.Installer.msi "JenkinsAsService_${v}_x64.msi" Move-Item msi/x86/JenkinsAsService.Installer.msi "JenkinsAsService_${v}_x86.msi" + Move-Item bundle/JenkinsAsService.Bundle.exe "JenkinsAsService_${v}.exe" - name: Generate SBOM (CycloneDX) run: | @@ -117,30 +128,25 @@ jobs: dotnet tool install --global CycloneDX --version 6.2.0 dotnet-CycloneDX src/JenkinsAsService/JenkinsAsService.csproj --output . --output-format Json --filename "JenkinsAsService_${v}_sbom.json" --set-version $v + # 7z only. RAR was dropped deliberately: WinRAR is proprietary trialware with no redistributable + # command-line licence for unattended public CI, so producing release archives with it in this repo was + # a licensing problem rather than a packaging choice. 7-Zip is free software, already on the runner, and + # LZMA2 at -mx=9 is the better compressor anyway. The one thing genuinely lost is RAR's recovery record; + # the published SHA256 checksums already cover integrity, which is what that was standing in for. - name: Create 7z archives run: | 7z a -t7z -mx=9 "JenkinsAsService_${{ steps.version.outputs.VERSION }}_x64.7z" ./publish/x64/JenkinsAsService.exe ./publish/x64/appsettings.json 7z a -t7z -mx=9 "JenkinsAsService_${{ steps.version.outputs.VERSION }}_x86.7z" ./publish/x86/JenkinsAsService.exe ./publish/x86/appsettings.json - - name: Install WinRAR - run: choco install winrar -y --no-progress - - - name: Create RAR5 archives with recovery record - run: | - $rar = "C:\Program Files\WinRAR\rar.exe" - & $rar a -ma5 -rr5p -ep1 "JenkinsAsService_${{ steps.version.outputs.VERSION }}_x64.rar" ./publish/x64/JenkinsAsService.exe ./publish/x64/appsettings.json - & $rar a -ma5 -rr5p -ep1 "JenkinsAsService_${{ steps.version.outputs.VERSION }}_x86.rar" ./publish/x86/JenkinsAsService.exe ./publish/x86/appsettings.json - - name: Generate checksums run: | $v = "${{ steps.version.outputs.VERSION }}" $files = [ordered]@{ + "JenkinsAsService_${v}.exe" = "JenkinsAsService_${v}.exe" "JenkinsAsService_${v}_x64.msi" = "JenkinsAsService_${v}_x64.msi" "JenkinsAsService_${v}_x86.msi" = "JenkinsAsService_${v}_x86.msi" "JenkinsAsService_${v}_x64.7z" = "JenkinsAsService_${v}_x64.7z" - "JenkinsAsService_${v}_x64.rar" = "JenkinsAsService_${v}_x64.rar" "JenkinsAsService_${v}_x86.7z" = "JenkinsAsService_${v}_x86.7z" - "JenkinsAsService_${v}_x86.rar" = "JenkinsAsService_${v}_x86.rar" "x64/JenkinsAsService.exe" = "publish/x64/JenkinsAsService.exe" "x64/appsettings.json" = "publish/x64/appsettings.json" "x86/JenkinsAsService.exe" = "publish/x86/JenkinsAsService.exe" @@ -154,17 +160,27 @@ jobs: } $checksums | ConvertTo-Json -Depth 3 | Set-Content "JenkinsAsService_${v}_checksums.json" -Encoding utf8 + # Generated from the artifact itself, so the SHA256 in the manifest cannot disagree with the file that + # ships. Attached to the release rather than submitted: opening the winget-pkgs PR stays a deliberate act. + - name: Generate winget manifests + shell: pwsh + run: | + $v = "${{ steps.version.outputs.VERSION }}" + $url = "https://github.com/${{ github.repository }}/releases/download/${{ steps.version.outputs.TAG }}/JenkinsAsService_${v}.exe" + .github/scripts/New-WingetManifest.ps1 -Version $v -BundlePath "JenkinsAsService_${v}.exe" -InstallerUrl $url -OutputDirectory winget/ + Compress-Archive -Path winget/* -DestinationPath "JenkinsAsService_${v}_winget-manifests.zip" + - name: Create GitHub Release uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3 with: tag_name: ${{ steps.version.outputs.TAG }} generate_release_notes: true files: | + JenkinsAsService_${{ steps.version.outputs.VERSION }}.exe + JenkinsAsService_${{ steps.version.outputs.VERSION }}_winget-manifests.zip JenkinsAsService_${{ steps.version.outputs.VERSION }}_x64.msi JenkinsAsService_${{ steps.version.outputs.VERSION }}_x86.msi JenkinsAsService_${{ steps.version.outputs.VERSION }}_x64.7z - JenkinsAsService_${{ steps.version.outputs.VERSION }}_x64.rar JenkinsAsService_${{ steps.version.outputs.VERSION }}_x86.7z - JenkinsAsService_${{ steps.version.outputs.VERSION }}_x86.rar JenkinsAsService_${{ steps.version.outputs.VERSION }}_sbom.json JenkinsAsService_${{ steps.version.outputs.VERSION }}_checksums.json diff --git a/CLAUDE.md b/CLAUDE.md index 4b7df60..686da36 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -106,12 +106,25 @@ Add tests alongside new logic; run `dotnet test -c Release` and confirm green be - Default secret mode is **`Dpapi`** (encrypted at rest, machine-scoped) — deliberately **not** the world-readable machine env var. The Security Options radio group lists DPAPI (recommended) → TPM (strongest) → Credential Manager → Environment Variable → Unprotected. - The install-time secret is passed on the `WriteConfig` deferred CA command line — an accepted MSI trade-off (EXE custom actions get no `CustomActionData`); mitigated by `Hidden="yes"` + `Impersonate="no"` (runs as SYSTEM). Scripted installs that must avoid even a verbose MSI log should call `update-secret` directly with the secret-env/secret-file input options. - Default identity is the virtual account `NT SERVICE\Jenkins`. Runtime data dir is set by a second CA (`--set-data-dir`) because folding it into the main command would exceed the MSI 255-char CA limit. +- **The wizard shows the config pages on a fresh install only.** On upgrade (`WIX_UPGRADE_DETECTED`) `InstallDirDlg/Next` routes straight to `VerifyReadyDlg` and `VerifyReadyDlg/Back` stands down so the stock publish returns to `InstallDirDlg`. The pages collect properties that `WriteConfig`/`WriteAdvanced1-3`/`SetDataDir` consume, and all five are gated `NOT WIX_UPGRADE_DETECTED` — showing them demanded the URL and the **agent secret** (`RequiredFieldDlg` blocks Next until both are set) and discarded both. Note `Installed` is **false** during a major upgrade, so `NOT Installed` alone does not gate the upgrade path — `WIX_UPGRADE_DETECTED` is the discriminator, in the UI exactly as in the execute sequence. `Test-MsiLifecycle.ps1` runs the upgrade with **no properties at all** (nothing can then be re-written rather than preserved) and, since a `/quiet` job never renders a dialog, asserts the gating by reading the package's `ControlEvent` table via `Get-MsiControlEvent`: every `NewDialog` edge into the config pages from outside them must carry `NOT WIX_UPGRADE_DETECTED`, and there must be at least one — the fragment is pulled in by a `UIRef` and has been silently dropped by the linker before. +- **`InstallLocation.wxs` remembers where the last version went.** Each install records `InstallPath`/`DataPath` under `HKLM\SOFTWARE\JenkinsAsService\x64` or `...\x86` — **which key holds the paths is the statement of the installed architecture**, so no separate `Platform` value can go stale. Every package reads *both* keys (x64 in the 64-bit view, x86 in the 32-bit view) plus a `ComponentSearch` fallback for installs predating the keys, then applies them with type-51 CAs at sequence 51–60 (after `AppSearch` at 50, before `CostFinalize` at 1000). Those are authored as **`SetProperty` with `Sequence="both"`** — the recovery has to run in the UI sequence (the paths the wizard displays) *and* the execute sequence (where the install writes, and all a `/quiet` run executes), and hand-mirroring two sequence tables meant a condition fixed in one gave a wizard install and a silent install different folders. Don't expand them back into paired `` rows; `BlockArchMigration` is the one exception, because an error CA cannot take `Sequence="both"`. The `ComponentSearch` GUIDs are ``d in **`ComponentGuids.wxi`**, included by both `Service.wxs` (which installs the components) and `InstallLocation.wxs` (which searches for them) — two literals that must agree, where an edit to one side alone fails no build and no test, and just silently stops the fallback from ever finding a pre-key install. Precedence is **first-match-wins**, encoded by the `NOT INSTALLFOLDER` / `NOT DATAFOLDER` clause each carries — which doubles as the operator-override guard, because before `CostFinalize` a directory property is set *only* if it came from the command line. Without this an upgrade reset both to defaults: the relocated install folder stranded `appsettings.json` (a CA-written file, not a tracked one) with the only copy of the secret, and the reset `DATAFOLDER` made `DataFolderAcl` create and ACL a stray `%ProgramData%\JenkinsAsService` that uninstall's purge — it reads `DataDirectory` from the config — then left behind forever. +- **x64 ↔ x86 migration is ASYMMETRIC, and deliberately so.** x86→x64 is gated on `FORCE_UPGRADE=1` (MSI takes properties, not dashed flags), in the wizard and silently alike. **x64→x86 is refused outright and `FORCE_UPGRADE` does not override it** — the x86 package's `always64` registry search works, but MSI's **`WIN64DUALFOLDERS`** substitution rewrites the folder-shaped result's `C:\Program Files\` prefix to `C:\Program Files (x86)\`, so the migration installs beside the real one and strands `appsettings.json` (the only copy of the secret). CI caught it as a 1603 from `UpgradeConfig` running an exe with no config beside it. The x86 package therefore omits `RecoverInstallDirFromX64` entirely, and only the override *term* of the gate condition differs per arch (a `` holding plain text — XML escapes inside a define arrive as literal `<` and fail ICE03). Nothing is lost: the bundle always installs the machine's **native** arch, so it only ever needs the supported direction. `DATAFOLDER` is unaffected — `%ProgramData%` is not a dual-folder pair. The packages share an `UpgradeCode` so `FindRelatedProducts` cannot tell the architectures apart; the mismatch is detected from which registry key was found. `BlockArchMigration` is a **type-19 error CA at sequence 60**, far ahead of `InstallInitialize` (1500)/`RemoveExistingProducts` (1501), so a refused run aborts before the installed product is touched. `InstallLocationRegistry` carries a **different GUID per architecture** — the two write different keys in different views, and sharing one GUID would refcount them as one resource and orphan the old arch's key on a forced migration. `ServiceComponent`/`DataFolderAcl` deliberately *do* share GUIDs across arches: same resource, same path, and that is what makes the `ComponentSearch` fallback work cross-arch. `Test-MsiArchMigration.ps1` asserts **both directions**: x86-over-x64 refused with *and without* the flag and inert each time, then x86→x64 refused without the flag and, with it, landing on the **original** folders with config/edits/secret intact and the arch confirmed from the PE header. It needs three packages in a strict version ladder, so it reuses the bundle's x64 package as its target; the x86 build suppresses **ICE80 only** (a 32-bit package reading the 64-bit view, which is the whole point). - Optional settings are collected across the two config pages plus an **Advanced Options** page and applied by three deferred `update-secret --merge` custom actions (`WriteAdvanced1/2/3`), split only to stay under the MSI 255-char CA limit. They run after `WriteConfig` and before `StartServices`, so the full config exists before the service launches. - XML comments must not contain `--` (WIX0104); validate `.wxs` well-formedness after edits. +## Bundle (`src/JenkinsAsService.Bundle/`, WiX v5 Burn) & winget + +- **`JenkinsAsService_.exe` is the primary installer**; both MSIs still ship. A single `.msi` **cannot** self-select architecture — the Summary Information `Template` holds exactly one platform token (the field `Test-MsiPlatform.ps1` reads), a 32-bit package can't install 64-bit components, and `ProgramFiles64Folder` isn't addressable from one. The selection therefore lives one level up, in a Burn bundle embedding both MSIs (~66 MB, both payloads attached so an offline install can't fail halfway). +- **Architecture policy: always native, always migrate.** Both `MsiPackage` entries are conditioned on `VersionNT64` (exact complements — the chain can neither install both nor neither) and both are handed **`FORCE_UPGRADE=1`**. The MSI gate stays strict for bare-MSI installs, where a wrong-arch install is a mistake; the bundle has already made the decision. +- `bal:DisplayInternalUICondition="1"` (WiX v4+ renamed `DisplayInternalUI`) keeps the MSI's own config wizard on interactive runs — the bundle has no UI for a URL and secret, and building one means a custom BA to keep in sync. Each `MsiPackage` needs a distinct **`Name`**: both source files are `JenkinsAsService.Installer.msi`, and `wix burn extract` would otherwise overwrite one with the other. Settings are forwarded as `bal:Overridable` `Variable`s → `MsiProperty`, all defaulting to empty so the **MSI's** defaults apply (no second copy to drift); the forwarded names are listed **once** as `JasForwardedSettings` in `Bundle.wxs` and turned into both packages' `MsiProperty` rows by `MsiProperties.wxi`, ``d into each `MsiPackage` — written out per package, an omission from one list ships as "that setting silently does nothing on 32-bit hosts", and CI runs the bundle only on a 64-bit runner; `JENKINS_SECRET`/`SERVICE_PASSWORD` are `Hidden` + `Persisted="no"` so they never reach Burn's registry-backed variable store or its log. +- **`Test-Bundle.ps1` exists for one specific hazard.** `PurgeInstallation` is gated only on `REMOVE="ALL" AND NOT UPGRADINGPRODUCTCODE`, so if Burn ever planned the old architecture as a *standalone uninstall* instead of letting the incoming MSI's `MajorUpgrade` replace it, the purge would fire and the migration would "succeed" onto a machine whose config and secret had just been destroyed. Reasoning says it won't (shared `UpgradeCode`; the chain's own x86 package has a different `ProductCode` and isn't detected as present) — that's not evidence for this failure mode, so CI installs x86 directly, runs the bundle over it, and asserts the config, an operator edit, the secret and the data folder all survived. Architecture is verified from the installed binary's **PE header**, not from what the package claimed. +- Bundle version ladder in `build.yml`: the embedded MSIs must be **newer than the x86 baseline** the test installs (1.0.2), hence a dedicated x64+x86 pair at 1.0.3 — reusing the earlier x64 1.0.1 would make the migration a downgrade that `MajorUpgrade` refuses, failing on a version rule while appearing to say something about architecture. +- `Build=false` in `.slnx` (it embeds MSIs that must exist first); built via `.github/actions/build-bundle`, which takes the two MSIs as inputs rather than building them — same anti-drift reason as `build-msi`/`publish`. +- **winget** manifests are generated by `New-WingetManifest.ps1` from the artifact itself (the SHA256 cannot disagree with the file that ships) and attached to the release as a zip; opening the `winget-pkgs` PR stays a deliberate act. The manifest points at the **bundle**, listed under **both** architectures with the same URL — winget picks by architecture, and the bundle is the thing that knows how to choose. Listing the MSIs directly would move that decision into winget, which would then offer x64 to a machine running the x86 install and produce a *failed upgrade* instead of a migration. + ## CI/CD & Security Scans -`.github/workflows/`: `build.yml` (job 1: restore→test→SBOM→dual-arch publish→MSI on every push/PR; job 2 `msi-lifecycle`: really installs the MSI at 1.0.0, upgrades in place to 1.0.1 and uninstalls, asserting config preservation/add/prune, that an upgrade **keeps** `%ProgramData%`, and that uninstall **removes** both the install folder and `%ProgramData%` — via `.github/scripts/Test-MsiLifecycle.ps1`. It also hard-stops if the two MSIs carry the same `ProductVersion`, because msiexec would then reconfigure instead of upgrading and every assertion would still pass), `release.yml` (tag `v*` or manual dispatch → dual-arch MSI + 7z/rar + SHA256 checksums + SBOM), plus security scans — `codeql.yml` (C# + Actions YAML), `semgrep.yml`, `gitleaks.yml`, `powershell.yml` (PSScriptAnalyzer via direct `pwsh` step), `dependency-review.yml`, `osv-scanner.yml`/`trivy-reusable.yml`. Also `html-lint.yml` for `docs/` (html5lib + Stylelint + `.github/scripts/check_doc_links.py`, which resolves every internal href and `search-index.json` URL to a real page *and* id — HTML edits must pass). `build.yml` mirrors every release compile/package step so failures surface on PRs — enforced, not just intended: both workflows publish through the local composite action `.github/actions/publish` (inputs `runtime`/`output`/optional `version`) and build the MSI through `.github/actions/build-msi` (inputs `publish-dir`/`platform`/`version`/`output`), so neither flag set can drift between them. Change build/publish flags **there**, not in a workflow. `build-msi` derives `BaseIntermediateOutputPath` from platform+version: MSBuild's up-to-date check does **not** track property changes, so a shared `obj/` silently makes the second build a sub-second copy of the first package — that shipped an x86 MSI that was byte-identical to the x64 one, and made the first `msi-lifecycle` run a maintenance reconfigure that passed its assertions without upgrading anything. MSI identity is read through the single module `.github/scripts/MsiQuery.psm1` (`Get-MsiProperty`/`Get-MsiPlatform`); `.github/scripts/Test-MsiPlatform.ps1` asserts each package's summary Template and that the two are not byte-identical. +`.github/workflows/`: `build.yml` (job 1: restore→test→SBOM→dual-arch publish→MSI on every push/PR; job 2 `msi-lifecycle`: really installs the MSI at 1.0.0, upgrades in place to 1.0.1 and uninstalls, asserting config preservation/add/prune, that an upgrade **keeps** `%ProgramData%`, and that uninstall **removes** both the install folder and `%ProgramData%` — via `.github/scripts/Test-MsiLifecycle.ps1`. It also hard-stops if the two MSIs carry the same `ProductVersion`, because msiexec would then reconfigure instead of upgrading and every assertion would still pass), `release.yml` (tag `v*` or manual dispatch → dual-arch MSI + 7z + SHA256 checksums + SBOM; **7z only — do not add RAR back.** WinRAR is proprietary trialware with no redistributable CLI licence for unattended public CI, so it was dropped as a licensing problem, not a packaging preference. The SHA256 checksums cover what RAR's recovery record was standing in for), plus security scans — `codeql.yml` (C# + Actions YAML), `semgrep.yml`, `gitleaks.yml`, `powershell.yml` (PSScriptAnalyzer via direct `pwsh` step), `dependency-review.yml`, `osv-scanner.yml`/`trivy-reusable.yml`. Also `html-lint.yml` for `docs/` (html5lib + Stylelint + `.github/scripts/check_doc_links.py`, which resolves every internal href and `search-index.json` URL to a real page *and* id — HTML edits must pass). `build.yml` mirrors every release compile/package step so failures surface on PRs — enforced, not just intended: both workflows publish through the local composite action `.github/actions/publish` (inputs `runtime`/`output`/optional `version`) and build the MSI through `.github/actions/build-msi` (inputs `publish-dir`/`platform`/`version`/`output`), so neither flag set can drift between them. Change build/publish flags **there**, not in a workflow. `build-msi` derives `BaseIntermediateOutputPath` from platform+version: MSBuild's up-to-date check does **not** track property changes, so a shared `obj/` silently makes the second build a sub-second copy of the first package — that shipped an x86 MSI that was byte-identical to the x64 one, and made the first `msi-lifecycle` run a maintenance reconfigure that passed its assertions without upgrading anything. MSI identity is read through the single module `.github/scripts/MsiQuery.psm1` (`Get-MsiProperty`/`Get-MsiPlatform`/`Get-MsiArchitecture` — the last normalizes the summary token, so no caller restates that a 32-bit package spells itself `Intel`; the late-bound COM interop lives once in its internal `Invoke-MsiQuery`), and the three real-install suites share their scaffolding through **`.github/scripts/MsiTestHelpers.psm1`** (`Assert-That`/`Complete-AssertionReport`, `Invoke-Msiexec`/`Assert-InstallerSucceeded`, `Get-RecordedLocation`/`Get-InstalledPlatform`, `Get-PeMachine`). The location-key layout is stated **there and nowhere else**: rename a key with per-script copies and `Get-InstalledPlatform` returns `$null` everywhere, which makes "the other arch key is absent" pass *harder* than before. `msi-lifecycle`'s version ladder is job-level `env` (`BASELINE`/`UPGRADE`/`X86`/`BUNDLE`), and each rung must outrank the one below it; `.github/scripts/Test-MsiPlatform.ps1` asserts each package's summary Template and that the two are not byte-identical. ## Documentation diff --git a/JenkinsAsService.slnx b/JenkinsAsService.slnx index 549af2a..b919090 100644 --- a/JenkinsAsService.slnx +++ b/JenkinsAsService.slnx @@ -5,6 +5,13 @@ + + + + + diff --git a/README.md b/README.md index 603550b..2245cda 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ JenkinsAsService replaces all of that with a proper Windows Service built on .NE - **Unit-tested** — xUnit + NSubstitute + FluentAssertions (incl. an end-to-end watchdog harness driven by a fake process), CI on every push - **6 security scans** — CodeQL (C# + Actions YAML), Semgrep, Gitleaks, PSScriptAnalyzer, Dependency Review, Trivy; all actions SHA-pinned - **Single-file deploy** — self-contained `.exe` with R2R, compression, and embedded PDB symbols -- **Dual-arch releases** — x64 + x86 MSI installers, 7z/RAR archives, SHA256 checksums +- **Dual-arch releases** — x64 + x86 MSI installers, 7z archives, SHA256 checksums ## How It Works @@ -99,7 +99,7 @@ msiexec /i JenkinsAsService_x64.msi /qn ` ### Portable (Archive) -For environments where MSI installation isn't possible, download the `.7z` or `.rar` archive from [Releases](https://github.com/EliorMachlev/JenkinsAsService/releases): +For environments where MSI installation isn't possible, download the `.7z` archive from [Releases](https://github.com/EliorMachlev/JenkinsAsService/releases): 1. Extract to a folder of your choice (e.g. `D:\Jenkins`) 2. Edit `appsettings.json` — fill in `Connection:Url`, `Secret:Value`, and any other settings @@ -129,6 +129,20 @@ sc.exe delete Jenkins ``` > [!WARNING] +### Install + +```powershell +winget install EliorMachlev.JenkinsAsService +``` + +Or download `JenkinsAsService_.exe` from [Releases](https://github.com/EliorMachlev/JenkinsAsService/releases) — one file, both architectures. It installs the one matching your machine, and migrates an existing install of the other architecture in place, keeping your install folder, data folder, configuration and secret. Silent installs take the same properties as the MSI: + +```powershell +JenkinsAsService_1.16.0.exe -quiet JENKINS_URL=https://ci.example.com:8443 JENKINS_SECRET=... +``` + +The per-architecture `.msi` packages are still published for deployment tools that only speak MSI; they install exactly one architecture. Replacing an x86 install with x64 needs `FORCE_UPGRADE=1`; the reverse is refused outright, because a 32-bit package cannot recover a 64-bit install folder and would strand your config and secret. + > **Uninstalling is destructive.** The MSI removes the install folder *and* `%ProgramData%\JenkinsAsService` — logs, the cached `agent.jar`, and the `work\` directory (build workspaces, `remoting/` state) all go with it, without prompting. Copy anything you need out first. An **upgrade** preserves the data folder in full; only a genuine uninstall clears it. > > It also removes the **secret from its store** — the TPM key, Credential Manager entry, or machine environment variable, per `Secret:Mode` — so nothing usable is left behind. A Credential Manager entry written with `--impersonate` belongs to that user's vault and must be removed while logged on as them (`cmdkey /delete:JenkinsAsService/AgentSecret`); the purge tells you if it hit this. @@ -161,7 +175,7 @@ All settings live in the `Jenkins` section of `appsettings.json`, grouped into t | `Logging:RetainedLogs` | No | `3` | Number of rolled log files to keep. Oldest are permanently deleted. | | `Recovery:MaxRetries` | No | `0` | Max consecutive agent *crashes* before the service stops itself for SCM recovery. Unreachable-controller retries don't count. `0` = infinite | -> **Upgrades:** An in-place MSI upgrade reconciles `appsettings.json` to the new version's schema — settings introduced in the release appear at their defaults, settings the schema no longer defines are pruned, and your existing values and secret are preserved. The secret is detected without decryption, so User-scope DPAPI / TPM / Credential Manager secrets (bound to the service account) survive untouched. Because unknown keys are removed, configure only documented settings. +> **Upgrades:** An upgrade prompts for nothing — the wizard skips the configuration pages and **never asks for the agent secret**, and `msiexec /i JenkinsAsService.msi /quiet` needs no properties. It also keeps a non-default install or data folder, recovered from `HKLM\SOFTWARE\JenkinsAsService\`. Switching architecture is asymmetric: x86 → x64 needs `FORCE_UPGRADE=1` (the two packages share an `UpgradeCode`, so either would otherwise silently replace the other), while x64 → x86 is refused outright and the flag does not override it — a 32-bit package cannot read back a 64-bit install folder, so it would install beside the existing one and strand `appsettings.json` with the only copy of the secret. An in-place MSI upgrade reconciles `appsettings.json` to the new version's schema — settings introduced in the release appear at their defaults, settings the schema no longer defines are pruned, and your existing values and secret are preserved. The secret is detected without decryption, so User-scope DPAPI / TPM / Credential Manager secrets (bound to the service account) survive untouched. Because unknown keys are removed, configure only documented settings. ### Secret Protection diff --git a/docs/ci-cd.html b/docs/ci-cd.html index d537b08..2900ec5 100644 --- a/docs/ci-cd.html +++ b/docs/ci-cd.html @@ -82,7 +82,9 @@

MSI Install / Upgrade / Uninstall (build.yml
  • Purpose: Actually install the MSI. ServiceSettingsNormalizer is unit-tested, but custom-action sequencing during a real in-place upgrade (WIX_UPGRADE_DETECTED gating, UpgradeConfig before StartServices, the config surviving RemoveExistingProducts) is only reachable by installing — which is why the upgrade path stayed on the “not verified” list for so long.
  • Flow: publish x64 → build the MSI at 1.0.0 and 1.0.1 → install 1.0.0 → edit the config as an operator would → upgrade in place → uninstall.
  • +
  • The upgrade is run with no properties at all — no URL, no data folder, and above all no JENKINS_SECRET. That matches the wizard, which shows no configuration pages on an upgrade, and it is the stronger assertion: supplying the same values twice made a preserved value indistinguishable from a re-written one. The wizard itself is never displayed by a /quiet job, so its upgrade gating is checked separately by reading the package's ControlEvent table and asserting that every route into the configuration pages carries NOT WIX_UPGRADE_DETECTED — which also catches the custom pages being dropped from the package altogether.
  • Asserted: the two packages really differ in ProductVersion (checked before anything is installed — identical versions would make the upgrade a no-op reconfigure whose assertions all still pass); the service registers and is Running (a stopped service means startup itself broke); edited values, the secret and the data folder survive the upgrade; a schema key missing from the old config is re-added at its default; an unknown key is pruned; the installed ProductVersion really advanced; and uninstall removes the service, the install folder and the data folder.
  • +
  • Cross-architecture migration policy — both halves of the asymmetric gate. First it installs x64 and points the x86 package at it, asserting the install is refused both without and with FORCE_UPGRADE=1 (that direction cannot recover the install folder, so the flag must not be a way through) and that each refusal is inert — product still x64, service still running, config untouched. Then it installs x86 and migrates to a higher x64 package: refused without the flag, and with it succeeding onto the original install and data folders with the config, operator edits and secret intact, the architecture confirmed from the binary’s PE header. A gate that never blocks and a gate that blocks everything would each pass on half the suite.
  • The upgrade/uninstall pair is the point. The purge custom action must fire on a real uninstall and never during an upgrade's removal of the old product; asserting only one of the two would let the opposite defect through unnoticed.
  • No controller needed: the URL points at a closed port, so the agent never connects — every assertion is about files, the registry and SCM. Verbose msiexec logs are uploaded as an artifact when the job fails.
@@ -109,7 +111,6 @@

Release (.github/workflows/release.yml)

  • Optimized publish for win-x64 and win-x86 via the shared publish action (R2R, compression, embedded PDB, deterministic, speed-optimized, locked restore)
  • Build MSI installers for both architectures via WiX v5
  • Create .7z archives (LZMA2, max compression) via 7-Zip
  • -
  • Install WinRAR via Chocolatey → create .rar archives (RAR5 with 5% recovery record)
  • Generate a CycloneDX SBOM (JenkinsAsService_<version>_sbom.json) and attach it to the release
  • Generate checksums.json with SHA256 hashes for all artifacts (SBOM included)
  • Create GitHub Release with all artifacts and auto-generated release notes
  • diff --git a/docs/configuration.html b/docs/configuration.html index 4b95fe4..65c612f 100644 --- a/docs/configuration.html +++ b/docs/configuration.html @@ -52,7 +52,7 @@

    Configuration

    Configuration File

    The service reads its settings from appsettings.json, located in the same directory as the executable. The Jenkins section is grouped into topic sub-sections — Connection, Secret, Agent, Hardening, Logging, and Recovery. Settings below are written as Section:Key.

    -

    The MSI wizard collects most of these across three pages — JenkinsConfigDlg (URL, secret, node name, Java), SecurityOptionsDlg (secret protection mode) and Advanced Options (AdvancedOptionsDlg: transport, certificate pin, ViaFile, environment sanitization, logging and recovery). Fields left untouched keep their defaults, and anything you edit by hand afterwards survives upgrades — see Config migration on MSI upgrade.

    +

    The MSI wizard collects most of these across three pages — JenkinsConfigDlg (URL, secret, node name, Java), SecurityOptionsDlg (secret protection mode) and Advanced Options (AdvancedOptionsDlg: transport, certificate pin, ViaFile, environment sanitization, logging and recovery). Fields left untouched keep their defaults, and anything you edit by hand afterwards survives upgrades — see Config migration on MSI upgrade. These three pages are shown on a fresh install only; an upgrade skips them.

    Settings Reference

    @@ -528,6 +528,7 @@

    CLI: purge

    Config migration on MSI upgrade

    +

    On an in-place MSI upgrade the wizard shows no configuration pages — it goes from the install-folder page straight to the confirm screen. Nothing those pages collect is used on an upgrade, so being asked for the controller URL and the agent secret again (and being blocked until both were filled in) only wasted the operator's time and put a credential on a command line that would never read it. A scripted upgrade likewise needs no properties at all: msiexec /i JenkinsAsService.msi /quiet is the whole command.

    On an in-place MSI upgrade, the installer runs update-secret --upgrade as SYSTEM. It first reconciles appsettings.json to the current schema: settings the new version adds are written at their POCO defaults, settings the schema no longer defines are removed, and every value still in the schema — including the secret — is preserved as-is.

    The check for an existing secret is a raw-string presence check on Jenkins:Secret:Value, not a decryption attempt — so it works as SYSTEM regardless of which identity the secret is bound to. A secret protected with User-scope DPAPI, TPM, or Credential Manager under the service account is preserved untouched rather than re-written. If no secret is present after reconciliation, the upgrade performs a full write from the --secret/--url/--mode values supplied to the CA; with none supplied, it fails the same way a fresh silent install with no secret does.

    diff --git a/docs/installation.html b/docs/installation.html index 91568b4..70a75ae 100644 --- a/docs/installation.html +++ b/docs/installation.html @@ -212,9 +212,36 @@

    MSI Installer

    Configuration on upgrade
    +

    An upgrade asks you nothing, and never asks for the agent secret. The wizard skips the three configuration pages entirely and goes straight to the confirm screen, because none of what they collect is used on an upgrade path.

    An in-place upgrade reconciles the existing appsettings.json to the new version's schema: settings introduced in the release appear at their defaults, settings the schema no longer defines are removed, and your existing values — including the secret — are preserved. The secret is detected by a decryption-free presence check, so a secret bound to the service account (User-scope DPAPI, TPM, or Credential Manager) survives the upgrade untouched; if no secret is present, the upgrade writes a fresh configuration from the supplied properties. Because unknown keys are pruned, configure only documented settings. See Configuration → Config migration on MSI upgrade.

    +
    +
    Where the previous version was installed
    +

    Each install records its own locations under HKLM\SOFTWARE\JenkinsAsService\x64 or HKLM\SOFTWARE\JenkinsAsService\x86 (values InstallPath and DataPath), and the next package reads both keys back. An upgrade therefore keeps a non-default install folder and a non-default DATAFOLDER instead of resetting them to the defaults. That matters more than it sounds: appsettings.json is written by a custom action rather than installed as a tracked file, so an upgrade that relocated the install folder would leave the only copy of your secret behind at the old path.

    +

    Passing INSTALLFOLDER= or DATAFOLDER= explicitly still wins — the recovery only fills in what you did not specify. For a product installed by a version that predates these keys, the install folder is instead recovered from Windows Installer's own component registration, and the data folder falls back to the default.

    +
    + +
    +
    Which download to use
    +

    JenkinsAsService_<version>.exe is the one to pick. It carries both architectures and installs the one matching the machine — and if the other architecture is already installed, it migrates it, keeping your install folder, data folder, configuration and secret. Silent installs take the same properties as the MSI:

    +
    JenkinsAsService_1.16.0.exe -quiet JENKINS_URL=https://ci.example.com:8443 JENKINS_SECRET=...
    +

    Or through winget:

    +
    winget install EliorMachlev.JenkinsAsService
    +

    The two .msi packages are still published for deployment tools that only speak MSI. They install exactly one architecture and do not select for you.

    +
    + +
    +
    Changing architecture (x64 ↔ x86)
    +

    The two MSI packages share an UpgradeCode, so either will replace the other — which is almost never what someone who grabbed the wrong file intended, and there is no undo once the old product has been removed. The policy is therefore asymmetric, because the two directions are not equally safe.

    +

    32-bit → 64-bit is supported, opt-in from a bare MSI:

    +
    msiexec /i JenkinsAsService_1.16.0_x64.msi FORCE_UPGRADE=1
    +

    The requirement is the same in the wizard and in a silent install. A refused run is inert — it aborts before the installed product is touched, so the service keeps running and the configuration is untouched. A forced migration keeps the existing install folder, data folder, configuration and secret; only the binaries change architecture.

    +

    64-bit → 32-bit is refused outright, and FORCE_UPGRADE=1 does not override it. A 32-bit package cannot read a 64-bit install folder back out of the registry: the search succeeds, but Windows Installer’s WIN64DUALFOLDERS substitution then rewrites the result, mapping the C:\Program Files\ prefix to C:\Program Files (x86)\. The migration would install beside the existing one and leave appsettings.json — holding the only copy of your agent secret — behind at the original path. A migration that destroys what it promises to keep is worse than one that refuses, so the 32-bit package refuses and names the package to run instead.

    +

    To move a machine deliberately from the 64-bit to the 32-bit agent, uninstall and install fresh. Nothing is lost by the refusal in practice: the configuration would have to be re-created at the new location either way.

    +

    The .exe never asks. Installing the machine's native architecture is its policy rather than an option — there is no reason to run the 32-bit agent on a 64-bit machine — so it supplies the flag itself and migrates without prompting. It only ever needs the supported direction: on a 64-bit machine it installs x64, and on a 32-bit machine no 64-bit install can exist.

    +
    +
    Service account

    The default NT SERVICE\Jenkins virtual account needs no password and no Active Directory. For a domain gMSA or a dedicated password-based account, override SERVICE_ACCOUNT (plus SERVICE_ACCOUNT_DOMAIN / SERVICE_ACCOUNT_NAME for the folder ACL, and SERVICE_PASSWORD — leave empty for gMSA).

    @@ -407,7 +434,7 @@

    Silent Install

    Portable (Archive)

    -

    For environments where MSI installation isn't possible, download the .7z or .rar archive from Releases:

    +

    For environments where MSI installation isn't possible, download the .7z archive from Releases:

    1. Extract to a folder of your choice (e.g. D:\Jenkins)
    2. Edit appsettings.json — fill in Connection:Url, Secret:Value, and any other settings
    3. @@ -483,7 +510,7 @@

      Automated Release

    4. Push a tag matching v* (e.g., v1.0.4)
    5. Runs tests, publishes optimized self-contained executables for x64 and x86
    6. Builds MSI installers for both architectures via WiX v5
    7. -
    8. Creates .7z (LZMA2, max compression) and .rar (RAR5 with 5% recovery record) archives
    9. +
    10. Creates .7z (LZMA2, max compression) archives
    11. Generates a CycloneDX SBOM for the service project
    12. Generates checksums.json with SHA256 hashes for all artifacts (SBOM included)
    13. Creates GitHub Release with auto-generated release notes
    14. @@ -512,18 +539,10 @@

      Release Artifacts

      JenkinsAsService_1.0.4_x64.7z 64-bit archive (7z) - - JenkinsAsService_1.0.4_x64.rar - 64-bit archive (RAR5 + recovery) - JenkinsAsService_1.0.4_x86.7z 32-bit archive (7z) - - JenkinsAsService_1.0.4_x86.rar - 32-bit archive (RAR5 + recovery) - JenkinsAsService_1.0.4_sbom.json CycloneDX SBOM of the service project diff --git a/src/JenkinsAsService.Bundle/Bundle.wxs b/src/JenkinsAsService.Bundle/Bundle.wxs new file mode 100644 index 0000000..d77c3d1 --- /dev/null +++ b/src/JenkinsAsService.Bundle/Bundle.wxs @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/JenkinsAsService.Bundle/JenkinsAsService.Bundle.wixproj b/src/JenkinsAsService.Bundle/JenkinsAsService.Bundle.wixproj new file mode 100644 index 0000000..779486d --- /dev/null +++ b/src/JenkinsAsService.Bundle/JenkinsAsService.Bundle.wixproj @@ -0,0 +1,25 @@ + + + + + false + true + Bundle + + + + 1.0.0 + + ..\..\msi\x64\JenkinsAsService.Installer.msi + ..\..\msi\x86\JenkinsAsService.Installer.msi + Version=$(Version);X64Msi=$(X64Msi);X86Msi=$(X86Msi) + + + + + + + diff --git a/src/JenkinsAsService.Bundle/MsiProperties.wxi b/src/JenkinsAsService.Bundle/MsiProperties.wxi new file mode 100644 index 0000000..c665e2c --- /dev/null +++ b/src/JenkinsAsService.Bundle/MsiProperties.wxi @@ -0,0 +1,27 @@ + + + + + + + + + + + + + diff --git a/src/JenkinsAsService.Bundle/packages.lock.json b/src/JenkinsAsService.Bundle/packages.lock.json new file mode 100644 index 0000000..5706a58 --- /dev/null +++ b/src/JenkinsAsService.Bundle/packages.lock.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "dependencies": { + "native,Version=v0.0": { + "WixToolset.BootstrapperApplications.wixext": { + "type": "Direct", + "requested": "[5.0.2, )", + "resolved": "5.0.2", + "contentHash": "4FDrmTcApM4fy2BcIRnrSJ04Tpv6+3rd2vOdrN8zo7OQ23gzGYY7tCv57Vb+EQogJ2PP6iU+5v4Y4yrVJLV+vg==" + } + } + } +} \ No newline at end of file diff --git a/src/JenkinsAsService.Installer/ComponentGuids.wxi b/src/JenkinsAsService.Installer/ComponentGuids.wxi new file mode 100644 index 0000000..a9411bc --- /dev/null +++ b/src/JenkinsAsService.Installer/ComponentGuids.wxi @@ -0,0 +1,22 @@ + + + + + + + diff --git a/src/JenkinsAsService.Installer/ConfigDialog.wxs b/src/JenkinsAsService.Installer/ConfigDialog.wxs index 1681d53..07100a4 100644 --- a/src/JenkinsAsService.Installer/ConfigDialog.wxs +++ b/src/JenkinsAsService.Installer/ConfigDialog.wxs @@ -242,12 +242,28 @@ config pages; the same path-valid condition is kept so an invalid folder still re-prompts rather than skipping ahead. The previous Order 2 lost to the stock Order 4 publish, so every config page was skipped and WriteConfig ran with an empty URL and secret (installer error 1722). --> + + Condition="WIXUI_INSTALLDIR_VALID = "1" AND NOT WIX_UPGRADE_DETECTED" /> + + navigation (Order 2) untouched when the product is already Installed. On an upgrade the config + pages were never shown, so this must stand down and let the stock Order 1 publish (NOT Installed -> + InstallDirDlg) take Back - otherwise Back walks into AdvancedOptionsDlg and the operator lands on + JenkinsConfigDlg, blocked by RequiredFieldDlg on fields the upgrade will not use. --> + Condition="NOT Installed AND NOT WIX_UPGRADE_DETECTED" /> diff --git a/src/JenkinsAsService.Installer/InstallLocation.wxs b/src/JenkinsAsService.Installer/InstallLocation.wxs new file mode 100644 index 0000000..fc23535 --- /dev/null +++ b/src/JenkinsAsService.Installer/InstallLocation.wxs @@ -0,0 +1,249 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/JenkinsAsService.Installer/JenkinsAsService.Installer.wixproj b/src/JenkinsAsService.Installer/JenkinsAsService.Installer.wixproj index 1d54ee8..33e1223 100644 --- a/src/JenkinsAsService.Installer/JenkinsAsService.Installer.wixproj +++ b/src/JenkinsAsService.Installer/JenkinsAsService.Installer.wixproj @@ -8,10 +8,24 @@ x64 ..\..\publish\x64\ + + + ICE80 1.0.0 + PublishDir=$(PublishDir);Version=$(Version) diff --git a/src/JenkinsAsService.Installer/Package.wxs b/src/JenkinsAsService.Installer/Package.wxs index b0ba57e..ca9f952 100644 --- a/src/JenkinsAsService.Installer/Package.wxs +++ b/src/JenkinsAsService.Installer/Package.wxs @@ -52,6 +52,10 @@