Skip to content

Find, verify and apply a newer release from the command line - #13

Open
owenpkent wants to merge 4 commits into
windows-release-bundlefrom
windows-updater
Open

owenpkent wants to merge 4 commits into
windows-release-bundlefrom
windows-updater

Conversation

@owenpkent

Copy link
Copy Markdown
Owner

Summary

The installer landed in #7 with nothing to drive it. This is the update client the release plan's Updater boundary section described, and it meets the conditions that section set: signature, publisher and embedded version verified before elevation, and the /D= target computed from the running executable rather than read from the uninstall registry key.

offloader update              # report what is available
offloader update --install    # download, verify, hand to the installer

Exit status distinguishes "nothing to do" (0) from "an update exists but was not applied" (1) from a verification failure (2), so it can be scripted.

Two deliberate divergences from alpha-osk

Worth reviewing first, because they are where I did not copy the reference:

  1. It never closes a running copy. alpha-osk's installer taskkills the app and a helper process restarts it. Offloader's installer refuses maintenance while a transfer is in flight and exits non-zero, and docs/data-safety.md makes that a promise. So this verifies and hands over. The command prints the consequence before the UAC prompt, because an operator who does not know it reads the installer's refusal as a broken update.
  2. Prereleases are ordered, not rejected. alpha-osk's updater only accepts X.Y.Z tags. Offloader's first packaged release is planned as 0.1.0b1, so that rule would make the updater blind to the very build it ships in. The grammar here is the one build/windows/versioning.py already enforces, and test_the_ordering_agrees_with_the_windows_version_fields asserts both orderings agree across a list of versions — a disagreement would let an update install a build Windows then considers older than the one it replaced.

What it refuses

An updater runs network-delivered code with elevation, so the refusals are the design:

Check Stops
Host allowlist re-applied after redirects GitHub redirects assets to another domain, so the host serving the bytes is the one that matters
Ceiling on Content-Length and mid-stream A hostile server owes us no honest header
SHA-256 taken while streaming, re-compared before launch The window in which anything able to write to the download directory could swap the file
Authenticode Valid + our thumbprint + our publisher name A valid signature belonging to somebody else
Installer's embedded FileVersion matches the release Rollback. Re-uploading an older, still validly signed installer under a newer asset name would otherwise move every install onto a build whose faults are fixed
Tag must be a version this project publishes latest or 1.0.3-evil being string-compared into an upgrade
Asset named exactly Offloader-Setup-{version}.exe A file attached beside the real installer being served instead

/D= is emitted last and unquoted, the two rules NSIS enforces silently. Certificate constants are duplicated from build/windows/sign.py rather than imported, since the build scripts are not shipped — flagging that as the one bit of duplication, in case you would rather move them into the package.

Not included

  • No in-app check. A timer, notification and progress surface belong with the desktop interface; the release plan already treats that as separate. download() takes a progress callback so the GUI can wire straight into it.
  • No release workflow. The updater needs a published release to see, and .github/workflows/release.yml still does not exist. Worth knowing: alpha-osk has no release automation either (releases are built and gh release created by hand), so this is net-new work rather than parity. Happy to do it next.
  • No rollback, by design: the installer's own transactional recovery owns that, and a second recovery mechanism layered on top would fight it.

Test plan

  • tests/test_update.py — 62 tests, mostly refusals: unparseable versions fail closed, prerelease ordering, the cross-check against versioning.py, malformed feeds, wrong/renamed assets, non-HTTPS and off-allowlist hosts, a redirect to another host, the ceiling both ways, digest-changed-after-download, each signature rule failing on its own, thumbprint compared without spacing or case, /D= shape, and a declined UAC prompt reported as such rather than as a failed download
  • Full suite 782 passed, 12 skipped; ruff check src tests scripts build/windows clean
  • Live smoke: offloader update against the real feed with no releases published returns "0.1.0 is the newest release available", exit 0 (fails closed on the 404 rather than raising)
  • End-to-end against an actual signed published release — cannot be done until one exists

The installer existed with nothing to drive it. This adds the client the
release plan's updater boundary described, meeting the conditions it set:
signature, publisher and embedded version all verified before elevation, and
the /D= target computed from the running executable rather than read from the
uninstall registry key, which anything running as the user can write.

GitHub Releases is the feed, so there is no manifest server to run and no
second place a version is recorded. A tag that is not a version this project
publishes is refused rather than string-compared, and the asset has to be
named for the version the tag claims, so a file attached beside the real
installer cannot be served in its place.

An updater executes code that arrived over the network, with elevation, so
what it refuses matters more than how it installs. The host allowlist is
applied after redirects, because that is where the host can change. The size
ceiling holds against Content-Length and again while the bytes arrive, since
a hostile server owes us no honest header. The digest is taken while
streaming and compared again before launch, closing the gap in which anything
able to write to the download directory could swap the file. Authenticode
must report Valid, with this project's thumbprint and publisher name, and the
installer's own embedded FileVersion must match the release: without that
last check, re-uploading an older but still validly signed installer under a
newer asset name would roll every install back onto a build whose faults are
already fixed.

