Repository files navigation

Surge

Surge

Automatic updates for any application. Built in Rust. Ships in 5 minutes.

Why Surge5-Minute SetupCI/CDApp PlaybookHow It WorksFeaturesIntegrationReferenceBuilding


Why Surge

Your users should always be on the latest version. Chrome, VS Code, and Slack do this transparently — the app checks for updates, downloads a small patch, and applies it. The user never thinks about it.

Building that yourself means solving a dozen hard problems: hosting an update server, generating delta patches, handling partial downloads, supporting multiple platforms, managing release channels, coordinating deployments across servers, preserving user data across updates, creating installers, setting up shortcuts. Most teams either skip it entirely or ship a half-baked updater that breaks silently.

Surge gives you Chrome-style automatic updates for any application, on any platform, in about 5 minutes.

  • No update server to run. Releases are stored directly in S3, Azure Blob, GCS, GitHub Releases, or a plain directory. You already have one of these.
  • No framework lock-in. Surge is a native shared library with a stable C ABI. Call it from Rust, C, C++, .NET, Go, Python — anything that can load a .so or .dll.
  • Small downloads. Binary delta patches (bsdiff + zstd) mean users download only what changed between versions. Typically 5-20% of the full package.
  • Release channels. Ship to beta first, then promote the exact same build to stable when you're confident. No rebuild, no re-upload.
  • User data survives updates. Mark config files, databases, and user content as persistent assets — Surge preserves them across every version.
  • Fits your CI pipeline.surge pack and surge push are plain CLI commands. Add them to GitHub Actions, GitLab CI, or Jenkins — works in any matrix build across OS, architecture, and build variants.
  • Cross-platform from day one. Linux, Windows, and macOS. Native shortcuts (.desktop files, .lnk files, .app bundles), platform-correct install directories, and architecture detection built in.

5-Minute Setup

You need two things: somewhere to store your releases and the surge CLI.

If you are wiring Surge into CI, prefer the official release bundle for your platform. It includes the full publishing toolchain (surge, surge-supervisor, surge-installer, surge-installer-ui, and the native runtime) so pack/push jobs do not have to assemble it from multiple crates.

If your artifacts contain Surge.NET.dll but not the native runtime, surge pack will bundle the matching libsurge/surge.dll from the installed Surge toolchain automatically.

If you are publishing preview versions in GitHub Actions and do not want to ship prebuilt release bundles, the fastest CI pattern is:

  1. Check out Surge once per host architecture.
  2. Restore Rust build outputs with Swatinem/rust-cache.
  3. Run ./scripts/stage-toolchain-artifact.sh --output "$RUNNER_TEMP/surge-toolchain".
  4. Upload that directory as a workflow artifact.
  5. Download it in every publish job and prepend it to PATH.

That avoids repeated cargo install misses across the publish matrix and removes the separate libsurge bootstrap step.

1. Initialize your project

surge init --wizard

The wizard walks you through storage provider, app name, and target platform. Or do it non-interactively:

surge init \
--app-id my-app \
--name "My App" \
--provider s3 \
--bucket my-app-releases

The result is a surge.yml manifest:

schema: 1storage:
provider: s3bucket: my-app-releasesregion: us-east-1apps:
- id: my-appname: My Appmain: my-apptarget:
rid: linux-x64

Credentials are never stored in the manifest. Surge reads them from process environment variables (AWS_ACCESS_KEY_ID, GITHUB_TOKEN, etc.), from .env.surge files discovered next to the active manifest (and from project-root .env.surge when using the default .surge/surge.yml layout), from per-app overrides in .env.surge.<app-id>, or from provider-native identity mechanisms such as IAM roles.

Storage credentials with .env.surge

Use .env.surge when you want storage credentials to follow a project, manifest, or installer without exporting them globally in your shell or CI job.

Lookup rules:

  • Process environment variables always win.
  • With the default .surge/surge.yml layout, Surge loads <project>/.env.surge first and .surge/.env.surge second. Later files override earlier ones.
  • With a custom manifest path such as surge --manifest-path ./deploy/prod.yml ..., Surge loads ./deploy/.env.surge.
  • Per-app overrides live beside the shared file as .env.surge.<app-id> and override shared values for that app only.
  • surge install loads overrides from the manifest it actually installs from: .surge/application.yml if present, otherwise the fallback manifest path.
  • surge migrate scopes source and destination manifests separately, so each side can use different backend credentials safely.
  • surge setup reads .env.surge next to the extracted installer.yml.

Supported file syntax:

  • KEY=value
  • export KEY=value
  • blank lines and # comments
  • single-quoted or double-quoted values

Example project layout:

my-app/
├── .env.surge
├── .env.surge.admin-ui
└── .surge/
├── .env.surge
└── surge.yml

Example files:

# my-app/.env.surge
GITHUB_TOKEN=ghp_shared_token
# my-app/.env.surge.admin-ui
GITHUB_TOKEN=ghp_admin_ui_token
# my-app/.surge/.env.surge
GITHUB_TOKEN=ghp_local_override

In that layout:

  • Most commands against .surge/surge.yml see ghp_local_override.
  • Commands for app admin-ui first try .env.surge.admin-ui, then fall back to the shared .env.surge values for that same manifest scope.
  • Running with a different manifest path uses the .env.surge files next to that manifest instead of reusing another manifest's credentials.

2. Pack a release

Point Surge at your build output:

surge pack \
--app-id my-app \
--rid linux-x64 \
--version 1.0.0

By default, surge pack reads artifacts from .surge/artifacts/<app-id>/<rid>/<version>, writes packages to .surge/packages, and writes installers to .surge/installers/<app-id>/<rid>. Use --artifacts-dir/--output-dir to override.

Surge compresses everything into a tar.zst package. If a previous version exists in storage, it also generates a binary delta patch automatically.

If you want to benchmark pack policy on a real payload before publishing, run:

surge tune pack \
--app-id my-app \
--rid linux-x64 \
--version 1.0.0 \
--write-manifest

This benchmarks candidate pack settings on the current artifacts and can write the recommended pack.delta.strategy and pack.compression.level back to surge.yml.

3. Push to storage

surge push \
--app-id my-app \
--rid linux-x64 \
--version 1.0.0 \
--channel stable

Done. Your release is live. Clients on the stable channel will pick it up on their next update check.

Optional: install package (backend or Tailscale)

Install from the backend configured in .surge/application.yml (falls back to .surge/surge.yml):

surge install \
--channel stable

Override backend fields without editing manifest:

surge install backend \
--provider s3 \
--bucket my-release-bucket \
--region eu-north-1 \
--prefix production

Install to a remote node on your tailnet:

surge install tailscale \
--node my-node \
--node-user operator \
--channel stable

This command:

  • probes remote OS/architecture and checks for NVIDIA GPU support,
  • resolves the newest matching release on the selected channel,
  • downloads it locally and sends it with tailscale file cp.

Use --plan-only to preview selection without transfer, --rid to force a specific RID, or --force to reinstall even when the same version/channel is already installed on the target. If your tailnet requires explicit SSH identity, pass --node-user <account> (or set --node <account>@<node> directly).

4. Add update checking to your app

.NET

usingvarmgr=newSurgeUpdateManager();awaitmgr.UpdateToLatestReleaseAsync(onUpdatesAvailable: releases =>Console.WriteLine($"{releases.Count} update(s), latest: {releases.Latest?.Version}"),onAfterApplyUpdate: release =>Console.WriteLine($"Updated to {release.Version}"));

Rust

letmut mgr = UpdateManager::new(ctx,"my-app","1.0.0","stable", install_dir)?;ifletSome(info) = mgr.check_for_updates().await? {
mgr.download_and_apply(&info,None::<fn(_)>).await?;}

C / C++ / anything else

surge_update_manager*mgr=surge_update_manager_create(ctx, "my-app", "1.0.0", "stable", dir);
surge_releases_info*info=NULL;
if (surge_update_check(mgr, &info) ==SURGE_OK) {
surge_update_download_and_apply(mgr, info, progress_cb, NULL);
surge_releases_destroy(info);
}
surge_update_manager_destroy(mgr);
surge_context_destroy(ctx);

CI/CD Integration

Surge is built for automated pipelines. The CLI does all the heavy lifting — your CI just calls surge pack and surge push after each build. GitHub Actions is the most common setup.

Single-platform example

# .github/workflows/release.ymljobs:
build:
runs-on: ubuntu-lateststeps:
- uses: actions/checkout@v6
- run: cargo build --release
- run: surge pack --version ${{ env.VERSION }}
- run: surge push --version ${{ env.VERSION }} --channel stable

Multi-platform matrix

Real applications target multiple OS and architecture combinations. Use a matrix strategy to build each variant in parallel, then pack and push each one:

jobs:
build:
strategy:
matrix:
include:
- os: ubuntu-latestrid: linux-x64
- os: windows-latestrid: win-x64
- os: macos-latestrid: osx-arm64runs-on: ${{ matrix.os }}steps:
- uses: actions/checkout@v6
- run: dotnet publish -c Release -r ${{ matrix.rid }}
- run: surge pack --rid ${{ matrix.rid }} --version ${{ env.VERSION }}
- run: surge push --rid ${{ matrix.rid }} --version ${{ env.VERSION }} --channel stable

Each matrix entry produces its own platform-specific package and delta patch. Clients only download the package matching their OS and architecture.

Staged rollouts

Combine matrix builds with channel promotion for safe deployments:

jobs:
deploy-beta:
needs: [build]runs-on: ubuntu-latestif: github.ref == 'refs/heads/develop'steps:
- run: surge push --version ${{ env.VERSION }} --channel betapromote-stable:
needs: [build]runs-on: ubuntu-latestif: github.ref == 'refs/heads/main'steps:
- run: surge promote --version ${{ env.VERSION }} --from beta --to stable

Push to develop ships to beta testers. Merge to main promotes the exact same build to stable — no rebuild, no re-upload, no risk of a different binary reaching production.

Distributed lock for safe concurrent pushes

When multiple matrix jobs push to the same storage backend, use the distributed lock to prevent race conditions on the release index:

steps:
- run: surge lock acquire --name "${{ matrix.rid }}-deploy"
- run: surge push --version ${{ env.VERSION }} --rid ${{ matrix.rid }} --channel stable
- run: surge lock release --name "${{ matrix.rid }}-deploy"

How It Works

 You (developer) Your Users
────────────── ──────────
cargo build / dotnet publish
│
▼
surge pack ──► tar.zst full package
+ bsdiff delta patch
│
▼
surge push ──► S3 / Azure / GCS / GitHub Releases / filesystem
│
│ release index (compressed YAML)
│ + package files
│
▼
┌──────────────┐
│ Cloud Storage │
└──────┬───────┘
│
┌───────────┼───────────┐
▼ ▼ ▼
Linux Windows macOS
app app app
│ │ │
└───────────┴───────────┘
│
check_for_updates()
download_and_apply()
│
▼
Update applied.
User never noticed.

The update pipeline

When a client calls download_and_apply, Surge runs a 6-phase pipeline:

  1. Check — validate update info and prepare staging directory
  2. Download — fetch delta patch (or full package as fallback) from storage
  3. Verify — SHA-256 hash check of every downloaded file
  4. Extract — decompress the tar.zst archive
  5. Apply delta — apply bsdiff patches if using delta updates
  6. Finalize — atomic move into place, clean up staging, preserve persistent assets

Progress callbacks fire at each phase with percentage, bytes transferred, and speed.

Features

Release channels

Channels are labels on releases. A single version can be on multiple channels simultaneously.

# Ship to beta testers first
surge push --version 2.1.0 --channel beta
# A week later, promote the exact same build to stable (no re-upload)
surge promote --version 2.1.0 --from beta --to stable
# Something wrong? Pull it back
surge demote --version 2.1.0 --channel stable

Clients specify which channel they follow. Switching channels at runtime is a single API call — useful for opt-in beta programs.

Persistent assets

Files and directories that should survive across updates:

apps:
- id: my-apppersistentAssets:
- config.json
- user-data/
- settings.ini

During updates, Surge copies these from the old version directory to the new one before removing the old version.

Platform-native shortcuts

apps:
- id: my-appicon: icon.pngshortcuts:
- desktop
- start_menu
- startup

Surge creates real platform shortcuts:

  • Linux.desktop files in ~/.local/share/applications and ~/.config/autostart (XDG freedesktop spec)
  • Windows.lnk shortcuts on Desktop, Start Menu, and Startup via WScript.Shell
  • macOS.app bundles with Info.plist in ~/Applications, LaunchAgent for startup

Process supervisor

The supervisor binary monitors your application, restarts on crash, and coordinates version handoffs:

surge-supervisor --supervisor-id <uuid> --install-dir /opt/my-app --exe-path /opt/my-app/my-app

Or from code:

SurgeApp.StartSupervisor();

It handles graceful shutdown on SIGTERM/SIGINT (Unix) and Ctrl+C (Windows).

Lifecycle events

Hook into first-run, post-install, and post-update events:

if(SurgeApp.ProcessEvents(args,onFirstRun: v =>ShowWelcomeScreen(),onInstalled: v =>RunMigrations(),onUpdated: v =>ShowChangelogFor(v))){return;}

Installer generation

Surge can produce installer bundles in two modes:

target:
rid: win-x64installers:
- online # Small bootstrap, downloads app on first run
- offline # Self-contained, includes full package

Resource budgets

Throttle resource usage for constrained environments:

