feat(release): ship self-updating Velopack packages on GitHub Releases

Publish-Windows.ps1 now packs the smoke-tested folder with the pinned vpk
CLI (.config/dotnet-tools.json, 1.2.0 to match the Velopack package) into
velopack/: Setup.exe, Portable.zip, the full nupkg, releases.win.json and
RELEASES, checked for the full package and the portable layout and hashed
into OpenNest.vX.Y.Z.velopack.sha256. The release workflow uploads them
with the candidate.

With no feed URL set, installed copies now check GitHub Releases (full
releases only) instead of skipping update checks; a URL in Options still
selects a self-hosted feed. Deploy-OpenNest.ps1 uses the pinned vpk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-27 17:52:30 -04:00
1 parent cc16590881
commit 7fe4de4b6d
12 files changed
+108 -39

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"vpk": {
"version": "1.2.0",
"commands": [
"vpk"
],
"rollForward": false
}
}
}
+5
View File
@@ -96,6 +96,11 @@ jobs:
path: |
artifacts/*.zip
artifacts/*.sha256
artifacts/velopack/OpenNest-win-Setup.exe
artifacts/velopack/OpenNest-win-Portable.zip
artifacts/velopack/*-full.nupkg
artifacts/velopack/releases.win.json
artifacts/velopack/RELEASES
if-no-files-found: error
retention-days: 14
compression-level: 0
+1 -1
View File
@@ -73,6 +73,6 @@ Read the relevant contract before changing its behavior:
- [Cut-time estimates](docs/cut-time-estimates.md): simulator/Calculate Cut Time acceleration planner and placeholder machine limits
- [Cincinnati CL](docs/cincinnati-post-output.md) and [CI Fiber](docs/cincinnati-ci-fiber-post-output.md)
- [Fill verification](docs/performance/fill-verification.md): opt-in benchmarks, frozen oracles, Debug-only counters and predictor initialization. Zero Release counters do not prove work removal.
- [Desktop auto-update](docs/auto-update.md): Velopack feed, `UpdateFeedUrl` setting and `Deploy-OpenNest.ps1`.
- [Desktop auto-update](docs/auto-update.md): GitHub Releases by default, `UpdateFeedUrl` for a self-hosted feed, release Velopack assets and `Deploy-OpenNest.ps1`.
- [Undo and Redo](docs/undo-redo.md): whole-nest history, the recording rule, what a restore rebuilds and what is excluded.
- [Desktop help](docs/desktop-help.md): WebView2 help window, F1 topic IDs and the pinned help release both packaging scripts install.
+1 -1
View File
@@ -267,7 +267,7 @@
this.updateGroupBox.Size = new System.Drawing.Size(684, 60);
this.updateGroupBox.TabIndex = 3;
this.updateGroupBox.TabStop = false;
this.updateGroupBox.Text = "Auto-update feed URL (blank disables update checks; set per machine)";
this.updateGroupBox.Text = "Auto-update feed URL (blank uses GitHub Releases; set per machine)";
//
// updateFeedUrlBox
//
+2 -2
View File
@@ -252,14 +252,14 @@ namespace OpenNest.Forms
Settings.Default.UpdateFeedUrl = "";
Settings.Default.Save();
updateFeedUrlBox.Text = "";
MessageBox.Show(this, "Update feed URL cleared. Update checks are disabled.",
MessageBox.Show(this, "Update feed URL cleared. Updates will come from GitHub Releases after a restart.",
"Update Feed URL", MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
if (!NestStorageSettings.TryNormalizeServerUrl(text, out var url))
{
MessageBox.Show(this, "Enter a valid http or https feed URL, or clear the field to disable updates.",
MessageBox.Show(this, "Enter a valid http or https feed URL, or clear the field to update from GitHub Releases.",
"Update Feed URL", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
+2 -2
View File
@@ -296,8 +296,8 @@ namespace OpenNest.Properties {
[global::System.Configuration.UserScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Configuration.DefaultSettingValueAttribute("")]
// Velopack update feed base URL (e.g. http://host:port). Blank disables
// update checks; each machine sets its own value via Options, never committed.
// Velopack update feed base URL (e.g. http://host:port). Blank uses GitHub
// Releases; each machine sets its own value via Options, never committed.
public string UpdateFeedUrl {
get {
return ((string)(this["UpdateFeedUrl"]));
+9 -8
View File
@@ -15,14 +15,15 @@ namespace OpenNest
/// <see cref="ApplyAndRestart"/> once the user agrees to restart.
/// </summary>
/// <remarks>
/// The feed URL is never hard-coded: it is read from the per-machine
/// <see cref="Settings.UpdateFeedUrl"/> setting (Options dialog), which is
/// user-scoped and lives outside source control. A blank URL disables
/// update checks entirely, which is the default for every install.
/// A blank <see cref="Settings.UpdateFeedUrl"/> (the default) checks the
/// public GitHub Releases for full, non-pre-release versions. A URL set in
/// the Options dialog points this machine at a self-hosted feed instead;
/// that URL is operator infrastructure and never lives in source control.
/// </remarks>
public sealed class UpdaterService : IDisposable
{
private static readonly TimeSpan CheckInterval = TimeSpan.FromMinutes(30);
private const string GitHubRepositoryUrl = "https://github.com/ajisaacs/OpenNest";
private readonly UpdateManager _manager;
private Timer _timer;
@@ -36,10 +37,10 @@ namespace OpenNest
public UpdaterService(string feedUrl)
{
if (string.IsNullOrWhiteSpace(feedUrl))
return;
_manager = new UpdateManager(new SimpleWebSource(feedUrl));
IUpdateSource source = string.IsNullOrWhiteSpace(feedUrl)
? new GithubSource(GitHubRepositoryUrl, null, false)
: new SimpleWebSource(feedUrl);
_manager = new UpdateManager(source);
}
/// <summary>True once a downloaded update is waiting for <see cref="ApplyAndRestart"/>.</summary>
+1 -1
View File
@@ -21,7 +21,7 @@ A Windows desktop application for CNC nesting — imports DXF drawings, arranges
- Windows 10+ for the desktop app; the console, API, shared desktop library (`OpenNest.Desktop`) and most test projects build on Linux/macOS too. `OpenNest.Avalonia` is an early cross-platform desktop prototype, not yet a usable replacement.
- [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0) to build from source.
Prebuilt Windows downloads are on [GitHub Releases](https://github.com/ajisaacs/OpenNest/releases), or [build from source](#build-test-run) to run the latest code. Release ZIPs are self-contained: extract the entire archive into a new folder and run `OpenNest.exe`; no separate .NET installation is needed. To update, close OpenNest and extract the new release into a new folder; ZIP releases do not auto-update (optional installer-based updates need a separately hosted [Velopack feed](docs/auto-update.md)). All nesting engines (Default, Rectangles, Irregular, Circles, FlatBar and the fill strategies) are built in; see [nesting engines](docs/nesting-engines.md).
Prebuilt Windows downloads are on [GitHub Releases](https://github.com/ajisaacs/OpenNest/releases), or [build from source](#build-test-run) to run the latest code. Release ZIPs are self-contained: extract the entire archive into a new folder and run `OpenNest.exe`; no separate .NET installation is needed. For automatic updates, install with `OpenNest-win-Setup.exe` or extract `OpenNest-win-Portable.zip` instead: those copies download new full releases from GitHub in the background and restart only when you agree ([auto-update](docs/auto-update.md)). The plain ZIP does not auto-update; to update it, close OpenNest and extract the new release into a new folder. All nesting engines (Default, Rectangles, Irregular, Circles, FlatBar and the fill strategies) are built in; see [nesting engines](docs/nesting-engines.md).
## Build, Test, Run
+34 -16
View File
@@ -1,32 +1,49 @@
# Desktop auto-update
The Windows desktop app (`OpenNest.exe`) can self-update via
[Velopack](https://velopack.io): `OpenNest.UpdaterService` polls a static
file feed every 30 minutes, downloads a newer release in the background, and
[Velopack](https://velopack.io): `OpenNest.UpdaterService` polls its update
source every 30 minutes, downloads a newer release in the background, and
only restarts to apply it after the user confirms (a clickable status-bar
item appears once a download finishes). It never force-restarts; an open,
unsaved nest is never discarded automatically.
## Configuring the feed
## Where updates come from
The feed URL is saved for the current Windows user
(`OpenNest.Properties.Settings.UpdateFeedUrl`), set from **Tools > Options >
Auto-update feed URL**. It is blank by default, which disables update checks
entirely — this is intentional: the feed URL is operator infrastructure, not
something that belongs in source control, so no default ships in the repo.
Enter a feed's base URL (`http://host:port`) and restart the app to pick it
up.
By default an installed copy checks the public
[GitHub Releases](https://github.com/ajisaacs/OpenNest/releases) for full,
non-pre-release versions (`GithubSource`, unauthenticated: GitHub allows 60 API
requests per hour per IP address, ample for a 30-minute poll but shared by every
machine behind one address). Pre-releases are never offered.
Velopack only applies updates to an installed copy (`UpdateManager.IsInstalled`):
one installed with the `Setup.exe` that `Deploy-OpenNest.ps1` produces. The ZIPs on
GitHub Releases and ordinary build output are not Velopack installations and never
auto-update, even with a feed URL configured.
To use a self-hosted feed instead, set **Tools > Options > Auto-update feed URL**
(`OpenNest.Properties.Settings.UpdateFeedUrl`, saved for the current Windows
user) to the feed's base URL (`http://host:port`) and restart. A self-hosted
feed URL is operator infrastructure and never belongs in source control; clearing
the field returns to GitHub Releases.
Velopack only applies updates to a Velopack installation (`UpdateManager.IsInstalled`):
a copy installed with `OpenNest-win-Setup.exe` or extracted from
`OpenNest-win-Portable.zip`. The plain `OpenNest.vX.Y.Z.win-x64.zip` and ordinary
build output contain no `Update.exe` and never auto-update; moving from one of
them to an updating copy is a one-time manual install.
## Release packages
`scripts/Publish-Windows.ps1` packs the same smoke-tested folder as the plain ZIP
with the `vpk` CLI pinned in `.config/dotnet-tools.json` (keep it equal to the
`Velopack` package version), into `velopack/`: `OpenNest-win-Setup.exe`,
`OpenNest-win-Portable.zip`, `OpenNest-X.Y.Z-full.nupkg`, `releases.win.json`
and `RELEASES`, with their hashes in `OpenNest.vX.Y.Z.velopack.sha256`. Installed
copies find an update through the `releases.win.json` and full package attached to
the newest full GitHub Release, so all five must be uploaded with it (see
[releasing](releasing.md#publish)). Packages are full only; delta packages would
need the previous release's packages beside the output before `vpk pack`.
## Hosting a feed
Any static file server that can serve a `vpk pack` output directory works.
`scripts/Deploy-OpenNest.ps1` builds a self-contained `win-x64` publish,
packs it with the `vpk` CLI (`dotnet tool install -g vpk`), and `scp`s the
packs it with the pinned `vpk` CLI, and `scp`s the
result to a directory you provide:
```powershell
@@ -36,4 +53,5 @@ result to a directory you provide:
Bump `<Version>` in `OpenNest/OpenNest.csproj` before each run. This is
independent of [the GitHub release process](releasing.md) and of
`Publish-Windows.ps1` — the latter always overrides `<Version>` via
`-p:Version`, so the two don't conflict.
`-p:Version`, so the two don't conflict. Machines on a self-hosted feed only see
versions pushed to that feed.
+7 -1
View File
@@ -27,7 +27,8 @@ pulls from GitHub every hour; it refuses pushes.
(see [desktop help](desktop-help.md)), and `build-info.json` identifying the exact
source commit and help version. The nesting engines
are built into `OpenNest.Engine.dll`; no external engine repository or plug-in
DLL is packaged. `scripts/ReleaseSmoke` loads the packaged engine assembly and
DLL is packaged. Its `velopack/` folder holds the self-updating packages built
from the same folder; see [desktop auto-update](auto-update.md#release-packages). `scripts/ReleaseSmoke` loads the packaged engine assembly and
checks that every built-in engine instantiates from it, that the renamed
`Opus55NestingEngine` selection resolves to Irregular, and that an unknown
engine name is rejected.
@@ -54,6 +55,11 @@ After the candidate passes, integrate the release tooling into the chosen branch
create an annotated `vX.Y.Z` tag on the exact release commit and push it to
GitHub. Use the successful **tag build's** artifacts
for the GitHub Release; do not substitute packages built from another commit.
Upload the plain ZIP, its `.sha256`, `OpenNest.vX.Y.Z.velopack.sha256` and all five
files in `velopack/` as release assets, keeping their names. Installed copies update
only from the newest full (non-pre-release) Release that carries
`releases.win.json` and its `-full.nupkg`; a full Release without them leaves every
installation on its current version.
Verify the public asset names, sizes, download/checksum, and release status after
upload. Do not overwrite an existing release/tag or asset silently.
+6 -7
View File
@@ -42,12 +42,10 @@ if ($csprojContent -notmatch '<Version>(?<v>[\d.]+)</Version>') { throw "Could n
$Version = $Matches.v
Write-Host "Packaging OpenNest v$Version"
# Ensure the vpk (Velopack) CLI is available
if (-not (Get-Command vpk -ErrorAction SilentlyContinue)) {
Write-Host "Installing vpk CLI (dotnet tool)..."
dotnet tool install -g vpk
if ($LASTEXITCODE -ne 0) { throw "dotnet tool install vpk failed" }
}
# The vpk (Velopack) CLI is pinned in .config/dotnet-tools.json to match the Velopack package
Push-Location $RepoRoot
try { & dotnet tool restore } finally { Pop-Location }
if ($LASTEXITCODE -ne 0) { throw "dotnet tool restore failed" }
$PublishDir = Join-Path $RepoRoot 'OpenNest\bin\publish-tmp'
$ReleasesDir = Join-Path $RepoRoot 'OpenNest\bin\releases'
@@ -61,7 +59,8 @@ $helpVersion = & (Join-Path $ScriptDir 'Install-Help.ps1') -Destination (Join-Pa
Write-Host "Packaged help $helpVersion"
Write-Host "Packing Velopack release v$Version..."
& vpk pack -u OpenNest -v $Version -p $PublishDir -e OpenNest.exe -o $ReleasesDir
Push-Location $RepoRoot
try { & dotnet tool run vpk pack -u OpenNest -v $Version -p $PublishDir -e OpenNest.exe -o $ReleasesDir } finally { Pop-Location }
if ($LASTEXITCODE -ne 0) { throw "vpk pack failed with exit code $LASTEXITCODE" }
Write-Host "Pushing release feed to $FeedHost..."
+27
View File
@@ -114,5 +114,32 @@ try {
$hash = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLowerInvariant()
"$hash $zipName" | Set-Content "$zip.sha256" -Encoding ascii
Write-Host "Verified $zipName ($hash), source $commit"
# Velopack packages of the same verified folder: Setup.exe and the portable ZIP
# self-update from GitHub Releases; the plain ZIP above never does.
dotnet tool restore
if ($LASTEXITCODE -ne 0) { throw 'dotnet tool restore failed.' }
$velopack = Join-Path $output 'velopack'
dotnet tool run vpk pack -u OpenNest -v $Version -p $package -e OpenNest.exe -o $velopack --packTitle OpenNest
if ($LASTEXITCODE -ne 0) { throw 'vpk pack failed.' }
$assets = @('OpenNest-win-Setup.exe', 'OpenNest-win-Portable.zip', "OpenNest-$Version-full.nupkg", 'releases.win.json', 'RELEASES')
foreach ($file in $assets) {
if (-not (Test-Path (Join-Path $velopack $file) -PathType Leaf)) { throw "Velopack output missing $file" }
}
$feed = Get-Content (Join-Path $velopack 'releases.win.json') -Raw | ConvertFrom-Json
if (@($feed.Assets | Where-Object { $_.Version -eq $Version -and $_.Type -eq 'Full' }).Count -ne 1) {
throw "releases.win.json does not list the $Version full package."
}
$portable = Join-Path $output 'velopack-portable'
Expand-Archive -Path (Join-Path $velopack 'OpenNest-win-Portable.zip') -DestinationPath $portable
foreach ($file in @('Update.exe') + ($required | ForEach-Object { "current/$_" })) {
if (-not (Test-Path (Join-Path $portable $file) -PathType Leaf)) { throw "Portable ZIP missing $file" }
}
$sums = foreach ($file in $assets) {
"$((Get-FileHash (Join-Path $velopack $file) -Algorithm SHA256).Hash.ToLowerInvariant()) $file"
}
$sums | Set-Content (Join-Path $output "OpenNest.v$Version.velopack.sha256") -Encoding ascii
Write-Host "Packed Velopack release $Version"
}
finally { Pop-Location }