Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,42 @@ project uses [semantic versioning][semver].

### Added

- **`offloader update` finds, verifies and applies a newer release.** GitHub
Releases is the feed, so there is no manifest server and no second place a
version is written down. Before anything runs: HTTPS with a host allowlist
re-checked after redirects, a size ceiling held against `Content-Length` and
again mid-stream, a SHA-256 taken while streaming and compared before
launch, and an Authenticode check that requires a valid status, this
project's certificate thumbprint, its publisher name, and an embedded
`FileVersion` matching the release. That last check is what stops a rollback:
re-serving an older, still validly signed installer under a newer asset name
would otherwise move every install back onto a build whose faults are fixed.
The install target is computed from the running executable rather than read
from the uninstall registry key, which anything running as the user can
write.

Prereleases are ordered rather than rejected, matching the grammar
`build/windows/versioning.py` already enforces, and a test asserts the two
orderings agree. The feed is the releases collection rather than
`/releases/latest`, which GitHub documents as excluding prereleases: on that
endpoint an installed `0.1.0b1` could never see `0.1.0b2`, and a repository
holding only the betas the candidate workflow publishes would answer with
nothing at all. The collection is ordered by creation date rather than by
version, so every entry is read and the greatest eligible one wins; drafts
are skipped, since their assets are not published.

**An install is offered what is newer on the channel it is already on.** A
build that is itself a prerelease is testing the prereleases and takes the
next one, and takes the stable release when it arrives, because `0.1.0b2` is
older than `0.1.0`. An install on a stable release is offered only stable
releases: `0.1.0b1` finding `0.1.0b2` must not also mean `1.0.0` finding
`1.0.1b1`.

The updater never closes a running copy: the installer's
refusal while a transfer is in flight is the guarantee, so the command says
so before the elevation prompt appears. See
[`docs/updates.md`](docs/updates.md); the in-app check is still deferred.

- Windows desktop/CLI bundles and an NSIS installer, with pinned dependencies,
embedded version metadata, signing by default, explicit unsigned CI builds,
source/file inventories, checksums, and headless artifact checks. Installation
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ offloader verify D:\video\080426\A001
| `report` | regenerate paperwork for an existing tree, copying nothing |
| `info` | show tool and environment status |
| `gui` | launch the desktop app (also `offloader-gui`) |
| `update` | check for a newer release, and install it (Windows) |

### `offload` and `report`

Expand Down
27 changes: 17 additions & 10 deletions docs/release-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ tests or builds were run for this documentation task.
| Dependencies | Minimum versions and optional extras | Recorded build environment and pinned release dependency sets |
| Media tools | ffmpeg/ffprobe discovered externally; copying works without them | Explicit installer dependency policy and useful missing-tool messaging |
| Integrity | Detailed guarantees and remaining limits in `data-safety.md` | Release-specific regression evidence and operational validation |
| Updates | No updater found | Manual updates for the beta; documented safe upgrade and recovery |
| Updates | `offloader update` checks GitHub Releases, verifies the signed installer and hands it over; no in-app check or notification | Wire the check into the desktop interface; qualify an end-to-end update against a published release |

Sources: [`pyproject.toml`](../pyproject.toml),
[`CI`](../.github/workflows/ci.yml), [`README`](../README.md),
Expand Down Expand Up @@ -107,8 +107,10 @@ credentials, update endpoints, or installation paths.
- **Timeline support:** include and test OpenTimelineIO and the currently
declared adapter in the desktop bundle if timeline import is advertised for
that bundle. Otherwise mark that capability source-only for the beta.
- **Updates:** manual installation initially. Refuse replacement while the app
or CLI has an active job; never force-kill a copy to install an update.
- **Updates:** `offloader update` finds and verifies a release and runs the
signed installer; the in-app check remains deferred. Refuse replacement
while the app or CLI has an active job; never force-kill a copy to install
an update.
- **Scope freeze:** defer new media features, cloud services, notifications,
auto-update, and a marketing website. Fix integrity and packaging blockers
discovered during qualification.
Expand Down Expand Up @@ -194,13 +196,18 @@ user data. Remove only inventoried application files, never recursively erase
an arbitrary install directory that might contain user material.

**Updater boundary:** installer parity includes the silent-install contract,
user-context relaunch, and safe settings preservation. An in-app download/
update client is still a separate deferred feature. Future callers must verify
the installer signature, publisher, and version before elevation. Preserve
NSIS's `/D=` contract: last argument, unquoted even when the path has spaces,
and computed from a trusted installation target rather than an unvalidated
registry command. A generic unattended deployment must not launch an app in
a missing or unrelated user's session.
user-context relaunch, and safe settings preservation. A generic unattended
deployment must not launch an app in a missing or unrelated user's session.

