ci(release): build and verify Windows desktop packages
This commit is contained in:
@@ -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
|
||||
@@ -11,6 +11,7 @@
|
||||
*.userprefs
|
||||
|
||||
# Build results
|
||||
/artifacts/
|
||||
[Dd]ebug/
|
||||
[Dd]ebugPublic/
|
||||
[Rr]elease/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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 }
|
||||
Reference in New Issue
Block a user