Add signed Windows MSIX release pipeline

This commit is contained in:
Ochenstarik 2026-07-17 09:19:09 +07:00
parent a8978c085e
commit 5c383ec9e2
7 changed files with 291 additions and 0 deletions

View file

@ -28,3 +28,26 @@ jobs:
- name: Verify formatting
run: dotnet format src/ServerMonitorManager.Desktop/ServerMonitorManager.Desktop.csproj --verify-no-changes --no-restore
- name: Build test-signed MSIX installer
shell: pwsh
run: |
$directory = Join-Path $env:RUNNER_TEMP 'smm-test-signing'
$password = [Convert]::ToBase64String([Security.Cryptography.RandomNumberGenerator]::GetBytes(24))
./build/windows/New-TestSigningCertificate.ps1 -OutputDirectory $directory -Password $password
./build/windows/Build-Installer.ps1 `
-CertificatePath (Join-Path $directory 'server-monitor-manager-test-signing.pfx') `
-CertificatePassword $password
Copy-Item `
-LiteralPath (Join-Path $directory 'server-monitor-manager-test-signing.cer') `
-Destination artifacts/windows-installer/ServerMonitorManager-test-signing.cer
- name: Upload test installer
uses: actions/upload-artifact@v6
with:
name: ServerMonitorManager-win-x64-test
path: |
artifacts/windows-installer/ServerMonitorManager-win-x64.msix
artifacts/windows-installer/ServerMonitorManager-test-signing.cer
artifacts/windows-installer/SHA256SUMS
if-no-files-found: error

83
.github/workflows/windows-release.yml vendored Normal file
View file

@ -0,0 +1,83 @@
name: Windows installer release
on:
workflow_dispatch:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
package:
runs-on: windows-latest
env:
SIGNING_CERTIFICATE_BASE64: ${{ secrets.WINDOWS_SIGNING_CERTIFICATE_BASE64 }}
SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_SIGNING_CERTIFICATE_PASSWORD }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up .NET 10
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.0.x
- name: Prepare signing certificate
id: signing
shell: pwsh
run: |
$directory = Join-Path $env:RUNNER_TEMP 'smm-signing'
New-Item -ItemType Directory -Path $directory -Force | Out-Null
if ($env:SIGNING_CERTIFICATE_BASE64 -and $env:SIGNING_CERTIFICATE_PASSWORD) {
$pfx = Join-Path $directory 'trusted-signing.pfx'
[IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($env:SIGNING_CERTIFICATE_BASE64))
"certificate=$pfx" >> $env:GITHUB_OUTPUT
"password=$env:SIGNING_CERTIFICATE_PASSWORD" >> $env:GITHUB_OUTPUT
"test_certificate=false" >> $env:GITHUB_OUTPUT
} else {
$password = [Convert]::ToBase64String([Security.Cryptography.RandomNumberGenerator]::GetBytes(24))
./build/windows/New-TestSigningCertificate.ps1 -OutputDirectory $directory -Password $password
"certificate=$(Join-Path $directory 'server-monitor-manager-test-signing.pfx')" >> $env:GITHUB_OUTPUT
"public_certificate=$(Join-Path $directory 'server-monitor-manager-test-signing.cer')" >> $env:GITHUB_OUTPUT
"password=$password" >> $env:GITHUB_OUTPUT
"test_certificate=true" >> $env:GITHUB_OUTPUT
}
- name: Build signed MSIX
shell: pwsh
run: ./build/windows/Build-Installer.ps1 -CertificatePath '${{ steps.signing.outputs.certificate }}' -CertificatePassword '${{ steps.signing.outputs.password }}'
- name: Include test certificate
if: steps.signing.outputs.test_certificate == 'true'
shell: pwsh
run: Copy-Item -LiteralPath '${{ steps.signing.outputs.public_certificate }}' -Destination artifacts/windows-installer/ServerMonitorManager-test-signing.cer
- name: Verify checksum
shell: pwsh
run: |
$line = Get-Content artifacts/windows-installer/SHA256SUMS
$expected = ($line -split ' ')[0]
$actual = (Get-FileHash artifacts/windows-installer/ServerMonitorManager-win-x64.msix -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actual -ne $expected) { throw 'Windows installer checksum mismatch.' }
- name: Upload installer artifact
uses: actions/upload-artifact@v6
with:
name: ServerMonitorManager-win-x64
path: |
artifacts/windows-installer/ServerMonitorManager-win-x64.msix
artifacts/windows-installer/ServerMonitorManager-test-signing.cer
artifacts/windows-installer/SHA256SUMS
if-no-files-found: error
- name: Attach installer to GitHub Release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
with:
prerelease: ${{ contains(github.ref_name, '-') }}
files: |
artifacts/windows-installer/ServerMonitorManager-win-x64.msix
artifacts/windows-installer/ServerMonitorManager-test-signing.cer
artifacts/windows-installer/SHA256SUMS

