Lightning can tell you when a newer release exists and, for the package types that allow it, install that release for you. This document describes exactly what it contacts, what it sends, how an update is verified, and what happens for each package format.
Historically Lightning did not check for updates at all. That is no longer true, so the privacy section below is part of the contract, not a footnote.
Two hosts, with sharply different roles.
gitlab.smetonis.net — where releases are built and signed. Update
metadata — which version exists, what the artifact is called, how big it is,
and what its SHA-256 must be — comes from a signed document, signed in the
release pipeline with a key that exists nowhere else, and from nothing else.
Where that document is fetched from is a matter of availability, not trust:
since the GitLab project may become private, the PRIMARY source is the fixed
GitHub release slot below and GitLab is the single FALLBACK, read only when
GitHub does not answer or answers with something that does not verify. The
signature check is identical either way.
github.com — the public source of the metadata and of the files.
Lightning reads the signed manifest pair from one fixed GitHub release slot
first (see Reading order and a private GitLab below) and downloads the update
file from the project's GitHub mirror first, too. GitHub redirects both to
objects.githubusercontent.com or release-assets.githubusercontent.com, so
those hosts are contacted too. If the mirror cannot supply the file, Lightning
falls back to GitLab and the update proceeds normally.
GitHub is never asked what to install. Lightning makes no GitHub API call, and
never reads GitHub's releases list, /releases/latest, tags, or any other
GitHub information — the mirror's URL is itself part of the signed manifest, so
the release authority chooses it rather than Lightning discovering it. Anything
the mirror returns is treated as a bag of bytes and is immediately checked
against the SHA-256 the signed manifest already fixed. See Why a mirror cannot
publish an update below.
An update check is an ordinary anonymous HTTPS GET of two small public files from the fixed GitHub release slot first, and from the project's GitLab package registry only when GitHub does not answer or does not verify:
https://github.com/Mizerd/lightning/releases/download/update-latest/update-manifest-v1.json
https://github.com/Mizerd/lightning/releases/download/update-latest/update-manifest-v1.json.sig
.../api/v4/projects/6/packages/generic/lightning-update/latest/update-manifest-v1.json (fallback)
.../api/v4/projects/6/packages/generic/lightning-update/latest/update-manifest-v1.json.sig (fallback)
The request carries the URL and Lightning's update user agent, which is exactly
Lightning/<version> — the application version and nothing else. The same is
true of the artifact download, whichever host serves it, and of a fallback
metadata check: GitHub sees an anonymous request for a public release file,
plus the connecting IP address, as
any file download does. Specifically, neither a check nor a download ever
includes:
- your Matrix user ID, display name or avatar
- your homeserver
- your device ID or any session identifier
- an access or refresh token
- any room, message or contact information
- any analytics identifier, and there is no updater-specific tracking ID — none is generated, stored or sent
Update preferences are stored outside every account: the key update/ in
application settings holds only the automatic-check preference, the time of the
last successful check, and the version you last dismissed. No Matrix data is
stored with them, and switching accounts does not touch update state or restart
a download.
Manual — Settings → Updates → Check for updates. Always available, always explicitly initiated by you.
Automatic — on by default, and switchable off in Settings → Updates. Lightning checks at most once every 24 hours, never during the first 30 seconds after launch, and never in response to switching room or account. Checks are asynchronous: startup is never delayed waiting for the network.
The trust chain, in order. Every step must pass; there is no way to continue past a failure, and the interface deliberately offers none.
public key compiled into Lightning
-> Ed25519 signature over the exact manifest bytes (whichever host served it)
-> expected filename, size and SHA-256 of the artifact
-> those exact bytes fetched from the GitHub mirror, or GitLab if that fails
-> SHA-256 verified against the signed value
-> package-specific installation
- The signature is checked over the raw manifest bytes before a single field
is read. The
.sigfile is a small JSON envelope naming an algorithm and a key ID; the key ID must already be in Lightning's compiled-in trust table. A manifest cannot introduce a key, and an unknown key ID is a hard failure. - The signature algorithm is Ed25519, verified through OpenSSL 3's EVP API. Lightning implements no cryptography of its own.
- The artifact is streamed to a private temporary file and its SHA-256 is computed as it arrives, then compared against the value in the signed manifest. The manifest's declared size is enforced as a hard ceiling during the transfer, so a truncated or oversized download fails rather than completing.
- The download host is chosen from the signed manifest, never discovered.
The mirror is tried first and the canonical host is the fallback. Both are
checked against a compiled-in allowlist of hosts, exact-matched. A manifest
naming an unknown host for the canonical address is rejected outright; an
unknown mirror host is simply ignored, and that artifact downloads from
the canonical source as if no mirror had been offered — so moving the mirror
in future cannot strand clients built before the move. The manifest
pair may be served from the mirror slot (primary) or the canonical registry
(fallback) and nowhere else, and only because its signature verifies; the
manifest's artifact
urlandrelease_notes_urlmust still name the canonical host, so a mirror-delivered manifest cannot move the fallback or the notes link onto a host the key did not name. - A mismatch from the mirror is retried once from the canonical host, and a mismatch there stops everything. Falling back keeps updates working when a mirror is broken or stale, and costs nothing: both attempts are checked against the same signed hash, so neither can install different bytes than the other would have.
- Failure is terminal. Invalid signature, unknown key, wrong hash, wrong size, unsupported schema, or a URL that is not HTTPS on an allowed host all stop the update, and nothing in the interface offers a way past them.
The mirror holds bytes. It holds no key, and it is not consulted about versions. Concretely:
- If GitHub serves a modified binary, its SHA-256 will not match the value in the signed manifest and the download is discarded. Lightning then retries from GitLab; if that also fails, the update fails. A tampered file is never installed, and there is no way for a user to override that.
- If GitHub serves modified metadata, nothing happens, because Lightning never reads metadata from GitHub. The manifest and its signature come only from the canonical host, and the mirror's host is not accepted for either.
- If GitHub hosts a newer or extra release, it is ignored entirely. Lightning's idea of "what version exists" comes from the signed manifest, so an unsigned GitHub release — however new it looks — is invisible to it.
- If someone takes over the GitHub account, they can break downloads and they can be noticed doing it, but they cannot make Lightning install anything: publishing a trusted update requires the Ed25519 signing key, which lives only in the release pipeline's protected variables and never touches GitHub.
The property to hold on to is that the signed manifest fixes the exact filename, size and hash before any download starts. Choosing a different host to fetch from cannot change what is considered acceptable to install.
This is the property that matters most. The manifest describes which file, how big, and what hash — never what to do. There is no command, argument, script or URL-to-execute field, and Lightning has no code path that would execute one. Every installation strategy below is compiled into Lightning and the updater helper. Remote metadata selects among fixed, trusted behaviours; it can never define one.
A signature proves who produced the manifest; it cannot prove the manifest is
the current one. Every manifest therefore carries a signed expires
instant (released plus 120 days from the pipeline), and Lightning shows,
once that instant has passed, that the update information was expected to be
refreshed by then and the project may be offline. Nothing else changes:
the update the manifest names is still offered, its downloads are still
verified against the signed hashes, a manifest with no expires at all is
accepted as one that never expires, and one whose expires cannot be read
is treated as already stale (the line shows; nothing else changes), so a
generator defect cannot silently switch the signal off. This is deliberate. The maintainer's
requirement is that installed clients keep working — and keep updating from
the GitHub mirror — if his servers lose power or disappear for good, with no
action from him or from GitLab; a manifest that turned into a failure on a
date would do the opposite. What the expiry gives up is freeze detection: a
captured pair can be replayed to keep a client on an old release. What a
replay cannot do is install anything the signing key did not sign, or
downgrade anyone. The pipeline can still refresh latest without a release
(lightning-deploy docs/update-manifest.md), which is what keeps the status
line quiet during a long lull.
Semantic, never lexicographic, so 0.10.0 is correctly newer than 0.9.0.
Prerelease versions sort below their release. This build never installs a
prerelease, whatever the manifest calls its channel. The earlier rule ("the
stable channel never offers a prerelease") read the manifest's own channel
field, so a document that simply called itself something else walked past it;
the decision now belongs to the build, not the document. If the published
version equals yours, or is older than yours, Lightning reports that you are
up to date and does not downgrade. A version string that does not parse is an error, never an
assumption that an update exists.
The client compiles in two addresses for its update metadata: the fixed GitHub
release slot …/releases/download/update-latest/… (PRIMARY) and the canonical
Generic Package Registry path on GitLab (the single FALLBACK). The release
pipeline writes the slot after every GitLab promotion
(mirror-update-manifest-to-github), and every package is mirrored per release,
so with GitLab private or down the whole path — manifest, signature, artifacts —
is served by GitHub and verified by the same compiled-in Ed25519 key, and a
check costs one request pair with no failed GitLab attempt in front of it. The
fallback is read once per check if the primary is unreachable (DNS, TLS, 4xx,
5xx, timeout, oversized) or answers with a document that does not verify (a
stale slot, a lapsed domain, a hijacking proxy); that copy is held to the
identical verification, and a second failure ends the check. A new check
starts at the primary again, so an outage is never sticky.
Nothing about trust changed with the order. GitHub decides nothing: the client
reads no release metadata, calls no API and never follows a "latest" URL GitHub
could choose; only strictly newer versions are offered, so the slot can never
cause an unsigned install or a downgrade. The document fetcher's host policy is fixed by
the address it STARTS at: from the GitHub slot it accepts redirects among the
mirror hosts only (github.com to its asset object hosts), from GitLab the
canonical host only. Artifacts: the signed manifest's mirror_url (GitHub) is
tried first and the canonical url only after it fails, so a GitLab that
answers 401/404/5xx or not at all is never contacted when GitHub serves the
file, and when GitHub fails and GitLab does too the update ends in one clean
failure.
Trade-off, accepted (freeze and pin): the fallback runs only on a transport
failure or a failed signature, not because the primary's answer is old. An OLD
but validly signed manifest in the GitHub slot therefore makes the client
report "up to date" without asking GitLab. Whoever controls the slot — the
GitHub account, a leaked GITHUB_MIRROR_TOKEN, or a failed pipeline write —
can freeze clients, or pin them to any signed release newer than the one they
run; they cannot install anything unsigned and cannot downgrade. Before this
order, that control sat with GitLab's rollback-guarded latest. It is
inherent once GitHub is the only public source, so it is accepted; no
behavioural mitigation exists yet.
Trade-off, accepted: the slot is the only thing that keeps GitHub-first clients
current, so a pipeline that promoted on GitLab and failed to write the slot
leaves clients on the previous release until the slot is refreshed (the pipeline
preflights and reports this, and the write job mirror-update-manifest-to-github runs after promotion, is retried, idempotent and NOT allow_failure, so a failed write turns the release pipeline red; attach-existing with
UPDATE_REFRESH_LATEST_ONLY=true rewrites it). Clients from 0.7.3 to the release before
this order shipped already read the slot as their fallback, so a private GitLab
costs them one failed request per check but nothing else; clients older than
0.7.3 have no GitHub slot at all and stop updating when GitLab goes private.
Verifying with GitLab blocked: point gitlab.smetonis.net at 0.0.0.0 in
/etc/hosts (and separately drop its traffic, to see the timeout case), run a
check and a download with --console --log-file, and confirm the requests go
only to github.com and *.githubusercontent.com, the update is offered and
the hash verifies. Then block GitHub instead and confirm the check succeeds via
the GitLab fallback.
Lightning must know how it was installed before it can offer to update itself,
and it does not guess from directory names. The package build stamps an
explicit identifier into the binary (LIGHTNING_INSTALL_TYPE), and runtime
evidence overrides it only for the three ecosystems that can host any build:
FLATPAK_ID or /.flatpak-info means Flatpak, SNAP/SNAP_NAME means Snap,
and a valid APPIMAGE path means AppImage.
Identifiers: windows-msi, windows-setup, windows-portable,
linux-appimage, linux-deb, linux-rpm, linux-flatpak, linux-snap,
linux-rpm-repo, macos-dmg, development, unknown. linux-rpm-repo is the
rpm Fedora COPR builds from packaging-ci/packaging/rpm/lightning-copr.spec.in:
the same package as linux-rpm at runtime, so only its build can say it. The current type is shown in
Settings → Updates.
A development build never installs an update. A build from source reports
development, which permits a manual check only if explicitly enabled and
refuses installation outright, so a source tree can never turn itself into a
packaged installation.
| Format | What Lightning does |
|---|---|
| Windows MSI | Downloads and verifies the MSI, then the helper runs Windows Installer against it: an ordinary major upgrade (stable UpgradeCode). A per-user copy needs no elevation. A copy installed for all users is upgraded with ALLUSERS=1, started elevated, so Windows shows one UAC prompt; declining it changes nothing. |
| Windows Setup EXE | Downloads and verifies the NSIS installer, then the helper runs it silently with the installation's own scope (/S /CURRENTUSER, or /S /ALLUSERS started elevated for an all-users copy — one UAC prompt). A per-user copy gets no prompt. |
| Windows portable ZIP | Downloads and verifies the ZIP, extracts it to a staging directory with strict path checks, validates that the result really is a Lightning layout, then swaps directories and rolls back on any failure. |
| Linux AppImage | Downloads and verifies the new AppImage, preserves the executable bit, and atomically replaces the running AppImage, restoring the previous file if the replacement fails. |
| Linux DEB | Downloads and verifies the .deb, then hands it to the system package manager through PolicyKit. dpkg/APT stays the owner of every installed file. |
| Linux RPM | Downloads and verifies the .rpm, then hands it to dnf5/dnf/zypper/rpm-ostree (the first that exists) through PolicyKit. RPM stays the owner of every installed file. On openSUSE that is zypper --non-interactive install --allow-unsigned-rpm, which also pulls in a dependency a new release adds: the package is unsigned, and the helper has already checked its SHA-256 against the signed manifest. rpm -U is left for a system with none of the four. The zypper path has not been exercised live. |
| Linux RPM from COPR | Nothing is downloaded. dnf owns this installation; Lightning says so and offers sudo dnf upgrade --refresh lightning-matrix (the rpm package's name from 0.10.1). A COPR install on openSUSE (zypper present, no dnf) names zypper instead and offers sudo zypper refresh && sudo zypper update lightning-matrix. |
| Linux Flatpak | Nothing is downloaded. Flatpak owns this installation; Lightning says so and offers the correct command. |
| Linux Snap | Nothing is downloaded. Snap owns this installation and refreshes it itself; Lightning says so and offers the correct command. |
Lightning never copies files into /usr, never unpacks a .deb or .rpm over
the filesystem, never uses rpm --force or a --force-* dpkg option to make an
install succeed, never runs sudo, and never asks for your password. Where
elevation is genuinely required it uses PolicyKit, which prompts through your
desktop's normal mechanism. Nothing is ever passed through a shell: the helper
builds an argument vector, so a path containing spaces, quotes, parentheses or
non-ASCII characters is handled correctly and cannot be reinterpreted as a
command.
A GitLab release existing does not mean every downstream channel has published
it. The Flatpak is published on Flathub, which builds and delivers its own
updates on its own schedule; the Snap is produced but not published to the
Snap Store. The signed manifest marks both channels unavailable, so Lightning
never offers to install an update inside a Flatpak or a Snap: a Flatpak user
updates through their software centre or flatpak update. There is no APT
repository. The DNF one is Fedora COPR (mizerd/lightning-matrix), which builds each
release after it is published; its linux-rpm-repo channel stays unavailable
until that build is green, and only an install from COPR reads it.
Some steps cannot happen while Lightning is running, so a small separate binary,
lightning-updater, is installed alongside it. It receives a fixed, validated
set of arguments — the install type, the path to an artifact Lightning has
already verified, the process ID to wait for, and where to write a status file —
waits for Lightning to exit, performs exactly one operation, and records the
result. It restarts Lightning only when you chose Install and restart; if you
chose to install without restarting, it applies the update and leaves the
application closed.
It accepts no commands and no scripts. It links only Qt Core (and zlib for archive extraction): no network stack, and none of Lightning's Matrix, storage or account code. It cannot download anything, cannot read a token, and cannot open a Matrix store, because none of that code is in it.
The download verifies the manifest's SHA-256 while the bytes stream in. After
that, the application and the helper are connected by nothing but a
filesystem path, and that path can sit armed for hours on the
"install when I quit" path before pkexec dpkg -i <path> reads it as root.
So the digest is taken again at each hand-over: the application re-hashes the
staged file right before it launches the helper (and again at quit, for the
deferred path, recording a refusal in the status file since the UI is gone by
then), and the helper receives the manifest's digest on its argv as
--sha256 <64 hex> and re-hashes the file itself immediately before acting.
A mismatch installs nothing, deletes the staged file, and is reported on the
next launch as artifact-digest-mismatch (a file that has simply vanished
reports artifact-missing). The helper also refuses a symbolic link for every
path option, and the application resolves the target and relaunch paths
before handing them over, so a link cannot redirect a chmod, a replace, or a
package-manager read. One consequence: if ~/Applications/Lightning.AppImage
is a symlink to a versioned file, the pointed-to file is now replaced
rather than the link — better, but if it sits somewhere unwritable the
update fails where it used to succeed by clobbering the link.
The helper starts Lightning again by inheriting its own environment, which is Lightning's environment, which is whatever launched Lightning in the first place. Two things in there are wrong by then, and both were measured on 2026-09-08 against a real 0.9.3 AppImage on NixOS.
A shell that owns a private temporary directory deletes it when it exits,
and it exits when Lightning does, which is exactly when the helper starts
work. nix-shell is the common case, systemd's PrivateTmp another. The
AppImage runtime creates its mount point under TMPDIR, so the relaunched
process died on create mount dir error: No such file or directory before
running an instruction of its own. Any TMPDIR, TMP, TEMP or TEMPDIR
naming a directory that no longer exists is dropped, for every install type,
because a Qt temporary file in the new process would fail the same way.
An AppImage started through an extractor rather than its own runtime —
appimage-run on NixOS, Gearlever, or a previous
--appimage-extract-and-run — is running inside that wrapper's sandbox, and
so is the helper, and so is the relaunch. bubblewrap sets no_new_privs
there, so the setuid fusermount3 cannot gain privilege: the runtime answers
No suitable fusermount binary found on the $PATH and exits 127. The same
AppImage relaunched with APPIMAGE_EXTRACT_AND_RUN=1 in that same sandbox
started normally. Lightning asks for extraction only when the running instance
was itself extracted, which APPDIR states exactly: a mounted AppImage points
it at its own .mount_XXXXXX, and that is the evidence that a mount works
here. A value already in the environment is never overridden.
Both corrections live in src/updater/RelaunchEnvironment.cpp. As always,
they take effect from the version that CARRIES them: the relaunch is performed
by the updater already on disk, so an update into the version with this fix
still restarts the old way.
For the two formats Lightning replaces itself (portable ZIP and AppImage) the replacement is transactional: the previous version is moved aside first and restored if anything fails, so an interrupted update never leaves half of one version and half of another. If the install directory is not writable, that is reported clearly rather than partially attempted.
For MSI, Setup EXE, DEB and RPM, the installer or package manager owns the transaction and Lightning does not attempt a second one on top of it.
Your data is never touched by an update. Matrix sessions, crypto stores, settings and saved media live outside the installation directory and are neither moved nor deleted by any update path.
If more than one Lightning instance is running, an update lock ensures only one of them can download or install; the others report that an update is already in progress.
On the next start after an update, Lightning reads the helper's small status file so it can tell you whether the update succeeded, failed, or was rolled back. That report is cleared once you have seen it and is never shown again.
Release notes come from the signed manifest or the release page and are treated as untrusted remote text. They are not rendered as HTML and cannot run scripts or invoke application commands.
Nothing in this document has been exercised by a real upgrade yet. 0.7.1 is the first release that can be updated from, so the upgrade into 0.7.2 is the first end-to-end test of the whole chain, and it should be treated as a test rather than assumed to work.
Do this on Windows first, because all three Windows formats install differently and none of them has ever been driven by the updater on a real machine.
Have a 0.7.1 install that you did not build locally — download the published artifact, so the thing being upgraded is the same bytes a user has. Sign in, join a room, send a message, and leave the account signed in. That matters: the update must not disturb the session, the store, or settings, and you cannot tell whether it did unless there was something there to disturb.
Note the install location and, for the portable ZIP, take a copy of the whole directory. Rolling back a portable install is a directory swap; having the original makes a failed run recoverable in seconds.
Repeat for MSI, Setup EXE and portable ZIP separately. They take three different code paths and passing one says nothing about the others.
- Settings -> Updates -> Check for updates. It should report 0.7.2.
If it reports nothing, the manifest is the first thing to look at, not
the client: fetch it yourself and check its
version. - Install. For a per-user MSI or Setup EXE installation this needs no elevation prompt — if Windows asks for administrator rights there, that is a defect. An installation made for all users (Program Files) is the one case where exactly one UAC prompt is expected.
- After the restart, confirm all of:
--versionreports 0.7.2;- you are still signed in — a re-login prompt means the session or the store was disturbed and is a serious defect;
- the room you left open still has its history, and encrypted rooms still decrypt (this is the check that proves the crypto store survived);
- your settings — theme, text size, notification modes — are unchanged.
- For the portable ZIP specifically, confirm the directory really was swapped and not merged: no stale files from 0.7.1 should remain.
The updater writes a status file; its path is in the update logs. Capture that, the application log, and the exact point the run stopped. A failure after verification but during installation is a very different defect from a verification failure, and the status file is what distinguishes them.
Do not work around a failure by installing 0.7.2 by hand and calling the update tested — that is the one outcome that would leave this document saying something untrue.
The AppImage, DEB and RPM paths are equally untested. The AppImage is the
cheapest to try (it replaces a single file and restores it on failure). The
DEB and RPM paths hand the package to the system package manager through
PolicyKit, so expect a normal desktop authentication prompt — and confirm
afterwards that dpkg -S / rpm -qf still report the package as owning its
files, which is the property that whole design exists to preserve.
The signed update manifest is Lightning's integrity and authenticity guarantee today, on every platform.
Windows packages are not Authenticode-signed. The infrastructure for it is prepared but not active, and no signing identity exists yet. When Windows code signing becomes available, Lightning will verify it as an additional layer; it will not replace manifest verification. Linux packages and repository metadata are likewise not GPG-signed today, because no APT or DNF repository is published. Where a native package manager does verify signatures, Lightning never disables or bypasses those checks — the signed manifest complements them.
Release signing keys, rotation and the compromise procedure are documented in
the deployment repository, in docs/update-manifest.md.
The release-notes link the update dialog shows is derived from the signed,
validated version (https://github.com/Mizerd/lightning/releases/tag/v<version>),
not read from the manifest's release_notes_url. That field is still parsed and
must name the canonical host or the whole manifest is refused, and the
generator keeps emitting the GitLab URL, because deployed 0.7.3+ clients reject
anything else.