varbudget=newSurgeResourceBudget{MaxMemoryBytes=256*1024*1024,// 256 MBMaxConcurrentDownloads=2,MaxDownloadSpeedBps=1_000_000,// 1 MB/sZstdCompressionLevel=6// faster compression};

Distributed locking

For server-side deployments where multiple CI runners might push releases concurrently, Surge provides a distributed mutex via snapx.dev:

surge lock acquire --name "my-app-deploy" --timeout 300
# ... push release ...
surge lock release --name "my-app-deploy"

Backend migration

Move all your releases from one storage provider to another without downtime:

surge migrate --dest-manifest new-backend.yml

Storage Backends

Use whatever you already have.

ProviderConfig valueNotes
Amazon S3s3Any S3-compatible API (MinIO, Cloudflare R2, DigitalOcean Spaces). Auth via AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY or IAM roles
Azure Blob Storageazure_blobAuth via AZURE_STORAGE_ACCOUNT_NAME/AZURE_STORAGE_ACCOUNT_KEY
Google Cloud StoragegcsAuth via GOOGLE_APPLICATION_CREDENTIALS or application default credentials
GitHub Releasesgithub_releasesFree for public repos. bucket = owner/repo. Auth via GITHUB_TOKEN
Local filesystemfilesystemFor testing or air-gapped environments. bucket = root directory path

Integration

For a production app integration checklist, smoke expectations, and guidance for both humans and agents, see docs/integrating-surge.md.

Surge is a native shared library (libsurge.so / surge.dll / libsurge.dylib) with a C ABI. You don't need Rust in your project.

.NET

The Surge.NET NuGet package provides the full API:

  • netstandard2.0[DllImport] for .NET Framework 4.6.1+, .NET Core, Mono, Xamarin
  • net10.0[LibraryImport] with full AOT and trimming support
  • Zero external managed dependencies; ship the matching native Surge library from the same version and runtime identifier with the application
  • SurgeUpdateManager.UpdateToLatestReleaseAsync() — one call that checks, downloads, verifies, extracts, and applies
  • Per-phase progress callbacks, cancellation tokens, pre/post-update hooks

C / C++

Include surge_api.h and link against the shared library. The API uses opaque handles, surge_result return codes, explicit ownership rules, and thread-safe cancellation.

Rust

Use surge-core as a Cargo dependency for direct access to the async API without the FFI overhead.

Reference

CLI commands

surge init Create a surge.yml manifest (--wizard for interactive)
surge pack Build full and delta packages from artifacts
surge tune Benchmark pack policy candidates
surge push Upload packages and update the release index
surge list List releases on a channel
surge promote Promote a release to another channel
surge demote Remove a release from a channel
surge migrate Copy releases between storage backends
surge restore Restore artifacts from backup
surge install Install package via method (backend, tailscale)
surge lock Acquire/release distributed locks

If the manifest has one app, --app-id is optional. If the app has one target, --rid is optional. surge list now defaults to a status overview table. For multi-app manifests it shows one row per app/rid by default; use --app-id (and optionally --rid) to scope down.

surge restore also supports installer-only generation (snapx-style restore -i) from existing full packages:

surge restore -i

By default this resolves the latest release for the manifest app/target on the app's default channel, restores missing full packages from storage into .surge/packages, and builds installers using artifacts from .surge/artifacts/<app-id>/<rid>/<version>. The generated installers are written to .surge/installers/<app-id>/<rid>. Use --channel <name> to rebuild installers for a non-default channel after a promotion flow.

Explicit override example:

surge restore -i \
--channel production \
--version 1.2.3 \
--artifacts-dir ./publish \
--packages-dir .surge/packages

C API function groups

GroupFunctions
Lifecyclesurge_context_create, surge_context_destroy, surge_context_last_error
Configurationsurge_config_set_storage, surge_config_set_lock_server, surge_config_set_resource_budget
Update Managersurge_update_manager_create, surge_update_manager_destroy, surge_update_manager_set_channel, surge_update_manager_set_current_version, surge_update_manager_set_release_retention_limit, surge_update_manager_set_artifact_retention_policy, surge_update_check, surge_update_download_and_apply, surge_update_status_read_json, surge_free_cstring
Release Infosurge_releases_count, surge_releases_destroy, surge_release_version, surge_release_channel, surge_release_full_size, surge_release_is_genesis
Binary Diffsurge_bsdiff, surge_bspatch, surge_bsdiff_free, surge_bspatch_free
Pack Buildersurge_pack_create, surge_pack_build, surge_pack_push, surge_pack_destroy
Distributed Locksurge_lock_acquire, surge_lock_release
Supervisorsurge_supervisor_start, surge_supervisor_stop
Eventssurge_process_events
Cancellationsurge_cancel, surge_reset_cancel

Manifest reference

schema: 1storage:
provider: s3# s3 | azure_blob | gcs | github_releases | filesystembucket: my-bucket # bucket, container, owner/repo, or directoryregion: us-east-1 # cloud region (or release tag for github_releases)endpoint: ""# custom endpoint (MinIO, R2, etc.)prefix: ""# path prefix within bucketlock:
url: https://snapx.dev # distributed lock server (optional)pack: # optional; omitted uses built-in defaultsdelta:
strategy: sparse-file-opsmax_chain_length: 8chunked_patch_format: 1# 1 = readable by every client (default); 2 = identity-chunk bitset, needs clients that know format 2compression:
format: zstdlevel: 3retention:
keep_latest_fulls: 2checkpoint_every: 10cache: # optional device-side artifact cache policyinstallArtifacts:
retention: latest_full # release_graph | latest_full | just_installed | nonekeepFullCount: 1# full archives retained when retention is latest_fullapps:
- id: my-app # unique identifiername: My App # display namemain: my-app # main executable (defaults to id)installDirectory: my-app # install dir name (defaults to id)icon: icon.png # application iconchannels: [stable, beta] # supported channelsshortcuts: [desktop, start_menu, startup]persistentAssets: [config.json, user-data/]installers: [online, offline]environment:
MY_VAR: valuetarget:
rid: linux-x64 # linux-x64, win-x64, win-arm64, osx-x64, osx-arm64

Target-level settings override app-level defaults for icon, shortcuts, persistentAssets, installers, and environment. pack policy is global and controls delta strategy, compression, and remote full fallback retention for surge pack/surge push. The generated surge init policy optimizes managed fleets for fast latest-following updates: a node on N-1 should normally apply the direct N-1 -> N delta, while checkpoint fulls remain fallback baselines for recovery and stale installs. cache.installArtifacts controls package artifacts kept under .surge-cache/artifacts/ after setup and successful updates:

  • latest_full is the recommended managed-fleet setting. It keeps the newest keepFullCount full archives per RID and drops deltas, so normal updates stay delta-based while each device keeps a compact reinstall/restore cushion.
  • release_graph keeps the local release graph plus warm full checkpoints. Use it when offline restore to older versions matters; it uses the most disk.
  • just_installed keeps only the installed full archive when that archive is already cached. Use it when full-update reinstall warmth is useful but disk should stay tight. Delta-only updates do not synthesize a new full archive just to warm this cache.
  • none keeps no package artifacts after a successful update. Use it when optimizing for minimum disk usage and accepting that restore/reinstall downloads from storage again.

Use surge compact after rollout convergence, or for deliberate recovery/cleanup, when you want to prune old remote artifacts. Avoid making compaction the immediate default rollout step for a fleet that is still catching up.

Architecture

┌──────────────────────────────────────────────────────────┐
│ Your Application │
│ (.NET / C / C++ / any FFI) │
└─────────────────────────┬────────────────────────────────┘
│ P/Invoke or C calls
┌─────────────────────────▼────────────────────────────────┐
│ surge-ffi (cdylib) │
│ C ABI · surge_api.h │
└─────────────────────────┬────────────────────────────────┘
│
┌─────────────────────────▼────────────────────────────────┐
│ surge-core │
│ config · crypto · storage · archive · diff · releases │
│ update · pack · supervisor · platform · download │
└──────────────────────────────────────────────────────────┘
CrateDescription
surge-coreCore library — config, crypto, storage backends, archive (tar+zstd), bsdiff, release index, update manager, pack builder, supervisor, platform detection
surge-ffiC API shared library exporting the interface declared in surge_api.h
surge-cliCommand-line tool for packing, pushing, and managing releases
surge-supervisorStandalone process supervisor binary

Building from Source

git clone --recurse-submodules https://github.com/fintermobilityas/surge.git
cd surge

If you already cloned without --recurse-submodules:

git submodule update --init

Requirements

  • Rust 1.95+ (Edition 2024) — install via rustup
  • .NET 10 SDK (optional, for the .NET wrapper and demo app)

Build and test

cargo build --release
cargo test
cargo clippy --all-targets --all-features -- -D warnings
cargo fmt --all
cd dotnet
dotnet build --configuration Release
dotnet test --configuration Release

Release artifact trust

Official archives are covered by SHA256SUMS.txt, including the public C header. The Windows and macOS binaries are currently unsigned, and the macOS binaries are not notarized. Verify the published checksums before use and apply the signing/notarization required by your product distribution before shipping to end users.

License

MIT © 2026 Finter As

About

Automatic updates for any application. Built in Rust. Ships in 5 minutes.

Resources

Stars

10 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

Surge

Surge

Automatic updates for any application. Built in Rust. Ships in 5 minutes.

Why Surge5-Minute SetupCI/CDApp PlaybookHow It WorksFeaturesIntegrationReferenceBuilding


Why Surge

Your users should always be on the latest version. Chrome, VS Code, and Slack do this transparently — the app checks for updates, downloads a small patch, and applies it. The user never thinks about it.

Building that yourself means solving a dozen hard problems: hosting an update server, generating delta patches, handling partial downloads, supporting multiple platforms, managing release channels, coordinating deployments across servers, preserving user data across updates, creating installers, setting up shortcuts. Most teams either skip it entirely or ship a half-baked updater that breaks silently.

Surge gives you Chrome-style automatic updates for any application, on any platform, in about 5 minutes.

  • No update server to run. Releases are stored directly in S3, Azure Blob, GCS, GitHub Releases, or a plain directory. You already have one of these.
  • No framework lock-in. Surge is a native shared library with a stable C ABI. Call it from Rust, C, C++, .NET, Go, Python — anything that can load a .so or .dll.
  • Small downloads. Binary delta patches (bsdiff + zstd) mean users download only what changed between versions. Typically 5-20% of the full package.
  • Release channels. Ship to beta first, then promote the exact same build to stable when you're confident. No rebuild, no re-upload.
  • User data survives updates. Mark config files, databases, and user content as persistent assets — Surge preserves them across every version.
  • Fits your CI pipeline.surge pack and surge push are plain CLI commands. Add them to GitHub Actions, GitLab CI, or Jenkins — works in any matrix build across OS, architecture, and build variants.
  • Cross-platform from day one. Linux, Windows, and macOS. Native shortcuts (.desktop files, .lnk files, .app bundles), platform-correct install directories, and architecture detection built in.

5-Minute Setup

You need two things: somewhere to store your releases and the surge CLI.

If you are wiring Surge into CI, prefer the official release bundle for your platform. It includes the full publishing toolchain (surge, surge-supervisor, surge-installer, surge-installer-ui, and the native runtime) so pack/push jobs do not have to assemble it from multiple crates.

If your artifacts contain Surge.NET.dll but not the native runtime, surge pack will bundle the matching libsurge/surge.dll from the installed Surge toolchain automatically.

If you are publishing preview versions in GitHub Actions and do not want to ship prebuilt release bundles, the fastest CI pattern is:

  1. Check out Surge once per host architecture.
  2. Restore Rust build outputs with Swatinem/rust-cache.
  3. Run ./scripts/stage-toolchain-artifact.sh --output "$RUNNER_TEMP/surge-toolchain".
  4. Upload that directory as a workflow artifact.
  5. Download it in every publish job and prepend it to PATH.

That avoids repeated cargo install misses across the publish matrix and removes the separate libsurge bootstrap step.

1. Initialize your project

surge init --wizard

The wizard walks you through storage provider, app name, and target platform. Or do it non-interactively:

surge init \
--app-id my-app \
--name "My App" \
--provider s3 \
--bucket my-app-releases

The result is a surge.yml manifest:

schema: 1storage:
provider: s3bucket: my-app-releasesregion: us-east-1apps:
- id: my-appname: My Appmain: my-apptarget:
rid: linux-x64

Credentials are never stored in the manifest. Surge reads them from process environment variables (AWS_ACCESS_KEY_ID, GITHUB_TOKEN, etc.), from .env.surge files discovered next to the active manifest (and from project-root .env.surge when using the default .surge/surge.yml layout), from per-app overrides in .env.surge.<app-id>, or from provider-native identity mechanisms such as IAM roles.

Storage credentials with .env.surge

Use .env.surge when you want storage credentials to follow a project, manifest, or installer without exporting them globally in your shell or CI job.

Lookup rules:

  • Process environment variables always win.
  • With the default .surge/surge.yml layout, Surge loads <project>/.env.surge first and .surge/.env.surge second. Later files override earlier ones.
  • With a custom manifest path such as surge --manifest-path ./deploy/prod.yml ..., Surge loads ./deploy/.env.surge.
  • Per-app overrides live beside the shared file as .env.surge.<app-id> and override shared values for that app only.
  • surge install loads overrides from the manifest it actually installs from: .surge/application.yml if present, otherwise the fallback manifest path.
  • surge migrate scopes source and destination manifests separately, so each side can use different backend credentials safely.
  • surge setup reads .env.surge next to the extracted installer.yml.

Supported file syntax:

  • KEY=value
  • export KEY=value
  • blank lines and # comments
  • single-quoted or double-quoted values

Example project layout:

my-app/
├── .env.surge
├── .env.surge.admin-ui
└── .surge/
├── .env.surge
└── surge.yml

Example files:

# my-app/.env.surge
GITHUB_TOKEN=ghp_shared_token
# my-app/.env.surge.admin-ui
GITHUB_TOKEN=ghp_admin_ui_token
# my-app/.surge/.env.surge
GITHUB_TOKEN=ghp_local_override

In that layout:

  • Most commands against .surge/surge.yml see ghp_local_override.
  • Commands for app admin-ui first try .env.surge.admin-ui, then fall back to the shared .env.surge values for that same manifest scope.
  • Running with a different manifest path uses the .env.surge files next to that manifest instead of reusing another manifest's credentials.

2. Pack a release

Point Surge at your build output:

surge pack \
--app-id my-app \
--rid linux-x64 \
--version 1.0.0

By default, surge pack reads artifacts from .surge/artifacts/<app-id>/<rid>/<version>, writes packages to .surge/packages, and writes installers to .surge/installers/<app-id>/<rid>. Use --artifacts-dir/--output-dir to override.

Surge compresses everything into a tar.zst package. If a previous version exists in storage, it also generates a binary delta patch automatically.

If you want to benchmark pack policy on a real payload before publishing, run:

surge tune pack \
--app-id my-app \
--rid linux-x64 \
--version 1.0.0 \
--write-manifest

This benchmarks candidate pack settings on the current artifacts and can write the recommended pack.delta.strategy and pack.compression.level back to surge.yml.

3. Push to storage

surge push \
--app-id my-app \
--rid linux-x64 \
--version 1.0.0 \
--channel stable

Done. Your release is live. Clients on the stable channel will pick it up on their next update check.

Optional: install package (backend or Tailscale)

Install from the backend configured in .surge/application.yml (falls back to .surge/surge.yml):

surge install \
--channel stable

Override backend fields without editing manifest:

surge install backend \
--provider s3 \
--bucket my-release-bucket \
--region eu-north-1 \
--prefix production

Install to a remote node on your tailnet:

surge install tailscale \
--node my-node \
--node-user operator \
--channel stable

This command:

  • probes remote OS/architecture and checks for NVIDIA GPU support,
  • resolves the newest matching release on the selected channel,
  • downloads it locally and sends it with tailscale file cp.

Use --plan-only to preview selection without transfer, --rid to force a specific RID, or --force to reinstall even when the same version/channel is already installed on the target. If your tailnet requires explicit SSH identity, pass --node-user <account> (or set --node <account>@<node> directly).

4. Add update checking to your app

.NET

usingvarmgr=newSurgeUpdateManager();awaitmgr.UpdateToLatestReleaseAsync(onUpdatesAvailable: releases =>Console.WriteLine($"{releases.Count} update(s), latest: {releases.Latest?.Version}"),onAfterApplyUpdate: release =>Console.WriteLine($"Updated to {release.Version}"));

Rust

letmut mgr = UpdateManager::new(ctx,"my-app","1.0.0","stable", install_dir)?;ifletSome(info) = mgr.check_for_updates().await? {
mgr.download_and_apply(&info,None::<fn(_)>).await?;}

C / C++ / anything else

surge_update_manager*mgr=surge_update_manager_create(ctx, "my-app", "1.0.0", "stable", dir);
surge_releases_info*info=NULL;
if (surge_update_check(mgr, &info) ==SURGE_OK) {
surge_update_download_and_apply(mgr, info, progress_cb, NULL);
surge_releases_destroy(info);
}
surge_update_manager_destroy(mgr);
surge_context_destroy(ctx);

CI/CD Integration

Surge is built for automated pipelines. The CLI does all the heavy lifting — your CI just calls surge pack and surge push after each build. GitHub Actions is the most common setup.

Single-platform example

# .github/workflows/release.ymljobs:
build:
runs-on: ubuntu-lateststeps:
- uses: actions/checkout@v6
- run: cargo build --release
- run: surge pack --version ${{ env.VERSION }}
- run: surge push --version ${{ env.VERSION }} --channel stable

Multi-platform matrix

Real applications target multiple OS and architecture combinations. Use a matrix strategy to build each variant in parallel, then pack and push each one:

jobs:
build:
strategy:
matrix:
include:
- os: ubuntu-latestrid: linux-x64
- os: windows-latestrid: win-x64
- os: macos-latestrid: osx-arm64runs-on: ${{ matrix.os }}steps:
- uses: actions/checkout@v6
- run: dotnet publish -c Release -r ${{ matrix.rid }}
- run: surge pack --rid ${{ matrix.rid }} --version ${{ env.VERSION }}
- run: surge push --rid ${{ matrix.rid }} --version ${{ env.VERSION }} --channel stable

Each matrix entry produces its own platform-specific package and delta patch. Clients only download the package matching their OS and architecture.

Staged rollouts

Combine matrix builds with channel promotion for safe deployments:

jobs:
deploy-beta:
needs: [build]runs-on: ubuntu-latestif: github.ref == 'refs/heads/develop'steps:
- run: surge push --version ${{ env.VERSION }} --channel betapromote-stable:
needs: [build]runs-on: ubuntu-latestif: github.ref == 'refs/heads/main'steps:
- run: surge promote --version ${{ env.VERSION }} --from beta --to stable

Push to develop ships to beta testers. Merge to main promotes the exact same build to stable — no rebuild, no re-upload, no risk of a different binary reaching production.

Distributed lock for safe concurrent pushes

When multiple matrix jobs push to the same storage backend, use the distributed lock to prevent race conditions on the release index:

steps:
- run: surge lock acquire --name "${{ matrix.rid }}-deploy"
- run: surge push --version ${{ env.VERSION }} --rid ${{ matrix.rid }} --channel stable
- run: surge lock release --name "${{ matrix.rid }}-deploy"

How It Works

 You (developer) Your Users
────────────── ──────────
cargo build / dotnet publish
│
▼
surge pack ──► tar.zst full package
+ bsdiff delta patch
│
▼
surge push ──► S3 / Azure / GCS / GitHub Releases / filesystem
│
│ release index (compressed YAML)
│ + package files
│
▼
┌──────────────┐
│ Cloud Storage │
└──────┬───────┘
│
┌───────────┼───────────┐
▼ ▼ ▼
Linux Windows macOS
app app app
│ │ │
└───────────┴───────────┘
│
check_for_updates()
download_and_apply()
│
▼
Update applied.
User never noticed.

The update pipeline

When a client calls download_and_apply, Surge runs a 6-phase pipeline:

  1. Check — validate update info and prepare staging directory
  2. Download — fetch delta patch (or full package as fallback) from storage
  3. Verify — SHA-256 hash check of every downloaded file
  4. Extract — decompress the tar.zst archive
  5. Apply delta — apply bsdiff patches if using delta updates
  6. Finalize — atomic move into place, clean up staging, preserve persistent assets

Progress callbacks fire at each phase with percentage, bytes transferred, and speed.

Features

Release channels

Channels are labels on releases. A single version can be on multiple channels simultaneously.

# Ship to beta testers first
surge push --version 2.1.0 --channel beta
# A week later, promote the exact same build to stable (no re-upload)
surge promote --version 2.1.0 --from beta --to stable
# Something wrong? Pull it back
surge demote --version 2.1.0 --channel stable

Clients specify which channel they follow. Switching channels at runtime is a single API call — useful for opt-in beta programs.

Persistent assets

Files and directories that should survive across updates:

apps:
- id: my-apppersistentAssets:
- config.json
- user-data/
- settings.ini

During updates, Surge copies these from the old version directory to the new one before removing the old version.

Platform-native shortcuts

apps:
- id: my-appicon: icon.pngshortcuts:
- desktop
- start_menu
- startup

Surge creates real platform shortcuts:

  • Linux.desktop files in ~/.local/share/applications and ~/.config/autostart (XDG freedesktop spec)
  • Windows.lnk shortcuts on Desktop, Start Menu, and Startup via WScript.Shell
  • macOS.app bundles with Info.plist in ~/Applications, LaunchAgent for startup

Process supervisor

The supervisor binary monitors your application, restarts on crash, and coordinates version handoffs:

surge-supervisor --supervisor-id <uuid> --install-dir /opt/my-app --exe-path /opt/my-app/my-app

Or from code:

SurgeApp.StartSupervisor();

It handles graceful shutdown on SIGTERM/SIGINT (Unix) and Ctrl+C (Windows).

Lifecycle events

Hook into first-run, post-install, and post-update events:

if(SurgeApp.ProcessEvents(args,onFirstRun: v =>ShowWelcomeScreen(),onInstalled: v =>RunMigrations(),onUpdated: v =>ShowChangelogFor(v))){return;}

Installer generation

Surge can produce installer bundles in two modes:

target:
rid: win-x64installers:
- online # Small bootstrap, downloads app on first run
- offline # Self-contained, includes full package

Resource budgets

Throttle resource usage for constrained environments:

varbudget=newSurgeResourceBudget{MaxMemoryBytes=256*1024*1024,// 256 MBMaxConcurrentDownloads=2,MaxDownloadSpeedBps=1_000_000,// 1 MB/sZstdCompressionLevel=6// faster compression};

Distributed locking

For server-side deployments where multiple CI runners might push releases concurrently, Surge provides a distributed mutex via snapx.dev:

surge lock acquire --name "my-app-deploy" --timeout 300
# ... push release ...
surge lock release --name "my-app-deploy"

Backend migration

Move all your releases from one storage provider to another without downtime:

surge migrate --dest-manifest new-backend.yml

Storage Backends

Use whatever you already have.

ProviderConfig valueNotes
Amazon S3s3Any S3-compatible API (MinIO, Cloudflare R2, DigitalOcean Spaces). Auth via AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY or IAM roles
Azure Blob Storageazure_blobAuth via AZURE_STORAGE_ACCOUNT_NAME/AZURE_STORAGE_ACCOUNT_KEY
Google Cloud StoragegcsAuth via GOOGLE_APPLICATION_CREDENTIALS or application default credentials
GitHub Releasesgithub_releasesFree for public repos. bucket = owner/repo. Auth via GITHUB_TOKEN
Local filesystemfilesystemFor testing or air-gapped environments. bucket = root directory path

Integration

For a production app integration checklist, smoke expectations, and guidance for both humans and agents, see docs/integrating-surge.md.

Surge is a native shared library (libsurge.so / surge.dll / libsurge.dylib) with a C ABI. You don't need Rust in your project.

.NET

The Surge.NET NuGet package provides the full API:

  • netstandard2.0[DllImport] for .NET Framework 4.6.1+, .NET Core, Mono, Xamarin
  • net10.0[LibraryImport] with full AOT and trimming support
  • Zero external managed dependencies; ship the matching native Surge library from the same version and runtime identifier with the application
  • SurgeUpdateManager.UpdateToLatestReleaseAsync() — one call that checks, downloads, verifies, extracts, and applies
  • Per-phase progress callbacks, cancellation tokens, pre/post-update hooks

C / C++

Include surge_api.h and link against the shared library. The API uses opaque handles, surge_result return codes, explicit ownership rules, and thread-safe cancellation.

Rust

Use surge-core as a Cargo dependency for direct access to the async API without the FFI overhead.

Reference

CLI commands

surge init Create a surge.yml manifest (--wizard for interactive)
surge pack Build full and delta packages from artifacts
surge tune Benchmark pack policy candidates
surge push Upload packages and update the release index
surge list List releases on a channel
surge promote Promote a release to another channel
surge demote Remove a release from a channel
surge migrate Copy releases between storage backends
surge restore Restore artifacts from backup
surge install Install package via method (backend, tailscale)
surge lock Acquire/release distributed locks

If the manifest has one app, --app-id is optional. If the app has one target, --rid is optional. surge list now defaults to a status overview table. For multi-app manifests it shows one row per app/rid by default; use --app-id (and optionally --rid) to scope down.

surge restore also supports installer-only generation (snapx-style restore -i) from existing full packages:

surge restore -i

By default this resolves the latest release for the manifest app/target on the app's default channel, restores missing full packages from storage into .surge/packages, and builds installers using artifacts from .surge/artifacts/<app-id>/<rid>/<version>. The generated installers are written to .surge/installers/<app-id>/<rid>. Use --channel <name> to rebuild installers for a non-default channel after a promotion flow.

Explicit override example:

surge restore -i \
--channel production \
--version 1.2.3 \
--artifacts-dir ./publish \
--packages-dir .surge/packages

C API function groups

GroupFunctions
Lifecyclesurge_context_create, surge_context_destroy, surge_context_last_error
Configurationsurge_config_set_storage, surge_config_set_lock_server, surge_config_set_resource_budget
Update Managersurge_update_manager_create, surge_update_manager_destroy, surge_update_manager_set_channel, surge_update_manager_set_current_version, surge_update_manager_set_release_retention_limit, surge_update_manager_set_artifact_retention_policy, surge_update_check, surge_update_download_and_apply, surge_update_status_read_json, surge_free_cstring
Release Infosurge_releases_count, surge_releases_destroy, surge_release_version, surge_release_channel, surge_release_full_size, surge_release_is_genesis
Binary Diffsurge_bsdiff, surge_bspatch, surge_bsdiff_free, surge_bspatch_free
Pack Buildersurge_pack_create, surge_pack_build, surge_pack_push, surge_pack_destroy
Distributed Locksurge_lock_acquire, surge_lock_release
Supervisorsurge_supervisor_start, surge_supervisor_stop
Eventssurge_process_events
Cancellationsurge_cancel, surge_reset_cancel

Manifest reference

schema: 1storage:
provider: s3# s3 | azure_blob | gcs | github_releases | filesystembucket: my-bucket # bucket, container, owner/repo, or directoryregion: us-east-1 # cloud region (or release tag for github_releases)endpoint: ""# custom endpoint (MinIO, R2, etc.)prefix: ""# path prefix within bucketlock:
url: https://snapx.dev # distributed lock server (optional)pack: # optional; omitted uses built-in defaultsdelta:
strategy: sparse-file-opsmax_chain_length: 8chunked_patch_format: 1# 1 = readable by every client (default); 2 = identity-chunk bitset, needs clients that know format 2compression:
format: zstdlevel: 3retention:
keep_latest_fulls: 2checkpoint_every: 10cache: # optional device-side artifact cache policyinstallArtifacts:
retention: latest_full # release_graph | latest_full | just_installed | nonekeepFullCount: 1# full archives retained when retention is latest_fullapps:
- id: my-app # unique identifiername: My App # display namemain: my-app # main executable (defaults to id)installDirectory: my-app # install dir name (defaults to id)icon: icon.png # application iconchannels: [stable, beta] # supported channelsshortcuts: [desktop, start_menu, startup]persistentAssets: [config.json, user-data/]installers: [online, offline]environment:
MY_VAR: valuetarget:
rid: linux-x64 # linux-x64, win-x64, win-arm64, osx-x64, osx-arm64

Target-level settings override app-level defaults for icon, shortcuts, persistentAssets, installers, and environment. pack policy is global and controls delta strategy, compression, and remote full fallback retention for surge pack/surge push. The generated surge init policy optimizes managed fleets for fast latest-following updates: a node on N-1 should normally apply the direct N-1 -> N delta, while checkpoint fulls remain fallback baselines for recovery and stale installs. cache.installArtifacts controls package artifacts kept under .surge-cache/artifacts/ after setup and successful updates:

  • latest_full is the recommended managed-fleet setting. It keeps the newest keepFullCount full archives per RID and drops deltas, so normal updates stay delta-based while each device keeps a compact reinstall/restore cushion.
  • release_graph keeps the local release graph plus warm full checkpoints. Use it when offline restore to older versions matters; it uses the most disk.
  • just_installed keeps only the installed full archive when that archive is already cached. Use it when full-update reinstall warmth is useful but disk should stay tight. Delta-only updates do not synthesize a new full archive just to warm this cache.
  • none keeps no package artifacts after a successful update. Use it when optimizing for minimum disk usage and accepting that restore/reinstall downloads from storage again.

Use surge compact after rollout convergence, or for deliberate recovery/cleanup, when you want to prune old remote artifacts. Avoid making compaction the immediate default rollout step for a fleet that is still catching up.

Architecture

┌──────────────────────────────────────────────────────────┐
│ Your Application │
│ (.NET / C / C++ / any FFI) │
└─────────────────────────┬────────────────────────────────┘
│ P/Invoke or C calls
┌─────────────────────────▼────────────────────────────────┐
│ surge-ffi (cdylib) │
│ C ABI · surge_api.h │
└─────────────────────────┬────────────────────────────────┘
│
┌─────────────────────────▼────────────────────────────────┐
│ surge-core │
│ config · crypto · storage · archive · diff · releases │
│ update · pack · supervisor · platform · download │
└──────────────────────────────────────────────────────────┘
CrateDescription
surge-coreCore library — config, crypto, storage backends, archive (tar+zstd), bsdiff, release index, update manager, pack builder, supervisor, platform detection
surge-ffiC API shared library exporting the interface declared in surge_api.h
surge-cliCommand-line tool for packing, pushing, and managing releases
surge-supervisorStandalone process supervisor binary

Building from Source

git clone --recurse-submodules https://github.com/fintermobilityas/surge.git
cd surge

If you already cloned without --recurse-submodules:

git submodule update --init

Requirements

  • Rust 1.95+ (Edition 2024) — install via rustup
  • .NET 10 SDK (optional, for the .NET wrapper and demo app)

Build and test

cargo build --release
cargo test
cargo clippy --all-targets --all-features -- -D warnings
cargo fmt --all
cd dotnet
dotnet build --configuration Release
dotnet test --configuration Release

Release artifact trust

Official archives are covered by SHA256SUMS.txt, including the public C header. The Windows and macOS binaries are currently unsigned, and the macOS binaries are not notarized. Verify the published checksums before use and apply the signing/notarization required by your product distribution before shipping to end users.

License

MIT © 2026 Finter As

About

Automatic updates for any application. Built in Rust. Ships in 5 minutes.

Resources

Stars

10 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Surge

Surge

Automatic updates for any application. Built in Rust. Ships in 5 minutes.

Why Surge5-Minute SetupCI/CDApp PlaybookHow It WorksFeaturesIntegrationReferenceBuilding


Why Surge

Your users should always be on the latest version. Chrome, VS Code, and Slack do this transparently — the app checks for updates, downloads a small patch, and applies it. The user never thinks about it.

Building that yourself means solving a dozen hard problems: hosting an update server, generating delta patches, handling partial downloads, supporting multiple platforms, managing release channels, coordinating deployments across servers, preserving user data across updates, creating installers, setting up shortcuts. Most teams either skip it entirely or ship a half-baked updater that breaks silently.

Surge gives you Chrome-style automatic updates for any application, on any platform, in about 5 minutes.

  • No update server to run. Releases are stored directly in S3, Azure Blob, GCS, GitHub Releases, or a plain directory. You already have one of these.
  • No framework lock-in. Surge is a native shared library with a stable C ABI. Call it from Rust, C, C++, .NET, Go, Python — anything that can load a .so or .dll.
  • Small downloads. Binary delta patches (bsdiff + zstd) mean users download only what changed between versions. Typically 5-20% of the full package.
  • Release channels. Ship to beta first, then promote the exact same build to stable when you're confident. No rebuild, no re-upload.
  • User data survives updates. Mark config files, databases, and user content as persistent assets — Surge preserves them across every version.
  • Fits your CI pipeline.surge pack and surge push are plain CLI commands. Add them to GitHub Actions, GitLab CI, or Jenkins — works in any matrix build across OS, architecture, and build variants.
  • Cross-platform from day one. Linux, Windows, and macOS. Native shortcuts (.desktop files, .lnk files, .app bundles), platform-correct install directories, and architecture detection built in.

5-Minute Setup

You need two things: somewhere to store your releases and the surge CLI.

If you are wiring Surge into CI, prefer the official release bundle for your platform. It includes the full publishing toolchain (surge, surge-supervisor, surge-installer, surge-installer-ui, and the native runtime) so pack/push jobs do not have to assemble it from multiple crates.

If your artifacts contain Surge.NET.dll but not the native runtime, surge pack will bundle the matching libsurge/surge.dll from the installed Surge toolchain automatically.

If you are publishing preview versions in GitHub Actions and do not want to ship prebuilt release bundles, the fastest CI pattern is:

  1. Check out Surge once per host architecture.
  2. Restore Rust build outputs with Swatinem/rust-cache.
  3. Run ./scripts/stage-toolchain-artifact.sh --output "$RUNNER_TEMP/surge-toolchain".
  4. Upload that directory as a workflow artifact.
  5. Download it in every publish job and prepend it to PATH.

That avoids repeated cargo install misses across the publish matrix and removes the separate libsurge bootstrap step.

1. Initialize your project

surge init --wizard

The wizard walks you through storage provider, app name, and target platform. Or do it non-interactively:

surge init \
--app-id my-app \
--name "My App" \
--provider s3 \
--bucket my-app-releases

The result is a surge.yml manifest:

schema: 1storage:
provider: s3bucket: my-app-releasesregion: us-east-1apps:
- id: my-appname: My Appmain: my-apptarget:
rid: linux-x64

Credentials are never stored in the manifest. Surge reads them from process environment variables (AWS_ACCESS_KEY_ID, GITHUB_TOKEN, etc.), from .env.surge files discovered next to the active manifest (and from project-root .env.surge when using the default .surge/surge.yml layout), from per-app overrides in .env.surge.<app-id>, or from provider-native identity mechanisms such as IAM roles.

Storage credentials with .env.surge

Use .env.surge when you want storage credentials to follow a project, manifest, or installer without exporting them globally in your shell or CI job.

Lookup rules:

  • Process environment variables always win.
  • With the default .surge/surge.yml layout, Surge loads <project>/.env.surge first and .surge/.env.surge second. Later files override earlier ones.
  • With a custom manifest path such as surge --manifest-path ./deploy/prod.yml ..., Surge loads ./deploy/.env.surge.
  • Per-app overrides live beside the shared file as .env.surge.<app-id> and override shared values for that app only.
  • surge install loads overrides from the manifest it actually installs from: .surge/application.yml if present, otherwise the fallback manifest path.
  • surge migrate scopes source and destination manifests separately, so each side can use different backend credentials safely.
  • surge setup reads .env.surge next to the extracted installer.yml.

Supported file syntax:

  • KEY=value
  • export KEY=value
  • blank lines and # comments
  • single-quoted or double-quoted values

Example project layout:

my-app/
├── .env.surge
├── .env.surge.admin-ui
└── .surge/
├── .env.surge
└── surge.yml

Example files:

# my-app/.env.surge
GITHUB_TOKEN=ghp_shared_token
# my-app/.env.surge.admin-ui
GITHUB_TOKEN=ghp_admin_ui_token
# my-app/.surge/.env.surge
GITHUB_TOKEN=ghp_local_override

In that layout:

  • Most commands against .surge/surge.yml see ghp_local_override.
  • Commands for app admin-ui first try .env.surge.admin-ui, then fall back to the shared .env.surge values for that same manifest scope.
  • Running with a different manifest path uses the .env.surge files next to that manifest instead of reusing another manifest's credentials.

2. Pack a release

Point Surge at your build output:

surge pack \
--app-id my-app \
--rid linux-x64 \
--version 1.0.0

By default, surge pack reads artifacts from .surge/artifacts/<app-id>/<rid>/<version>, writes packages to .surge/packages, and writes installers to .surge/installers/<app-id>/<rid>. Use --artifacts-dir/--output-dir to override.

Surge compresses everything into a tar.zst package. If a previous version exists in storage, it also generates a binary delta patch automatically.

If you want to benchmark pack policy on a real payload before publishing, run:

surge tune pack \
--app-id my-app \
--rid linux-x64 \
--version 1.0.0 \
--write-manifest

This benchmarks candidate pack settings on the current artifacts and can write the recommended pack.delta.strategy and pack.compression.level back to surge.yml.

3. Push to storage

surge push \
--app-id my-app \
--rid linux-x64 \
--version 1.0.0 \
--channel stable

Done. Your release is live. Clients on the stable channel will pick it up on their next update check.

Optional: install package (backend or Tailscale)

Install from the backend configured in .surge/application.yml (falls back to .surge/surge.yml):

surge install \
--channel stable

Override backend fields without editing manifest:

surge install backend \
--provider s3 \
--bucket my-release-bucket \
--region eu-north-1 \
--prefix production

Install to a remote node on your tailnet:

surge install tailscale \
--node my-node \
--node-user operator \
--channel stable

This command:

  • probes remote OS/architecture and checks for NVIDIA GPU support,
  • resolves the newest matching release on the selected channel,
  • downloads it locally and sends it with tailscale file cp.

Use --plan-only to preview selection without transfer, --rid to force a specific RID, or --force to reinstall even when the same version/channel is already installed on the target. If your tailnet requires explicit SSH identity, pass --node-user <account> (or set --node <account>@<node> directly).

4. Add update checking to your app

.NET

usingvarmgr=newSurgeUpdateManager();awaitmgr.UpdateToLatestReleaseAsync(onUpdatesAvailable: releases =>Console.WriteLine($"{releases.Count} update(s), latest: {releases.Latest?.Version}"),onAfterApplyUpdate: release =>Console.WriteLine($"Updated to {release.Version}"));

Rust

letmut mgr = UpdateManager::new(ctx,"my-app","1.0.0","stable", install_dir)?;ifletSome(info) = mgr.check_for_updates().await? {
mgr.download_and_apply(&info,None::<fn(_)>).await?;}

C / C++ / anything else

surge_update_manager*mgr=surge_update_manager_create(ctx, "my-app", "1.0.0", "stable", dir);
surge_releases_info*info=NULL;
if (surge_update_check(mgr, &info) ==SURGE_OK) {
surge_update_download_and_apply(mgr, info, progress_cb, NULL);
surge_releases_destroy(info);
}
surge_update_manager_destroy(mgr);
surge_context_destroy(ctx);

CI/CD Integration

Surge is built for automated pipelines. The CLI does all the heavy lifting — your CI just calls surge pack and surge push after each build. GitHub Actions is the most common setup.

Single-platform example

# .github/workflows/release.ymljobs:
build:
runs-on: ubuntu-lateststeps:
- uses: actions/checkout@v6
- run: cargo build --release
- run: surge pack --version ${{ env.VERSION }}
- run: surge push --version ${{ env.VERSION }} --channel stable

Multi-platform matrix

Real applications target multiple OS and architecture combinations. Use a matrix strategy to build each variant in parallel, then pack and push each one:

jobs:
build:
strategy:
matrix:
include:
- os: ubuntu-latestrid: linux-x64
- os: windows-latestrid: win-x64
- os: macos-latestrid: osx-arm64runs-on: ${{ matrix.os }}steps:
- uses: actions/checkout@v6
- run: dotnet publish -c Release -r ${{ matrix.rid }}
- run: surge pack --rid ${{ matrix.rid }} --version ${{ env.VERSION }}
- run: surge push --rid ${{ matrix.rid }} --version ${{ env.VERSION }} --channel stable

Each matrix entry produces its own platform-specific package and delta patch. Clients only download the package matching their OS and architecture.

Staged rollouts

Combine matrix builds with channel promotion for safe deployments:

jobs:
deploy-beta:
needs: [build]runs-on: ubuntu-latestif: github.ref == 'refs/heads/develop'steps:
- run: surge push --version ${{ env.VERSION }} --channel betapromote-stable:
needs: [build]runs-on: ubuntu-latestif: github.ref == 'refs/heads/main'steps:
- run: surge promote --version ${{ env.VERSION }} --from beta --to stable

Push to develop ships to beta testers. Merge to main promotes the exact same build to stable — no rebuild, no re-upload, no risk of a different binary reaching production.

Distributed lock for safe concurrent pushes

When multiple matrix jobs push to the same storage backend, use the distributed lock to prevent race conditions on the release index:

steps:
- run: surge lock acquire --name "${{ matrix.rid }}-deploy"
- run: surge push --version ${{ env.VERSION }} --rid ${{ matrix.rid }} --channel stable
- run: surge lock release --name "${{ matrix.rid }}-deploy"

How It Works

 You (developer) Your Users
────────────── ──────────
cargo build / dotnet publish
│
▼
surge pack ──► tar.zst full package
+ bsdiff delta patch
│
▼
surge push ──► S3 / Azure / GCS / GitHub Releases / filesystem
│
│ release index (compressed YAML)
│ + package files
│
▼
┌──────────────┐
│ Cloud Storage │
└──────┬───────┘
│
┌───────────┼───────────┐
▼ ▼ ▼
Linux Windows macOS
app app app
│ │ │
└───────────┴───────────┘
│
check_for_updates()
download_and_apply()
│
▼
Update applied.
User never noticed.

The update pipeline

When a client calls download_and_apply, Surge runs a 6-phase pipeline:

  1. Check — validate update info and prepare staging directory
  2. Download — fetch delta patch (or full package as fallback) from storage
  3. Verify — SHA-256 hash check of every downloaded file
  4. Extract — decompress the tar.zst archive
  5. Apply delta — apply bsdiff patches if using delta updates
  6. Finalize — atomic move into place, clean up staging, preserve persistent assets

Progress callbacks fire at each phase with percentage, bytes transferred, and speed.

Features

Release channels

Channels are labels on releases. A single version can be on multiple channels simultaneously.

# Ship to beta testers first
surge push --version 2.1.0 --channel beta
# A week later, promote the exact same build to stable (no re-upload)
surge promote --version 2.1.0 --from beta --to stable
# Something wrong? Pull it back
surge demote --version 2.1.0 --channel stable

Clients specify which channel they follow. Switching channels at runtime is a single API call — useful for opt-in beta programs.

Persistent assets

Files and directories that should survive across updates:

apps:
- id: my-apppersistentAssets:
- config.json
- user-data/
- settings.ini

During updates, Surge copies these from the old version directory to the new one before removing the old version.

Platform-native shortcuts

apps:
- id: my-appicon: icon.pngshortcuts:
- desktop
- start_menu
- startup

Surge creates real platform shortcuts:

  • Linux.desktop files in ~/.local/share/applications and ~/.config/autostart (XDG freedesktop spec)
  • Windows.lnk shortcuts on Desktop, Start Menu, and Startup via WScript.Shell
  • macOS.app bundles with Info.plist in ~/Applications, LaunchAgent for startup

Process supervisor

The supervisor binary monitors your application, restarts on crash, and coordinates version handoffs:

surge-supervisor --supervisor-id <uuid> --install-dir /opt/my-app --exe-path /opt/my-app/my-app

Or from code:

SurgeApp.StartSupervisor();

It handles graceful shutdown on SIGTERM/SIGINT (Unix) and Ctrl+C (Windows).

Lifecycle events

Hook into first-run, post-install, and post-update events:

if(SurgeApp.ProcessEvents(args,onFirstRun: v =>ShowWelcomeScreen(),onInstalled: v =>RunMigrations(),onUpdated: v =>ShowChangelogFor(v))){return;}

Installer generation

Surge can produce installer bundles in two modes:

target:
rid: win-x64installers:
- online # Small bootstrap, downloads app on first run
- offline # Self-contained, includes full package

Resource budgets

Throttle resource usage for constrained environments:

varbudget=newSurgeResourceBudget{MaxMemoryBytes=256*1024*1024,// 256 MBMaxConcurrentDownloads=2,MaxDownloadSpeedBps=1_000_000,// 1 MB/sZstdCompressionLevel=6// faster compression};

Distributed locking

For server-side deployments where multiple CI runners might push releases concurrently, Surge provides a distributed mutex via snapx.dev:

surge lock acquire --name "my-app-deploy" --timeout 300
# ... push release ...
surge lock release --name "my-app-deploy"

Backend migration

Move all your releases from one storage provider to another without downtime:

surge migrate --dest-manifest new-backend.yml

Storage Backends

Use whatever you already have.

ProviderConfig valueNotes
Amazon S3s3Any S3-compatible API (MinIO, Cloudflare R2, DigitalOcean Spaces). Auth via AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY or IAM roles
Azure Blob Storageazure_blobAuth via AZURE_STORAGE_ACCOUNT_NAME/AZURE_STORAGE_ACCOUNT_KEY
Google Cloud StoragegcsAuth via GOOGLE_APPLICATION_CREDENTIALS or application default credentials
GitHub Releasesgithub_releasesFree for public repos. bucket = owner/repo. Auth via GITHUB_TOKEN
Local filesystemfilesystemFor testing or air-gapped environments. bucket = root directory path

Integration

For a production app integration checklist, smoke expectations, and guidance for both humans and agents, see docs/integrating-surge.md.

Surge is a native shared library (libsurge.so / surge.dll / libsurge.dylib) with a C ABI. You don't need Rust in your project.

.NET

The Surge.NET NuGet package provides the full API:

  • netstandard2.0[DllImport] for .NET Framework 4.6.1+, .NET Core, Mono, Xamarin
  • net10.0[LibraryImport] with full AOT and trimming support
  • Zero external managed dependencies; ship the matching native Surge library from the same version and runtime identifier with the application
  • SurgeUpdateManager.UpdateToLatestReleaseAsync() — one call that checks, downloads, verifies, extracts, and applies
  • Per-phase progress callbacks, cancellation tokens, pre/post-update hooks

C / C++

Include surge_api.h and link against the shared library. The API uses opaque handles, surge_result return codes, explicit ownership rules, and thread-safe cancellation.

Rust

Use surge-core as a Cargo dependency for direct access to the async API without the FFI overhead.

Reference

CLI commands

surge init Create a surge.yml manifest (--wizard for interactive)
surge pack Build full and delta packages from artifacts
surge tune Benchmark pack policy candidates
surge push Upload packages and update the release index
surge list List releases on a channel
surge promote Promote a release to another channel
surge demote Remove a release from a channel
surge migrate Copy releases between storage backends
surge restore Restore artifacts from backup
surge install Install package via method (backend, tailscale)
surge lock Acquire/release distributed locks

If the manifest has one app, --app-id is optional. If the app has one target, --rid is optional. surge list now defaults to a status overview table. For multi-app manifests it shows one row per app/rid by default; use --app-id (and optionally --rid) to scope down.

surge restore also supports installer-only generation (snapx-style restore -i) from existing full packages:

surge restore -i

By default this resolves the latest release for the manifest app/target on the app's default channel, restores missing full packages from storage into .surge/packages, and builds installers using artifacts from .surge/artifacts/<app-id>/<rid>/<version>. The generated installers are written to .surge/installers/<app-id>/<rid>. Use --channel <name> to rebuild installers for a non-default channel after a promotion flow.

Explicit override example:

surge restore -i \
--channel production \
--version 1.2.3 \
--artifacts-dir ./publish \
--packages-dir .surge/packages

C API function groups

GroupFunctions
Lifecyclesurge_context_create, surge_context_destroy, surge_context_last_error
Configurationsurge_config_set_storage, surge_config_set_lock_server, surge_config_set_resource_budget
Update Managersurge_update_manager_create, surge_update_manager_destroy, surge_update_manager_set_channel, surge_update_manager_set_current_version, surge_update_manager_set_release_retention_limit, surge_update_manager_set_artifact_retention_policy, surge_update_check, surge_update_download_and_apply, surge_update_status_read_json, surge_free_cstring
Release Infosurge_releases_count, surge_releases_destroy, surge_release_version, surge_release_channel, surge_release_full_size, surge_release_is_genesis
Binary Diffsurge_bsdiff, surge_bspatch, surge_bsdiff_free, surge_bspatch_free
Pack Buildersurge_pack_create, surge_pack_build, surge_pack_push, surge_pack_destroy
Distributed Locksurge_lock_acquire, surge_lock_release
Supervisorsurge_supervisor_start, surge_supervisor_stop
Eventssurge_process_events
Cancellationsurge_cancel, surge_reset_cancel

Manifest reference

schema: 1storage:
provider: s3# s3 | azure_blob | gcs | github_releases | filesystembucket: my-bucket # bucket, container, owner/repo, or directoryregion: us-east-1 # cloud region (or release tag for github_releases)endpoint: ""# custom endpoint (MinIO, R2, etc.)prefix: ""# path prefix within bucketlock:
url: https://snapx.dev # distributed lock server (optional)pack: # optional; omitted uses built-in defaultsdelta:
strategy: sparse-file-opsmax_chain_length: 8chunked_patch_format: 1# 1 = readable by every client (default); 2 = identity-chunk bitset, needs clients that know format 2compression:
format: zstdlevel: 3retention:
keep_latest_fulls: 2checkpoint_every: 10cache: # optional device-side artifact cache policyinstallArtifacts:
retention: latest_full # release_graph | latest_full | just_installed | nonekeepFullCount: 1# full archives retained when retention is latest_fullapps:
- id: my-app # unique identifiername: My App # display namemain: my-app # main executable (defaults to id)installDirectory: my-app # install dir name (defaults to id)icon: icon.png # application iconchannels: [stable, beta] # supported channelsshortcuts: [desktop, start_menu, startup]persistentAssets: [config.json, user-data/]installers: [online, offline]environment:
MY_VAR: valuetarget:
rid: linux-x64 # linux-x64, win-x64, win-arm64, osx-x64, osx-arm64

Target-level settings override app-level defaults for icon, shortcuts, persistentAssets, installers, and environment. pack policy is global and controls delta strategy, compression, and remote full fallback retention for surge pack/surge push. The generated surge init policy optimizes managed fleets for fast latest-following updates: a node on N-1 should normally apply the direct N-1 -> N delta, while checkpoint fulls remain fallback baselines for recovery and stale installs. cache.installArtifacts controls package artifacts kept under .surge-cache/artifacts/ after setup and successful updates:

  • latest_full is the recommended managed-fleet setting. It keeps the newest keepFullCount full archives per RID and drops deltas, so normal updates stay delta-based while each device keeps a compact reinstall/restore cushion.
  • release_graph keeps the local release graph plus warm full checkpoints. Use it when offline restore to older versions matters; it uses the most disk.
  • just_installed keeps only the installed full archive when that archive is already cached. Use it when full-update reinstall warmth is useful but disk should stay tight. Delta-only updates do not synthesize a new full archive just to warm this cache.
  • none keeps no package artifacts after a successful update. Use it when optimizing for minimum disk usage and accepting that restore/reinstall downloads from storage again.

Use surge compact after rollout convergence, or for deliberate recovery/cleanup, when you want to prune old remote artifacts. Avoid making compaction the immediate default rollout step for a fleet that is still catching up.

Architecture

┌──────────────────────────────────────────────────────────┐
│ Your Application │
│ (.NET / C / C++ / any FFI) │
└─────────────────────────┬────────────────────────────────┘
│ P/Invoke or C calls
┌─────────────────────────▼────────────────────────────────┐
│ surge-ffi (cdylib) │
│ C ABI · surge_api.h │
└─────────────────────────┬────────────────────────────────┘
│
┌─────────────────────────▼────────────────────────────────┐
│ surge-core │
│ config · crypto · storage · archive · diff · releases │
│ update · pack · supervisor · platform · download │
└──────────────────────────────────────────────────────────┘
CrateDescription
surge-coreCore library — config, crypto, storage backends, archive (tar+zstd), bsdiff, release index, update manager, pack builder, supervisor, platform detection
surge-ffiC API shared library exporting the interface declared in surge_api.h
surge-cliCommand-line tool for packing, pushing, and managing releases
surge-supervisorStandalone process supervisor binary

Building from Source

git clone --recurse-submodules https://github.com/fintermobilityas/surge.git
cd surge

If you already cloned without --recurse-submodules:

git submodule update --init

Requirements

  • Rust 1.95+ (Edition 2024) — install via rustup
  • .NET 10 SDK (optional, for the .NET wrapper and demo app)

Build and test

cargo build --release
cargo test
cargo clippy --all-targets --all-features -- -D warnings
cargo fmt --all
cd dotnet
dotnet build --configuration Release
dotnet test --configuration Release

Release artifact trust

Official archives are covered by SHA256SUMS.txt, including the public C header. The Windows and macOS binaries are currently unsigned, and the macOS binaries are not notarized. Verify the published checksums before use and apply the signing/notarization required by your product distribution before shipping to end users.

License

MIT © 2026 Finter As

About

Automatic updates for any application. Built in Rust. Ships in 5 minutes.

Resources

Stars

10 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Surge

Surge

Automatic updates for any application. Built in Rust. Ships in 5 minutes.

Why Surge5-Minute SetupCI/CDApp PlaybookHow It WorksFeaturesIntegrationReferenceBuilding


Why Surge

Your users should always be on the latest version. Chrome, VS Code, and Slack do this transparently — the app checks for updates, downloads a small patch, and applies it. The user never thinks about it.

Building that yourself means solving a dozen hard problems: hosting an update server, generating delta patches, handling partial downloads, supporting multiple platforms, managing release channels, coordinating deployments across servers, preserving user data across updates, creating installers, setting up shortcuts. Most teams either skip it entirely or ship a half-baked updater that breaks silently.

Surge gives you Chrome-style automatic updates for any application, on any platform, in about 5 minutes.

  • No update server to run. Releases are stored directly in S3, Azure Blob, GCS, GitHub Releases, or a plain directory. You already have one of these.
  • No framework lock-in. Surge is a native shared library with a stable C ABI. Call it from Rust, C, C++, .NET, Go, Python — anything that can load a .so or .dll.
  • Small downloads. Binary delta patches (bsdiff + zstd) mean users download only what changed between versions. Typically 5-20% of the full package.
  • Release channels. Ship to beta first, then promote the exact same build to stable when you're confident. No rebuild, no re-upload.
  • User data survives updates. Mark config files, databases, and user content as persistent assets — Surge preserves them across every version.
  • Fits your CI pipeline.surge pack and surge push are plain CLI commands. Add them to GitHub Actions, GitLab CI, or Jenkins — works in any matrix build across OS, architecture, and build variants.
  • Cross-platform from day one. Linux, Windows, and macOS. Native shortcuts (.desktop files, .lnk files, .app bundles), platform-correct install directories, and architecture detection built in.

5-Minute Setup

You need two things: somewhere to store your releases and the surge CLI.

If you are wiring Surge into CI, prefer the official release bundle for your platform. It includes the full publishing toolchain (surge, surge-supervisor, surge-installer, surge-installer-ui, and the native runtime) so pack/push jobs do not have to assemble it from multiple crates.

If your artifacts contain Surge.NET.dll but not the native runtime, surge pack will bundle the matching libsurge/surge.dll from the installed Surge toolchain automatically.

If you are publishing preview versions in GitHub Actions and do not want to ship prebuilt release bundles, the fastest CI pattern is:

  1. Check out Surge once per host architecture.
  2. Restore Rust build outputs with Swatinem/rust-cache.
  3. Run ./scripts/stage-toolchain-artifact.sh --output "$RUNNER_TEMP/surge-toolchain".
  4. Upload that directory as a workflow artifact.
  5. Download it in every publish job and prepend it to PATH.

That avoids repeated cargo install misses across the publish matrix and removes the separate libsurge bootstrap step.

1. Initialize your project

surge init --wizard

The wizard walks you through storage provider, app name, and target platform. Or do it non-interactively:

surge init \
--app-id my-app \
--name "My App" \
--provider s3 \
--bucket my-app-releases

The result is a surge.yml manifest:

schema: 1storage:
provider: s3bucket: my-app-releasesregion: us-east-1apps:
- id: my-appname: My Appmain: my-apptarget:
rid: linux-x64

Credentials are never stored in the manifest. Surge reads them from process environment variables (AWS_ACCESS_KEY_ID, GITHUB_TOKEN, etc.), from .env.surge files discovered next to the active manifest (and from project-root .env.surge when using the default .surge/surge.yml layout), from per-app overrides in .env.surge.<app-id>, or from provider-native identity mechanisms such as IAM roles.

Storage credentials with .env.surge

Use .env.surge when you want storage credentials to follow a project, manifest, or installer without exporting them globally in your shell or CI job.

Lookup rules:

  • Process environment variables always win.
  • With the default .surge/surge.yml layout, Surge loads <project>/.env.surge first and .surge/.env.surge second. Later files override earlier ones.
  • With a custom manifest path such as surge --manifest-path ./deploy/prod.yml ..., Surge loads ./deploy/.env.surge.
  • Per-app overrides live beside the shared file as .env.surge.<app-id> and override shared values for that app only.
  • surge install loads overrides from the manifest it actually installs from: .surge/application.yml if present, otherwise the fallback manifest path.
  • surge migrate scopes source and destination manifests separately, so each side can use different backend credentials safely.
  • surge setup reads .env.surge next to the extracted installer.yml.

Supported file syntax:

  • KEY=value
  • export KEY=value
  • blank lines and # comments
  • single-quoted or double-quoted values

Example project layout:

my-app/
├── .env.surge
├── .env.surge.admin-ui
└── .surge/
├── .env.surge
└── surge.yml

Example files:

# my-app/.env.surge
GITHUB_TOKEN=ghp_shared_token
# my-app/.env.surge.admin-ui
GITHUB_TOKEN=ghp_admin_ui_token
# my-app/.surge/.env.surge
GITHUB_TOKEN=ghp_local_override

In that layout:

  • Most commands against .surge/surge.yml see ghp_local_override.
  • Commands for app admin-ui first try .env.surge.admin-ui, then fall back to the shared .env.surge values for that same manifest scope.
  • Running with a different manifest path uses the .env.surge files next to that manifest instead of reusing another manifest's credentials.

2. Pack a release

Point Surge at your build output:

surge pack \
--app-id my-app \
--rid linux-x64 \
--version 1.0.0

By default, surge pack reads artifacts from .surge/artifacts/<app-id>/<rid>/<version>, writes packages to .surge/packages, and writes installers to .surge/installers/<app-id>/<rid>. Use --artifacts-dir/--output-dir to override.

Surge compresses everything into a tar.zst package. If a previous version exists in storage, it also generates a binary delta patch automatically.

If you want to benchmark pack policy on a real payload before publishing, run:

surge tune pack \
--app-id my-app \
--rid linux-x64 \
--version 1.0.0 \
--write-manifest

This benchmarks candidate pack settings on the current artifacts and can write the recommended pack.delta.strategy and pack.compression.level back to surge.yml.

3. Push to storage

surge push \
--app-id my-app \
--rid linux-x64 \
--version 1.0.0 \
--channel stable

Done. Your release is live. Clients on the stable channel will pick it up on their next update check.

Optional: install package (backend or Tailscale)

Install from the backend configured in .surge/application.yml (falls back to .surge/surge.yml):

surge install \
--channel stable

Override backend fields without editing manifest:

surge install backend \
--provider s3 \
--bucket my-release-bucket \
--region eu-north-1 \
--prefix production

Install to a remote node on your tailnet:

surge install tailscale \
--node my-node \
--node-user operator \
--channel stable

This command:

  • probes remote OS/architecture and checks for NVIDIA GPU support,
  • resolves the newest matching release on the selected channel,
  • downloads it locally and sends it with tailscale file cp.

Use --plan-only to preview selection without transfer, --rid to force a specific RID, or --force to reinstall even when the same version/channel is already installed on the target. If your tailnet requires explicit SSH identity, pass --node-user <account> (or set --node <account>@<node> directly).

4. Add update checking to your app

.NET

usingvarmgr=newSurgeUpdateManager();awaitmgr.UpdateToLatestReleaseAsync(onUpdatesAvailable: releases =>Console.WriteLine($"{releases.Count} update(s), latest: {releases.Latest?.Version}"),onAfterApplyUpdate: release =>Console.WriteLine($"Updated to {release.Version}"));

Rust

letmut mgr = UpdateManager::new(ctx,"my-app","1.0.0","stable", install_dir)?;ifletSome(info) = mgr.check_for_updates().await? {
mgr.download_and_apply(&info,None::<fn(_)>).await?;}

C / C++ / anything else

surge_update_manager*mgr=surge_update_manager_create(ctx, "my-app", "1.0.0", "stable", dir);
surge_releases_info*info=NULL;
if (surge_update_check(mgr, &info) ==SURGE_OK) {
surge_update_download_and_apply(mgr, info, progress_cb, NULL);
surge_releases_destroy(info);
}
surge_update_manager_destroy(mgr);
surge_context_destroy(ctx);

CI/CD Integration

Surge is built for automated pipelines. The CLI does all the heavy lifting — your CI just calls surge pack and surge push after each build. GitHub Actions is the most common setup.

Single-platform example

# .github/workflows/release.ymljobs:
build:
runs-on: ubuntu-lateststeps:
- uses: actions/checkout@v6
- run: cargo build --release
- run: surge pack --version ${{ env.VERSION }}
- run: surge push --version ${{ env.VERSION }} --channel stable

Multi-platform matrix

Real applications target multiple OS and architecture combinations. Use a matrix strategy to build each variant in parallel, then pack and push each one:

jobs:
build:
strategy:
matrix:
include:
- os: ubuntu-latestrid: linux-x64
- os: windows-latestrid: win-x64
- os: macos-latestrid: osx-arm64runs-on: ${{ matrix.os }}steps:
- uses: actions/checkout@v6
- run: dotnet publish -c Release -r ${{ matrix.rid }}
- run: surge pack --rid ${{ matrix.rid }} --version ${{ env.VERSION }}
- run: surge push --rid ${{ matrix.rid }} --version ${{ env.VERSION }} --channel stable

Each matrix entry produces its own platform-specific package and delta patch. Clients only download the package matching their OS and architecture.

Staged rollouts

Combine matrix builds with channel promotion for safe deployments:

jobs:
deploy-beta:
needs: [build]runs-on: ubuntu-latestif: github.ref == 'refs/heads/develop'steps:
- run: surge push --version ${{ env.VERSION }} --channel betapromote-stable:
needs: [build]runs-on: ubuntu-latestif: github.ref == 'refs/heads/main'steps:
- run: surge promote --version ${{ env.VERSION }} --from beta --to stable

Push to develop ships to beta testers. Merge to main promotes the exact same build to stable — no rebuild, no re-upload, no risk of a different binary reaching production.

Distributed lock for safe concurrent pushes

When multiple matrix jobs push to the same storage backend, use the distributed lock to prevent race conditions on the release index:

steps:
- run: surge lock acquire --name "${{ matrix.rid }}-deploy"
- run: surge push --version ${{ env.VERSION }} --rid ${{ matrix.rid }} --channel stable
- run: surge lock release --name "${{ matrix.rid }}-deploy"

How It Works

 You (developer) Your Users
────────────── ──────────
cargo build / dotnet publish
│
▼
surge pack ──► tar.zst full package
+ bsdiff delta patch
│
▼
surge push ──► S3 / Azure / GCS / GitHub Releases / filesystem
│
│ release index (compressed YAML)
│ + package files
│
▼
┌──────────────┐
│ Cloud Storage │
└──────┬───────┘
│
┌───────────┼───────────┐
▼ ▼ ▼
Linux Windows macOS
app app app
│ │ │
└───────────┴───────────┘
│
check_for_updates()
download_and_apply()
│
▼
Update applied.
User never noticed.

The update pipeline

When a client calls download_and_apply, Surge runs a 6-phase pipeline:

  1. Check — validate update info and prepare staging directory
  2. Download — fetch delta patch (or full package as fallback) from storage
  3. Verify — SHA-256 hash check of every downloaded file
  4. Extract — decompress the tar.zst archive
  5. Apply delta — apply bsdiff patches if using delta updates
  6. Finalize — atomic move into place, clean up staging, preserve persistent assets

Progress callbacks fire at each phase with percentage, bytes transferred, and speed.

Features

Release channels

Channels are labels on releases. A single version can be on multiple channels simultaneously.

# Ship to beta testers first
surge push --version 2.1.0 --channel beta
# A week later, promote the exact same build to stable (no re-upload)
surge promote --version 2.1.0 --from beta --to stable
# Something wrong? Pull it back
surge demote --version 2.1.0 --channel stable

Clients specify which channel they follow. Switching channels at runtime is a single API call — useful for opt-in beta programs.

Persistent assets

Files and directories that should survive across updates:

apps:
- id: my-apppersistentAssets:
- config.json
- user-data/
- settings.ini

During updates, Surge copies these from the old version directory to the new one before removing the old version.

Platform-native shortcuts

apps:
- id: my-appicon: icon.pngshortcuts:
- desktop
- start_menu
- startup

Surge creates real platform shortcuts:

  • Linux.desktop files in ~/.local/share/applications and ~/.config/autostart (XDG freedesktop spec)
  • Windows.lnk shortcuts on Desktop, Start Menu, and Startup via WScript.Shell
  • macOS.app bundles with Info.plist in ~/Applications, LaunchAgent for startup

Process supervisor

The supervisor binary monitors your application, restarts on crash, and coordinates version handoffs:

surge-supervisor --supervisor-id <uuid> --install-dir /opt/my-app --exe-path /opt/my-app/my-app

Or from code:

SurgeApp.StartSupervisor();

It handles graceful shutdown on SIGTERM/SIGINT (Unix) and Ctrl+C (Windows).

Lifecycle events

Hook into first-run, post-install, and post-update events:

if(SurgeApp.ProcessEvents(args,onFirstRun: v =>ShowWelcomeScreen(),onInstalled: v =>RunMigrations(),onUpdated: v =>ShowChangelogFor(v))){return;}

Installer generation

Surge can produce installer bundles in two modes:

target:
rid: win-x64installers:
- online # Small bootstrap, downloads app on first run
- offline # Self-contained, includes full package

Resource budgets

Throttle resource usage for constrained environments:

varbudget=newSurgeResourceBudget{MaxMemoryBytes=256*1024*1024,// 256 MBMaxConcurrentDownloads=2,MaxDownloadSpeedBps=1_000_000,// 1 MB/sZstdCompressionLevel=6// faster compression};

Distributed locking

For server-side deployments where multiple CI runners might push releases concurrently, Surge provides a distributed mutex via snapx.dev:

surge lock acquire --name "my-app-deploy" --timeout 300
# ... push release ...
surge lock release --name "my-app-deploy"

Backend migration

Move all your releases from one storage provider to another without downtime:

surge migrate --dest-manifest new-backend.yml

Storage Backends

Use whatever you already have.

ProviderConfig valueNotes
Amazon S3s3Any S3-compatible API (MinIO, Cloudflare R2, DigitalOcean Spaces). Auth via AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY or IAM roles
Azure Blob Storageazure_blobAuth via AZURE_STORAGE_ACCOUNT_NAME/AZURE_STORAGE_ACCOUNT_KEY
Google Cloud StoragegcsAuth via GOOGLE_APPLICATION_CREDENTIALS or application default credentials
GitHub Releasesgithub_releasesFree for public repos. bucket = owner/repo. Auth via GITHUB_TOKEN
Local filesystemfilesystemFor testing or air-gapped environments. bucket = root directory path

Integration

For a production app integration checklist, smoke expectations, and guidance for both humans and agents, see docs/integrating-surge.md.

Surge is a native shared library (libsurge.so / surge.dll / libsurge.dylib) with a C ABI. You don't need Rust in your project.

.NET

The Surge.NET NuGet package provides the full API:

  • netstandard2.0[DllImport] for .NET Framework 4.6.1+, .NET Core, Mono, Xamarin
  • net10.0[LibraryImport] with full AOT and trimming support
  • Zero external managed dependencies; ship the matching native Surge library from the same version and runtime identifier with the application
  • SurgeUpdateManager.UpdateToLatestReleaseAsync() — one call that checks, downloads, verifies, extracts, and applies
  • Per-phase progress callbacks, cancellation tokens, pre/post-update hooks

C / C++

Include surge_api.h and link against the shared library. The API uses opaque handles, surge_result return codes, explicit ownership rules, and thread-safe cancellation.

Rust

Use surge-core as a Cargo dependency for direct access to the async API without the FFI overhead.

Reference

CLI commands

surge init Create a surge.yml manifest (--wizard for interactive)
surge pack Build full and delta packages from artifacts
surge tune Benchmark pack policy candidates
surge push Upload packages and update the release index
surge list List releases on a channel
surge promote Promote a release to another channel
surge demote Remove a release from a channel
surge migrate Copy releases between storage backends
surge restore Restore artifacts from backup
surge install Install package via method (backend, tailscale)
surge lock Acquire/release distributed locks

If the manifest has one app, --app-id is optional. If the app has one target, --rid is optional. surge list now defaults to a status overview table. For multi-app manifests it shows one row per app/rid by default; use --app-id (and optionally --rid) to scope down.

surge restore also supports installer-only generation (snapx-style restore -i) from existing full packages:

surge restore -i

By default this resolves the latest release for the manifest app/target on the app's default channel, restores missing full packages from storage into .surge/packages, and builds installers using artifacts from .surge/artifacts/<app-id>/<rid>/<version>. The generated installers are written to .surge/installers/<app-id>/<rid>. Use --channel <name> to rebuild installers for a non-default channel after a promotion flow.

Explicit override example:

surge restore -i \
--channel production \
--version 1.2.3 \
--artifacts-dir ./publish \
--packages-dir .surge/packages

C API function groups

GroupFunctions
Lifecyclesurge_context_create, surge_context_destroy, surge_context_last_error
Configurationsurge_config_set_storage, surge_config_set_lock_server, surge_config_set_resource_budget
Update Managersurge_update_manager_create, surge_update_manager_destroy, surge_update_manager_set_channel, surge_update_manager_set_current_version, surge_update_manager_set_release_retention_limit, surge_update_manager_set_artifact_retention_policy, surge_update_check, surge_update_download_and_apply, surge_update_status_read_json, surge_free_cstring
Release Infosurge_releases_count, surge_releases_destroy, surge_release_version, surge_release_channel, surge_release_full_size, surge_release_is_genesis
Binary Diffsurge_bsdiff, surge_bspatch, surge_bsdiff_free, surge_bspatch_free
Pack Buildersurge_pack_create, surge_pack_build, surge_pack_push, surge_pack_destroy
Distributed Locksurge_lock_acquire, surge_lock_release
Supervisorsurge_supervisor_start, surge_supervisor_stop
Eventssurge_process_events
Cancellationsurge_cancel, surge_reset_cancel

Manifest reference

schema: 1storage:
provider: s3# s3 | azure_blob | gcs | github_releases | filesystembucket: my-bucket # bucket, container, owner/repo, or directoryregion: us-east-1 # cloud region (or release tag for github_releases)endpoint: ""# custom endpoint (MinIO, R2, etc.)prefix: ""# path prefix within bucketlock:
url: https://snapx.dev # distributed lock server (optional)pack: # optional; omitted uses built-in defaultsdelta:
strategy: sparse-file-opsmax_chain_length: 8chunked_patch_format: 1# 1 = readable by every client (default); 2 = identity-chunk bitset, needs clients that know format 2compression:
format: zstdlevel: 3retention:
keep_latest_fulls: 2checkpoint_every: 10cache: # optional device-side artifact cache policyinstallArtifacts:
retention: latest_full # release_graph | latest_full | just_installed | nonekeepFullCount: 1# full archives retained when retention is latest_fullapps:
- id: my-app # unique identifiername: My App # display namemain: my-app # main executable (defaults to id)installDirectory: my-app # install dir name (defaults to id)icon: icon.png # application iconchannels: [stable, beta] # supported channelsshortcuts: [desktop, start_menu, startup]persistentAssets: [config.json, user-data/]installers: [online, offline]environment:
MY_VAR: valuetarget:
rid: linux-x64 # linux-x64, win-x64, win-arm64, osx-x64, osx-arm64

Target-level settings override app-level defaults for icon, shortcuts, persistentAssets, installers, and environment. pack policy is global and controls delta strategy, compression, and remote full fallback retention for surge pack/surge push. The generated surge init policy optimizes managed fleets for fast latest-following updates: a node on N-1 should normally apply the direct N-1 -> N delta, while checkpoint fulls remain fallback baselines for recovery and stale installs. cache.installArtifacts controls package artifacts kept under .surge-cache/artifacts/ after setup and successful updates:

  • latest_full is the recommended managed-fleet setting. It keeps the newest keepFullCount full archives per RID and drops deltas, so normal updates stay delta-based while each device keeps a compact reinstall/restore cushion.
  • release_graph keeps the local release graph plus warm full checkpoints. Use it when offline restore to older versions matters; it uses the most disk.
  • just_installed keeps only the installed full archive when that archive is already cached. Use it when full-update reinstall warmth is useful but disk should stay tight. Delta-only updates do not synthesize a new full archive just to warm this cache.
  • none keeps no package artifacts after a successful update. Use it when optimizing for minimum disk usage and accepting that restore/reinstall downloads from storage again.

Use surge compact after rollout convergence, or for deliberate recovery/cleanup, when you want to prune old remote artifacts. Avoid making compaction the immediate default rollout step for a fleet that is still catching up.

Architecture

┌──────────────────────────────────────────────────────────┐
│ Your Application │
│ (.NET / C / C++ / any FFI) │
└─────────────────────────┬────────────────────────────────┘
│ P/Invoke or C calls
┌─────────────────────────▼────────────────────────────────┐
│ surge-ffi (cdylib) │
│ C ABI · surge_api.h │
└─────────────────────────┬────────────────────────────────┘
│
┌─────────────────────────▼────────────────────────────────┐
│ surge-core │
│ config · crypto · storage · archive · diff · releases │
│ update · pack · supervisor · platform · download │
└──────────────────────────────────────────────────────────┘
CrateDescription
surge-coreCore library — config, crypto, storage backends, archive (tar+zstd), bsdiff, release index, update manager, pack builder, supervisor, platform detection
surge-ffiC API shared library exporting the interface declared in surge_api.h
surge-cliCommand-line tool for packing, pushing, and managing releases
surge-supervisorStandalone process supervisor binary

Building from Source

git clone --recurse-submodules https://github.com/fintermobilityas/surge.git
cd surge

If you already cloned without --recurse-submodules:

git submodule update --init

Requirements

  • Rust 1.95+ (Edition 2024) — install via rustup
  • .NET 10 SDK (optional, for the .NET wrapper and demo app)

Build and test

cargo build --release
cargo test
cargo clippy --all-targets --all-features -- -D warnings
cargo fmt --all
cd dotnet
dotnet build --configuration Release
dotnet test --configuration Release

Release artifact trust

Official archives are covered by SHA256SUMS.txt, including the public C header. The Windows and macOS binaries are currently unsigned, and the macOS binaries are not notarized. Verify the published checksums before use and apply the signing/notarization required by your product distribution before shipping to end users.

License

MIT © 2026 Finter As

About

Automatic updates for any application. Built in Rust. Ships in 5 minutes.

Resources

Stars

10 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

Surge

Surge

Automatic updates for any application. Built in Rust. Ships in 5 minutes.

Why Surge5-Minute SetupCI/CDApp PlaybookHow It WorksFeaturesIntegrationReferenceBuilding


Why Surge

Your users should always be on the latest version. Chrome, VS Code, and Slack do this transparently — the app checks for updates, downloads a small patch, and applies it. The user never thinks about it.

Building that yourself means solving a dozen hard problems: hosting an update server, generating delta patches, handling partial downloads, supporting multiple platforms, managing release channels, coordinating deployments across servers, preserving user data across updates, creating installers, setting up shortcuts. Most teams either skip it entirely or ship a half-baked updater that breaks silently.

Surge gives you Chrome-style automatic updates for any application, on any platform, in about 5 minutes.

  • No update server to run. Releases are stored directly in S3, Azure Blob, GCS, GitHub Releases, or a plain directory. You already have one of these.
  • No framework lock-in. Surge is a native shared library with a stable C ABI. Call it from Rust, C, C++, .NET, Go, Python — anything that can load a .so or .dll.
  • Small downloads. Binary delta patches (bsdiff + zstd) mean users download only what changed between versions. Typically 5-20% of the full package.
  • Release channels. Ship to beta first, then promote the exact same build to stable when you're confident. No rebuild, no re-upload.
  • User data survives updates. Mark config files, databases, and user content as persistent assets — Surge preserves them across every version.
  • Fits your CI pipeline.surge pack and surge push are plain CLI commands. Add them to GitHub Actions, GitLab CI, or Jenkins — works in any matrix build across OS, architecture, and build variants.
  • Cross-platform from day one. Linux, Windows, and macOS. Native shortcuts (.desktop files, .lnk files, .app bundles), platform-correct install directories, and architecture detection built in.

5-Minute Setup

You need two things: somewhere to store your releases and the surge CLI.

If you are wiring Surge into CI, prefer the official release bundle for your platform. It includes the full publishing toolchain (surge, surge-supervisor, surge-installer, surge-installer-ui, and the native runtime) so pack/push jobs do not have to assemble it from multiple crates.

If your artifacts contain Surge.NET.dll but not the native runtime, surge pack will bundle the matching libsurge/surge.dll from the installed Surge toolchain automatically.

If you are publishing preview versions in GitHub Actions and do not want to ship prebuilt release bundles, the fastest CI pattern is:

  1. Check out Surge once per host architecture.
  2. Restore Rust build outputs with Swatinem/rust-cache.
  3. Run ./scripts/stage-toolchain-artifact.sh --output "$RUNNER_TEMP/surge-toolchain".
  4. Upload that directory as a workflow artifact.
  5. Download it in every publish job and prepend it to PATH.

That avoids repeated cargo install misses across the publish matrix and removes the separate libsurge bootstrap step.

1. Initialize your project

surge init --wizard

The wizard walks you through storage provider, app name, and target platform. Or do it non-interactively:

surge init \
--app-id my-app \
--name "My App" \
--provider s3 \
--bucket my-app-releases

The result is a surge.yml manifest:

schema: 1storage:
provider: s3bucket: my-app-releasesregion: us-east-1apps:
- id: my-appname: My Appmain: my-apptarget:
rid: linux-x64

Credentials are never stored in the manifest. Surge reads them from process environment variables (AWS_ACCESS_KEY_ID, GITHUB_TOKEN, etc.), from .env.surge files discovered next to the active manifest (and from project-root .env.surge when using the default .surge/surge.yml layout), from per-app overrides in .env.surge.<app-id>, or from provider-native identity mechanisms such as IAM roles.

Storage credentials with .env.surge

Use .env.surge when you want storage credentials to follow a project, manifest, or installer without exporting them globally in your shell or CI job.

Lookup rules:

  • Process environment variables always win.
  • With the default .surge/surge.yml layout, Surge loads <project>/.env.surge first and .surge/.env.surge second. Later files override earlier ones.
  • With a custom manifest path such as surge --manifest-path ./deploy/prod.yml ..., Surge loads ./deploy/.env.surge.
  • Per-app overrides live beside the shared file as .env.surge.<app-id> and override shared values for that app only.
  • surge install loads overrides from the manifest it actually installs from: .surge/application.yml if present, otherwise the fallback manifest path.
  • surge migrate scopes source and destination manifests separately, so each side can use different backend credentials safely.
  • surge setup reads .env.surge next to the extracted installer.yml.

Supported file syntax:

  • KEY=value
  • export KEY=value
  • blank lines and # comments
  • single-quoted or double-quoted values

Example project layout:

my-app/
├── .env.surge
├── .env.surge.admin-ui
└── .surge/
├── .env.surge
└── surge.yml

Example files:

# my-app/.env.surge
GITHUB_TOKEN=ghp_shared_token
# my-app/.env.surge.admin-ui
GITHUB_TOKEN=ghp_admin_ui_token
# my-app/.surge/.env.surge
GITHUB_TOKEN=ghp_local_override

In that layout:

  • Most commands against .surge/surge.yml see ghp_local_override.
  • Commands for app admin-ui first try .env.surge.admin-ui, then fall back to the shared .env.surge values for that same manifest scope.
  • Running with a different manifest path uses the .env.surge files next to that manifest instead of reusing another manifest's credentials.

2. Pack a release

Point Surge at your build output:

surge pack \
--app-id my-app \
--rid linux-x64 \
--version 1.0.0

By default, surge pack reads artifacts from .surge/artifacts/<app-id>/<rid>/<version>, writes packages to .surge/packages, and writes installers to .surge/installers/<app-id>/<rid>. Use --artifacts-dir/--output-dir to override.

Surge compresses everything into a tar.zst package. If a previous version exists in storage, it also generates a binary delta patch automatically.

If you want to benchmark pack policy on a real payload before publishing, run:

surge tune pack \
--app-id my-app \
--rid linux-x64 \
--version 1.0.0 \
--write-manifest

This benchmarks candidate pack settings on the current artifacts and can write the recommended pack.delta.strategy and pack.compression.level back to surge.yml.

3. Push to storage

surge push \
--app-id my-app \
--rid linux-x64 \
--version 1.0.0 \
--channel stable

Done. Your release is live. Clients on the stable channel will pick it up on their next update check.

Optional: install package (backend or Tailscale)

Install from the backend configured in .surge/application.yml (falls back to .surge/surge.yml):

surge install \
--channel stable

Override backend fields without editing manifest:

surge install backend \
--provider s3 \
--bucket my-release-bucket \
--region eu-north-1 \
--prefix production

Install to a remote node on your tailnet:

surge install tailscale \
--node my-node \
--node-user operator \
--channel stable

This command:

  • probes remote OS/architecture and checks for NVIDIA GPU support,
  • resolves the newest matching release on the selected channel,
  • downloads it locally and sends it with tailscale file cp.

Use --plan-only to preview selection without transfer, --rid to force a specific RID, or --force to reinstall even when the same version/channel is already installed on the target. If your tailnet requires explicit SSH identity, pass --node-user <account> (or set --node <account>@<node> directly).

4. Add update checking to your app

.NET

usingvarmgr=newSurgeUpdateManager();awaitmgr.UpdateToLatestReleaseAsync(onUpdatesAvailable: releases =>Console.WriteLine($"{releases.Count} update(s), latest: {releases.Latest?.Version}"),onAfterApplyUpdate: release =>Console.WriteLine($"Updated to {release.Version}"));

Rust

letmut mgr = UpdateManager::new(ctx,"my-app","1.0.0","stable", install_dir)?;ifletSome(info) = mgr.check_for_updates().await? {
mgr.download_and_apply(&info,None::<fn(_)>).await?;}

C / C++ / anything else

surge_update_manager*mgr=surge_update_manager_create(ctx, "my-app", "1.0.0", "stable", dir);
surge_releases_info*info=NULL;
if (surge_update_check(mgr, &info) ==SURGE_OK) {
surge_update_download_and_apply(mgr, info, progress_cb, NULL);
surge_releases_destroy(info);
}
surge_update_manager_destroy(mgr);
surge_context_destroy(ctx);

CI/CD Integration

Surge is built for automated pipelines. The CLI does all the heavy lifting — your CI just calls surge pack and surge push after each build. GitHub Actions is the most common setup.

Single-platform example

# .github/workflows/release.ymljobs:
build:
runs-on: ubuntu-lateststeps:
- uses: actions/checkout@v6
- run: cargo build --release
- run: surge pack --version ${{ env.VERSION }}
- run: surge push --version ${{ env.VERSION }} --channel stable

Multi-platform matrix

Real applications target multiple OS and architecture combinations. Use a matrix strategy to build each variant in parallel, then pack and push each one:

jobs:
build:
strategy:
matrix:
include:
- os: ubuntu-latestrid: linux-x64
- os: windows-latestrid: win-x64
- os: macos-latestrid: osx-arm64runs-on: ${{ matrix.os }}steps:
- uses: actions/checkout@v6
- run: dotnet publish -c Release -r ${{ matrix.rid }}
- run: surge pack --rid ${{ matrix.rid }} --version ${{ env.VERSION }}
- run: surge push --rid ${{ matrix.rid }} --version ${{ env.VERSION }} --channel stable

Each matrix entry produces its own platform-specific package and delta patch. Clients only download the package matching their OS and architecture.

Staged rollouts

Combine matrix builds with channel promotion for safe deployments:

jobs:
deploy-beta:
needs: [build]runs-on: ubuntu-latestif: github.ref == 'refs/heads/develop'steps:
- run: surge push --version ${{ env.VERSION }} --channel betapromote-stable:
needs: [build]runs-on: ubuntu-latestif: github.ref == 'refs/heads/main'steps:
- run: surge promote --version ${{ env.VERSION }} --from beta --to stable

Push to develop ships to beta testers. Merge to main promotes the exact same build to stable — no rebuild, no re-upload, no risk of a different binary reaching production.

Distributed lock for safe concurrent pushes

When multiple matrix jobs push to the same storage backend, use the distributed lock to prevent race conditions on the release index:

steps:
- run: surge lock acquire --name "${{ matrix.rid }}-deploy"
- run: surge push --version ${{ env.VERSION }} --rid ${{ matrix.rid }} --channel stable
- run: surge lock release --name "${{ matrix.rid }}-deploy"

How It Works

 You (developer) Your Users
────────────── ──────────
cargo build / dotnet publish
│
▼
surge pack ──► tar.zst full package
+ bsdiff delta patch
│
▼
surge push ──► S3 / Azure / GCS / GitHub Releases / filesystem
│
│ release index (compressed YAML)
│ + package files
│
▼
┌──────────────┐
│ Cloud Storage │
└──────┬───────┘
│
┌───────────┼───────────┐
▼ ▼ ▼
Linux Windows macOS
app app app
│ │ │
└───────────┴───────────┘
│
check_for_updates()
download_and_apply()
│
▼
Update applied.
User never noticed.

The update pipeline

When a client calls download_and_apply, Surge runs a 6-phase pipeline:

  1. Check — validate update info and prepare staging directory
  2. Download — fetch delta patch (or full package as fallback) from storage
  3. Verify — SHA-256 hash check of every downloaded file
  4. Extract — decompress the tar.zst archive
  5. Apply delta — apply bsdiff patches if using delta updates
  6. Finalize — atomic move into place, clean up staging, preserve persistent assets

Progress callbacks fire at each phase with percentage, bytes transferred, and speed.

Features

Release channels

Channels are labels on releases. A single version can be on multiple channels simultaneously.

# Ship to beta testers first
surge push --version 2.1.0 --channel beta
# A week later, promote the exact same build to stable (no re-upload)
surge promote --version 2.1.0 --from beta --to stable
# Something wrong? Pull it back
surge demote --version 2.1.0 --channel stable

Clients specify which channel they follow. Switching channels at runtime is a single API call — useful for opt-in beta programs.

Persistent assets

Files and directories that should survive across updates:

apps:
- id: my-apppersistentAssets:
- config.json
- user-data/
- settings.ini

During updates, Surge copies these from the old version directory to the new one before removing the old version.

Platform-native shortcuts

apps:
- id: my-appicon: icon.pngshortcuts:
- desktop
- start_menu
- startup

Surge creates real platform shortcuts:

  • Linux.desktop files in ~/.local/share/applications and ~/.config/autostart (XDG freedesktop spec)
  • Windows.lnk shortcuts on Desktop, Start Menu, and Startup via WScript.Shell
  • macOS.app bundles with Info.plist in ~/Applications, LaunchAgent for startup

Process supervisor

The supervisor binary monitors your application, restarts on crash, and coordinates version handoffs:

surge-supervisor --supervisor-id <uuid> --install-dir /opt/my-app --exe-path /opt/my-app/my-app

Or from code:

SurgeApp.StartSupervisor();

It handles graceful shutdown on SIGTERM/SIGINT (Unix) and Ctrl+C (Windows).

Lifecycle events

Hook into first-run, post-install, and post-update events:

if(SurgeApp.ProcessEvents(args,onFirstRun: v =>ShowWelcomeScreen(),onInstalled: v =>RunMigrations(),onUpdated: v =>ShowChangelogFor(v))){return;}

Installer generation

Surge can produce installer bundles in two modes:

target:
rid: win-x64installers:
- online # Small bootstrap, downloads app on first run
- offline # Self-contained, includes full package

Resource budgets

Throttle resource usage for constrained environments:

varbudget=newSurgeResourceBudget{MaxMemoryBytes=256*1024*1024,// 256 MBMaxConcurrentDownloads=2,MaxDownloadSpeedBps=1_000_000,// 1 MB/sZstdCompressionLevel=6// faster compression};

Distributed locking

For server-side deployments where multiple CI runners might push releases concurrently, Surge provides a distributed mutex via snapx.dev:

surge lock acquire --name "my-app-deploy" --timeout 300
# ... push release ...
surge lock release --name "my-app-deploy"

Backend migration

Move all your releases from one storage provider to another without downtime:

surge migrate --dest-manifest new-backend.yml

Storage Backends

Use whatever you already have.

ProviderConfig valueNotes
Amazon S3s3Any S3-compatible API (MinIO, Cloudflare R2, DigitalOcean Spaces). Auth via AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY or IAM roles
Azure Blob Storageazure_blobAuth via AZURE_STORAGE_ACCOUNT_NAME/AZURE_STORAGE_ACCOUNT_KEY
Google Cloud StoragegcsAuth via GOOGLE_APPLICATION_CREDENTIALS or application default credentials
GitHub Releasesgithub_releasesFree for public repos. bucket = owner/repo. Auth via GITHUB_TOKEN
Local filesystemfilesystemFor testing or air-gapped environments. bucket = root directory path

Integration

For a production app integration checklist, smoke expectations, and guidance for both humans and agents, see docs/integrating-surge.md.

Surge is a native shared library (libsurge.so / surge.dll / libsurge.dylib) with a C ABI. You don't need Rust in your project.

.NET

The Surge.NET NuGet package provides the full API:

  • netstandard2.0[DllImport] for .NET Framework 4.6.1+, .NET Core, Mono, Xamarin
  • net10.0[LibraryImport] with full AOT and trimming support
  • Zero external managed dependencies; ship the matching native Surge library from the same version and runtime identifier with the application
  • SurgeUpdateManager.UpdateToLatestReleaseAsync() — one call that checks, downloads, verifies, extracts, and applies
  • Per-phase progress callbacks, cancellation tokens, pre/post-update hooks

C / C++

Include surge_api.h and link against the shared library. The API uses opaque handles, surge_result return codes, explicit ownership rules, and thread-safe cancellation.

Rust

Use surge-core as a Cargo dependency for direct access to the async API without the FFI overhead.

Reference

CLI commands

surge init Create a surge.yml manifest (--wizard for interactive)
surge pack Build full and delta packages from artifacts
surge tune Benchmark pack policy candidates
surge push Upload packages and update the release index
surge list List releases on a channel
surge promote Promote a release to another channel
surge demote Remove a release from a channel
surge migrate Copy releases between storage backends
surge restore Restore artifacts from backup
surge install Install package via method (backend, tailscale)
surge lock Acquire/release distributed locks

If the manifest has one app, --app-id is optional. If the app has one target, --rid is optional. surge list now defaults to a status overview table. For multi-app manifests it shows one row per app/rid by default; use --app-id (and optionally --rid) to scope down.

surge restore also supports installer-only generation (snapx-style restore -i) from existing full packages:

surge restore -i

By default this resolves the latest release for the manifest app/target on the app's default channel, restores missing full packages from storage into .surge/packages, and builds installers using artifacts from .surge/artifacts/<app-id>/<rid>/<version>. The generated installers are written to .surge/installers/<app-id>/<rid>. Use --channel <name> to rebuild installers for a non-default channel after a promotion flow.

Explicit override example:

surge restore -i \
--channel production \
--version 1.2.3 \
--artifacts-dir ./publish \
--packages-dir .surge/packages

C API function groups

GroupFunctions
Lifecyclesurge_context_create, surge_context_destroy, surge_context_last_error
Configurationsurge_config_set_storage, surge_config_set_lock_server, surge_config_set_resource_budget
Update Managersurge_update_manager_create, surge_update_manager_destroy, surge_update_manager_set_channel, surge_update_manager_set_current_version, surge_update_manager_set_release_retention_limit, surge_update_manager_set_artifact_retention_policy, surge_update_check, surge_update_download_and_apply, surge_update_status_read_json, surge_free_cstring
Release Infosurge_releases_count, surge_releases_destroy, surge_release_version, surge_release_channel, surge_release_full_size, surge_release_is_genesis
Binary Diffsurge_bsdiff, surge_bspatch, surge_bsdiff_free, surge_bspatch_free
Pack Buildersurge_pack_create, surge_pack_build, surge_pack_push, surge_pack_destroy
Distributed Locksurge_lock_acquire, surge_lock_release
Supervisorsurge_supervisor_start, surge_supervisor_stop
Eventssurge_process_events
Cancellationsurge_cancel, surge_reset_cancel

Manifest reference

schema: 1storage:
provider: s3# s3 | azure_blob | gcs | github_releases | filesystembucket: my-bucket # bucket, container, owner/repo, or directoryregion: us-east-1 # cloud region (or release tag for github_releases)endpoint: ""# custom endpoint (MinIO, R2, etc.)prefix: ""# path prefix within bucketlock:
url: https://snapx.dev # distributed lock server (optional)pack: # optional; omitted uses built-in defaultsdelta:
strategy: sparse-file-opsmax_chain_length: 8chunked_patch_format: 1# 1 = readable by every client (default); 2 = identity-chunk bitset, needs clients that know format 2compression:
format: zstdlevel: 3retention:
keep_latest_fulls: 2checkpoint_every: 10cache: # optional device-side artifact cache policyinstallArtifacts:
retention: latest_full # release_graph | latest_full | just_installed | nonekeepFullCount: 1# full archives retained when retention is latest_fullapps:
- id: my-app # unique identifiername: My App # display namemain: my-app # main executable (defaults to id)installDirectory: my-app # install dir name (defaults to id)icon: icon.png # application iconchannels: [stable, beta] # supported channelsshortcuts: [desktop, start_menu, startup]persistentAssets: [config.json, user-data/]installers: [online, offline]environment:
MY_VAR: valuetarget:
rid: linux-x64 # linux-x64, win-x64, win-arm64, osx-x64, osx-arm64

Target-level settings override app-level defaults for icon, shortcuts, persistentAssets, installers, and environment. pack policy is global and controls delta strategy, compression, and remote full fallback retention for surge pack/surge push. The generated surge init policy optimizes managed fleets for fast latest-following updates: a node on N-1 should normally apply the direct N-1 -> N delta, while checkpoint fulls remain fallback baselines for recovery and stale installs. cache.installArtifacts controls package artifacts kept under .surge-cache/artifacts/ after setup and successful updates:

  • latest_full is the recommended managed-fleet setting. It keeps the newest keepFullCount full archives per RID and drops deltas, so normal updates stay delta-based while each device keeps a compact reinstall/restore cushion.
  • release_graph keeps the local release graph plus warm full checkpoints. Use it when offline restore to older versions matters; it uses the most disk.
  • just_installed keeps only the installed full archive when that archive is already cached. Use it when full-update reinstall warmth is useful but disk should stay tight. Delta-only updates do not synthesize a new full archive just to warm this cache.
  • none keeps no package artifacts after a successful update. Use it when optimizing for minimum disk usage and accepting that restore/reinstall downloads from storage again.

Use surge compact after rollout convergence, or for deliberate recovery/cleanup, when you want to prune old remote artifacts. Avoid making compaction the immediate default rollout step for a fleet that is still catching up.

Architecture

┌──────────────────────────────────────────────────────────┐
│ Your Application │
│ (.NET / C / C++ / any FFI) │
└─────────────────────────┬────────────────────────────────┘
│ P/Invoke or C calls
┌─────────────────────────▼────────────────────────────────┐
│ surge-ffi (cdylib) │
│ C ABI · surge_api.h │
└─────────────────────────┬────────────────────────────────┘
│
┌─────────────────────────▼────────────────────────────────┐
│ surge-core │
│ config · crypto · storage · archive · diff · releases │
│ update · pack · supervisor · platform · download │
└──────────────────────────────────────────────────────────┘
CrateDescription
surge-coreCore library — config, crypto, storage backends, archive (tar+zstd), bsdiff, release index, update manager, pack builder, supervisor, platform detection
surge-ffiC API shared library exporting the interface declared in surge_api.h
surge-cliCommand-line tool for packing, pushing, and managing releases
surge-supervisorStandalone process supervisor binary

Building from Source

git clone --recurse-submodules https://github.com/fintermobilityas/surge.git
cd surge

If you already cloned without --recurse-submodules:

git submodule update --init

Requirements

  • Rust 1.95+ (Edition 2024) — install via rustup
  • .NET 10 SDK (optional, for the .NET wrapper and demo app)

Build and test

cargo build --release
cargo test
cargo clippy --all-targets --all-features -- -D warnings
cargo fmt --all
cd dotnet
dotnet build --configuration Release
dotnet test --configuration Release

Release artifact trust

Official archives are covered by SHA256SUMS.txt, including the public C header. The Windows and macOS binaries are currently unsigned, and the macOS binaries are not notarized. Verify the published checksums before use and apply the signing/notarization required by your product distribution before shipping to end users.

License

MIT © 2026 Finter As

About

Automatic updates for any application. Built in Rust. Ships in 5 minutes.

Resources

Stars

10 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Surge

Surge

Automatic updates for any application. Built in Rust. Ships in 5 minutes.

Why Surge5-Minute SetupCI/CDApp PlaybookHow It WorksFeaturesIntegrationReferenceBuilding


Why Surge

Your users should always be on the latest version. Chrome, VS Code, and Slack do this transparently — the app checks for updates, downloads a small patch, and applies it. The user never thinks about it.

Building that yourself means solving a dozen hard problems: hosting an update server, generating delta patches, handling partial downloads, supporting multiple platforms, managing release channels, coordinating deployments across servers, preserving user data across updates, creating installers, setting up shortcuts. Most teams either skip it entirely or ship a half-baked updater that breaks silently.

Surge gives you Chrome-style automatic updates for any application, on any platform, in about 5 minutes.

  • No update server to run. Releases are stored directly in S3, Azure Blob, GCS, GitHub Releases, or a plain directory. You already have one of these.
  • No framework lock-in. Surge is a native shared library with a stable C ABI. Call it from Rust, C, C++, .NET, Go, Python — anything that can load a .so or .dll.
  • Small downloads. Binary delta patches (bsdiff + zstd) mean users download only what changed between versions. Typically 5-20% of the full package.
  • Release channels. Ship to beta first, then promote the exact same build to stable when you're confident. No rebuild, no re-upload.
  • User data survives updates. Mark config files, databases, and user content as persistent assets — Surge preserves them across every version.
  • Fits your CI pipeline.surge pack and surge push are plain CLI commands. Add them to GitHub Actions, GitLab CI, or Jenkins — works in any matrix build across OS, architecture, and build variants.
  • Cross-platform from day one. Linux, Windows, and macOS. Native shortcuts (.desktop files, .lnk files, .app bundles), platform-correct install directories, and architecture detection built in.

5-Minute Setup

You need two things: somewhere to store your releases and the surge CLI.

If you are wiring Surge into CI, prefer the official release bundle for your platform. It includes the full publishing toolchain (surge, surge-supervisor, surge-installer, surge-installer-ui, and the native runtime) so pack/push jobs do not have to assemble it from multiple crates.

If your artifacts contain Surge.NET.dll but not the native runtime, surge pack will bundle the matching libsurge/surge.dll from the installed Surge toolchain automatically.

If you are publishing preview versions in GitHub Actions and do not want to ship prebuilt release bundles, the fastest CI pattern is:

  1. Check out Surge once per host architecture.
  2. Restore Rust build outputs with Swatinem/rust-cache.
  3. Run ./scripts/stage-toolchain-artifact.sh --output "$RUNNER_TEMP/surge-toolchain".
  4. Upload that directory as a workflow artifact.
  5. Download it in every publish job and prepend it to PATH.

That avoids repeated cargo install misses across the publish matrix and removes the separate libsurge bootstrap step.

1. Initialize your project

surge init --wizard

The wizard walks you through storage provider, app name, and target platform. Or do it non-interactively:

surge init \
--app-id my-app \
--name "My App" \
--provider s3 \
--bucket my-app-releases

The result is a surge.yml manifest:

schema: 1storage:
provider: s3bucket: my-app-releasesregion: us-east-1apps:
- id: my-appname: My Appmain: my-apptarget:
rid: linux-x64

Credentials are never stored in the manifest. Surge reads them from process environment variables (AWS_ACCESS_KEY_ID, GITHUB_TOKEN, etc.), from .env.surge files discovered next to the active manifest (and from project-root .env.surge when using the default .surge/surge.yml layout), from per-app overrides in .env.surge.<app-id>, or from provider-native identity mechanisms such as IAM roles.

Storage credentials with .env.surge

Use .env.surge when you want storage credentials to follow a project, manifest, or installer without exporting them globally in your shell or CI job.

Lookup rules:

  • Process environment variables always win.
  • With the default .surge/surge.yml layout, Surge loads <project>/.env.surge first and .surge/.env.surge second. Later files override earlier ones.
  • With a custom manifest path such as surge --manifest-path ./deploy/prod.yml ..., Surge loads ./deploy/.env.surge.
  • Per-app overrides live beside the shared file as .env.surge.<app-id> and override shared values for that app only.
  • surge install loads overrides from the manifest it actually installs from: .surge/application.yml if present, otherwise the fallback manifest path.
  • surge migrate scopes source and destination manifests separately, so each side can use different backend credentials safely.
  • surge setup reads .env.surge next to the extracted installer.yml.

Supported file syntax:

  • KEY=value
  • export KEY=value
  • blank lines and # comments
  • single-quoted or double-quoted values

Example project layout:

my-app/
├── .env.surge
├── .env.surge.admin-ui
└── .surge/
├── .env.surge
└── surge.yml

Example files:

# my-app/.env.surge
GITHUB_TOKEN=ghp_shared_token
# my-app/.env.surge.admin-ui
GITHUB_TOKEN=ghp_admin_ui_token
# my-app/.surge/.env.surge
GITHUB_TOKEN=ghp_local_override

In that layout:

  • Most commands against .surge/surge.yml see ghp_local_override.
  • Commands for app admin-ui first try .env.surge.admin-ui, then fall back to the shared .env.surge values for that same manifest scope.
  • Running with a different manifest path uses the .env.surge files next to that manifest instead of reusing another manifest's credentials.

2. Pack a release

Point Surge at your build output:

surge pack \
--app-id my-app \
--rid linux-x64 \
--version 1.0.0

By default, surge pack reads artifacts from .surge/artifacts/<app-id>/<rid>/<version>, writes packages to .surge/packages, and writes installers to .surge/installers/<app-id>/<rid>. Use --artifacts-dir/--output-dir to override.

Surge compresses everything into a tar.zst package. If a previous version exists in storage, it also generates a binary delta patch automatically.

If you want to benchmark pack policy on a real payload before publishing, run:

surge tune pack \
--app-id my-app \
--rid linux-x64 \
--version 1.0.0 \
--write-manifest

This benchmarks candidate pack settings on the current artifacts and can write the recommended pack.delta.strategy and pack.compression.level back to surge.yml.

3. Push to storage

surge push \
--app-id my-app \
--rid linux-x64 \
--version 1.0.0 \
--channel stable

Done. Your release is live. Clients on the stable channel will pick it up on their next update check.

Optional: install package (backend or Tailscale)

Install from the backend configured in .surge/application.yml (falls back to .surge/surge.yml):

surge install \
--channel stable

Override backend fields without editing manifest:

surge install backend \
--provider s3 \
--bucket my-release-bucket \
--region eu-north-1 \
--prefix production

Install to a remote node on your tailnet:

surge install tailscale \
--node my-node \
--node-user operator \
--channel stable

This command:

  • probes remote OS/architecture and checks for NVIDIA GPU support,
  • resolves the newest matching release on the selected channel,
  • downloads it locally and sends it with tailscale file cp.

Use --plan-only to preview selection without transfer, --rid to force a specific RID, or --force to reinstall even when the same version/channel is already installed on the target. If your tailnet requires explicit SSH identity, pass --node-user <account> (or set --node <account>@<node> directly).

4. Add update checking to your app

.NET

usingvarmgr=newSurgeUpdateManager();awaitmgr.UpdateToLatestReleaseAsync(onUpdatesAvailable: releases =>Console.WriteLine($"{releases.Count} update(s), latest: {releases.Latest?.Version}"),onAfterApplyUpdate: release =>Console.WriteLine($"Updated to {release.Version}"));

Rust

letmut mgr = UpdateManager::new(ctx,"my-app","1.0.0","stable", install_dir)?;ifletSome(info) = mgr.check_for_updates().await? {
mgr.download_and_apply(&info,None::<fn(_)>).await?;}

C / C++ / anything else

surge_update_manager*mgr=surge_update_manager_create(ctx, "my-app", "1.0.0", "stable", dir);
surge_releases_info*info=NULL;
if (surge_update_check(mgr, &info) ==SURGE_OK) {
surge_update_download_and_apply(mgr, info, progress_cb, NULL);
surge_releases_destroy(info);
}
surge_update_manager_destroy(mgr);
surge_context_destroy(ctx);

CI/CD Integration

Surge is built for automated pipelines. The CLI does all the heavy lifting — your CI just calls surge pack and surge push after each build. GitHub Actions is the most common setup.

Single-platform example

# .github/workflows/release.ymljobs:
build:
runs-on: ubuntu-lateststeps:
- uses: actions/checkout@v6
- run: cargo build --release
- run: surge pack --version ${{ env.VERSION }}
- run: surge push --version ${{ env.VERSION }} --channel stable

Multi-platform matrix

Real applications target multiple OS and architecture combinations. Use a matrix strategy to build each variant in parallel, then pack and push each one:

jobs:
build:
strategy:
matrix:
include:
- os: ubuntu-latestrid: linux-x64
- os: windows-latestrid: win-x64
- os: macos-latestrid: osx-arm64runs-on: ${{ matrix.os }}steps:
- uses: actions/checkout@v6
- run: dotnet publish -c Release -r ${{ matrix.rid }}
- run: surge pack --rid ${{ matrix.rid }} --version ${{ env.VERSION }}
- run: surge push --rid ${{ matrix.rid }} --version ${{ env.VERSION }} --channel stable

Each matrix entry produces its own platform-specific package and delta patch. Clients only download the package matching their OS and architecture.

Staged rollouts

Combine matrix builds with channel promotion for safe deployments:

jobs:
deploy-beta:
needs: [build]runs-on: ubuntu-latestif: github.ref == 'refs/heads/develop'steps:
- run: surge push --version ${{ env.VERSION }} --channel betapromote-stable:
needs: [build]runs-on: ubuntu-latestif: github.ref == 'refs/heads/main'steps:
- run: surge promote --version ${{ env.VERSION }} --from beta --to stable

Push to develop ships to beta testers. Merge to main promotes the exact same build to stable — no rebuild, no re-upload, no risk of a different binary reaching production.

Distributed lock for safe concurrent pushes

When multiple matrix jobs push to the same storage backend, use the distributed lock to prevent race conditions on the release index:

steps:
- run: surge lock acquire --name "${{ matrix.rid }}-deploy"
- run: surge push --version ${{ env.VERSION }} --rid ${{ matrix.rid }} --channel stable
- run: surge lock release --name "${{ matrix.rid }}-deploy"

How It Works

 You (developer) Your Users
────────────── ──────────
cargo build / dotnet publish
│
▼
surge pack ──► tar.zst full package
+ bsdiff delta patch
│
▼
surge push ──► S3 / Azure / GCS / GitHub Releases / filesystem
│
│ release index (compressed YAML)
│ + package files
│
▼
┌──────────────┐
│ Cloud Storage │
└──────┬───────┘
│
┌───────────┼───────────┐
▼ ▼ ▼
Linux Windows macOS
app app app
│ │ │
└───────────┴───────────┘
│
check_for_updates()
download_and_apply()
│
▼
Update applied.
User never noticed.

The update pipeline

When a client calls download_and_apply, Surge runs a 6-phase pipeline:

  1. Check — validate update info and prepare staging directory
  2. Download — fetch delta patch (or full package as fallback) from storage
  3. Verify — SHA-256 hash check of every downloaded file
  4. Extract — decompress the tar.zst archive
  5. Apply delta — apply bsdiff patches if using delta updates
  6. Finalize — atomic move into place, clean up staging, preserve persistent assets

Progress callbacks fire at each phase with percentage, bytes transferred, and speed.

Features

Release channels

Channels are labels on releases. A single version can be on multiple channels simultaneously.

# Ship to beta testers first
surge push --version 2.1.0 --channel beta
# A week later, promote the exact same build to stable (no re-upload)
surge promote --version 2.1.0 --from beta --to stable
# Something wrong? Pull it back
surge demote --version 2.1.0 --channel stable

Clients specify which channel they follow. Switching channels at runtime is a single API call — useful for opt-in beta programs.

Persistent assets

Files and directories that should survive across updates:

apps:
- id: my-apppersistentAssets:
- config.json
- user-data/
- settings.ini

During updates, Surge copies these from the old version directory to the new one before removing the old version.

Platform-native shortcuts

apps:
- id: my-appicon: icon.pngshortcuts:
- desktop
- start_menu
- startup

Surge creates real platform shortcuts:

  • Linux.desktop files in ~/.local/share/applications and ~/.config/autostart (XDG freedesktop spec)
  • Windows.lnk shortcuts on Desktop, Start Menu, and Startup via WScript.Shell
  • macOS.app bundles with Info.plist in ~/Applications, LaunchAgent for startup

Process supervisor

The supervisor binary monitors your application, restarts on crash, and coordinates version handoffs:

surge-supervisor --supervisor-id <uuid> --install-dir /opt/my-app --exe-path /opt/my-app/my-app

Or from code:

SurgeApp.StartSupervisor();

It handles graceful shutdown on SIGTERM/SIGINT (Unix) and Ctrl+C (Windows).

Lifecycle events

Hook into first-run, post-install, and post-update events:

if(SurgeApp.ProcessEvents(args,onFirstRun: v =>ShowWelcomeScreen(),onInstalled: v =>RunMigrations(),onUpdated: v =>ShowChangelogFor(v))){return;}

Installer generation

Surge can produce installer bundles in two modes:

target:
rid: win-x64installers:
- online # Small bootstrap, downloads app on first run
- offline # Self-contained, includes full package

Resource budgets

Throttle resource usage for constrained environments:

varbudget=newSurgeResourceBudget{MaxMemoryBytes=256*1024*1024,// 256 MBMaxConcurrentDownloads=2,MaxDownloadSpeedBps=1_000_000,// 1 MB/sZstdCompressionLevel=6// faster compression};

Distributed locking

For server-side deployments where multiple CI runners might push releases concurrently, Surge provides a distributed mutex via snapx.dev:

surge lock acquire --name "my-app-deploy" --timeout 300
# ... push release ...
surge lock release --name "my-app-deploy"

Backend migration

Move all your releases from one storage provider to another without downtime:

surge migrate --dest-manifest new-backend.yml

Storage Backends

Use whatever you already have.

ProviderConfig valueNotes
Amazon S3s3Any S3-compatible API (MinIO, Cloudflare R2, DigitalOcean Spaces). Auth via AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY or IAM roles
Azure Blob Storageazure_blobAuth via AZURE_STORAGE_ACCOUNT_NAME/AZURE_STORAGE_ACCOUNT_KEY
Google Cloud StoragegcsAuth via GOOGLE_APPLICATION_CREDENTIALS or application default credentials
GitHub Releasesgithub_releasesFree for public repos. bucket = owner/repo. Auth via GITHUB_TOKEN
Local filesystemfilesystemFor testing or air-gapped environments. bucket = root directory path

Integration

For a production app integration checklist, smoke expectations, and guidance for both humans and agents, see docs/integrating-surge.md.

Surge is a native shared library (libsurge.so / surge.dll / libsurge.dylib) with a C ABI. You don't need Rust in your project.

.NET

The Surge.NET NuGet package provides the full API:

  • netstandard2.0[DllImport] for .NET Framework 4.6.1+, .NET Core, Mono, Xamarin
  • net10.0[LibraryImport] with full AOT and trimming support
  • Zero external managed dependencies; ship the matching native Surge library from the same version and runtime identifier with the application
  • SurgeUpdateManager.UpdateToLatestReleaseAsync() — one call that checks, downloads, verifies, extracts, and applies
  • Per-phase progress callbacks, cancellation tokens, pre/post-update hooks

C / C++

Include surge_api.h and link against the shared library. The API uses opaque handles, surge_result return codes, explicit ownership rules, and thread-safe cancellation.

Rust

Use surge-core as a Cargo dependency for direct access to the async API without the FFI overhead.

Reference

CLI commands

surge init Create a surge.yml manifest (--wizard for interactive)
surge pack Build full and delta packages from artifacts
surge tune Benchmark pack policy candidates
surge push Upload packages and update the release index
surge list List releases on a channel
surge promote Promote a release to another channel
surge demote Remove a release from a channel
surge migrate Copy releases between storage backends
surge restore Restore artifacts from backup
surge install Install package via method (backend, tailscale)
surge lock Acquire/release distributed locks

If the manifest has one app, --app-id is optional. If the app has one target, --rid is optional. surge list now defaults to a status overview table. For multi-app manifests it shows one row per app/rid by default; use --app-id (and optionally --rid) to scope down.

surge restore also supports installer-only generation (snapx-style restore -i) from existing full packages:

surge restore -i

By default this resolves the latest release for the manifest app/target on the app's default channel, restores missing full packages from storage into .surge/packages, and builds installers using artifacts from .surge/artifacts/<app-id>/<rid>/<version>. The generated installers are written to .surge/installers/<app-id>/<rid>. Use --channel <name> to rebuild installers for a non-default channel after a promotion flow.

Explicit override example:

surge restore -i \
--channel production \
--version 1.2.3 \
--artifacts-dir ./publish \
--packages-dir .surge/packages

C API function groups

GroupFunctions
Lifecyclesurge_context_create, surge_context_destroy, surge_context_last_error
Configurationsurge_config_set_storage, surge_config_set_lock_server, surge_config_set_resource_budget
Update Managersurge_update_manager_create, surge_update_manager_destroy, surge_update_manager_set_channel, surge_update_manager_set_current_version, surge_update_manager_set_release_retention_limit, surge_update_manager_set_artifact_retention_policy, surge_update_check, surge_update_download_and_apply, surge_update_status_read_json, surge_free_cstring
Release Infosurge_releases_count, surge_releases_destroy, surge_release_version, surge_release_channel, surge_release_full_size, surge_release_is_genesis
Binary Diffsurge_bsdiff, surge_bspatch, surge_bsdiff_free, surge_bspatch_free
Pack Buildersurge_pack_create, surge_pack_build, surge_pack_push, surge_pack_destroy
Distributed Locksurge_lock_acquire, surge_lock_release
Supervisorsurge_supervisor_start, surge_supervisor_stop
Eventssurge_process_events
Cancellationsurge_cancel, surge_reset_cancel

Manifest reference

schema: 1storage:
provider: s3# s3 | azure_blob | gcs | github_releases | filesystembucket: my-bucket # bucket, container, owner/repo, or directoryregion: us-east-1 # cloud region (or release tag for github_releases)endpoint: ""# custom endpoint (MinIO, R2, etc.)prefix: ""# path prefix within bucketlock:
url: https://snapx.dev # distributed lock server (optional)pack: # optional; omitted uses built-in defaultsdelta:
strategy: sparse-file-opsmax_chain_length: 8chunked_patch_format: 1# 1 = readable by every client (default); 2 = identity-chunk bitset, needs clients that know format 2compression:
format: zstdlevel: 3retention:
keep_latest_fulls: 2checkpoint_every: 10cache: # optional device-side artifact cache policyinstallArtifacts:
retention: latest_full # release_graph | latest_full | just_installed | nonekeepFullCount: 1# full archives retained when retention is latest_fullapps:
- id: my-app # unique identifiername: My App # display namemain: my-app # main executable (defaults to id)installDirectory: my-app # install dir name (defaults to id)icon: icon.png # application iconchannels: [stable, beta] # supported channelsshortcuts: [desktop, start_menu, startup]persistentAssets: [config.json, user-data/]installers: [online, offline]environment:
MY_VAR: valuetarget:
rid: linux-x64 # linux-x64, win-x64, win-arm64, osx-x64, osx-arm64

Target-level settings override app-level defaults for icon, shortcuts, persistentAssets, installers, and environment. pack policy is global and controls delta strategy, compression, and remote full fallback retention for surge pack/surge push. The generated surge init policy optimizes managed fleets for fast latest-following updates: a node on N-1 should normally apply the direct N-1 -> N delta, while checkpoint fulls remain fallback baselines for recovery and stale installs. cache.installArtifacts controls package artifacts kept under .surge-cache/artifacts/ after setup and successful updates:

  • latest_full is the recommended managed-fleet setting. It keeps the newest keepFullCount full archives per RID and drops deltas, so normal updates stay delta-based while each device keeps a compact reinstall/restore cushion.
  • release_graph keeps the local release graph plus warm full checkpoints. Use it when offline restore to older versions matters; it uses the most disk.
  • just_installed keeps only the installed full archive when that archive is already cached. Use it when full-update reinstall warmth is useful but disk should stay tight. Delta-only updates do not synthesize a new full archive just to warm this cache.
  • none keeps no package artifacts after a successful update. Use it when optimizing for minimum disk usage and accepting that restore/reinstall downloads from storage again.

Use surge compact after rollout convergence, or for deliberate recovery/cleanup, when you want to prune old remote artifacts. Avoid making compaction the immediate default rollout step for a fleet that is still catching up.

Architecture

┌──────────────────────────────────────────────────────────┐
│ Your Application │
│ (.NET / C / C++ / any FFI) │
└─────────────────────────┬────────────────────────────────┘
│ P/Invoke or C calls
┌─────────────────────────▼────────────────────────────────┐
│ surge-ffi (cdylib) │
│ C ABI · surge_api.h │
└─────────────────────────┬────────────────────────────────┘
│
┌─────────────────────────▼────────────────────────────────┐
│ surge-core │
│ config · crypto · storage · archive · diff · releases │
│ update · pack · supervisor · platform · download │
└──────────────────────────────────────────────────────────┘
CrateDescription
surge-coreCore library — config, crypto, storage backends, archive (tar+zstd), bsdiff, release index, update manager, pack builder, supervisor, platform detection
surge-ffiC API shared library exporting the interface declared in surge_api.h
surge-cliCommand-line tool for packing, pushing, and managing releases
surge-supervisorStandalone process supervisor binary

Building from Source

git clone --recurse-submodules https://github.com/fintermobilityas/surge.git
cd surge

If you already cloned without --recurse-submodules:

git submodule update --init

Requirements

  • Rust 1.95+ (Edition 2024) — install via rustup
  • .NET 10 SDK (optional, for the .NET wrapper and demo app)

Build and test

cargo build --release
cargo test
cargo clippy --all-targets --all-features -- -D warnings
cargo fmt --all
cd dotnet
dotnet build --configuration Release
dotnet test --configuration Release

Release artifact trust

Official archives are covered by SHA256SUMS.txt, including the public C header. The Windows and macOS binaries are currently unsigned, and the macOS binaries are not notarized. Verify the published checksums before use and apply the signing/notarization required by your product distribution before shipping to end users.

License

MIT © 2026 Finter As

About

Automatic updates for any application. Built in Rust. Ships in 5 minutes.

Resources

Stars

10 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Surge

Surge

Automatic updates for any application. Built in Rust. Ships in 5 minutes.

Why Surge5-Minute SetupCI/CDApp PlaybookHow It WorksFeaturesIntegrationReferenceBuilding


Why Surge

Your users should always be on the latest version. Chrome, VS Code, and Slack do this transparently — the app checks for updates, downloads a small patch, and applies it. The user never thinks about it.

Building that yourself means solving a dozen hard problems: hosting an update server, generating delta patches, handling partial downloads, supporting multiple platforms, managing release channels, coordinating deployments across servers, preserving user data across updates, creating installers, setting up shortcuts. Most teams either skip it entirely or ship a half-baked updater that breaks silently.

Surge gives you Chrome-style automatic updates for any application, on any platform, in about 5 minutes.

  • No update server to run. Releases are stored directly in S3, Azure Blob, GCS, GitHub Releases, or a plain directory. You already have one of these.
  • No framework lock-in. Surge is a native shared library with a stable C ABI. Call it from Rust, C, C++, .NET, Go, Python — anything that can load a .so or .dll.
  • Small downloads. Binary delta patches (bsdiff + zstd) mean users download only what changed between versions. Typically 5-20% of the full package.
  • Release channels. Ship to beta first, then promote the exact same build to stable when you're confident. No rebuild, no re-upload.
  • User data survives updates. Mark config files, databases, and user content as persistent assets — Surge preserves them across every version.
  • Fits your CI pipeline.surge pack and surge push are plain CLI commands. Add them to GitHub Actions, GitLab CI, or Jenkins — works in any matrix build across OS, architecture, and build variants.
  • Cross-platform from day one. Linux, Windows, and macOS. Native shortcuts (.desktop files, .lnk files, .app bundles), platform-correct install directories, and architecture detection built in.

5-Minute Setup

You need two things: somewhere to store your releases and the surge CLI.

If you are wiring Surge into CI, prefer the official release bundle for your platform. It includes the full publishing toolchain (surge, surge-supervisor, surge-installer, surge-installer-ui, and the native runtime) so pack/push jobs do not have to assemble it from multiple crates.

If your artifacts contain Surge.NET.dll but not the native runtime, surge pack will bundle the matching libsurge/surge.dll from the installed Surge toolchain automatically.

If you are publishing preview versions in GitHub Actions and do not want to ship prebuilt release bundles, the fastest CI pattern is:

  1. Check out Surge once per host architecture.
  2. Restore Rust build outputs with Swatinem/rust-cache.
  3. Run ./scripts/stage-toolchain-artifact.sh --output "$RUNNER_TEMP/surge-toolchain".
  4. Upload that directory as a workflow artifact.
  5. Download it in every publish job and prepend it to PATH.

That avoids repeated cargo install misses across the publish matrix and removes the separate libsurge bootstrap step.

1. Initialize your project

surge init --wizard

The wizard walks you through storage provider, app name, and target platform. Or do it non-interactively:

surge init \
--app-id my-app \
--name "My App" \
--provider s3 \
--bucket my-app-releases

The result is a surge.yml manifest:

schema: 1storage:
provider: s3bucket: my-app-releasesregion: us-east-1apps:
- id: my-appname: My Appmain: my-apptarget:
rid: linux-x64

Credentials are never stored in the manifest. Surge reads them from process environment variables (AWS_ACCESS_KEY_ID, GITHUB_TOKEN, etc.), from .env.surge files discovered next to the active manifest (and from project-root .env.surge when using the default .surge/surge.yml layout), from per-app overrides in .env.surge.<app-id>, or from provider-native identity mechanisms such as IAM roles.

Storage credentials with .env.surge

Use .env.surge when you want storage credentials to follow a project, manifest, or installer without exporting them globally in your shell or CI job.

Lookup rules:

  • Process environment variables always win.
  • With the default .surge/surge.yml layout, Surge loads <project>/.env.surge first and .surge/.env.surge second. Later files override earlier ones.
  • With a custom manifest path such as surge --manifest-path ./deploy/prod.yml ..., Surge loads ./deploy/.env.surge.
  • Per-app overrides live beside the shared file as .env.surge.<app-id> and override shared values for that app only.
  • surge install loads overrides from the manifest it actually installs from: .surge/application.yml if present, otherwise the fallback manifest path.
  • surge migrate scopes source and destination manifests separately, so each side can use different backend credentials safely.
  • surge setup reads .env.surge next to the extracted installer.yml.

Supported file syntax:

  • KEY=value
  • export KEY=value
  • blank lines and # comments
  • single-quoted or double-quoted values

Example project layout:

my-app/
├── .env.surge
├── .env.surge.admin-ui
└── .surge/
├── .env.surge
└── surge.yml

Example files:

# my-app/.env.surge
GITHUB_TOKEN=ghp_shared_token
# my-app/.env.surge.admin-ui
GITHUB_TOKEN=ghp_admin_ui_token
# my-app/.surge/.env.surge
GITHUB_TOKEN=ghp_local_override

In that layout:

  • Most commands against .surge/surge.yml see ghp_local_override.
  • Commands for app admin-ui first try .env.surge.admin-ui, then fall back to the shared .env.surge values for that same manifest scope.
  • Running with a different manifest path uses the .env.surge files next to that manifest instead of reusing another manifest's credentials.

2. Pack a release

Point Surge at your build output:

surge pack \
--app-id my-app \
--rid linux-x64 \
--version 1.0.0

By default, surge pack reads artifacts from .surge/artifacts/<app-id>/<rid>/<version>, writes packages to .surge/packages, and writes installers to .surge/installers/<app-id>/<rid>. Use --artifacts-dir/--output-dir to override.

Surge compresses everything into a tar.zst package. If a previous version exists in storage, it also generates a binary delta patch automatically.

If you want to benchmark pack policy on a real payload before publishing, run:

surge tune pack \
--app-id my-app \
--rid linux-x64 \
--version 1.0.0 \
--write-manifest

This benchmarks candidate pack settings on the current artifacts and can write the recommended pack.delta.strategy and pack.compression.level back to surge.yml.

3. Push to storage

surge push \
--app-id my-app \
--rid linux-x64 \
--version 1.0.0 \
--channel stable

Done. Your release is live. Clients on the stable channel will pick it up on their next update check.

Optional: install package (backend or Tailscale)

Install from the backend configured in .surge/application.yml (falls back to .surge/surge.yml):

surge install \
--channel stable

Override backend fields without editing manifest:

surge install backend \
--provider s3 \
--bucket my-release-bucket \
--region eu-north-1 \
--prefix production

Install to a remote node on your tailnet:

surge install tailscale \
--node my-node \
--node-user operator \
--channel stable

This command:

  • probes remote OS/architecture and checks for NVIDIA GPU support,
  • resolves the newest matching release on the selected channel,
  • downloads it locally and sends it with tailscale file cp.

Use --plan-only to preview selection without transfer, --rid to force a specific RID, or --force to reinstall even when the same version/channel is already installed on the target. If your tailnet requires explicit SSH identity, pass --node-user <account> (or set --node <account>@<node> directly).

4. Add update checking to your app

.NET

usingvarmgr=newSurgeUpdateManager();awaitmgr.UpdateToLatestReleaseAsync(onUpdatesAvailable: releases =>Console.WriteLine($"{releases.Count} update(s), latest: {releases.Latest?.Version}"),onAfterApplyUpdate: release =>Console.WriteLine($"Updated to {release.Version}"));

Rust

letmut mgr = UpdateManager::new(ctx,"my-app","1.0.0","stable", install_dir)?;ifletSome(info) = mgr.check_for_updates().await? {
mgr.download_and_apply(&info,None::<fn(_)>).await?;}

C / C++ / anything else

surge_update_manager*mgr=surge_update_manager_create(ctx, "my-app", "1.0.0", "stable", dir);
surge_releases_info*info=NULL;
if (surge_update_check(mgr, &info) ==SURGE_OK) {
surge_update_download_and_apply(mgr, info, progress_cb, NULL);
surge_releases_destroy(info);
}
surge_update_manager_destroy(mgr);
surge_context_destroy(ctx);

CI/CD Integration

Surge is built for automated pipelines. The CLI does all the heavy lifting — your CI just calls surge pack and surge push after each build. GitHub Actions is the most common setup.

Single-platform example

# .github/workflows/release.ymljobs:
build:
runs-on: ubuntu-lateststeps:
- uses: actions/checkout@v6
- run: cargo build --release
- run: surge pack --version ${{ env.VERSION }}
- run: surge push --version ${{ env.VERSION }} --channel stable

Multi-platform matrix

Real applications target multiple OS and architecture combinations. Use a matrix strategy to build each variant in parallel, then pack and push each one:

jobs:
build:
strategy:
matrix:
include:
- os: ubuntu-latestrid: linux-x64
- os: windows-latestrid: win-x64
- os: macos-latestrid: osx-arm64runs-on: ${{ matrix.os }}steps:
- uses: actions/checkout@v6
- run: dotnet publish -c Release -r ${{ matrix.rid }}
- run: surge pack --rid ${{ matrix.rid }} --version ${{ env.VERSION }}
- run: surge push --rid ${{ matrix.rid }} --version ${{ env.VERSION }} --channel stable

Each matrix entry produces its own platform-specific package and delta patch. Clients only download the package matching their OS and architecture.

Staged rollouts

Combine matrix builds with channel promotion for safe deployments:

jobs:
deploy-beta:
needs: [build]runs-on: ubuntu-latestif: github.ref == 'refs/heads/develop'steps:
- run: surge push --version ${{ env.VERSION }} --channel betapromote-stable:
needs: [build]runs-on: ubuntu-latestif: github.ref == 'refs/heads/main'steps:
- run: surge promote --version ${{ env.VERSION }} --from beta --to stable

Push to develop ships to beta testers. Merge to main promotes the exact same build to stable — no rebuild, no re-upload, no risk of a different binary reaching production.

Distributed lock for safe concurrent pushes

When multiple matrix jobs push to the same storage backend, use the distributed lock to prevent race conditions on the release index:

steps:
- run: surge lock acquire --name "${{ matrix.rid }}-deploy"
- run: surge push --version ${{ env.VERSION }} --rid ${{ matrix.rid }} --channel stable
- run: surge lock release --name "${{ matrix.rid }}-deploy"

How It Works

 You (developer) Your Users
────────────── ──────────
cargo build / dotnet publish
│
▼
surge pack ──► tar.zst full package
+ bsdiff delta patch
│
▼
surge push ──► S3 / Azure / GCS / GitHub Releases / filesystem
│
│ release index (compressed YAML)
│ + package files
│
▼
┌──────────────┐
│ Cloud Storage │
└──────┬───────┘
│
┌───────────┼───────────┐
▼ ▼ ▼
Linux Windows macOS
app app app
│ │ │
└───────────┴───────────┘
│
check_for_updates()
download_and_apply()
│
▼
Update applied.
User never noticed.

The update pipeline

When a client calls download_and_apply, Surge runs a 6-phase pipeline:

  1. Check — validate update info and prepare staging directory
  2. Download — fetch delta patch (or full package as fallback) from storage
  3. Verify — SHA-256 hash check of every downloaded file
  4. Extract — decompress the tar.zst archive
  5. Apply delta — apply bsdiff patches if using delta updates
  6. Finalize — atomic move into place, clean up staging, preserve persistent assets

Progress callbacks fire at each phase with percentage, bytes transferred, and speed.

Features

Release channels

Channels are labels on releases. A single version can be on multiple channels simultaneously.

# Ship to beta testers first
surge push --version 2.1.0 --channel beta
# A week later, promote the exact same build to stable (no re-upload)
surge promote --version 2.1.0 --from beta --to stable
# Something wrong? Pull it back
surge demote --version 2.1.0 --channel stable

Clients specify which channel they follow. Switching channels at runtime is a single API call — useful for opt-in beta programs.

Persistent assets

Files and directories that should survive across updates:

apps:
- id: my-apppersistentAssets:
- config.json
- user-data/
- settings.ini

During updates, Surge copies these from the old version directory to the new one before removing the old version.

Platform-native shortcuts

apps:
- id: my-appicon: icon.pngshortcuts:
- desktop
- start_menu
- startup

Surge creates real platform shortcuts:

  • Linux.desktop files in ~/.local/share/applications and ~/.config/autostart (XDG freedesktop spec)
  • Windows.lnk shortcuts on Desktop, Start Menu, and Startup via WScript.Shell
  • macOS.app bundles with Info.plist in ~/Applications, LaunchAgent for startup

Process supervisor

The supervisor binary monitors your application, restarts on crash, and coordinates version handoffs:

surge-supervisor --supervisor-id <uuid> --install-dir /opt/my-app --exe-path /opt/my-app/my-app

Or from code:

SurgeApp.StartSupervisor();

It handles graceful shutdown on SIGTERM/SIGINT (Unix) and Ctrl+C (Windows).

Lifecycle events

Hook into first-run, post-install, and post-update events:

if(SurgeApp.ProcessEvents(args,onFirstRun: v =>ShowWelcomeScreen(),onInstalled: v =>RunMigrations(),onUpdated: v =>ShowChangelogFor(v))){return;}

Installer generation

Surge can produce installer bundles in two modes:

target:
rid: win-x64installers:
- online # Small bootstrap, downloads app on first run
- offline # Self-contained, includes full package

Resource budgets

Throttle resource usage for constrained environments:

varbudget=newSurgeResourceBudget{MaxMemoryBytes=256*1024*1024,// 256 MBMaxConcurrentDownloads=2,MaxDownloadSpeedBps=1_000_000,// 1 MB/sZstdCompressionLevel=6// faster compression};

Distributed locking

For server-side deployments where multiple CI runners might push releases concurrently, Surge provides a distributed mutex via snapx.dev:

surge lock acquire --name "my-app-deploy" --timeout 300
# ... push release ...
surge lock release --name "my-app-deploy"

Backend migration

Move all your releases from one storage provider to another without downtime:

surge migrate --dest-manifest new-backend.yml

Storage Backends

Use whatever you already have.

ProviderConfig valueNotes
Amazon S3s3Any S3-compatible API (MinIO, Cloudflare R2, DigitalOcean Spaces). Auth via AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY or IAM roles
Azure Blob Storageazure_blobAuth via AZURE_STORAGE_ACCOUNT_NAME/AZURE_STORAGE_ACCOUNT_KEY
Google Cloud StoragegcsAuth via GOOGLE_APPLICATION_CREDENTIALS or application default credentials
GitHub Releasesgithub_releasesFree for public repos. bucket = owner/repo. Auth via GITHUB_TOKEN
Local filesystemfilesystemFor testing or air-gapped environments. bucket = root directory path

Integration

For a production app integration checklist, smoke expectations, and guidance for both humans and agents, see docs/integrating-surge.md.

Surge is a native shared library (libsurge.so / surge.dll / libsurge.dylib) with a C ABI. You don't need Rust in your project.

.NET

The Surge.NET NuGet package provides the full API:

  • netstandard2.0[DllImport] for .NET Framework 4.6.1+, .NET Core, Mono, Xamarin
  • net10.0[LibraryImport] with full AOT and trimming support
  • Zero external managed dependencies; ship the matching native Surge library from the same version and runtime identifier with the application
  • SurgeUpdateManager.UpdateToLatestReleaseAsync() — one call that checks, downloads, verifies, extracts, and applies
  • Per-phase progress callbacks, cancellation tokens, pre/post-update hooks

C / C++

Include surge_api.h and link against the shared library. The API uses opaque handles, surge_result return codes, explicit ownership rules, and thread-safe cancellation.

Rust

Use surge-core as a Cargo dependency for direct access to the async API without the FFI overhead.

Reference

CLI commands

surge init Create a surge.yml manifest (--wizard for interactive)
surge pack Build full and delta packages from artifacts
surge tune Benchmark pack policy candidates
surge push Upload packages and update the release index
surge list List releases on a channel
surge promote Promote a release to another channel
surge demote Remove a release from a channel
surge migrate Copy releases between storage backends
surge restore Restore artifacts from backup
surge install Install package via method (backend, tailscale)
surge lock Acquire/release distributed locks

If the manifest has one app, --app-id is optional. If the app has one target, --rid is optional. surge list now defaults to a status overview table. For multi-app manifests it shows one row per app/rid by default; use --app-id (and optionally --rid) to scope down.

surge restore also supports installer-only generation (snapx-style restore -i) from existing full packages:

surge restore -i

By default this resolves the latest release for the manifest app/target on the app's default channel, restores missing full packages from storage into .surge/packages, and builds installers using artifacts from .surge/artifacts/<app-id>/<rid>/<version>. The generated installers are written to .surge/installers/<app-id>/<rid>. Use --channel <name> to rebuild installers for a non-default channel after a promotion flow.

Explicit override example:

surge restore -i \
--channel production \
--version 1.2.3 \
--artifacts-dir ./publish \
--packages-dir .surge/packages

C API function groups

GroupFunctions
Lifecyclesurge_context_create, surge_context_destroy, surge_context_last_error
Configurationsurge_config_set_storage, surge_config_set_lock_server, surge_config_set_resource_budget
Update Managersurge_update_manager_create, surge_update_manager_destroy, surge_update_manager_set_channel, surge_update_manager_set_current_version, surge_update_manager_set_release_retention_limit, surge_update_manager_set_artifact_retention_policy, surge_update_check, surge_update_download_and_apply, surge_update_status_read_json, surge_free_cstring
Release Infosurge_releases_count, surge_releases_destroy, surge_release_version, surge_release_channel, surge_release_full_size, surge_release_is_genesis
Binary Diffsurge_bsdiff, surge_bspatch, surge_bsdiff_free, surge_bspatch_free
Pack Buildersurge_pack_create, surge_pack_build, surge_pack_push, surge_pack_destroy
Distributed Locksurge_lock_acquire, surge_lock_release
Supervisorsurge_supervisor_start, surge_supervisor_stop
Eventssurge_process_events
Cancellationsurge_cancel, surge_reset_cancel

Manifest reference

schema: 1storage:
provider: s3# s3 | azure_blob | gcs | github_releases | filesystembucket: my-bucket # bucket, container, owner/repo, or directoryregion: us-east-1 # cloud region (or release tag for github_releases)endpoint: ""# custom endpoint (MinIO, R2, etc.)prefix: ""# path prefix within bucketlock:
url: https://snapx.dev # distributed lock server (optional)pack: # optional; omitted uses built-in defaultsdelta:
strategy: sparse-file-opsmax_chain_length: 8chunked_patch_format: 1# 1 = readable by every client (default); 2 = identity-chunk bitset, needs clients that know format 2compression:
format: zstdlevel: 3retention:
keep_latest_fulls: 2checkpoint_every: 10cache: # optional device-side artifact cache policyinstallArtifacts:
retention: latest_full # release_graph | latest_full | just_installed | nonekeepFullCount: 1# full archives retained when retention is latest_fullapps:
- id: my-app # unique identifiername: My App # display namemain: my-app # main executable (defaults to id)installDirectory: my-app # install dir name (defaults to id)icon: icon.png # application iconchannels: [stable, beta] # supported channelsshortcuts: [desktop, start_menu, startup]persistentAssets: [config.json, user-data/]installers: [online, offline]environment:
MY_VAR: valuetarget:
rid: linux-x64 # linux-x64, win-x64, win-arm64, osx-x64, osx-arm64

Target-level settings override app-level defaults for icon, shortcuts, persistentAssets, installers, and environment. pack policy is global and controls delta strategy, compression, and remote full fallback retention for surge pack/surge push. The generated surge init policy optimizes managed fleets for fast latest-following updates: a node on N-1 should normally apply the direct N-1 -> N delta, while checkpoint fulls remain fallback baselines for recovery and stale installs. cache.installArtifacts controls package artifacts kept under .surge-cache/artifacts/ after setup and successful updates:

  • latest_full is the recommended managed-fleet setting. It keeps the newest keepFullCount full archives per RID and drops deltas, so normal updates stay delta-based while each device keeps a compact reinstall/restore cushion.
  • release_graph keeps the local release graph plus warm full checkpoints. Use it when offline restore to older versions matters; it uses the most disk.
  • just_installed keeps only the installed full archive when that archive is already cached. Use it when full-update reinstall warmth is useful but disk should stay tight. Delta-only updates do not synthesize a new full archive just to warm this cache.
  • none keeps no package artifacts after a successful update. Use it when optimizing for minimum disk usage and accepting that restore/reinstall downloads from storage again.

Use surge compact after rollout convergence, or for deliberate recovery/cleanup, when you want to prune old remote artifacts. Avoid making compaction the immediate default rollout step for a fleet that is still catching up.

Architecture

┌──────────────────────────────────────────────────────────┐
│ Your Application │
│ (.NET / C / C++ / any FFI) │
└─────────────────────────┬────────────────────────────────┘
│ P/Invoke or C calls
┌─────────────────────────▼────────────────────────────────┐
│ surge-ffi (cdylib) │
│ C ABI · surge_api.h │
└─────────────────────────┬────────────────────────────────┘
│
┌─────────────────────────▼────────────────────────────────┐
│ surge-core │
│ config · crypto · storage · archive · diff · releases │
│ update · pack · supervisor · platform · download │
└──────────────────────────────────────────────────────────┘
CrateDescription
surge-coreCore library — config, crypto, storage backends, archive (tar+zstd), bsdiff, release index, update manager, pack builder, supervisor, platform detection
surge-ffiC API shared library exporting the interface declared in surge_api.h
surge-cliCommand-line tool for packing, pushing, and managing releases
surge-supervisorStandalone process supervisor binary

Building from Source

git clone --recurse-submodules https://github.com/fintermobilityas/surge.git
cd surge

If you already cloned without --recurse-submodules:

git submodule update --init

Requirements

  • Rust 1.95+ (Edition 2024) — install via rustup
  • .NET 10 SDK (optional, for the .NET wrapper and demo app)

Build and test

cargo build --release
cargo test
cargo clippy --all-targets --all-features -- -D warnings
cargo fmt --all
cd dotnet
dotnet build --configuration Release
dotnet test --configuration Release

Release artifact trust

Official archives are covered by SHA256SUMS.txt, including the public C header. The Windows and macOS binaries are currently unsigned, and the macOS binaries are not notarized. Verify the published checksums before use and apply the signing/notarization required by your product distribution before shipping to end users.

License

MIT © 2026 Finter As

About

Automatic updates for any application. Built in Rust. Ships in 5 minutes.

Resources

Stars

10 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

Surge

Surge

Automatic updates for any application. Built in Rust. Ships in 5 minutes.

Why Surge5-Minute SetupCI/CDApp PlaybookHow It WorksFeaturesIntegrationReferenceBuilding


Why Surge

Your users should always be on the latest version. Chrome, VS Code, and Slack do this transparently — the app checks for updates, downloads a small patch, and applies it. The user never thinks about it.

Building that yourself means solving a dozen hard problems: hosting an update server, generating delta patches, handling partial downloads, supporting multiple platforms, managing release channels, coordinating deployments across servers, preserving user data across updates, creating installers, setting up shortcuts. Most teams either skip it entirely or ship a half-baked updater that breaks silently.

Surge gives you Chrome-style automatic updates for any application, on any platform, in about 5 minutes.

  • No update server to run. Releases are stored directly in S3, Azure Blob, GCS, GitHub Releases, or a plain directory. You already have one of these.
  • No framework lock-in. Surge is a native shared library with a stable C ABI. Call it from Rust, C, C++, .NET, Go, Python — anything that can load a .so or .dll.
  • Small downloads. Binary delta patches (bsdiff + zstd) mean users download only what changed between versions. Typically 5-20% of the full package.
  • Release channels. Ship to beta first, then promote the exact same build to stable when you're confident. No rebuild, no re-upload.
  • User data survives updates. Mark config files, databases, and user content as persistent assets — Surge preserves them across every version.
  • Fits your CI pipeline.surge pack and surge push are plain CLI commands. Add them to GitHub Actions, GitLab CI, or Jenkins — works in any matrix build across OS, architecture, and build variants.
  • Cross-platform from day one. Linux, Windows, and macOS. Native shortcuts (.desktop files, .lnk files, .app bundles), platform-correct install directories, and architecture detection built in.

5-Minute Setup

You need two things: somewhere to store your releases and the surge CLI.

If you are wiring Surge into CI, prefer the official release bundle for your platform. It includes the full publishing toolchain (surge, surge-supervisor, surge-installer, surge-installer-ui, and the native runtime) so pack/push jobs do not have to assemble it from multiple crates.

If your artifacts contain Surge.NET.dll but not the native runtime, surge pack will bundle the matching libsurge/surge.dll from the installed Surge toolchain automatically.

If you are publishing preview versions in GitHub Actions and do not want to ship prebuilt release bundles, the fastest CI pattern is:

  1. Check out Surge once per host architecture.
  2. Restore Rust build outputs with Swatinem/rust-cache.
  3. Run ./scripts/stage-toolchain-artifact.sh --output "$RUNNER_TEMP/surge-toolchain".
  4. Upload that directory as a workflow artifact.
  5. Download it in every publish job and prepend it to PATH.

That avoids repeated cargo install misses across the publish matrix and removes the separate libsurge bootstrap step.

1. Initialize your project

surge init --wizard

The wizard walks you through storage provider, app name, and target platform. Or do it non-interactively:

surge init \
--app-id my-app \
--name "My App" \
--provider s3 \
--bucket my-app-releases

The result is a surge.yml manifest:

schema: 1storage:
provider: s3bucket: my-app-releasesregion: us-east-1apps:
- id: my-appname: My Appmain: my-apptarget:
rid: linux-x64

Credentials are never stored in the manifest. Surge reads them from process environment variables (AWS_ACCESS_KEY_ID, GITHUB_TOKEN, etc.), from .env.surge files discovered next to the active manifest (and from project-root .env.surge when using the default .surge/surge.yml layout), from per-app overrides in .env.surge.<app-id>, or from provider-native identity mechanisms such as IAM roles.

Storage credentials with .env.surge

Use .env.surge when you want storage credentials to follow a project, manifest, or installer without exporting them globally in your shell or CI job.

Lookup rules:

  • Process environment variables always win.
  • With the default .surge/surge.yml layout, Surge loads <project>/.env.surge first and .surge/.env.surge second. Later files override earlier ones.
  • With a custom manifest path such as surge --manifest-path ./deploy/prod.yml ..., Surge loads ./deploy/.env.surge.
  • Per-app overrides live beside the shared file as .env.surge.<app-id> and override shared values for that app only.
  • surge install loads overrides from the manifest it actually installs from: .surge/application.yml if present, otherwise the fallback manifest path.
  • surge migrate scopes source and destination manifests separately, so each side can use different backend credentials safely.
  • surge setup reads .env.surge next to the extracted installer.yml.

Supported file syntax:

  • KEY=value
  • export KEY=value
  • blank lines and # comments
  • single-quoted or double-quoted values

Example project layout:

my-app/
├── .env.surge
├── .env.surge.admin-ui
└── .surge/
├── .env.surge
└── surge.yml

Example files:

# my-app/.env.surge
GITHUB_TOKEN=ghp_shared_token
# my-app/.env.surge.admin-ui
GITHUB_TOKEN=ghp_admin_ui_token
# my-app/.surge/.env.surge
GITHUB_TOKEN=ghp_local_override

In that layout:

  • Most commands against .surge/surge.yml see ghp_local_override.
  • Commands for app admin-ui first try .env.surge.admin-ui, then fall back to the shared .env.surge values for that same manifest scope.
  • Running with a different manifest path uses the .env.surge files next to that manifest instead of reusing another manifest's credentials.

2. Pack a release

Point Surge at your build output:

surge pack \
--app-id my-app \
--rid linux-x64 \
--version 1.0.0

By default, surge pack reads artifacts from .surge/artifacts/<app-id>/<rid>/<version>, writes packages to .surge/packages, and writes installers to .surge/installers/<app-id>/<rid>. Use --artifacts-dir/--output-dir to override.

Surge compresses everything into a tar.zst package. If a previous version exists in storage, it also generates a binary delta patch automatically.

If you want to benchmark pack policy on a real payload before publishing, run:

surge tune pack \
--app-id my-app \
--rid linux-x64 \
--version 1.0.0 \
--write-manifest

This benchmarks candidate pack settings on the current artifacts and can write the recommended pack.delta.strategy and pack.compression.level back to surge.yml.

3. Push to storage

surge push \
--app-id my-app \
--rid linux-x64 \
--version 1.0.0 \
--channel stable

Done. Your release is live. Clients on the stable channel will pick it up on their next update check.

Optional: install package (backend or Tailscale)

Install from the backend configured in .surge/application.yml (falls back to .surge/surge.yml):

surge install \
--channel stable

Override backend fields without editing manifest:

surge install backend \
--provider s3 \
--bucket my-release-bucket \
--region eu-north-1 \
--prefix production

Install to a remote node on your tailnet:

surge install tailscale \
--node my-node \
--node-user operator \
--channel stable

This command:

  • probes remote OS/architecture and checks for NVIDIA GPU support,
  • resolves the newest matching release on the selected channel,
  • downloads it locally and sends it with tailscale file cp.

Use --plan-only to preview selection without transfer, --rid to force a specific RID, or --force to reinstall even when the same version/channel is already installed on the target. If your tailnet requires explicit SSH identity, pass --node-user <account> (or set --node <account>@<node> directly).

4. Add update checking to your app

.NET

usingvarmgr=newSurgeUpdateManager();awaitmgr.UpdateToLatestReleaseAsync(onUpdatesAvailable: releases =>Console.WriteLine($"{releases.Count} update(s), latest: {releases.Latest?.Version}"),onAfterApplyUpdate: release =>Console.WriteLine($"Updated to {release.Version}"));

Rust

letmut mgr = UpdateManager::new(ctx,"my-app","1.0.0","stable", install_dir)?;ifletSome(info) = mgr.check_for_updates().await? {
mgr.download_and_apply(&info,None::<fn(_)>).await?;}

C / C++ / anything else

surge_update_manager*mgr=surge_update_manager_create(ctx, "my-app", "1.0.0", "stable", dir);
surge_releases_info*info=NULL;
if (surge_update_check(mgr, &info) ==SURGE_OK) {
surge_update_download_and_apply(mgr, info, progress_cb, NULL);
surge_releases_destroy(info);
}
surge_update_manager_destroy(mgr);
surge_context_destroy(ctx);

CI/CD Integration

Surge is built for automated pipelines. The CLI does all the heavy lifting — your CI just calls surge pack and surge push after each build. GitHub Actions is the most common setup.

Single-platform example

# .github/workflows/release.ymljobs:
build:
runs-on: ubuntu-lateststeps:
- uses: actions/checkout@v6
- run: cargo build --release
- run: surge pack --version ${{ env.VERSION }}
- run: surge push --version ${{ env.VERSION }} --channel stable

Multi-platform matrix

Real applications target multiple OS and architecture combinations. Use a matrix strategy to build each variant in parallel, then pack and push each one:

jobs:
build:
strategy:
matrix:
include:
- os: ubuntu-latestrid: linux-x64
- os: windows-latestrid: win-x64
- os: macos-latestrid: osx-arm64runs-on: ${{ matrix.os }}steps:
- uses: actions/checkout@v6
- run: dotnet publish -c Release -r ${{ matrix.rid }}
- run: surge pack --rid ${{ matrix.rid }} --version ${{ env.VERSION }}
- run: surge push --rid ${{ matrix.rid }} --version ${{ env.VERSION }} --channel stable

Each matrix entry produces its own platform-specific package and delta patch. Clients only download the package matching their OS and architecture.

Staged rollouts

Combine matrix builds with channel promotion for safe deployments:

jobs:
deploy-beta:
needs: [build]runs-on: ubuntu-latestif: github.ref == 'refs/heads/develop'steps:
- run: surge push --version ${{ env.VERSION }} --channel betapromote-stable:
needs: [build]runs-on: ubuntu-latestif: github.ref == 'refs/heads/main'steps:
- run: surge promote --version ${{ env.VERSION }} --from beta --to stable

Push to develop ships to beta testers. Merge to main promotes the exact same build to stable — no rebuild, no re-upload, no risk of a different binary reaching production.

Distributed lock for safe concurrent pushes

When multiple matrix jobs push to the same storage backend, use the distributed lock to prevent race conditions on the release index:

steps:
- run: surge lock acquire --name "${{ matrix.rid }}-deploy"
- run: surge push --version ${{ env.VERSION }} --rid ${{ matrix.rid }} --channel stable
- run: surge lock release --name "${{ matrix.rid }}-deploy"

How It Works

 You (developer) Your Users
────────────── ──────────
cargo build / dotnet publish
│
▼
surge pack ──► tar.zst full package
+ bsdiff delta patch
│
▼
surge push ──► S3 / Azure / GCS / GitHub Releases / filesystem
│
│ release index (compressed YAML)
│ + package files
│
▼
┌──────────────┐
│ Cloud Storage │
└──────┬───────┘
│
┌───────────┼───────────┐
▼ ▼ ▼
Linux Windows macOS
app app app
│ │ │
└───────────┴───────────┘
│
check_for_updates()
download_and_apply()
│
▼
Update applied.
User never noticed.

The update pipeline

When a client calls download_and_apply, Surge runs a 6-phase pipeline:

  1. Check — validate update info and prepare staging directory
  2. Download — fetch delta patch (or full package as fallback) from storage
  3. Verify — SHA-256 hash check of every downloaded file
  4. Extract — decompress the tar.zst archive
  5. Apply delta — apply bsdiff patches if using delta updates
  6. Finalize — atomic move into place, clean up staging, preserve persistent assets

Progress callbacks fire at each phase with percentage, bytes transferred, and speed.

Features

Release channels

Channels are labels on releases. A single version can be on multiple channels simultaneously.

# Ship to beta testers first
surge push --version 2.1.0 --channel beta
# A week later, promote the exact same build to stable (no re-upload)
surge promote --version 2.1.0 --from beta --to stable
# Something wrong? Pull it back
surge demote --version 2.1.0 --channel stable

Clients specify which channel they follow. Switching channels at runtime is a single API call — useful for opt-in beta programs.

Persistent assets

Files and directories that should survive across updates:

apps:
- id: my-apppersistentAssets:
- config.json
- user-data/
- settings.ini

During updates, Surge copies these from the old version directory to the new one before removing the old version.

Platform-native shortcuts

apps:
- id: my-appicon: icon.pngshortcuts:
- desktop
- start_menu
- startup

Surge creates real platform shortcuts:

  • Linux.desktop files in ~/.local/share/applications and ~/.config/autostart (XDG freedesktop spec)
  • Windows.lnk shortcuts on Desktop, Start Menu, and Startup via WScript.Shell
  • macOS.app bundles with Info.plist in ~/Applications, LaunchAgent for startup

Process supervisor

The supervisor binary monitors your application, restarts on crash, and coordinates version handoffs:

surge-supervisor --supervisor-id <uuid> --install-dir /opt/my-app --exe-path /opt/my-app/my-app

Or from code:

SurgeApp.StartSupervisor();

It handles graceful shutdown on SIGTERM/SIGINT (Unix) and Ctrl+C (Windows).

Lifecycle events

Hook into first-run, post-install, and post-update events:

if(SurgeApp.ProcessEvents(args,onFirstRun: v =>ShowWelcomeScreen(),onInstalled: v =>RunMigrations(),onUpdated: v =>ShowChangelogFor(v))){return;}

Installer generation

Surge can produce installer bundles in two modes:

target:
rid: win-x64installers:
- online # Small bootstrap, downloads app on first run
- offline # Self-contained, includes full package

Resource budgets

Throttle resource usage for constrained environments:

varbudget=newSurgeResourceBudget{MaxMemoryBytes=256*1024*1024,// 256 MBMaxConcurrentDownloads=2,MaxDownloadSpeedBps=1_000_000,// 1 MB/sZstdCompressionLevel=6// faster compression};

Distributed locking

For server-side deployments where multiple CI runners might push releases concurrently, Surge provides a distributed mutex via snapx.dev:

surge lock acquire --name "my-app-deploy" --timeout 300
# ... push release ...
surge lock release --name "my-app-deploy"

Backend migration

Move all your releases from one storage provider to another without downtime:

surge migrate --dest-manifest new-backend.yml

Storage Backends

Use whatever you already have.

ProviderConfig valueNotes
Amazon S3s3Any S3-compatible API (MinIO, Cloudflare R2, DigitalOcean Spaces). Auth via AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY or IAM roles
Azure Blob Storageazure_blobAuth via AZURE_STORAGE_ACCOUNT_NAME/AZURE_STORAGE_ACCOUNT_KEY
Google Cloud StoragegcsAuth via GOOGLE_APPLICATION_CREDENTIALS or application default credentials
GitHub Releasesgithub_releasesFree for public repos. bucket = owner/repo. Auth via GITHUB_TOKEN
Local filesystemfilesystemFor testing or air-gapped environments. bucket = root directory path

Integration

For a production app integration checklist, smoke expectations, and guidance for both humans and agents, see docs/integrating-surge.md.

Surge is a native shared library (libsurge.so / surge.dll / libsurge.dylib) with a C ABI. You don't need Rust in your project.

.NET

The Surge.NET NuGet package provides the full API:

  • netstandard2.0[DllImport] for .NET Framework 4.6.1+, .NET Core, Mono, Xamarin
  • net10.0[LibraryImport] with full AOT and trimming support
  • Zero external managed dependencies; ship the matching native Surge library from the same version and runtime identifier with the application
  • SurgeUpdateManager.UpdateToLatestReleaseAsync() — one call that checks, downloads, verifies, extracts, and applies
  • Per-phase progress callbacks, cancellation tokens, pre/post-update hooks

C / C++

Include surge_api.h and link against the shared library. The API uses opaque handles, surge_result return codes, explicit ownership rules, and thread-safe cancellation.

Rust

Use surge-core as a Cargo dependency for direct access to the async API without the FFI overhead.

Reference

CLI commands

surge init Create a surge.yml manifest (--wizard for interactive)
surge pack Build full and delta packages from artifacts
surge tune Benchmark pack policy candidates
surge push Upload packages and update the release index
surge list List releases on a channel
surge promote Promote a release to another channel
surge demote Remove a release from a channel
surge migrate Copy releases between storage backends
surge restore Restore artifacts from backup
surge install Install package via method (backend, tailscale)
surge lock Acquire/release distributed locks

If the manifest has one app, --app-id is optional. If the app has one target, --rid is optional. surge list now defaults to a status overview table. For multi-app manifests it shows one row per app/rid by default; use --app-id (and optionally --rid) to scope down.

surge restore also supports installer-only generation (snapx-style restore -i) from existing full packages:

surge restore -i

By default this resolves the latest release for the manifest app/target on the app's default channel, restores missing full packages from storage into .surge/packages, and builds installers using artifacts from .surge/artifacts/<app-id>/<rid>/<version>. The generated installers are written to .surge/installers/<app-id>/<rid>. Use --channel <name> to rebuild installers for a non-default channel after a promotion flow.

Explicit override example:

surge restore -i \
--channel production \
--version 1.2.3 \
--artifacts-dir ./publish \
--packages-dir .surge/packages

C API function groups

GroupFunctions
Lifecyclesurge_context_create, surge_context_destroy, surge_context_last_error
Configurationsurge_config_set_storage, surge_config_set_lock_server, surge_config_set_resource_budget
Update Managersurge_update_manager_create, surge_update_manager_destroy, surge_update_manager_set_channel, surge_update_manager_set_current_version, surge_update_manager_set_release_retention_limit, surge_update_manager_set_artifact_retention_policy, surge_update_check, surge_update_download_and_apply, surge_update_status_read_json, surge_free_cstring
Release Infosurge_releases_count, surge_releases_destroy, surge_release_version, surge_release_channel, surge_release_full_size, surge_release_is_genesis
Binary Diffsurge_bsdiff, surge_bspatch, surge_bsdiff_free, surge_bspatch_free
Pack Buildersurge_pack_create, surge_pack_build, surge_pack_push, surge_pack_destroy
Distributed Locksurge_lock_acquire, surge_lock_release
Supervisorsurge_supervisor_start, surge_supervisor_stop
Eventssurge_process_events
Cancellationsurge_cancel, surge_reset_cancel

Manifest reference

schema: 1storage:
provider: s3# s3 | azure_blob | gcs | github_releases | filesystembucket: my-bucket # bucket, container, owner/repo, or directoryregion: us-east-1 # cloud region (or release tag for github_releases)endpoint: ""# custom endpoint (MinIO, R2, etc.)prefix: ""# path prefix within bucketlock:
url: https://snapx.dev # distributed lock server (optional)pack: # optional; omitted uses built-in defaultsdelta:
strategy: sparse-file-opsmax_chain_length: 8chunked_patch_format: 1# 1 = readable by every client (default); 2 = identity-chunk bitset, needs clients that know format 2compression:
format: zstdlevel: 3retention:
keep_latest_fulls: 2checkpoint_every: 10cache: # optional device-side artifact cache policyinstallArtifacts:
retention: latest_full # release_graph | latest_full | just_installed | nonekeepFullCount: 1# full archives retained when retention is latest_fullapps:
- id: my-app # unique identifiername: My App # display namemain: my-app # main executable (defaults to id)installDirectory: my-app # install dir name (defaults to id)icon: icon.png # application iconchannels: [stable, beta] # supported channelsshortcuts: [desktop, start_menu, startup]persistentAssets: [config.json, user-data/]installers: [online, offline]environment:
MY_VAR: valuetarget:
rid: linux-x64 # linux-x64, win-x64, win-arm64, osx-x64, osx-arm64

Target-level settings override app-level defaults for icon, shortcuts, persistentAssets, installers, and environment. pack policy is global and controls delta strategy, compression, and remote full fallback retention for surge pack/surge push. The generated surge init policy optimizes managed fleets for fast latest-following updates: a node on N-1 should normally apply the direct N-1 -> N delta, while checkpoint fulls remain fallback baselines for recovery and stale installs. cache.installArtifacts controls package artifacts kept under .surge-cache/artifacts/ after setup and successful updates:

  • latest_full is the recommended managed-fleet setting. It keeps the newest keepFullCount full archives per RID and drops deltas, so normal updates stay delta-based while each device keeps a compact reinstall/restore cushion.
  • release_graph keeps the local release graph plus warm full checkpoints. Use it when offline restore to older versions matters; it uses the most disk.
  • just_installed keeps only the installed full archive when that archive is already cached. Use it when full-update reinstall warmth is useful but disk should stay tight. Delta-only updates do not synthesize a new full archive just to warm this cache.
  • none keeps no package artifacts after a successful update. Use it when optimizing for minimum disk usage and accepting that restore/reinstall downloads from storage again.

Use surge compact after rollout convergence, or for deliberate recovery/cleanup, when you want to prune old remote artifacts. Avoid making compaction the immediate default rollout step for a fleet that is still catching up.

Architecture

┌──────────────────────────────────────────────────────────┐
│ Your Application │
│ (.NET / C / C++ / any FFI) │
└─────────────────────────┬────────────────────────────────┘
│ P/Invoke or C calls
┌─────────────────────────▼────────────────────────────────┐
│ surge-ffi (cdylib) │
│ C ABI · surge_api.h │
└─────────────────────────┬────────────────────────────────┘
│
┌─────────────────────────▼────────────────────────────────┐
│ surge-core │
│ config · crypto · storage · archive · diff · releases │
│ update · pack · supervisor · platform · download │
└──────────────────────────────────────────────────────────┘
CrateDescription
surge-coreCore library — config, crypto, storage backends, archive (tar+zstd), bsdiff, release index, update manager, pack builder, supervisor, platform detection
surge-ffiC API shared library exporting the interface declared in surge_api.h
surge-cliCommand-line tool for packing, pushing, and managing releases
surge-supervisorStandalone process supervisor binary

Building from Source

git clone --recurse-submodules https://github.com/fintermobilityas/surge.git
cd surge

If you already cloned without --recurse-submodules:

git submodule update --init

Requirements

  • Rust 1.95+ (Edition 2024) — install via rustup
  • .NET 10 SDK (optional, for the .NET wrapper and demo app)

Build and test

cargo build --release
cargo test
cargo clippy --all-targets --all-features -- -D warnings
cargo fmt --all
cd dotnet
dotnet build --configuration Release
dotnet test --configuration Release

Release artifact trust

Official archives are covered by SHA256SUMS.txt, including the public C header. The Windows and macOS binaries are currently unsigned, and the macOS binaries are not notarized. Verify the published checksums before use and apply the signing/notarization required by your product distribution before shipping to end users.

License

MIT © 2026 Finter As

About

Automatic updates for any application. Built in Rust. Ships in 5 minutes.

Resources

Stars

10 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages