From 1fdede396be428d088d1bf4b62b0cd8cb445c545 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 27 Sep 2026 19:11:14 -0400 Subject: [PATCH] ci(release): bundle and verify external nesting engines --- AGENTS.md | 2 +- README.md | 2 +- docs/releasing.md | 8 +++- scripts/Publish-Windows.ps1 | 53 ++++++++++++++++++++++-- scripts/ReleaseSmoke/Program.cs | 40 ++++++++++++++++++ scripts/ReleaseSmoke/ReleaseSmoke.csproj | 8 ++++ scripts/external-engines.json | 9 ++++ 7 files changed, 116 insertions(+), 6 deletions(-) create mode 100644 scripts/ReleaseSmoke/Program.cs create mode 100644 scripts/ReleaseSmoke/ReleaseSmoke.csproj create mode 100644 scripts/external-engines.json diff --git a/AGENTS.md b/AGENTS.md index f01a4de..f7519ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ NuGet dependencies: `ACadSharp` 3.1.32 (DXF/DWG import/export, in OpenNest.IO), ### 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. +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 plus pinned Gpt6Astra/Opus55/Qwen38FlashNext plug-ins from `scripts/external-engines.json`, runs their tests against this host, validates package contents/version and registry discovery (including a missing-DLL failure case), 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 diff --git a/README.md b/README.md index 2b66de0..5d4e31b 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ A Windows desktop application for CNC nesting — imports DXF drawings, arranges - 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) 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. +Windows release ZIPs are self-contained: extract the entire archive into a new folder and run `OpenNest.exe`; no separate .NET installation is needed. The package includes the Gpt6Astra, Opus55, and Qwen38FlashNext engine plug-ins. 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 index e9353cc..a154f9d 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -19,7 +19,13 @@ Git refs are owned by Gitea (`aj/OpenNest`) and push-mirrored to GitHub 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. + and `build-info.json` identifying the exact source commit. Gpt6Astra, Opus55, + and Qwen38FlashNext are bundled in `Engines/` with their MIT license and source + manifest. `scripts/external-engines.json` pins the external repository revision; + no moving branch or prebuilt third-party DLL is used. The script tests and + builds each engine against this host, then exercises actual packaged registry + discovery and an intentionally missing-DLL failure case. Keep the explicit + engine allowlist; never package the template, shared test kit, or test DLLs. The workflow has read-only repository permissions and does **not** publish releases. Once present on the default branch, it can also be dispatched manually diff --git a/scripts/Publish-Windows.ps1 b/scripts/Publish-Windows.ps1 index 8e429a2..4603fb6 100644 --- a/scripts/Publish-Windows.ps1 +++ b/scripts/Publish-Windows.ps1 @@ -7,6 +7,7 @@ param( ) $ErrorActionPreference = 'Stop' +$PSNativeCommandUseErrorActionPreference = $false Set-StrictMode -Version Latest if (-not $IsWindows) { throw 'Packaging includes a desktop smoke test and requires Windows.' } @@ -37,11 +38,41 @@ try { # 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 } } + $engineManifestPath = Join-Path $PSScriptRoot 'external-engines.json' + $engineManifest = Get-Content $engineManifestPath -Raw | ConvertFrom-Json + if ($engineManifest.commit -notmatch '^[0-9a-f]{40}$') { throw 'Engines must be pinned to a full commit SHA.' } + $engineSource = Join-Path $output 'external-engine-source' + git init $engineSource + if ($LASTEXITCODE -ne 0) { throw 'Cannot initialize engine checkout.' } + git -C $engineSource fetch --depth 1 $engineManifest.repository $engineManifest.commit + if ($LASTEXITCODE -ne 0) { throw 'Cannot fetch pinned engines.' } + git -C $engineSource checkout --detach FETCH_HEAD + if ($LASTEXITCODE -ne 0) { throw 'Cannot check out pinned engines.' } + $engineCommit = (git -C $engineSource rev-parse HEAD).Trim() + if ($LASTEXITCODE -ne 0 -or $engineCommit -ne $engineManifest.commit) { throw 'Engine source revision mismatch.' } + $engineDirectory = Join-Path $package 'Engines' + New-Item -ItemType Directory -Path $engineDirectory | Out-Null + # A global MSBuild property cannot be normalized by Directory.Build.props: retain the trailing slash. + $hostRoot = $root.Replace('\', '/') + '/' + foreach ($engine in $engineManifest.engines) { + $project = $engine.project + $projectDirectory = Join-Path $engineSource $project + dotnet test "$projectDirectory/tests/$project.Tests.csproj" -c Release "-p:OpenNestRoot=$hostRoot" "-p:Version=$Version" --logger "trx;LogFileName=$project.trx" --results-directory (Join-Path $root 'TestResults') + if ($LASTEXITCODE -ne 0) { throw "$project tests failed." } + $staging = Join-Path $output "engine-build/$project" + dotnet publish "$projectDirectory/$project.csproj" @common --self-contained false "-p:OpenNestRoot=$hostRoot" -o $staging + if ($LASTEXITCODE -ne 0) { throw "$project publish failed." } + Copy-Item "$staging/$project.dll" $engineDirectory + } + Copy-Item $engineManifestPath (Join-Path $engineDirectory 'manifest.json') + Copy-Item (Join-Path $engineSource 'LICENSE') (Join-Path $engineDirectory 'LICENSE.txt') + New-Item -ItemType Directory -Path (Join-Path $package 'Schemes') -Force | Out-Null Copy-Item LICENSE $package @{ version = $Version sourceCommit = $commit + enginesCommit = $engineCommit runtime = 'win-x64' selfContained = $true } | ConvertTo-Json | Set-Content (Join-Path $package 'build-info.json') -Encoding utf8NoBOM @@ -52,8 +83,10 @@ try { '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' + 'Posts/OpenNest.Posts.GravographIS.dll', 'LICENSE', 'build-info.json', + 'Engines/manifest.json', 'Engines/LICENSE.txt' ) + $required += $engineManifest.engines | ForEach-Object { "Engines/$($_.project).dll" } foreach ($file in $required) { if (-not (Test-Path (Join-Path $package $file) -PathType Leaf)) { throw "Package missing $file" } } @@ -73,6 +106,20 @@ try { foreach ($file in $required) { if (-not (Test-Path (Join-Path $expanded $file) -PathType Leaf)) { throw "ZIP missing $file" } } + $smokeProject = Join-Path $PSScriptRoot 'ReleaseSmoke/ReleaseSmoke.csproj' + dotnet build $smokeProject -c Release + if ($LASTEXITCODE -ne 0) { throw 'Engine discovery checker build failed.' } + # Verify silent plugin-load failure cannot be mistaken for a successful package. + $probe = Join-Path $expanded "Engines/$($engineManifest.engines[0].project).dll" + Move-Item $probe "$probe.disabled" + try { + dotnet run --project $smokeProject -c Release --no-build -- $expanded + if ($LASTEXITCODE -eq 0) { throw 'Missing-engine failure case was not detected.' } + } + finally { Move-Item "$probe.disabled" $probe } + dotnet run --project $smokeProject -c Release --no-build -- $expanded + if ($LASTEXITCODE -ne 0) { throw 'Packaged engine discovery failed.' } + $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.' } @@ -80,10 +127,10 @@ try { do { $process.Refresh() if ($process.HasExited) { throw "Desktop exited during startup: $($process.ExitCode)" } - if ($process.MainWindowHandle -ne 0 -and $process.MainWindowTitle -eq 'OpenNest') { break } + if ($process.MainWindowHandle -ne 0 -and $process.MainWindowTitle -match '^OpenNest(?: - \[.*\])?$') { break } Start-Sleep -Milliseconds 250 } while ([DateTime]::UtcNow -lt $deadline) - if ($process.MainWindowHandle -eq 0 -or $process.MainWindowTitle -ne 'OpenNest') { + if ($process.MainWindowHandle -eq 0 -or $process.MainWindowTitle -notmatch '^OpenNest(?: - \[.*\])?$') { throw "Expected OpenNest main window, found '$($process.MainWindowTitle)'." } Write-Host 'PASS: packaged OpenNest main window opened on Windows.' diff --git a/scripts/ReleaseSmoke/Program.cs b/scripts/ReleaseSmoke/Program.cs new file mode 100644 index 0000000..c024a50 --- /dev/null +++ b/scripts/ReleaseSmoke/Program.cs @@ -0,0 +1,40 @@ +using System.Reflection; +using System.Runtime.Loader; +using System.Text.Json; + +try +{ + if (args.Length != 1) + throw new ArgumentException("Usage: ReleaseSmoke "); + + var package = Path.GetFullPath(args[0]); + // No project references: resolve against the extracted release, never the build tree. + AssemblyLoadContext.Default.Resolving += (_, name) => + { + var path = Path.Combine(package, name.Name + ".dll"); + return File.Exists(path) ? AssemblyLoadContext.Default.LoadFromAssemblyPath(path) : null; + }; + + var engineAssembly = Assembly.LoadFrom(Path.Combine(package, "OpenNest.Engine.dll")); + var registry = engineAssembly.GetType("OpenNest.Engine.Jobs.NestingEngineRegistry", true)!; + var engineDirectory = Path.Combine(package, "Engines"); + registry.GetMethod("LoadPlugins")!.Invoke(null, [engineDirectory]); + using var manifest = JsonDocument.Parse(File.ReadAllText(Path.Combine(engineDirectory, "manifest.json"))); + foreach (var entry in manifest.RootElement.GetProperty("engines").EnumerateArray()) + { + var name = entry.GetProperty("registryName").GetString()!; + var expected = Path.Combine(engineDirectory, entry.GetProperty("project").GetString() + ".dll"); + if (!File.Exists(expected)) + throw new FileNotFoundException("Required engine missing", expected); + var engine = registry.GetMethod("Create")!.Invoke(null, [name])!; + if (!string.Equals(engine.GetType().Assembly.Location, expected, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"Engine {name} was not loaded from the release package."); + Console.WriteLine($"PASS: {name} loaded and instantiated from {expected}"); + } + return 0; +} +catch (Exception exception) +{ + Console.Error.WriteLine(exception); + return 1; +} diff --git a/scripts/ReleaseSmoke/ReleaseSmoke.csproj b/scripts/ReleaseSmoke/ReleaseSmoke.csproj new file mode 100644 index 0000000..64e34a8 --- /dev/null +++ b/scripts/ReleaseSmoke/ReleaseSmoke.csproj @@ -0,0 +1,8 @@ + + + Exe + net8.0 + enable + enable + + diff --git a/scripts/external-engines.json b/scripts/external-engines.json new file mode 100644 index 0000000..7ecdabc --- /dev/null +++ b/scripts/external-engines.json @@ -0,0 +1,9 @@ +{ + "repository": "https://git.thecozycat.net/aj/OpenNest-Engines.git", + "commit": "33cc2ee810a9408a4e720eecd5be30271f87d720", + "engines": [ + { "project": "OpenNest.Engine.Gpt6Astra", "registryName": "Gpt6AstraNestingEngine" }, + { "project": "OpenNest.Engine.Opus55", "registryName": "Opus55NestingEngine" }, + { "project": "OpenNest.Engine.Qwen38FlashNext", "registryName": "Qwen38FlashNextNestingEngine" } + ] +}