View file

@ -103,6 +103,8 @@ dotnet build ServerMonitorManager.slnx --configuration Release
dotnet test tests/ServerMonitorManager.Control.Tests/ServerMonitorManager.Control.Tests.csproj --configuration Release
```
Windows CI also produces a test-signed x64 MSIX with a public test certificate and `SHA256SUMS`. See [Windows installer documentation](docs/windows-installer.md). Public releases use a trusted PFX when the signing secrets are configured; otherwise the artifact is explicitly test-signed.
In the application, generate or copy the monitoring SSH key, add the Hub profile, mark it as the Mesh Hub, and use **Control Hub** to paste the `SMMDEV1` code. The Mesh view then reads inventory and Links from the authenticated Control API and receives live Link/heartbeat events.
## Security model

View file

@ -0,0 +1,82 @@
param(
[Parameter(Mandatory = $true)]
[string]$CertificatePath,
[Parameter(Mandatory = $true)]
[string]$CertificatePassword,
[string]$OutputDirectory = 'artifacts/windows-installer'
)
$ErrorActionPreference = 'Stop'
$root = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..'))
$project = Join-Path $root 'src\ServerMonitorManager.Desktop\ServerMonitorManager.Desktop.csproj'
$certificate = [System.IO.Path]::GetFullPath($CertificatePath)
$output = [System.IO.Path]::GetFullPath((Join-Path $root $OutputDirectory))
$appPackages = Join-Path $output 'AppPackages'
if (-not (Test-Path -LiteralPath $certificate -PathType Leaf)) {
throw "Signing certificate not found: $certificate"
}
if (Test-Path -LiteralPath $output) {
Remove-Item -LiteralPath $output -Recurse -Force
}
New-Item -ItemType Directory -Path $appPackages -Force | Out-Null
$securePassword = ConvertTo-SecureString -String $CertificatePassword -AsPlainText -Force
$signingCertificate = Import-PfxCertificate `
-FilePath $certificate `
-CertStoreLocation 'Cert:\CurrentUser\My' `
-Password $securePassword `
-Exportable
try {
if ($signingCertificate.Subject -ne 'CN=AppPublisher') {
throw "The certificate subject must match Package.appxmanifest Publisher=CN=AppPublisher; actual: $($signingCertificate.Subject)"
}
dotnet restore $project -r win-x64 -p:Platform=x64
if ($LASTEXITCODE -ne 0) { throw 'dotnet restore failed' }
dotnet publish $project `
--configuration Release `
--runtime win-x64 `
--no-restore `
-p:Platform=x64 `
-p:GenerateAppxPackageOnBuild=true `
-p:AppxPackageSigningEnabled=true `
-p:PackageCertificateThumbprint=$($signingCertificate.Thumbprint) `
-p:AppxBundle=Never `
-p:AppxSymbolPackageEnabled=false `
-p:UapAppxPackageBuildMode=SideloadOnly `
-p:AppxPackageDir="$appPackages\"
if ($LASTEXITCODE -ne 0) { throw 'dotnet publish failed' }
$package = Get-ChildItem -LiteralPath $appPackages -Recurse -File |
Where-Object { $_.Extension -in @('.msix', '.appx', '.msixbundle', '.appxbundle') } |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if ($null -eq $package) {
throw "No MSIX/AppX package was produced under $appPackages"
}
$finalPackage = Join-Path $output 'ServerMonitorManager-win-x64.msix'
Copy-Item -LiteralPath $package.FullName -Destination $finalPackage -Force
$signature = Get-AuthenticodeSignature -LiteralPath $finalPackage
if ($null -eq $signature.SignerCertificate -or
$signature.SignerCertificate.Thumbprint -ne $signingCertificate.Thumbprint) {
throw 'The generated MSIX is not signed by the requested certificate.'
}
$hash = Get-FileHash -LiteralPath $finalPackage -Algorithm SHA256
$checksumPath = Join-Path $output 'SHA256SUMS'
"$($hash.Hash.ToLowerInvariant()) *$([System.IO.Path]::GetFileName($finalPackage))" |
Set-Content -LiteralPath $checksumPath -Encoding ascii
Write-Output "PACKAGE=$finalPackage"
Write-Output "CHECKSUM=$checksumPath"
Write-Output "SIGNER=$($signature.SignerCertificate.Subject)"
}
finally {
Remove-Item -LiteralPath "Cert:\CurrentUser\My\$($signingCertificate.Thumbprint)" -Force -ErrorAction SilentlyContinue
}

View file

