<- Previous: DAG Controls | Back to Index | Next: Migration Guide ->
The software update system provides automatic update checking, downloading, and installation for WPF applications using GitHub Releases as the distribution channel. It consists of two components:
- SoftwareUpdate library (
SoftwareUpdatenamespace) -- Checks the GitHub Releases API for new versions, downloads update packages with progress reporting, validates SHA256 checksums, and launches the updater process. - SoftwareUpdate.Updater (
SoftwareUpdate.Updaternamespace) -- A standalone console application that performs the actual file replacement while the main application is closed. It creates backups, extracts the update zip, and restarts the application.
+---------------------------+ +----------------------------+
| Main Application | | GitHub Releases API |
| | HTTPS | |
| GitHubUpdateService +--------->| GET /repos/:owner/:repo/ |
| CheckForUpdateAsync() |<---------+ releases |
| DownloadUpdateAsync() | +----------------------------+
| InstallUpdateAndRestart()
| | |
| | launches |
| v |
| +--------------------+ |
| | Environment.Exit() | |
| +--------------------+ |
+---------------------------+
|
| spawns process
v
+---------------------------+
| SoftwareUpdate.Updater |
| |
| 1. Wait for PID to exit |
| 2. Create backup |
| 3. Extract zip to target |
| 4. Clean old backups |
| 5. Restart application |
+---------------------------+
Reference SoftwareUpdate from your main application project.
var options = new UpdateOptions
{
GitHubOwner = "USACE-RMC",
GitHubRepo = "RMC-BestFit",
CurrentVersion = new SemanticVersion(2, 0, 0),
AssetNamePattern = "RMC-BestFit.*.zip"
};var updateService = new GitHubUpdateService(options);The constructor validates options and configures an HttpClient with the GitHub API v3 accept header, a user agent, and an optional bearer token.
var result = await updateService.CheckForUpdateAsync();
if (result.IsUpdateAvailable && !result.IsSkippedVersion)
{
var update = result.Update;
// Show update dialog with update.Version, update.ReleaseNotes, etc.
}var progress = new Progress<UpdateDownloadProgress>(p =>
{
progressBar.Value = p.ProgressPercentage;
statusText.Text = p.ProgressText;
});
var downloadResult = await updateService.DownloadUpdateAsync(update, progress);
if (downloadResult.Success)
{
// This launches the updater and exits the application
updateService.InstallUpdateAndRestart(downloadResult.FilePath!);
}The SemanticVersion class implements SemVer 2.0 with the format MAJOR.MINOR.PATCH[-PRERELEASE][+BUILD].
// Parse (throws FormatException on failure)
var version = SemanticVersion.Parse("2.1.0-beta.1");
// Safe parse
if (SemanticVersion.TryParse("v2.1.0", out var version))
{
// version.Major == 2, version.Minor == 1, version.Patch == 0
}
// From System.Version
var semver = SemanticVersion.FromVersion(new Version(2, 1, 0));The parser accepts an optional v prefix (e.g., v2.0.0). The patch component defaults to 0 if omitted.
// Full constructor
var v1 = new SemanticVersion(major: 2, minor: 1, patch: 3);
var v2 = new SemanticVersion(2, 0, 0, preRelease: "beta.1");
var v3 = new SemanticVersion(2, 0, 0, preRelease: "rc.1", buildMetadata: "build.456");SemanticVersion implements IComparable<SemanticVersion> and overloads all comparison operators:
var v1 = SemanticVersion.Parse("1.0.0-alpha");
var v2 = SemanticVersion.Parse("1.0.0-beta");
var v3 = SemanticVersion.Parse("1.0.0");
// Pre-release versions have lower precedence than release versions
// v1 < v2 < v3
bool result = v1 < v2; // true
bool result2 = v2 < v3; // true| Property | Type | Description |
|---|---|---|
Major |
int |
Major version number |
Minor |
int |
Minor version number |
Patch |
int |
Patch version number |
PreRelease |
string? |
Pre-release identifier (e.g., "beta.1") |
BuildMetadata |
string? |
Build metadata (ignored in comparisons) |
IsPreRelease |
bool |
true if PreRelease is non-empty |
| Method | Description |
|---|---|
ToString() |
Returns "2.1.0" or "2.1.0-beta.1" or "2.1.0-beta.1+build.123" |
ToTagString() |
Returns "v2.1.0" (with v prefix, matching GitHub tag format) |
For the update service to find your releases, follow these conventions:
Tags must be valid semantic versions, optionally prefixed with v:
v2.0.0
v2.1.0-beta.1
2.0.0
Each release must include a zip file whose name matches the configured AssetNamePattern. The default pattern is *.zip.
Example release structure:
Release: v2.1.0
Tag: v2.1.0
Assets:
- RMC-BestFit.2.1.0.zip (matches "RMC-BestFit.*.zip")
Mark pre-release versions as "pre-release" in GitHub. They are excluded by default unless IncludePreReleases = true. Draft releases are always excluded.
If the UpdateInfo.Sha256Checksum property is populated, the download is validated against it. Set this property via the release metadata if your build pipeline generates checksums.
| Property | Type | Default | Description |
|---|---|---|---|
GitHubOwner |
string? |
null |
Required. GitHub repository owner (e.g., "USACE-RMC"). |
GitHubRepo |
string? |
null |
Required. GitHub repository name (e.g., "RMC-BestFit"). |
CurrentVersion |
SemanticVersion? |
null |
Required. The currently installed application version. |
AssetNamePattern |
string |
"*.zip" |
Glob pattern to match release asset filenames. Supports * and ? wildcards. |
IncludePreReleases |
bool |
false |
Whether to include pre-release versions in update checks. |
GitHubToken |
string? |
null |
Optional GitHub personal access token for private repos or higher rate limits. |
InstallDirectory |
string? |
null |
Installation directory. Defaults to the entry assembly's directory. |
MainExecutableName |
string? |
null |
Executable to restart after update. Defaults to the current process name. |
CreateBackup |
bool |
true |
Whether to create a backup before updating. |
SkippedVersionsFilePath |
string? |
null |
Path for storing skipped versions. Defaults to %LOCALAPPDATA%/{repo}/skipped_versions.txt. |
RequestTimeoutSeconds |
int |
30 |
HTTP request timeout in seconds. |
UpdaterExecutablePath |
string? |
null |
Path to the updater executable. Defaults to SoftwareUpdate.Updater.exe in the install directory. |
These read-only properties compute default values when the corresponding configurable property is not set:
| Property | Fallback Logic |
|---|---|
ResolvedInstallDirectory |
InstallDirectory or entry assembly's directory |
ResolvedMainExecutableName |
MainExecutableName or entry assembly filename |
ResolvedUpdaterPath |
UpdaterExecutablePath or {ResolvedInstallDirectory}/SoftwareUpdate.Updater.exe |
ResolvedSkippedVersionsPath |
SkippedVersionsFilePath or %LOCALAPPDATA%/{GitHubRepo}/skipped_versions.txt |
Call options.Validate() to verify required properties. It throws ArgumentException if GitHubOwner, GitHubRepo, or CurrentVersion is missing, or if owner/repo names contain invalid characters.
public interface IUpdateService
{
UpdateOptions Options { get; }
UpdateState State { get; }
UpdateInfo? AvailableUpdate { get; }
Task<UpdateCheckResult> CheckForUpdateAsync(CancellationToken cancellationToken = default);
Task<UpdateDownloadResult> DownloadUpdateAsync(
UpdateInfo update,
IProgress<UpdateDownloadProgress>? progress = null,
CancellationToken cancellationToken = default);
void InstallUpdateAndRestart(string downloadedFilePath);
void SkipVersion(SemanticVersion? version);
bool IsVersionSkipped(SemanticVersion? version);
void ClearSkippedVersions();
event EventHandler<UpdateCheckResult>? UpdateCheckCompleted;
event EventHandler<Exception>? UpdateError;
}| Value | Description |
|---|---|
Idle |
No check in progress |
Checking |
Currently checking the GitHub API |
UpdateAvailable |
A newer version was found |
Downloading |
Download is in progress |
ReadyToInstall |
Download completed, ready for installation |
Installing |
Updater has been launched |
Error |
An error occurred |
UpToDate |
Current version is the latest |
CheckForUpdateAsync() queries https://api.github.com/repos/{owner}/{repo}/releases, filters out drafts and (optionally) pre-releases, parses tags as semantic versions, finds the first release with a matching asset that is newer than CurrentVersion, and returns an UpdateCheckResult.
The service retries transient HTTP failures up to 3 times with exponential backoff (500ms, 1000ms).
var result = await updateService.CheckForUpdateAsync();
if (result.Success && result.IsUpdateAvailable)
{
UpdateInfo update = result.Update!;
// update.Version, update.ReleaseNotes, update.DownloadSize, etc.
}
else if (!result.Success)
{
Exception error = result.Error!;
}DownloadUpdateAsync() downloads the asset to a temp directory (%TEMP%/SoftwareUpdate/{repo}/) with progress reporting every 100ms. If a SHA256 checksum is provided, it validates the download.
var downloadResult = await updateService.DownloadUpdateAsync(
update,
new Progress<UpdateDownloadProgress>(p =>
{
// p.ProgressPercentage (0-100)
// p.ProgressText ("1.5 MB / 10.0 MB (15%)")
// p.SpeedText ("2.3 MB/s")
// p.BytesDownloaded, p.TotalBytes, p.BytesPerSecond
}),
cancellationToken);
if (downloadResult.Success)
{
string filePath = downloadResult.FilePath!;
}
else if (downloadResult.WasCancelled)
{
// User cancelled
}
else
{
Exception error = downloadResult.Error!;
}InstallUpdateAndRestart() launches the updater process with the required arguments and calls Environment.Exit(0). No code executes after this call.
updateService.InstallUpdateAndRestart(downloadResult.FilePath!);
// Application exits hereAllow users to skip a specific version:
updateService.SkipVersion(update.Version);
// On next check, result.IsSkippedVersion will be true for this version
// Clear all skipped versions
updateService.ClearSkippedVersions();Skipped versions are persisted to ResolvedSkippedVersionsPath.
When CreateBackup is true (the default), the updater creates a backup of the current installation before extracting new files.
- Backup location:
{TargetDirectory}/.backup_{timestamp}/ - Retention: Only the 2 most recent backups are kept. Older backups are deleted during cleanup.
- Recovery: If the extraction or file replacement fails, the updater automatically restores the entire backup directory.
To disable backups, set UpdateOptions.CreateBackup = false. Without backups, a failed update cannot be automatically restored.
Deploy SoftwareUpdate.Updater.exe alongside your main application:
MyApp/
MyApp.exe
SoftwareUpdate.Updater.exe <-- Required for installation
SoftwareUpdate.dll
... other assemblies ...
If the updater is in a different location, set UpdateOptions.UpdaterExecutablePath to its path.
The updater accepts these command-line arguments (built automatically by InstallUpdateAndRestart and UpdaterBootstrapper.LaunchUpdater):
| Argument | Required | Description |
|---|---|---|
--pid <id> |
Yes | Process ID of the main application to wait for |
--zip <path> |
Yes | Path to the downloaded update zip file |
--target <dir> |
Yes | Target installation directory |
--exe <name> |
Yes | Main executable name to restart |
--backup |
No | Flag to create a backup before updating |
For advanced scenarios where you want to control the updater launch independently of GitHubUpdateService, use UpdaterBootstrapper.LaunchUpdater:
UpdaterBootstrapper.LaunchUpdater(
updaterPath: @"C:\MyApp\SoftwareUpdate.Updater.exe",
zipPath: @"C:\Temp\update.zip",
targetDirectory: @"C:\MyApp",
mainExecutable: "MyApp.exe",
createBackup: true,
exitApplication: true // calls Environment.Exit(0)
);Set exitApplication: false if you need to perform cleanup before exiting.
CheckForUpdateAsync catches all exceptions and returns them in UpdateCheckResult.Error. It also raises the UpdateError event.
updateService.UpdateError += (sender, ex) =>
{
logger.Error($"Update error: {ex.Message}");
};
var result = await updateService.CheckForUpdateAsync();
if (!result.Success)
{
if (result.Error is HttpRequestException httpEx)
{
// Network error or GitHub API rate limit
}
}Unauthenticated GitHub API requests are limited to 60 per hour. When the rate limit is exceeded, the service throws HttpRequestException with a descriptive message. To increase the limit to 5,000 per hour, set UpdateOptions.GitHubToken to a personal access token.
Download failures are returned in UpdateDownloadResult:
var downloadResult = await updateService.DownloadUpdateAsync(update, progress, cts.Token);
if (downloadResult.WasCancelled)
{
// Cancellation via CancellationToken
}
else if (!downloadResult.Success)
{
// downloadResult.Error contains the exception
// Checksum validation failures throw InvalidOperationException
}InstallUpdateAndRestart throws immediately if the updater executable or downloaded file is not found:
FileNotFoundException-- Updater executable or zip file does not existArgumentNullException-- Downloaded file path is null or empty
The updater process itself logs all operations to {TargetDirectory}/logs/update_{timestamp}.log and displays errors in the console window. On failure, it attempts to restore from backup (if --backup was specified).
- The updater waits up to 60 seconds for the main application to exit. If the application does not exit in time, the updater proceeds.
- The updater detects single root folders in zip files and extracts their contents directly, avoiding nested directories.
- Zip entries containing path traversal sequences (
..) are skipped with a warning. - The updater console auto-closes after 3 seconds on success. On failure, it waits for a key press.