Skip to content

feat(updater): test updates without publishing a release - #725

Merged
kdroidFilter merged 1 commit into
nucleus-2.6from
feat/updater-dev-testing
Sep 25, 2026
Merged

kdroidFilter merged 1 commit into
nucleus-2.6from
feat/updater-dev-testing

Conversation

@kdroidFilter

Copy link
Copy Markdown
Collaborator

Lets developers test the auto-updater end to end without publishing a release, modeled on electron-updater's dev-app-update.yml and Squirrel / Velopack local sources, plus two things none of them ship: a first-class update simulation for the UI, and a fault-injecting loopback release host for automated tests.

Summary

  • Feed redirect: nucleus.updater.feedUrl (system property) / NUCLEUS_UPDATER_FEED_URL (env) replaces the configured provider with a local directory (LocalFileProvider, a path or file: URL), an https server, or plain http to a loopback host.
  • Simulation: UpdateSimulation (in code, or nucleus.updater.simulate* at launch) plays a scripted update — available / up-to-date / check-error / download-error / checksum-error, timed progress, differential, "just updated" — with the install skipped.
  • Safety: an installed app honours the launch-time switches only with the new UpdaterConfig.allowLaunchOverrides (otherwise whoever sets the variable chooses what the app installs, or silences its real updates). An unpackaged run always honours them and never installs (installAndRestart / installAndQuit log and return). Ignored and applied switches are logged; NucleusUpdater.feedOverride / .simulation expose what applies.
  • Plugin: ./gradlew run -Pnucleus.updater.… forwards the switches as -D, runDistributable as env; new serveUpdateFeed task (packages, then serves the merged manifests + artifacts with byte ranges on 127.0.0.1:8421; -Pnucleus.updater.serve.{port,throttle,latency,timeout}).
  • Every packaging output is now a complete feed (see bugs below).
  • New published module updater-testing: UpdateFeedServer — publishes artifacts with a generated manifest, serves ranges, records requests, injects FeedFault.Status / Delay / Throttle / Truncate / Corrupt / IgnoreRange (path glob, times).

Bugs found and fixed on the way

  • electron-builder writes no latest*.yml without a publish provider, so a packaging output was never a usable feed. The plugin now writes the manifest for every self-contained auto-updatable format (TargetFormat.updateArtifactExtension; not NSIS-Web).
  • electron-builder never cleans its output directory: after a version bump the generated manifest listed the previous installer first — the one every client would have downloaded as the new version. Only artifacts named with the packaged version are listed now (newest one when the name carries no version).
  • A manifest kept from a previous packaging run described the previous artifact (stale SHA-512 → every update fails its checksum). Each packaging run now deletes the old manifests first. This also affected MSI / Portable before this PR.
  • GenericProvider rejected http://[::1]: URI.host keeps the IPv6 brackets.
  • The download path goes through a small FeedFetcher (HTTP + file:), replacing four copies of the request/status boilerplate; differential downloads are skipped for file: feeds (ranges need HTTP).

Testing updates without publishing a release

Three levels, from the cheapest to the most faithful. None of them needs a code change beyond
the opt-in of the third.

I want to… Use What runs for real
build and review the update UI simulation: ./gradlew run -Pnucleus.updater.simulate=update nothing leaves the machine; install skipped
check + download against my next build feed redirect from ./gradlew run manifest, selection, download, SHA-512
update an installed copy to my next build feed redirect of the installed app + serveUpdateFeed everything, installer and restart included

1. Simulation — the update UI from ./gradlew run

./gradlew run -Pnucleus.updater.simulate=update           # an update is available and downloads
./gradlew run -Pnucleus.updater.simulate=download-error   # … or: up-to-date, check-error, checksum-error
./gradlew run -Pnucleus.updater.simulate=3.0.0 -Pnucleus.updater.simulate.duration=20 -Pnucleus.updater.simulate.size=250000000
./gradlew run -Pnucleus.updater.simulate.justUpdatedFrom=1.2.0   # the "what's new" launch

Every NucleusUpdater of the app then plays the scripted update: isUpdateSupported() is true,
checkForUpdates() offers the next minor version (or .version), downloadUpdate() reports
progress over .duration seconds (.differential=true reports a delta), and
installAndRestart() logs what it would install and returns — the app keeps running.
Failures surface as the real exceptions (NetworkException, ChecksumException).

In code, for a UI test or a debug menu:

NucleusUpdater {
    provider = GitHubProvider("myorg", "myapp")
    simulation = UpdateSimulation(UpdateSimulation.Scenario.DOWNLOAD_ERROR, downloadDuration = 3.seconds)
}

updater.simulation is non-null while a simulation plays — handy to badge the UI.

2. Feed redirect — electron-updater's dev-app-update.yml, without the file

nucleus.updater.feedUrl (system property) or NUCLEUS_UPDATER_FEED_URL (environment variable)
replaces the configured provider with a local directory (LocalFileProvider — a path or a
file: URL), an https server, or plain http to a loopback host:

./gradlew packageNsis                         # after bumping packageVersion (any auto-updatable format)
./gradlew run -Pnucleus.updater.feedUrl=build/compose/binaries/main/nsis

The packaging output of any auto-updatable format is a complete feed — the plugin writes the
latest*.yml manifest next to the artifact even when no publish provider is configured. An
unpackaged run (run, an IDE) checks and downloads for real; the install is skipped, since there
is no installed app to replace (this is also what installAndRestart does in any unpackaged run).

3. Updating an installed app — the whole path

An installed app honours the redirect (and a launch-time simulation) only when it opts in,
since whoever sets the variable would otherwise choose what it installs:

NucleusUpdater {
    provider = GitHubProvider("myorg", "myapp")
    allowLaunchOverrides = BuildConfig.isInternal   // or true, if the switch is part of how you test releases
}

Then, with the current version installed:

./gradlew serveUpdateFeed                     # bumped packageVersion: packages it, serves http://127.0.0.1:8421
NUCLEUS_UPDATER_FEED_URL=http://127.0.0.1:8421  "C:\Users\me\AppData\Local\Programs\MyApp\MyApp.exe"

serveUpdateFeed serves the merged manifests of every auto-updatable format of the current OS,
the artifacts, block maps and signatures, with byte ranges (differential downloads work as in
production). -Pnucleus.updater.serve.throttle=2m (bytes per second, k/m suffixes) and
-Pnucleus.updater.serve.latency=500 slow it down, -Pnucleus.updater.serve.port moves it,
-Pnucleus.updater.serve.timeout=<seconds> stops it on its own. Pointing the app at the directory
instead (NUCLEUS_UPDATER_FEED_URL=build/compose/binaries/main/nsis) needs no server, but always
downloads the whole artifact.

./gradlew runDistributable -Pnucleus.updater.… forwards the same switches as environment
variables. Ignored switches (an installed app without the opt-in, a remote http URL) are logged
as warnings, as is every redirect and simulation that applies.

Automated tests: updater-testing

dev.nucleusframework:nucleus.updater-testing ships UpdateFeedServer, the loopback release host
the Nucleus updater is tortured against: it publishes artifacts with a generated manifest, serves
ranges, records every request, and misbehaves on demand.

UpdateFeedServer().use { feed ->
    feed.publish("2.0.0", File("build/compose/binaries/main/nsis/myapp-2.0.0-win-x64-nsis.exe"))
    feed.fault(FeedFault.Throttle(bytesPerSecond = 1_000_000))            // a slow link
    feed.fault(FeedFault.Truncate(afterBytes = 4096), path = "*.exe", times = 1) // one dropped transfer
    // FeedFault.Status(503), Delay(2.seconds), Corrupt(offset), IgnoreRange

    val updater = NucleusUpdater {
        currentVersion = "1.0.0"
        executableType = "nsis"
        provider = GenericProvider(feed.baseUrl)
    }
    // drive checkForUpdates() / downloadUpdate() and assert on feed.requests
}

API

updater-runtime (dumped in updater-runtime.api):

  • UpdaterConfig.allowLaunchOverrides: Boolean = false, UpdaterConfig.simulation: UpdateSimulation? = null
  • NucleusUpdater.simulation: UpdateSimulation?, NucleusUpdater.feedOverride: String?, KDoc on downloadUpdate
  • UpdateSimulation(scenario, version, checkDuration, downloadDuration, downloadSize, isDifferential, justUpdatedFrom) + Scenario + UpdateSimulation.fromSettings()
  • provider.LocalFileProvider(directory) (stays inside its directory: ../ in a manifest is refused)

updater-testing (new, updater-testing.api): UpdateFeedServer, FeedFault, FeedRequest.

Launch switches (system property ↔ environment variable, camel humps become _):