@ -0,0 +1,33 @@
param(
[Parameter(Mandatory = $true)]
[string]$PackagePath,
[Parameter(Mandatory = $true)]
[string]$CertificatePath
)
$ErrorActionPreference = 'Stop'
$principal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'Run this test-certificate installer from an elevated PowerShell window (Run as administrator).'
}
$package = [System.IO.Path]::GetFullPath($PackagePath)
$certificate = [System.IO.Path]::GetFullPath($CertificatePath)
if (-not (Test-Path -LiteralPath $package -PathType Leaf)) { throw "Package not found: $package" }
if (-not (Test-Path -LiteralPath $certificate -PathType Leaf)) { throw "Certificate not found: $certificate" }
$trustedRoot = Import-Certificate -FilePath $certificate -CertStoreLocation 'Cert:\LocalMachine\Root'
$trustedPublisher = Import-Certificate -FilePath $certificate -CertStoreLocation 'Cert:\LocalMachine\TrustedPeople'
try {
Add-AppxPackage -Path $package -ForceApplicationShutdown -ForceUpdateFromAnyVersion
$installed = Get-AppxPackage -Name '81AD4B9B-7597-44AB-93A0-D5A695B2D35E'
if ($null -eq $installed) { throw 'Server Monitor Manager package was not installed.' }
Write-Output "INSTALLED=$($installed.PackageFullName)"
}
catch {
Remove-Item -LiteralPath "Cert:\LocalMachine\Root\$($trustedRoot.Thumbprint)" -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath "Cert:\LocalMachine\TrustedPeople\$($trustedPublisher.Thumbprint)" -Force -ErrorAction SilentlyContinue
throw
}
Write-Warning 'This is a test certificate. Remove the package and certificate after testing if they are no longer needed.'

View file

@ -0,0 +1,36 @@
param(
[Parameter(Mandatory = $true)]
[string]$OutputDirectory,
[Parameter(Mandatory = $true)]
[string]$Password
)
$ErrorActionPreference = 'Stop'
$output = [System.IO.Path]::GetFullPath($OutputDirectory)
New-Item -ItemType Directory -Path $output -Force | Out-Null
$securePassword = ConvertTo-SecureString -String $Password -AsPlainText -Force
$certificate = New-SelfSignedCertificate `
-Type Custom `
-Subject 'CN=AppPublisher' `
-FriendlyName 'Server Monitor Manager test signing' `
-CertStoreLocation 'Cert:\CurrentUser\My' `
-KeyAlgorithm RSA `
-KeyLength 3072 `
-HashAlgorithm SHA256 `
-KeyExportPolicy Exportable `
-KeyUsage DigitalSignature `
-TextExtension @('2.5.29.37={text}1.3.6.1.5.5.7.3.3') `
-NotAfter (Get-Date).AddYears(1)
try {
$pfxPath = Join-Path $output 'server-monitor-manager-test-signing.pfx'
$cerPath = Join-Path $output 'server-monitor-manager-test-signing.cer'
Export-PfxCertificate -Cert $certificate -FilePath $pfxPath -Password $securePassword | Out-Null
Export-Certificate -Cert $certificate -FilePath $cerPath | Out-Null
Write-Output $pfxPath
Write-Output $cerPath
}
finally {
Remove-Item -LiteralPath "Cert:\CurrentUser\My\$($certificate.Thumbprint)" -Force
}

32
docs/windows-installer.md Normal file
View file

@ -0,0 +1,32 @@
# Windows installer
Server Monitor Manager uses a packaged MSIX deployment model. The release workflow builds an x64 MSIX, verifies that it has a signing certificate, and publishes `SHA256SUMS` beside it.
## Test-signed builds
When trusted signing secrets are not configured, CI creates a temporary self-signed certificate and publishes its public `.cer` file with the MSIX. This is intended only for testing. Open PowerShell as administrator, then run:
```powershell
.\build\windows\Install-TestPackage.ps1 `
-PackagePath .\ServerMonitorManager-win-x64.msix `
-CertificatePath .\ServerMonitorManager-test-signing.cer
```
The private test key is never uploaded. Each CI run creates a new certificate, so it is not a replacement for a stable publisher certificate.
## Trusted release signing
Configure both repository secrets before creating a public release:
- `WINDOWS_SIGNING_CERTIFICATE_BASE64`: base64-encoded PFX whose subject matches `CN=AppPublisher`;
- `WINDOWS_SIGNING_CERTIFICATE_PASSWORD`: PFX password.
For a publicly trusted installer, the PFX must come from a suitable code-signing provider. When these secrets exist, the workflow uses that PFX and does not publish a test `.cer`.
Verify the downloaded package before installation:
```powershell
$expected = ((Get-Content .\SHA256SUMS) -split ' ')[0]
$actual = (Get-FileHash .\ServerMonitorManager-win-x64.msix -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actual -ne $expected) { throw 'Checksum mismatch' }
```