Two things the reference implementation does are deliberately not carried
over. It terminates the running application before installing, which would
contradict the promise that maintenance never force-kills a copy mid-transfer;
this verifies and hands over instead, and says so before the elevation prompt
so the installer's refusal does not read as a broken update. And it rejects
any tag that is not X.Y.Z, which would make the updater blind to Offloader's
own first packaged release. The grammar here is the one versioning.py already
enforces, with a test asserting both orderings agree, because a disagreement
would let an update install a build Windows then considers older.

The in-app check, notification and progress surface stay deferred.
The assertion hardcoded a Windows path, and a backslash is not a separator off Windows: install_target resolves the running executable, so on Linux and macOS the literal resolved against the working directory and the test failed everywhere except where it was written. The property it checks is platform-independent, so it now uses a real directory and the fallback is asserted separately.

@owenpkent owenpkent left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed feed discovery, version/asset selection, download handling, installer verification, installed-location handling, and the CLI handoff. The actual feed cannot deliver the planned beta-to-beta updates; details inline.

Additional updater-security findings are supplied privately under SECURITY.md, including their affected call paths and suggested regression coverage. This public comment omits those reproductions. Validation for this review was static code inspection and verification of GitHub's documented release-feed behavior; no downloaded installer was executed.

Comment thread src/offloader/update.py Outdated
#: Where releases are published. Pinned deliberately: this URL is compiled into
#: every shipped build, so moving it orphans every install that already exists.
#: Treat a rename or transfer of the repository as a breaking change.
FEED_URL = "https://api.github.com/repos/owenpkent/offloader/releases/latest"

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Use a feed that can discover the beta releases being shipped.

GitHub's /releases/latest endpoint returns a published full release and excludes releases marked as prereleases. Consequently an installed 0.1.0b1 cannot discover 0.1.0b2 or 0.1.0rc1 through this URL, despite the prerelease ordering supported below. If the repository has only beta releases, the endpoint does not return any eligible candidate. This also conflicts with the candidate workflow's use of --prerelease.

Select the greatest eligible version from the releases collection, or define explicit stable/beta channels and their selection rules. Add a feed-level test containing multiple beta/RC releases, a stable release, and drafts, rather than testing only version comparison against a hand-picked single payload.

Reference: GitHub's latest-release API contract.

/releases/latest is documented as returning the newest published full
release and excluding prereleases. Every release this project has planned so
far is a prerelease: the candidate workflow publishes with --prerelease, and
the first packaged build is 0.1.0b1. So an installed 0.1.0b1 could not
discover 0.1.0b2 or 0.1.0rc1 through that endpoint, and a repository holding
only betas answered with nothing at all. The module already went out of its
way not to reject prereleases the way Alpha-OSK's updater does, and then
asked an endpoint that had already dropped them.

The feed is the collection now. It is ordered by creation date rather than
by version, so every entry is read and the greatest eligible one wins: a
patched 0.1.0b2 published after 0.1.0rc1 is listed above it without being
above it. Drafts are skipped, because a draft is visible to anyone who can
write to the repository and its assets are not published, so offering one
would hand an installer to this machine before the release exists for
anybody else.

Which releases an install is offered is now stated rather than implied.
Newer, and on the channel it is already on: a build that is itself a
prerelease is testing the prereleases and takes the next one, and a stable
install is offered only stable releases. 0.1.0b1 finding 0.1.0b2 must not
also mean 1.0.0 finding 1.0.1b1. A prerelease install still takes the stable
release when it arrives, because 0.1.0b2 < 0.1.0 in the existing ordering,
which is how a beta tester ends up on the shipping build without doing
anything.

Changing FEED_URL is free today and would not be later: it is compiled into
every shipped build, and nothing has shipped.

Eight feed-level tests against a collection holding betas, a release
candidate, a stable release and a draft, rather than version comparison
against one hand-picked payload. One of them covers an unusable entry not
hiding the rest of the feed. docs/updates.md updated to match.
owenpkent added a commit that referenced this pull request Sep 22, 2026
…ring

This branch exists to make the docs describe the checkout, and one of the
claims it added does not. README, the release plan and updates.md all present
"offloader update finds a release" as working, and the version ordering
section explains at length that prereleases are ordered rather than rejected.
The endpoint underneath is /releases/latest, which GitHub documents as
returning the newest published full release and excluding prereleases. The
first packaged release is planned as 0.1.0b1, so an installed beta cannot
discover its successor, and a repository holding only the betas the candidate
workflow publishes with --prerelease answers with nothing at all.

The ordering is right; the endpoint is wrong. All three places now say so and
point at the correction pending on #13, and the release plan no longer counts
discovery as a gate that can be signed off. Nothing here claims the fix: it
states the limit as the checkout currently has it, which is what this branch
is for.
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