The command-line half of the update client is now implemented in
`src/offloader/update.py` and documented in [updates.md](updates.md). It meets
the conditions this section set: the signature, publisher and embedded version
are all verified before elevation, and the `/D=` target is computed from the
running executable rather than from the uninstall registry key. It does not
close a running instance, so an update requires the operator to finish first
and the installer's refusal remains the guarantee. What is still deferred is
the in-app part: an automatic check, a notification, and a progress surface in
the desktop interface.

**Acceptance gates:** exercise first install, custom path, same-version
reinstall, upgrade, failed upgrade recovery, silent install, and uninstall on
Expand Down
135 changes: 135 additions & 0 deletions docs/updates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# Updating an installed copy

`offloader update` asks GitHub whether a newer release exists, and
`offloader update --install` downloads it, proves it came from this project,
and hands it to the installer.

An updater runs code that arrived over the network, with elevation, on someone
else's machine. So the design question is not how it installs but what it
refuses, and every check below fails closed: an unreadable version, a
mismatched digest, a signature from another certificate all mean "no update",
never "install it anyway".

## The feed

GitHub Releases, read through the REST API. There is no manifest server to run
or keep honest, and no second place a version number is written down.

```
https://api.github.com/repos/owenpkent/offloader/releases?per_page=30
```

That URL is compiled into every shipped build, so moving it orphans every
install that already exists. Renaming or transferring the repository is a
breaking change for installed copies, not an administrative detail.

The collection, not `/releases/latest`. GitHub documents that endpoint as
returning the newest published *full* release and excluding prereleases, so an
installed `0.1.0b1` could never discover `0.1.0b2` through it, and a repository
holding only the betas the candidate workflow publishes with `--prerelease`
would answer with nothing at all. Ordering prereleases is the point of the
grammar below, so the feed has to be one that carries them.

The collection is ordered by creation date, which is not the same as version
order: a patched `0.1.0b2` published after `0.1.0rc1` is listed above it. So
every entry is read and the greatest eligible version wins, rather than the
first one that parses.

Four fields are read per release. `draft` excludes it: 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 the maintainer's own machine before the
release exists for anybody else. `tag_name` must be a version this project
publishes (`0.2.0`, `0.1.0b1`, with or without a leading `v`); anything else,
including `latest` or `1.0.3-evil`, is refused rather than compared as a
string. `assets[]` must contain an entry named exactly
`Offloader-Setup-{version}.exe` for the version the tag claims, so a file
attached beside the real installer cannot be served in its place. `body`
becomes the release notes.

## Which releases an install is offered