Property Env Values
nucleus.updater.feedUrl NUCLEUS_UPDATER_FEED_URL path, file: URL, https://…, http://127.0.0.1…
nucleus.updater.simulate NUCLEUS_UPDATER_SIMULATE update / true, up-to-date, check-error, download-error, checksum-error, or a version
nucleus.updater.simulate.version NUCLEUS_UPDATER_SIMULATE_VERSION offered version (default: next minor)
nucleus.updater.simulate.duration NUCLEUS_UPDATER_SIMULATE_DURATION download seconds
nucleus.updater.simulate.size NUCLEUS_UPDATER_SIMULATE_SIZE bytes
nucleus.updater.simulate.differential NUCLEUS_UPDATER_SIMULATE_DIFFERENTIAL true
nucleus.updater.simulate.justUpdatedFrom NUCLEUS_UPDATER_SIMULATE_JUST_UPDATED_FROM previous version for consumeUpdateEvent()

Test plan

  • :updater-runtime:check — 257 tests (23 pre-existing opt-in E2E skipped), detekt, ktlint, apiCheck
    • LaunchOverridesTest: settings/env mapping, redirect gating (unpackaged vs installed, opt-in), accepted/refused URLs, LocalFileProvider traversal, unpackaged check + download + skipped install, simulation parsing, every scenario, cancellation, differential, post-update event, code-set simulation wins
    • DifferentialTortureTest (real electron-builder block maps): healthy delta, host ignoring Range, truncated / corrupted / failing ranged response, missing block map, throttled host — always byte-identical, falling back to a full download
  • :updater-testing:check — UpdaterTortureTest, 20 cases: 503 / missing / garbage manifest, artifact gone after check, cut connection, corrupted byte, artifact replaced mid-flight, transient failure then retry, throttled link, cancellation mid-download, slow host, 6 parallel updaters, new release published while running, local feed with spaces + non-ASCII path, missing local artifact, manifest escaping its directory, server range + traversal behaviour. No staging directory may outlive a failure. 5 consecutive --reruns green.
  • Plugin: UpdateYmlGeneratorTest (feed without publish provider, stale previous-version artifact, version boundaries, deleted stale manifests), UpdaterLaunchSettingsTest (env names, byte rates, range parsing), existing UpdateYml* suites
  • Real E2E on Windows, installed NSIS examples/hot-update-demo 1.0.0 → 1.1.0 — scripts/updater-dev-testing-e2e.ps1, 10/10 passed on the final code:
    • file-feed — redirect to the packaging output dir: hot update, restart on 1.1.0, "updated from 1.0.0 to 1.1.0"
    • file-url-feed — file: URL of a copy in feed dir ünïcødé
    • http-feed — real serveUpdateFeed, throttled to 6 MB/s, cold cache: full 64 MB download, 8 800 progress reports
    • http-feed-cached — repackaged 1.1.0 against the cached one: differential, 66 KB of 64 MB, range requests served by the task
    • locked — allowLaunchOverrides = false: redirect ignored, production provider kept, nothing downloaded
    • simulate / simulate-error / simulate-updated — installed app: simulated update played, install skipped, app still running; ChecksumException surfaced; post-update event reported
    • run-simulate / run-feed — ./gradlew run -Pnucleus.updater.…: simulated failure; real check + download from the redirected feed, install skipped, installed app untouched
  • macOS / Linux real-artifact E2E (only covered by unit tests so far)
  • Re-run scripts/windows-hot-update-e2e.ps1 (the demo still honours HOT_UPDATE_DEMO_FEED, but its updater setup changed)

Feed redirect (nucleus.updater.feedUrl / NUCLEUS_UPDATER_FEED_URL) to a local
directory (LocalFileProvider), https or loopback http; UpdateSimulation for the
update UI; installed apps honour both only with allowLaunchOverrides, and an
unpackaged run never installs. The plugin forwards -Pnucleus.updater.* to run /
runDistributable, adds serveUpdateFeed, and makes every packaging output a
complete feed (manifest written without a publish provider, only this version's
artifacts, stale manifests deleted). New updater-testing module with
UpdateFeedServer, the fault-injecting loopback host the torture tests run on.
@kdroidFilter
kdroidFilter marked this pull request as ready for review September 25, 2026 13:22
@kdroidFilter
kdroidFilter merged commit 1e67978 into nucleus-2.6 Sep 25, 2026
11 of 12 checks passed
kdroidFilter added a commit to NucleusFramework/nucleus-website that referenced this pull request Sep 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant