windows-updater-dotnet is the reusable foundation for a native Windows
file-level updater. It is intentionally not a WinUI integration package and does
not use Squirrel, Velopack, WinSparkle, ClickOnce, MSIX App Installer, or another
ready-made product updater.
src/WindowsUpdater: reusable updater models, SHA-256 hashing, manifest signing, payload metadata, and immutable version-directory state.src/WindowsUpdater.Release: release-side manifest generation, compressed payload metadata, SemVer/build-number release state, Conventional Commits changelog drafts, and S3/CloudFront dry-run planning.src/WindowsUpdater.Launcher: generic launcher shell for an immutable version directory.src/WindowsUpdater.UpdateRunner: generic update-runner shell.src/WindowsUpdater.Cli: .NET tool packaged aswindows-updater-release.tests/WindowsUpdater.Tests: no-external-dependency console tests.
MarlonJD.WindowsUpdaterMarlonJD.WindowsUpdater.ReleaseMarlonJD.WindowsUpdater.Clias thewindows-updater-release.NET tool
Use the packages once they are published:
dotnet add package MarlonJD.WindowsUpdater
dotnet add package MarlonJD.WindowsUpdater.Release
dotnet tool install --global MarlonJD.WindowsUpdater.CliDuring local development, reference the projects directly:
dotnet add <host-app>.csproj reference src/WindowsUpdater/WindowsUpdater.csproj
dotnet add <release-tool>.csproj reference src/WindowsUpdater.Release/WindowsUpdater.Release.csprojThis is the full intended release flow from source changes to a published update.
Every user-visible change should reach the release branch with a Conventional Commit subject:
feat(updater): download changed payload files
fix(runner): restore previous version on launch failure
perf(release): reduce payload compression time
security: rotate updater signing key
docs: update release checklist
The release-note draft includes feat, fix, perf, security, and breaking
changes by default. It excludes docs, test, chore, and non-breaking
refactor commits unless the release owner edits the draft.
Before cutting a release, the host app repository should be clean, on the release branch, and up to date with its remote.
Use SemVer for the user-visible version and a monotonically increasing build number for updater ordering:
releaseId = <semver>+<buildNumber>
example = 1.1.0+110
Version bump rules:
MAJOR: incompatible product or updater contract changes.MINOR: backward-compatible features.PATCH: backward-compatible fixes.- prerelease: beta or release-candidate channels, for example
1.2.0-beta.1.
Clients decide whether a target is newer by comparing build numbers:
target.build > current.build
Keep the local build-number ledger in the host app or release repository:
release/windows-updater-release-state.json
Example:
{
"lastBuildNumber": 109,
"lastStableVersion": "1.0.0",
"lastBetaVersion": "1.1.0-beta.1"
}When releasing 1.1.0, allocate the next build number and commit the updated
state file with the release change:
windows-updater-release allocate \
--state release/windows-updater-release-state.json \
--version 1.1.0{
"lastBuildNumber": 110,
"lastStableVersion": "1.1.0",
"lastBetaVersion": "1.1.0-beta.1"
}This file is the source-controlled record of the latest allocated build number.
The public update channel still comes from latest.json, described below.
Collect commit subjects since the previous release tag:
git log --format=%s v1.0.0+100..HEADPass the subjects to the release tool as a pipe-separated list:
windows-updater-release changelog \
--version 1.1.0 \
--commits "feat(updater): download changed payload files|fix(runner): restore previous version on launch failure|docs: update checklist"Review the generated markdown. The release owner should edit it for clarity and save the final text in the host app release notes, for example:
release/notes/1.1.0+110.md
The changelog is user-facing release context. It is not the update channel pointer by itself.
Build the host app as an unpackaged release. The exact host app project name is
app-specific; this example uses ExampleApp.Windows.csproj.
dotnet publish .\src\ExampleApp.Windows\ExampleApp.Windows.csproj `
-c Release `
-r win-x64 `
--self-contained true `
-p:WindowsPackageType=None `
-p:PublishSingleFile=false `
-o .\artifacts\1.1.0+110\appBuild the stable launcher and update runner:
dotnet publish .\src\WindowsUpdater.Launcher\WindowsUpdater.Launcher.csproj `
-c Release `
-r win-x64 `
--self-contained true `
-p:PublishSingleFile=true `
-o .\artifacts\1.1.0+110\launcher
dotnet publish .\src\WindowsUpdater.UpdateRunner\WindowsUpdater.UpdateRunner.csproj `
-c Release `
-r win-x64 `
--self-contained true `
-p:PublishSingleFile=true `
-o .\artifacts\1.1.0+110\runnerCreate the signed release directory shape:
artifacts\
1.1.0+110\
release\
ExampleApp.exe
ExampleApp.dll
ExampleApp.deps.json
ExampleApp.runtimeconfig.json
WindowsUpdater.Launcher.exe
WindowsUpdater.UpdateRunner.exe
Resources\
update\
Copy the host app publish output into release\, then copy
WindowsUpdater.Launcher.exe and WindowsUpdater.UpdateRunner.exe into the
same directory.
Repeat the publish process for each supported architecture, such as win-x64
and win-arm64.
Sign every PE file after publish and before manifest generation. Do not modify signed files after this step.
$release = ".\artifacts\1.1.0+110\release"
$timestamp = "http://timestamp.digicert.com"
Get-ChildItem $release -Recurse -Include *.exe,*.dll |
ForEach-Object {
signtool sign /fd SHA256 /td SHA256 /tr $timestamp `
/sha1 $env:WINDOWS_CODE_SIGNING_CERT_THUMBPRINT $_.FullName
}
Get-ChildItem $release -Recurse -Include *.exe,*.dll |
ForEach-Object {
signtool verify /pa /tw $_.FullName
}The updater manifest hashes must be generated from the final signed bytes.
For the first release on a channel, generate only the target file manifest:
windows-updater-release generate \
--release-dir ./artifacts/1.0.0+100/release \
--output-dir ./artifacts/1.0.0+100/update \
--product ExampleApp.Windows \
--channel stable \
--architecture win-x64 \
--version 1.0.0 \
--build 100 \
--publisher "CN=Example Publisher" \
--required-files "ExampleApp.exe;ExampleApp.deps.json;ExampleApp.runtimeconfig.json;WindowsUpdater.Launcher.exe;WindowsUpdater.UpdateRunner.exe" \
--key-id updater-key-2026 \
--private-key "$WINDOWS_UPDATER_PRIVATE_KEY"For later releases, pass the previous target manifest so changed-file payloads and delete operations are generated:
windows-updater-release generate \
--release-dir ./artifacts/1.1.0+110/release \
--output-dir ./artifacts/1.1.0+110/update \
--product ExampleApp.Windows \
--channel stable \
--architecture win-x64 \
--version 1.1.0 \
--build 110 \
--publisher "CN=Example Publisher" \
--base-manifest ./artifacts/1.0.0+100/update/target-file-manifest.json \
--required-files "ExampleApp.exe;ExampleApp.deps.json;ExampleApp.runtimeconfig.json;WindowsUpdater.Launcher.exe;WindowsUpdater.UpdateRunner.exe" \
--key-id updater-key-2026 \
--private-key "$WINDOWS_UPDATER_PRIVATE_KEY"The update directory contains:
artifacts/1.1.0+110/update/
target-file-manifest.json
delta-from-100-to-110.json
payloads/
<prefix>/<compressed-payload-sha256>.gz
archives/
<prefix>/<full-archive-sha256>.zip
release.json
Only files with changed hashes become downloadFile payloads. Unchanged files
are represented as copyFromBase, and files removed from the target release are
represented as delete. Every release also has a full .zip fallback archive
containing the complete signed release directory. The archive is used when no
safe delta exists, when delta validation fails before activation, or when policy
decides the full archive is more efficient.
The host release process should write a release metadata file next to the manifest output:
artifacts/1.1.0+110/update/release.json
Example:
{
"product": "ExampleApp.Windows",
"channel": "stable",
"architecture": "win-x64",
"version": "1.1.0",
"build": 110,
"releaseId": "1.1.0+110",
"commit": "<host-app-git-sha>",
"targetManifestPath": "target-file-manifest.json",
"targetManifestSha256": "<sha256>",
"fullArchive": {
"archivePath": "archives/ab/cd/<full-archive-sha256>.zip",
"compression": "zip",
"sha256": "<sha256>",
"size": 1000000
},
"deltas": [
{
"baseBuild": 100,
"targetBuild": 110,
"path": "delta-from-100-to-110.json",
"sha256": "<sha256>",
"size": 25000
}
],
"changelogMarkdown": "## 1.1.0\n\n- updater: download changed payload files",
"publishedAtUtc": null
}The public channel pointer is:
windows/<channel>/latest.json
Example latest.json:
{
"product": "ExampleApp.Windows",
"channel": "stable",
"architecture": "win-x64",
"version": "1.1.0",
"build": 110,
"releaseId": "1.1.0+110",
"releaseManifestUrl": "https://updates.example.com/windows/stable/releases/1.1.0+110/release.json",
"targetManifestUrl": "https://updates.example.com/windows/stable/releases/1.1.0+110/target-file-manifest.json",
"minimumSupportedBuild": 100,
"mandatory": false,
"publishedAtUtc": "2026-05-31T19:00:00Z"
}This file answers "which version is currently published?" for clients. The source-controlled release state answers "which build number was allocated last?" and the git tag answers "which source commit produced this release?"
Create a dry-run plan before uploading release objects:
windows-updater-release dry-run \
--manifest ./artifacts/1.1.0+110/update/target-file-manifest.json \
--bucket windows-updates-prod \
--cloudfront https://updates.example.com \
--platform windows \
--channel stableThe upload order must be:
- compressed payload files under
windows/stable/releases/1.1.0+110/payloads/; - full archive under
windows/stable/releases/1.1.0+110/archives/; target-file-manifest.json;delta-from-100-to-110.json;release.json;windows/stable/latest.json.
Do not upload or overwrite latest.json until every immutable release object is
present and its SHA-256 hash matches local metadata.
Upload immutable objects first:
s3://windows-updates-prod/windows/stable/releases/1.1.0+110/
release.json
target-file-manifest.json
delta-from-100-to-110.json
payloads/<prefix>/<compressed-payload-sha256>.gz
archives/<prefix>/<full-archive-sha256>.zip
Verify each uploaded object by downloading it back or comparing S3 checksums.
Publish the release by uploading the mutable channel pointer last:
s3://windows-updates-prod/windows/stable/latest.json
If upload fails before latest.json, clients should not see the new release.
If latest.json is wrong, fix it by pointing back to the previous known-good
release or by publishing a corrected signed channel pointer.
After publishing:
git tag v1.1.0+110
git push origin v1.1.0+110Record release evidence in the host app repository:
release/evidence/1.1.0+110.md
Include:
- release id, version, build, channel, architecture, and git SHA;
- package paths and SHA-256 hashes;
- signing certificate subject and thumbprint;
- target manifest hash;
- delta manifest hashes;
latest.jsonURL and hash;- smoke test result;
- rollback test result;
- release owner approval.
Monitor update checks, changed bytes downloaded, staged versions, successful launch markers, failures, and rollbacks. Keep the previous version directory available until the new release has passed its retention window.
The host application should install into a per-user root with immutable version directories:
%LocalAppData%\Programs\<AppName>\
WindowsUpdater.Launcher.exe
WindowsUpdater.UpdateRunner.exe
state\
current.json
last-known-good.json
versions\
1.0.0+100\
App.exe
App.dll
App.deps.json
App.runtimeconfig.json
Resources\
1.1.0+110\
...
staging\
downloads\
Shortcuts should point to the stable launcher, not directly to a versioned app executable. Version directories should be treated as immutable after activation.
The host app owns UI prompts, shortcuts, app activation, runtime decisions, and certificate policy. This package owns reusable updater and release mechanics.
Write the initial active version state after install:
using WindowsUpdater;
var installRoot = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Programs",
"ExampleApp");
var state = new CurrentVersionState(
Version: "1.0.0",
Build: 100,
VersionDirectory: "versions/1.0.0+100",
ExecutablePath: "ExampleApp.exe",
ManifestHash: "<target-file-manifest-sha256>",
LastSuccessfulLaunchUtc: DateTimeOffset.UtcNow);
await new CurrentVersionStore(installRoot).WriteAtomicAsync(state);Launch the active version through the generic launcher core:
using WindowsUpdater;
var launcher = new LauncherCore(new CurrentVersionStore(installRoot));
var launch = await launcher.LaunchActiveVersionAsync();
if (!launch.Started)
{
Console.Error.WriteLine(launch.Error);
}Create a signed local update request after the host app has staged and verified the target version:
using WindowsUpdater;
var previous = await new CurrentVersionStore(installRoot).ReadAsync()
?? throw new InvalidOperationException("No active version is configured.");
var target = new CurrentVersionState(
Version: "1.1.0",
Build: 110,
VersionDirectory: "versions/1.1.0+110",
ExecutablePath: "ExampleApp.exe",
ManifestHash: "<target-file-manifest-sha256>");
var request = new LocalUpdateRequest(
InstallRoot: installRoot,
AppProcessId: Environment.ProcessId,
StagedVersionDirectory: Path.Combine(installRoot, "staging", "1.1.0+110"),
TargetState: target,
PreviousState: previous,
LauncherPath: Path.Combine(installRoot, "WindowsUpdater.Launcher.exe"),
SuccessMarkerPath: Path.Combine(installRoot, "state", "startup-success-1.1.0+110"),
LaunchProbeTimeoutSeconds: 30);
var signedRequest = new ManifestSignatureService().Sign(
request,
keyId: "updater-key-2026",
privateKey: Environment.GetEnvironmentVariable("WINDOWS_UPDATER_PRIVATE_KEY")
?? throw new InvalidOperationException("Missing updater signing key."));
await ManifestJson.WriteAsync(
Path.Combine(installRoot, "state", "pending-update-request.json"),
signedRequest);The host app should then start WindowsUpdater.UpdateRunner.exe detached and
exit. The update runner verifies the signed request and atomically switches the
active state. Future revisions should add host-specific staged-version
verification and launch probing before enabling production updates.
The release directory must already contain the signed application files, launcher, update runner, runtime files, resources, and manifests before running the release tool.
Generate a target manifest:
windows-updater-release generate \
--release-dir ./artifacts/v1/release \
--output-dir ./artifacts/v1/update \
--product ExampleApp.Windows \
--channel stable \
--architecture win-x64 \
--version 1.0.0 \
--build 100 \
--publisher "CN=Example Publisher" \
--required-files "ExampleApp.exe;ExampleApp.deps.json;ExampleApp.runtimeconfig.json;WindowsUpdater.Launcher.exe;WindowsUpdater.UpdateRunner.exe" \
--key-id updater-key-2026 \
--private-key "$WINDOWS_UPDATER_PRIVATE_KEY"Generate a target manifest plus changed-file delta from a previous release:
windows-updater-release generate \
--release-dir ./artifacts/v2/release \
--output-dir ./artifacts/v2/update \
--product ExampleApp.Windows \
--channel stable \
--architecture win-x64 \
--version 1.1.0 \
--build 110 \
--publisher "CN=Example Publisher" \
--base-manifest ./artifacts/v1/update/target-file-manifest.json \
--required-files "ExampleApp.exe;ExampleApp.deps.json;ExampleApp.runtimeconfig.json;WindowsUpdater.Launcher.exe;WindowsUpdater.UpdateRunner.exe" \
--key-id updater-key-2026 \
--private-key "$WINDOWS_UPDATER_PRIVATE_KEY"Outputs:
target-file-manifest.jsondelta-from-<base-build>-to-<target-build>.jsonwhen--base-manifestis setpayloads/<prefix>/<compressed-payload-sha256>.gzfor changed filesarchives/<prefix>/<full-archive-sha256>.zipas the complete signed fallbackrelease.json
Generate a release-note draft from Conventional Commit subjects:
windows-updater-release changelog \
--version 1.1.0 \
--commits "feat(updater): stage changed payloads|fix: reject stale builds|docs: update readme"Included by default: feat, fix, perf, security, and breaking changes.
Docs, chores, tests, and refactors are omitted unless they are breaking.
Create a dry-run plan before uploading release objects:
windows-updater-release dry-run \
--manifest ./artifacts/v2/update/target-file-manifest.json \
--bucket windows-updates-prod \
--cloudfront https://updates.example.com \
--platform windows \
--channel stableThe plan orders immutable release objects before the mutable channel pointer:
- compressed payload objects;
- full fallback archive;
- target manifest;
- delta manifests;
- release manifest;
latest.json.
Do not publish latest.json until every referenced immutable object is present
and verified.
Build, run the console tests, and package the libraries/tool:
dotnet build WindowsUpdater.sln -m:1 -p:UseSharedCompilation=false
dotnet run --no-build --project tests/WindowsUpdater.Tests/WindowsUpdater.Tests.csproj
dotnet pack WindowsUpdater.sln -m:1 -p:UseSharedCompilation=false -o artifacts/packagesThe -m:1 flag avoids MSBuild parallelism stalls seen on some macOS/.NET 10
developer machines.
Copyright (C) 2026 Burak Karahan.
This project is licensed under LGPL-3.0-or-later.
This initial package provides reusable foundation pieces only. It does not yet download manifests from a CDN, verify Authenticode signatures, install shortcuts, write uninstall registry entries, or implement host-app UI prompts. Host applications must add those pieces around these primitives before shipping production updates.