feat(updater): test updates without publishing a release - #725
Merged
Merged
Conversation
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
marked this pull request as ready for review
September 25, 2026 13:22
kdroidFilter
added a commit
to NucleusFramework/nucleus-website
that referenced
this pull request
Sep 25, 2026
…asspath fix Covers NucleusFramework/Nucleus#725, #728 and #729.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Lets developers test the auto-updater end to end without publishing a release, modeled on electron-updater's
dev-app-update.ymland 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
nucleus.updater.feedUrl(system property) /NUCLEUS_UPDATER_FEED_URL(env) replaces the configured provider with a local directory (LocalFileProvider, a path orfile:URL), anhttpsserver, or plainhttpto a loopback host.UpdateSimulation(in code, ornucleus.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.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/installAndQuitlog and return). Ignored and applied switches are logged;NucleusUpdater.feedOverride/.simulationexpose what applies../gradlew run -Pnucleus.updater.…forwards the switches as-D,runDistributableas env; newserveUpdateFeedtask (packages, then serves the merged manifests + artifacts with byte ranges on127.0.0.1:8421;-Pnucleus.updater.serve.{port,throttle,latency,timeout}).updater-testing:UpdateFeedServer— publishes artifacts with a generated manifest, serves ranges, records requests, injectsFeedFault.Status / Delay / Throttle / Truncate / Corrupt / IgnoreRange(path glob, times).Bugs found and fixed on the way
latest*.ymlwithout apublishprovider, 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).GenericProviderrejectedhttp://[::1]:URI.hostkeeps the IPv6 brackets.FeedFetcher(HTTP +file:), replacing four copies of the request/status boilerplate; differential downloads are skipped forfile: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.
./gradlew run -Pnucleus.updater.simulate=update./gradlew runserveUpdateFeed1. Simulation — the update UI from
./gradlew runEvery
NucleusUpdaterof the app then plays the scripted update:isUpdateSupported()istrue,checkForUpdates()offers the next minor version (or.version),downloadUpdate()reportsprogress over
.durationseconds (.differential=truereports a delta), andinstallAndRestart()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:
updater.simulationis non-null while a simulation plays — handy to badge the UI.2. Feed redirect — electron-updater's
dev-app-update.yml, without the filenucleus.updater.feedUrl(system property) orNUCLEUS_UPDATER_FEED_URL(environment variable)replaces the configured provider with a local directory (
LocalFileProvider— a path or afile:URL), anhttpsserver, or plainhttpto a loopback host:./gradlew packageNsis # after bumping packageVersion (any auto-updatable format) ./gradlew run -Pnucleus.updater.feedUrl=build/compose/binaries/main/nsisThe packaging output of any auto-updatable format is a complete feed — the plugin writes the
latest*.ymlmanifest next to the artifact even when nopublishprovider is configured. Anunpackaged run (
run, an IDE) checks and downloads for real; the install is skipped, since thereis no installed app to replace (this is also what
installAndRestartdoes 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:
Then, with the current version installed:
serveUpdateFeedserves 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/msuffixes) and-Pnucleus.updater.serve.latency=500slow it down,-Pnucleus.updater.serve.portmoves it,-Pnucleus.updater.serve.timeout=<seconds>stops it on its own. Pointing the app at the directoryinstead (
NUCLEUS_UPDATER_FEED_URL=build/compose/binaries/main/nsis) needs no server, but alwaysdownloads the whole artifact.
./gradlew runDistributable -Pnucleus.updater.…forwards the same switches as environmentvariables. Ignored switches (an installed app without the opt-in, a remote
httpURL) are loggedas warnings, as is every redirect and simulation that applies.
Automated tests:
updater-testingdev.nucleusframework:nucleus.updater-testingshipsUpdateFeedServer, the loopback release hostthe Nucleus updater is tortured against: it publishes artifacts with a generated manifest, serves
ranges, records every request, and misbehaves on demand.
API
updater-runtime(dumped inupdater-runtime.api):UpdaterConfig.allowLaunchOverrides: Boolean = false,UpdaterConfig.simulation: UpdateSimulation? = nullNucleusUpdater.simulation: UpdateSimulation?,NucleusUpdater.feedOverride: String?, KDoc ondownloadUpdateUpdateSimulation(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
_):nucleus.updater.feedUrlNUCLEUS_UPDATER_FEED_URLfile:URL,https://…,http://127.0.0.1…nucleus.updater.simulateNUCLEUS_UPDATER_SIMULATEupdate/true,up-to-date,check-error,download-error,checksum-error, or a versionnucleus.updater.simulate.versionNUCLEUS_UPDATER_SIMULATE_VERSIONnucleus.updater.simulate.durationNUCLEUS_UPDATER_SIMULATE_DURATIONnucleus.updater.simulate.sizeNUCLEUS_UPDATER_SIMULATE_SIZEnucleus.updater.simulate.differentialNUCLEUS_UPDATER_SIMULATE_DIFFERENTIALtruenucleus.updater.simulate.justUpdatedFromNUCLEUS_UPDATER_SIMULATE_JUST_UPDATED_FROMconsumeUpdateEvent()Test plan
:updater-runtime:check— 257 tests (23 pre-existing opt-in E2E skipped), detekt, ktlint, apiCheckLaunchOverridesTest: settings/env mapping, redirect gating (unpackaged vs installed, opt-in), accepted/refused URLs,LocalFileProvidertraversal, unpackaged check + download + skipped install, simulation parsing, every scenario, cancellation, differential, post-update event, code-set simulation winsDifferentialTortureTest(real electron-builder block maps): healthy delta, host ignoringRange, 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.UpdateYmlGeneratorTest(feed without publish provider, stale previous-version artifact, version boundaries, deleted stale manifests),UpdaterLaunchSettingsTest(env names, byte rates, range parsing), existingUpdateYml*suitesexamples/hot-update-demo1.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 infeed dir ünïcødéhttp-feed— realserveUpdateFeed, throttled to 6 MB/s, cold cache: full 64 MB download, 8 800 progress reportshttp-feed-cached— repackaged 1.1.0 against the cached one: differential, 66 KB of 64 MB, range requests served by the tasklocked—allowLaunchOverrides = false: redirect ignored, production provider kept, nothing downloadedsimulate/simulate-error/simulate-updated— installed app: simulated update played, install skipped, app still running;ChecksumExceptionsurfaced; post-update event reportedrun-simulate/run-feed—./gradlew run -Pnucleus.updater.…: simulated failure; real check + download from the redirected feed, install skipped, installed app untouchedscripts/windows-hot-update-e2e.ps1(the demo still honoursHOT_UPDATE_DEMO_FEED, but its updater setup changed)