From 5c383ec9e2f782d3b3d8bb25ca1be09901e0f18b Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Fri, 17 Jul 2026 09:19:09 +0700 Subject: [PATCH] Add signed Windows MSIX release pipeline --- .github/workflows/windows-build.yml | 23 ++++++ .github/workflows/windows-release.yml | 83 ++++++++++++++++++++ README.md | 2 + build/windows/Build-Installer.ps1 | 82 +++++++++++++++++++ build/windows/Install-TestPackage.ps1 | 33 ++++++++ build/windows/New-TestSigningCertificate.ps1 | 36 +++++++++ docs/windows-installer.md | 32 ++++++++ 7 files changed, 291 insertions(+) create mode 100644 .github/workflows/windows-release.yml create mode 100644 build/windows/Build-Installer.ps1 create mode 100644 build/windows/Install-TestPackage.ps1 create mode 100644 build/windows/New-TestSigningCertificate.ps1 create mode 100644 docs/windows-installer.md diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml index 6a79710..d663bd0 100644 --- a/.github/workflows/windows-build.yml +++ b/.github/workflows/windows-build.yml @@ -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 diff --git a/.github/workflows/windows-release.yml b/.github/workflows/windows-release.yml new file mode 100644 index 0000000..9e1dc96 --- /dev/null +++ b/.github/workflows/windows-release.yml @@ -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 diff --git a/README.md b/README.md index f138916..4dde94e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/build/windows/Build-Installer.ps1 b/build/windows/Build-Installer.ps1 new file mode 100644 index 0000000..87eab97 --- /dev/null +++ b/build/windows/Build-Installer.ps1 @@ -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 +} diff --git a/build/windows/Install-TestPackage.ps1 b/build/windows/Install-TestPackage.ps1 new file mode 100644 index 0000000..5b2d641 --- /dev/null +++ b/build/windows/Install-TestPackage.ps1 @@ -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.' diff --git a/build/windows/New-TestSigningCertificate.ps1 b/build/windows/New-TestSigningCertificate.ps1 new file mode 100644 index 0000000..4ecc44e --- /dev/null +++ b/build/windows/New-TestSigningCertificate.ps1 @@ -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 +} diff --git a/docs/windows-installer.md b/docs/windows-installer.md new file mode 100644 index 0000000..a60a900 --- /dev/null +++ b/docs/windows-installer.md @@ -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' } +```