Installer smoke test #13
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Installer smoke test | |
| # Installs the built installer on a fresh, throwaway GitHub-hosted Windows runner | |
| # and checks that it actually produces a working service - the one thing the | |
| # release process could not verify locally, because running the installer on the | |
| # maintainer's build machine hijacks the development service and COM registration | |
| # (see the release notes in RELEASE.md and the maintainer's environment memory). | |
| # | |
| # A GitHub-hosted runner is the right place for this precisely because it is | |
| # discarded after the job: a real silent install, a real service registration and | |
| # a real uninstall happen on a machine nobody else depends on. | |
| # | |
| # The installer is NOT built here - the server needs VS 2026 / v145 and the | |
| # prebuilt native dependencies, which only the self-hosted runner has. Instead this | |
| # takes an already-built installer: either an asset attached to a release, or an | |
| # artifact uploaded by a prior run. Point it at one with the inputs below. | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| release_tag: | |
| description: 'Release tag whose installer asset to smoke-test (e.g. v6.2.18). Leave blank to use an uploaded artifact named "installer".' | |
| type: string | |
| required: false | |
| run_id: | |
| description: 'Run ID to download the "installer" artifact from (when not using a release tag).' | |
| type: string | |
| required: false | |
| permissions: | |
| contents: read | |
| actions: read | |
| jobs: | |
| smoke: | |
| name: Install on a throwaway runner and verify | |
| runs-on: windows-latest | |
| steps: | |
| - name: Fetch the installer from the release | |
| if: ${{ inputs.release_tag != '' }} | |
| shell: pwsh | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| New-Item -ItemType Directory -Force installer | Out-Null | |
| gh release download "${{ inputs.release_tag }}" ` | |
| --repo "${{ github.repository }}" ` | |
| --pattern "hMailServer-*-x64.exe" ` | |
| --dir installer | |
| if (-not $LASTEXITCODE -eq 0) { throw "Could not download the installer asset from ${{ inputs.release_tag }}." } | |
| - name: Fetch the installer from an uploaded artifact | |
| if: ${{ inputs.release_tag == '' }} | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| name: installer | |
| path: installer | |
| run-id: ${{ inputs.run_id }} | |
| github-token: ${{ github.token }} | |
| - name: Locate the installer | |
| id: find | |
| shell: pwsh | |
| run: | | |
| $exe = Get-ChildItem installer -Recurse -Filter 'hMailServer-*-x64.exe' | Select-Object -First 1 | |
| if (-not $exe) { throw "No hMailServer-*-x64.exe found to test." } | |
| Write-Host "Installer: $($exe.FullName) ($([math]::Round($exe.Length/1MB,1)) MB)" | |
| "exe=$($exe.FullName)" >> $env:GITHUB_OUTPUT | |
| - name: Silent install | |
| shell: pwsh | |
| run: | | |
| $exe = '${{ steps.find.outputs.exe }}' | |
| # /VERYSILENT drives the embedded SQL CE backend, which needs no external | |
| # database - that is what makes the installer testable on a bare runner. | |
| # /SUPPRESSMSGBOXES matters: the installer shows a MsgBox on a DB-setup | |
| # error, and in silent mode that would otherwise pick the default button | |
| # and mask the failure. The admin password comes from a wizard page that | |
| # silent mode skips, so it is not set here and the checks below do not | |
| # assume one. | |
| # | |
| # /LOG and the timeout below both exist because of what happened on | |
| # 15 August 2026: the 6.2.19 installer hung in this step and the job sat | |
| # for 90 minutes and then had to be cancelled by hand, having produced no | |
| # evidence whatsoever about WHERE it stopped. The same test against 6.2.18 | |
| # had finished in 33 seconds. A hang that reports nothing is barely better | |
| # than no test, so this step now bounds itself and keeps the log either | |
| # way - Inno's own log names the last [Run] entry or [Code] call it | |
| # reached, which is the one thing needed to tell an installer that is slow | |
| # from one that is stuck. | |
| $log = Join-Path $env:RUNNER_TEMP 'hmailserver-install.log' | |
| "install_log=$log" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 | |
| $p = Start-Process -FilePath $exe ` | |
| -ArgumentList '/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART','/TYPE=full',"/LOG=$log" ` | |
| -PassThru | |
| # Ten minutes is far above the 33 seconds a healthy install of this | |
| # product takes, including the bundled .NET runtime, and far below the | |
| # six-hour job ceiling that made the last hang so expensive. | |
| if (-not $p.WaitForExit(10 * 60 * 1000)) { | |
| Write-Host "::error::The installer did not finish within 10 minutes - treating it as hung." | |
| try { $p.Kill() } catch { } | |
| if (Test-Path $log) { | |
| Write-Host "----- last 80 lines of the install log -----" | |
| Get-Content $log -Tail 80 | ForEach-Object { Write-Host $_ } | |
| } else { | |
| Write-Host "No install log was produced at $log." | |
| } | |
| throw "Installer hung." | |
| } | |
| Write-Host "Installer exit code: $($p.ExitCode)" | |
| if ($p.ExitCode -ne 0) { | |
| if (Test-Path $log) { | |
| Write-Host "----- last 80 lines of the install log -----" | |
| Get-Content $log -Tail 80 | ForEach-Object { Write-Host $_ } | |
| } | |
| throw "Installer returned a non-zero exit code." | |
| } | |
| id: install | |
| # Runs only when the install step failed, and it is the diagnostic that | |
| # matters: the installer's message box says merely "exit code 1", while | |
| # DBSetupQuick writes the real reason - the SQL error, the missing script, | |
| # the permission - to stderr, where nobody was reading it. The files are | |
| # already on disk by the time the box appears (Inno logs "Installation | |
| # process succeeded" first), so the tool can simply be run again here. | |
| - name: Why did the database setup fail | |
| if: ${{ failure() }} | |
| shell: pwsh | |
| run: | | |
| $tool = Get-ChildItem 'C:\Program Files*\hMailServer\Bin\DBSetupQuick.exe' -ErrorAction SilentlyContinue | | |
| Select-Object -First 1 | |
| if (-not $tool) { | |
| Write-Host 'DBSetupQuick.exe is not installed - the failure was earlier than the database step.' | |
| exit 0 | |
| } | |
| $out = Join-Path $env:RUNNER_TEMP 'dbsetup-stdout.txt' | |
| $err = Join-Path $env:RUNNER_TEMP 'dbsetup-stderr.txt' | |
| $p = Start-Process -FilePath $tool.FullName -ArgumentList '/silent' -PassThru ` | |
| -RedirectStandardOutput $out -RedirectStandardError $err | |
| if (-not $p.WaitForExit(3 * 60 * 1000)) { | |
| try { $p.Kill() } catch { } | |
| Write-Host 'DBSetupQuick did not finish within three minutes.' | |
| } else { | |
| Write-Host "DBSetupQuick exit code: $($p.ExitCode)" | |
| } | |
| foreach ($f in @($out, $err)) { | |
| if ((Test-Path $f) -and (Get-Item $f).Length -gt 0) { | |
| Write-Host "----- $(Split-Path $f -Leaf) -----" | |
| Get-Content $f | ForEach-Object { Write-Host $_ } | |
| } | |
| } | |
| $ini = 'C:\Program Files\hMailServer\Bin\hMailServer.INI' | |
| if (Test-Path $ini) { | |
| Write-Host '----- [Database] section as the installer left it -----' | |
| Get-Content $ini | Select-String -Pattern '^\[Database\]' -Context 0,12 | ForEach-Object { Write-Host $_ } | |
| } | |
| - name: The service is registered and points at the installed binary | |
| shell: pwsh | |
| run: | | |
| $svc = Get-CimInstance Win32_Service -Filter "Name='hMailServer'" -ErrorAction SilentlyContinue | |
| if (-not $svc) { throw "The installer did not register the hMailServer service." } | |
| Write-Host "Service image path: $($svc.PathName)" | |
| if ($svc.PathName -notmatch 'hMailServer\.exe') { throw "Service image path does not point at hMailServer.exe." } | |
| - name: The service starts and listens on 25 / 110 / 143 | |
| shell: pwsh | |
| run: | | |
| Start-Service hMailServer | |
| # The embedded database initialises on first start; give it a moment. | |
| Start-Sleep -Seconds 10 | |
| $svc = Get-Service hMailServer | |
| if ($svc.Status -ne 'Running') { throw "Service did not reach Running (state: $($svc.Status))." } | |
| foreach ($port in 25, 110, 143) { | |
| $listening = Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue | |
| if (-not $listening) { throw "Service is running but not listening on $port." } | |
| Write-Host "Listening on $port." | |
| } | |
| - name: COM server is registered and instantiable | |
| shell: pwsh | |
| run: | | |
| # Creating the object proves the installer registered the out-of-process | |
| # COM server (LocalServer32). The admin password is not known after a | |
| # silent install, so authentication is deliberately not attempted; a | |
| # working service that listens on all three ports (checked above) is the | |
| # evidence that DBSetupQuick built the database. | |
| $app = New-Object -ComObject hMailServer.Application | |
| if ($null -eq $app) { throw "Could not instantiate the hMailServer.Application COM object." } | |
| Write-Host "COM object created; server product string: $($app.Product)" | |
| # Kept whatever happened. On a green run it is a few KB nobody reads; on a | |
| # hang or a failure it is the only record of how far the installer got, and | |
| # the runner it was written on is discarded minutes later. | |
| - name: Keep the install log | |
| if: ${{ always() }} | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: install-log | |
| path: ${{ runner.temp }}/hmailserver-install.log | |
| if-no-files-found: warn | |
| - name: Uninstall cleanly | |
| if: ${{ always() }} | |
| shell: pwsh | |
| run: | | |
| Stop-Service hMailServer -ErrorAction SilentlyContinue | |
| $uninst = Get-ChildItem 'C:\Program Files*\hMailServer\unins*.exe' -ErrorAction SilentlyContinue | Select-Object -First 1 | |
| if ($uninst) { | |
| $p = Start-Process -FilePath $uninst.FullName -ArgumentList '/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART' -Wait -PassThru | |
| Write-Host "Uninstaller exit code: $($p.ExitCode)" | |
| } else { | |
| Write-Warning "No uninstaller found - the runner is ephemeral, so this is not fatal." | |
| } |