From 1b9988a1ba7c4b313bb88dea80de5275cf0f07fc Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 27 Sep 2026 18:57:38 -0400 Subject: [PATCH] ci(release): build and verify Windows desktop packages --- .github/workflows/windows-release.yml | 90 +++++++++++++++++++++++ .gitignore | 1 + AGENTS.md | 4 + README.md | 4 +- docs/releasing.md | 53 +++++++++++++ scripts/Publish-Windows.ps1 | 102 ++++++++++++++++++++++++++ 6 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/windows-release.yml create mode 100644 docs/releasing.md create mode 100644 scripts/Publish-Windows.ps1 diff --git a/.github/workflows/windows-release.yml b/.github/workflows/windows-release.yml new file mode 100644 index 0000000..13642ed --- /dev/null +++ b/.github/workflows/windows-release.yml @@ -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 diff --git a/.gitignore b/.gitignore index 3491a2f..7e7ecf1 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ *.userprefs # Build results +/artifacts/ [Dd]ebug/ [Dd]ebugPublic/ [Rr]elease/ diff --git a/AGENTS.md b/AGENTS.md index 3106f28..f01a4de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md index 8fdcb77..2b66de0 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..e9353cc --- /dev/null +++ b/docs/releasing.md @@ -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. diff --git a/scripts/Publish-Windows.ps1 b/scripts/Publish-Windows.ps1 new file mode 100644 index 0000000..8e429a2 --- /dev/null +++ b/scripts/Publish-Windows.ps1 @@ -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 }