Add signed Windows MSIX release pipeline
This commit is contained in:
parent
a8978c085e
commit
5c383ec9e2
7 changed files with 291 additions and 0 deletions
23
.github/workflows/windows-build.yml
vendored
23
.github/workflows/windows-build.yml
vendored
|
|
@ -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
83
.github/workflows/windows-release.yml
vendored
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
82
build/windows/Build-Installer.ps1
Normal file
82
build/windows/Build-Installer.ps1
Normal 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
|
||||
}
|
||||
33
build/windows/Install-TestPackage.ps1
Normal file
33
build/windows/Install-TestPackage.ps1
Normal 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.'
|
||||
36
build/windows/New-TestSigningCertificate.ps1
Normal file
36
build/windows/New-TestSigningCertificate.ps1
Normal 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
32
docs/windows-installer.md
Normal 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' }
|
||||
```
|
||||
Loading…
Reference in a new issue