Newer, and on the channel it is already on. An installation that is itself a
prerelease is testing the prereleases, so it takes the next one, beta or
release candidate or final. An installation on a stable release is offered only
stable releases: it did not volunteer for the next beta, and `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 ordering below. That is how a beta tester ends up on
the shipping build without doing anything.

## Version ordering

The grammar is the one `build/windows/versioning.py` already enforces, which
means prereleases are ordered rather than rejected: `0.1.0a2` < `0.1.0b1` <
`0.1.0rc1` < `0.1.0`. A late alpha never outranks a first beta, matching the
disjoint numeric ranges that module reserves when it writes the installer's
Windows version fields. `tests/test_update.py` asserts both implementations
agree on a list of versions, because if they diverge an update can install a
build that Windows then considers older than the one it replaced.

If either version is unreadable the answer is "not newer". String comparison
is what makes `1.0.10` look older than `1.0.9`.

## What is checked before anything runs

| Check | What it stops |
| --- | --- |
| HTTPS, and a host allowlist applied **after** redirects | GitHub redirects asset downloads to another domain, so the host that actually serves the bytes is the one that matters |
| A size ceiling, enforced against `Content-Length` and again while streaming | A hostile or confused feed filling the disk; a truthful `Content-Length` is not something a server owes us |
| SHA-256 taken while streaming, compared again before launch | The gap between writing the file and executing it, in which anything able to write to the download directory could swap it |
| Authenticode status is `Valid` | An unsigned or tampered installer |
| The signing certificate's thumbprint matches | A valid signature from somebody else. Anyone can obtain one; the question is whether this is the certificate the release was built with |
| The publisher name matches | The same, read the way the UAC prompt will read it |
| The installer's **embedded** `FileVersion` matches the release version | A rollback. Someone able to re-upload an asset could otherwise re-serve an older, still validly signed installer under a newer name, moving every install back onto a build whose faults are already fixed |

The certificate values are the ones in `build/windows/sign.py`, repeated in
`src/offloader/update.py` rather than imported, because the build scripts are
not part of the shipped package.

## Applying it

The verified installer is run through `ShellExecuteW` with the `runas` verb,
silently: `/S /D=<target>`. A plain process spawn does not honour the
installer's manifest request for elevation and fails instead of prompting.

Two details are easy to break silently. `/D=` must be the **last** argument
and must **not** be quoted even when the path contains spaces: quoting it
installs into a directory whose name contains a quote, and anything placed
after it is swallowed into the path. And the target is computed from the
running executable, never read from the uninstall registry key, which is
writable by anything running as the user and would otherwise let a planted
value redirect an elevated silent install.

## What it deliberately does not do

**It does not close anything.** Offloader's installer refuses maintenance
while a transfer is in flight and exits non-zero without changing the
installation; it never force-kills a running copy. That refusal is a promise
in [data-safety.md](data-safety.md), so the updater's job is to verify and
hand over, not to clear the way. `offloader update --install` says so before
the elevation prompt appears, because an operator who does not know it reads
the installer's refusal as a broken update.

The consequence is that updating requires closing the app and any CLI
transfer first. That is the intended trade: a packaged updater that could
interrupt a card offload would be a worse tool than one that asks.

**There is no rollback.** A failed install leaves the previous version in
place where the installer's own transactional recovery manages it (see
`installation.py`); the updater does not attempt a second recovery mechanism
on top. Nothing is deleted by the updater itself.

**There is no automatic check yet.** `offloader update` is explicit. An in-app
check, a notification and a timer belong with the desktop interface and are
not implemented here; see [release-plan.md](release-plan.md).

## Reference

Modelled on Alpha-OSK's `src/updater.py`, which solves the same problem for a
different app. Two of its decisions are deliberately not carried over: it
terminates the running application before installing, and it rejects any tag
that is not `X.Y.Z`. The first conflicts with the guarantee above; the second
would make this updater blind to Offloader's own first packaged release, which
is planned as a beta. Nothing else of its configuration, endpoints or product
identifiers is reused.
72 changes: 71 additions & 1 deletion src/offloader/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,15 @@ def build_parser() -> argparse.ArgumentParser:

sub.add_parser("info", help="show tool and environment status")
sub.add_parser("gui", help="launch the desktop interface")

update = sub.add_parser(
"update", help="check for a newer release, and install it (Windows)")
update.add_argument("--install", action="store_true",
help="download, verify and run the installer rather "
"than only reporting what is available")
update.add_argument("--dir", type=Path, default=None, metavar="PATH",
help="where to download the installer (default: a "
"private temporary directory)")
return parser


Expand Down Expand Up @@ -598,11 +607,72 @@ def cmd_gui(_args: argparse.Namespace) -> int:
return gui_main([sys.argv[0]])


def cmd_update(args: argparse.Namespace) -> int:
"""Report or apply a newer release.

Exit status: 0 when up to date or an install was started, 1 when an update
exists but was not applied, 2 on a usage or verification failure. A caller
scripting this can therefore tell "nothing to do" from "something to do".
"""
import tempfile

from . import update as update_mod

release = update_mod.check()
if release is None:
print(f"Offloader {__version__} is the newest release available.")
return 0

print(f"Offloader {release.version} is available "
f"(installed: {__version__}).")
if not args.install:
print(" offloader update --install download, verify and install")
return 1

if sys.platform != "win32":
print("error: only the Windows release can be installed this way",
file=sys.stderr)
return 2

directory = args.dir
if directory is None:
directory = Path(tempfile.mkdtemp(prefix="offloader-update-"))
directory.mkdir(parents=True, exist_ok=True)

def report(done: int, total: int) -> None:
if total:
sys.stderr.write(f"\r downloading {done * 100 // total:3d}%")
else:
sys.stderr.write(f"\r downloading {format_size(done)}")
sys.stderr.flush()

try:
installer, digest = update_mod.download(release, directory,
progress=report)
sys.stderr.write("\r" + " " * 32 + "\r")
update_mod.verify(installer, release,
expected_digest=digest, actual_digest=digest)
print(f" verified {installer.name}")
print(f" sha256 {digest}")
# Said before the prompt appears, because the installer refuses rather
# than closing anything: an operator who does not know that reads the
# refusal as a broken update.
print("\nThe installer will not replace a running Offloader. Close "
"the app and any\nCLI transfer first; an active job makes it "
"exit without changing anything.")
update_mod.apply(installer)
except update_mod.UpdateError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
return 0


def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
handlers = {"offload": cmd_offload, "report": cmd_report,
"verify": cmd_verify, "info": cmd_info, "gui": cmd_gui,
"resolve": cmd_resolve, "control": cmd_control}
"resolve": cmd_resolve, "control": cmd_control,
"update": cmd_update}
try:
return handlers[args.command](args)
except KeyboardInterrupt:
Expand Down
Loading
Loading