ci(release): build and verify Windows desktop packages

This commit is contained in:
aj
2026-09-27 18:57:38 -04:00
parent ca1902e075
commit 1b9988a1ba
6 changed files with 253 additions and 1 deletions
+90
View File
@@ -0,0 +1,90 @@
name: Windows release build
on:
push:
branches: ['release/**']
tags: ['v*']
workflow_dispatch:
inputs:
version:
description: 'Release version (for example 0.3.0)'
required: true
type: string
permissions:
contents: read
jobs:
windows:
runs-on: windows-2022
timeout-minutes: 30
defaults:
run:
shell: pwsh
env:
DOTNET_CLI_TELEMETRY_OPTOUT: '1'
DOTNET_NOLOGO: '1'
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
with:
dotnet-version: '8.0.x'
- name: Resolve version
env:
INPUT_VERSION: ${{ inputs.version }}
run: |
$version = $env:INPUT_VERSION
if ($env:GITHUB_EVENT_NAME -eq 'push') {
$version = $env:GITHUB_REF_NAME -replace '^release/', '' -replace '^v', ''
}
if ($version -notmatch '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$') {
throw "Expected an X.Y.Z version, not '$version'."
}
"RELEASE_VERSION=$version" >> $env:GITHUB_ENV
- name: Build solution
run: |
dotnet build OpenNest.sln -c Release
if ($LASTEXITCODE -ne 0) { throw 'Solution build failed.' }
- name: Run all test projects on Windows
run: |
foreach ($project in @('OpenNest.Tests', 'OpenNest.Engine.Tests', 'OpenNest.IO.Tests', 'OpenNest.WinForms.Tests')) {
dotnet test "$project/$project.csproj" -c Release --no-build --logger "trx;LogFileName=$project.trx" --results-directory TestResults
if ($LASTEXITCODE -ne 0) { throw "$project failed." }
}
dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Debug --logger 'trx;LogFileName=OpenNest.Tests.Debug.trx' --results-directory TestResults
if ($LASTEXITCODE -ne 0) { throw 'Debug tests failed.' }
- name: Publish and verify Windows package
run: ./scripts/Publish-Windows.ps1 -Version $env:RELEASE_VERSION
- name: Verify overwrite protection
run: |
$zip = Get-ChildItem artifacts/*.zip
$before = (Get-FileHash $zip.FullName).Hash
$rejected = $false
try { ./scripts/Publish-Windows.ps1 -Version $env:RELEASE_VERSION }
catch {
if ($_.Exception.Message -notlike 'Refusing to overwrite existing output:*') { throw }
$rejected = $true
}
if (-not $rejected) { throw 'Existing output was not rejected.' }
if ((Get-FileHash $zip.FullName).Hash -ne $before) { throw 'Existing ZIP changed.' }
Write-Host 'PASS: existing release package preserved.'
- name: Upload verified release candidate
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: OpenNest-${{ env.RELEASE_VERSION }}-win-x64
path: |
artifacts/*.zip
artifacts/*.sha256
if-no-files-found: error
retention-days: 14
compression-level: 0
- name: Upload test results
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: windows-test-results
path: TestResults/*.trx
if-no-files-found: warn
retention-days: 14
+1
View File
@@ -11,6 +11,7 @@
*.userprefs
# Build results
/artifacts/
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
+4
View File
@@ -22,6 +22,10 @@ Cross-platform CAD import tests: `dotnet test OpenNest.IO.Tests/OpenNest.IO.Test
NuGet dependencies: `ACadSharp` 3.1.32 (DXF/DWG import/export, in OpenNest.IO), `Clipper2` 2.0.0 (region offsetting, in OpenNest.Core), `System.Drawing.Common` 8.0.10, `ModelContextProtocol` + `Microsoft.Extensions.Hosting` (in OpenNest.Mcp), `Microsoft.ML.OnnxRuntime` (in OpenNest.Engine for ML angle prediction), `Microsoft.EntityFrameworkCore.Sqlite` (in OpenNest.Training).
### Windows release packaging
The GitHub `Windows release build` workflow runs on `release/vX.Y.Z` branches, `vX.Y.Z` tags, or manual dispatch. It builds with .NET 8 on `windows-2022`, runs all four test projects in Release plus the main Debug suite, and executes `scripts/Publish-Windows.ps1`. The script creates a self-contained win-x64 desktop ZIP with all three shipped posts, validates its contents/version, launches the extracted app, and writes a SHA-256 checksum. It refuses an existing output directory. Workflow artifacts are release candidates, not automatically published releases; follow [the release procedure](docs/releasing.md). Keep Gitea authoritative for Git refs.
### Fill performance verification
See [fill verification](docs/performance/fill-verification.md) for opt-in measurements, targeted tests, Debug counter isolation, and predictor initialization rules. Keep training bitmaps by default; the angle builder checks predictor availability before scalar-only extraction.
+3 -1
View File
@@ -17,7 +17,9 @@ A Windows desktop application for CNC nesting — imports DXF drawings, arranges
## Requirements
- Windows 10+ for the desktop app; the console, API, and most test projects build on Linux/macOS too.
- [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0)
- [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0) to build from source.
Windows release ZIPs are self-contained: extract the entire archive into a new folder and run `OpenNest.exe`; no separate .NET installation is needed. Use the ZIP and SHA-256 checksum from [GitHub Releases](https://github.com/ajisaacs/OpenNest/releases), not the source-code archives.
## Build, Test, Run
+53
View File
@@ -0,0 +1,53 @@
# Windows releases
Git refs are owned by Gitea (`aj/OpenNest`) and push-mirrored to GitHub
(`ajisaacs/OpenNest`). Create release branches and tags on Gitea, not GitHub.
## Build a candidate
1. Choose a committed source revision; never include uncommitted work implicitly.
2. Push a `release/vX.Y.Z` branch containing the release workflow to Gitea and
verify that the push mirror delivered the same commit to GitHub. The mirror's
GitHub credential needs **Contents: read/write** and **Workflows: read/write**
to introduce or update `.github/workflows` files. Do not change credentials
or broaden permissions without the owner's approval.
3. The `Windows release build` workflow uses a GitHub-hosted `windows-2022`
runner. It builds the solution, runs all four test projects in Release and
the main test project in Debug, then packages and smoke-tests the desktop app.
Optional local/proprietary fixture and opt-in measurement tests may skip;
inspect the uploaded TRX files rather than treating skips as passes.
4. Download the `OpenNest-X.Y.Z-win-x64` artifact and verify its `.sha256`.
It contains `OpenNest.vX.Y.Z.win-x64.zip`, with the .NET runtime, native
dependencies, shipped configurations, all three post-processors, license,
and `build-info.json` identifying the exact source commit.
The workflow has read-only repository permissions and does **not** publish
releases. Once present on the default branch, it can also be dispatched manually
with an `X.Y.Z` version. Running/dispatching via a PAT needs suitable Actions
permissions; public run metadata alone does not prove dispatch access.
For a local Windows build with PowerShell 7 and the .NET 8 SDK:
```powershell
./scripts/Publish-Windows.ps1 -Version X.Y.Z
```
Run the test suites separately before local packaging. The script refuses an
existing output directory; use a fresh `-OutputDirectory` rather than deleting
previous packages. CI also exercises this refusal and verifies the ZIP is unchanged.
## Publish
Review release notes, breaking API changes, and known limitations with the owner.
After the candidate passes, integrate the release tooling into the chosen branch,
create an annotated `vX.Y.Z` tag on the exact release commit, push to Gitea, and
verify the same tag/commit on GitHub. Use the successful **tag build's** artifacts
for the GitHub Release; do not substitute packages built from another commit.
Verify the public asset names, sizes, download/checksum, and release status after
upload. Do not overwrite an existing release/tag or asset silently.
The automated desktop smoke test proves the extracted app opens its main window
and discovers posts. It does not replace interactive CAD/nesting acceptance,
physical CNC/serial verification, GPU execution, or real-model ONNX accuracy.
Packages are unsigned; code signing and a fuller packaged-post execution test
remain follow-up hardening.
+102
View File
@@ -0,0 +1,102 @@
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidatePattern('^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$')]
[string] $Version,
[string] $OutputDirectory = 'artifacts'
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
if (-not $IsWindows) { throw 'Packaging includes a desktop smoke test and requires Windows.' }
$root = Split-Path $PSScriptRoot -Parent
Push-Location $root
try {
$output = [IO.Path]::GetFullPath($OutputDirectory)
if (Test-Path $output) { throw "Refusing to overwrite existing output: $output" }
$commit = (git rev-parse HEAD).Trim()
if ($LASTEXITCODE -ne 0) { throw 'Cannot resolve source commit.' }
New-Item -ItemType Directory -Path $output | Out-Null
$package = Join-Path $output 'OpenNest'
$common = @('-c', 'Release', '-r', 'win-x64', "-p:Version=$Version", '-p:DebugType=None', '-p:DebugSymbols=false')
dotnet publish OpenNest/OpenNest.csproj @common --self-contained true -o $package
if ($LASTEXITCODE -ne 0) { throw 'Desktop publish failed.' }
# Build hooks copy to bin/, not PublishDir. Explicitly package every shipped post.
$posts = Join-Path $package 'Posts'
New-Item -ItemType Directory -Path $posts -Force | Out-Null
foreach ($name in @('Cincinnati', 'CincinnatiCIFiber', 'GravographIS')) {
$project = "OpenNest.Posts.$name"
$staging = Join-Path $output "post-build/$name"
dotnet publish "Posts/$project/$project.csproj" @common --self-contained false -o $staging
if ($LASTEXITCODE -ne 0) { throw "$project publish failed." }
Copy-Item "$staging/$project.dll" $posts
if (Test-Path "$staging/$project.json") { Copy-Item "$staging/$project.json" $posts }
# Gravograph's serial-port runtime is not in the desktop project's dependency graph.
if ($name -eq 'GravographIS') { Copy-Item "$staging/System.IO.Ports.dll" $package }
}
New-Item -ItemType Directory -Path (Join-Path $package 'Schemes') -Force | Out-Null
Copy-Item LICENSE $package
@{
version = $Version
sourceCommit = $commit
runtime = 'win-x64'
selfContained = $true
} | ConvertTo-Json | Set-Content (Join-Path $package 'build-info.json') -Encoding utf8NoBOM
$required = @(
'OpenNest.exe', 'OpenNest.dll', 'OpenNest.Core.dll', 'OpenNest.Engine.dll',
'OpenNest.runtimeconfig.json', 'coreclr.dll', 'System.Windows.Forms.dll',
'onnxruntime.dll', 'System.IO.Ports.dll', 'Configurations/PipeFlangeShape.json',
'Posts/OpenNest.Posts.Cincinnati.dll', 'Posts/OpenNest.Posts.Cincinnati.json',
'Posts/OpenNest.Posts.CincinnatiCIFiber.dll', 'Posts/OpenNest.Posts.CincinnatiCIFiber.json',
'Posts/OpenNest.Posts.GravographIS.dll', 'LICENSE', 'build-info.json'
)
foreach ($file in $required) {
if (-not (Test-Path (Join-Path $package $file) -PathType Leaf)) { throw "Package missing $file" }
}
$assemblyVersion = [Reflection.AssemblyName]::GetAssemblyName((Join-Path $package 'OpenNest.dll')).Version
if ($assemblyVersion.ToString() -ne "$Version.0") { throw "Wrong assembly version: $assemblyVersion" }
$runtime = Get-Content (Join-Path $package 'OpenNest.runtimeconfig.json') -Raw | ConvertFrom-Json
if (-not $runtime.runtimeOptions.includedFrameworks) { throw 'Package is not self-contained.' }
$zipName = "OpenNest.v$Version.win-x64.zip"
$zip = Join-Path $output $zipName
Compress-Archive -Path "$package/*" -DestinationPath $zip
# Exercise the actual ZIP, not the build tree. This checks startup and post discovery,
# not interactive CAD workflows or machine/serial/GPU operation.
$expanded = Join-Path $output 'smoke-test'
Expand-Archive -Path $zip -DestinationPath $expanded
foreach ($file in $required) {
if (-not (Test-Path (Join-Path $expanded $file) -PathType Leaf)) { throw "ZIP missing $file" }
}
$process = Start-Process (Join-Path $expanded 'OpenNest.exe') -WorkingDirectory $expanded -PassThru
try {
if (-not $process.WaitForInputIdle(30000)) { throw 'Desktop did not become idle within 30 seconds.' }
$deadline = [DateTime]::UtcNow.AddSeconds(15)
do {
$process.Refresh()
if ($process.HasExited) { throw "Desktop exited during startup: $($process.ExitCode)" }
if ($process.MainWindowHandle -ne 0 -and $process.MainWindowTitle -eq 'OpenNest') { break }
Start-Sleep -Milliseconds 250
} while ([DateTime]::UtcNow -lt $deadline)
if ($process.MainWindowHandle -eq 0 -or $process.MainWindowTitle -ne 'OpenNest') {
throw "Expected OpenNest main window, found '$($process.MainWindowTitle)'."
}
Write-Host 'PASS: packaged OpenNest main window opened on Windows.'
}
finally {
if (-not $process.HasExited) {
$null = $process.CloseMainWindow()
if (-not $process.WaitForExit(5000)) { $process.Kill(); $process.WaitForExit() }
}
$process.Dispose()
}
$hash = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLowerInvariant()
"$hash $zipName" | Set-Content "$zip.sha256" -Encoding ascii
Write-Host "Verified $zipName ($hash), source $commit"
}
finally { Pop-Location }