Twin Update
Update the same desktop apps on two Linux machines in one run, keep a verified rollback copy of what was installed, and roll back with one command.
It targets Debian/Ubuntu-family desktops (tested on Ubuntu 24.04-based
systems with Python 3.12; needs Python 3.11+, systemd user sessions and apt)
and apps installed as apt packages from their vendor repos. Built-in
entries: Cursor (cursor), ChatGPT desktop (chatgpt) and Grok Bot desktop
(grok-bot). Other apt-packaged apps can be added in the config. Snaps,
Flatpaks, AppImages and CLIs are out of scope.
Machine labels are whatever you name them in the config; the examples below
use desktop-a and laptop-b.
Security model, in short
- Two machines you control, one trusted link. The machine you start the
run on reaches the other over
ssh(BatchMode, host keys checked): Tailscale SSH or an ordinary ssh key. Without that link only--local-only --dry-runworks. - sudo is asked once per run and checked on both machines. The password
goes only to
sudo -Son stdin (over the run's ssh session for the other machine); never argv, environment, files, logs or reports. See SECURITY.md. - apt only. Root runs only
apt-get update,apt-get installof a pinned version or of a stored, sha256-checked.deb, andapt-mark hold/unhold. Each machine installs from its own configured apt sources. - Graceful closes only. SIGTERM, never SIGKILL. If an app does not close in time, the run aborts on both machines before anything is updated.
- Rollback copy before every update, verified against what dpkg installed; no verified copy on either machine means the app is skipped on both.
- Nothing resident. No daemon, timer or listener; apps close only inside a run you start.
- It restores program files, not app data (see below).
What a run does
twin-update run [--apps cursor,chatgpt] [--dry-run]
- Preflight on both machines: identity (
whoami, optional machine-id) must match the config; apt/dpkg must be idle; everylock_pathsfile is locked for the whole run, so a sync or backup job cannot run during the update. Asks for the sudo password once and checks it on both machines, thenapt-get updateon both. - Plan: an app is updated only if both machines have it installed, are offered the same candidate version, and no kept hold blocks it. Otherwise it is skipped on both, with the reason. The exact version is pinned.
- Rollback copy first: the installed
.debis stored in~/.local/share/twin-update/rollback/<app>/<version>/with a sha256, taken from the apt cache,apt-get download <pkg>=<installed>, or a matching local.deb. It is checked against dpkg's md5sums of the installed build (for vendor debs without an md5sums member, the deb's files are hashed in a stream and compared). A mismatch is refused. No copy on either machine = the app is skipped on both. - Holds: holds on these apps that Twin Update did not set are cleared (one
report line each) unless
keep_holdslists them. Holds on other packages are only reported. - Graceful close on both: SIGTERM (via pidfd) to the main processes of the
app's process tree, found by executable path and the package's file list.
No force-kill. If anything is still running after
close_s, the run aborts on both machines before any update, names the app and process, and reopens what it closed. - Update each machine from its own repo:
apt-get install --only-upgrade <pkg>=<version>. Nothing is copied between machines. - Verify: dpkg version,
dpkg -V, and a launch smoke test in the graphical session (systemd-run --user): stays upsmoke_sseconds, no crash in the journal, closes gracefully. On failure you are offered a rollback. - Reopen only the apps that were open before.
- Prune: exactly one rollback copy per app is kept (the version before the latest update). Older copies are removed only after the new version passed its checks and a grace period (7 days or 2 good launches).
- Report:
~/.local/state/twin-update/runs/<ts>-run.json(0600, machine labels only) on both machines, plus a desktop notification such asCursor laptop-b 2.4.1→2.5.0 ✓(vianotify-send, orgdbuswhennotify-sendis not installed).
Apps are only ever closed inside a run you start. There is no daemon, timer,
watcher or listener; the ssh session to the other machine exists only for the run.
The run moves itself into its own systemd-run --user --scope, so closing the
app whose terminal launched it does not stop it.
Other commands
twin-update check [--notify] [--json] # read-only: are updates available? (no sudo)
twin-update status [--local-only] # per machine/app: installed, candidate, running, rollback copy, hold
twin-update rollback <app> [--machine <label>|both] [--dry-run] [--no-hold]
twin-update holds
twin-update unhold <app> [--machine ...] [--dry-run]
twin-update doctor # identity, sudo group, apt idle, locks, session, ssh; exit 4 on problems
twin-update local --serve # per-machine engine; the peer runs this over ssh
Daily check (optional)
twin-update check asks both machines, without root, whether the enabled apps
have newer versions. First it fetches fresh package lists for only the
apps' own repositories into a user-owned cache
(~/.cache/twin-update/apt, or $XDG_CACHE_HOME/twin-update/apt) with
apt-get update pointed at that cache: the repositories' existing
sources.list.d entries are linked in unchanged, so their Signed-By keyrings
and apt's signature checks apply as configured; the system's apt update hooks
are not run; /var/lib/apt is never written. Then apt-cache policy reads that
same cache. If the fetch fails or times out (2 minutes), it uses the system
lists and says so, with how long ago they were last refreshed. --no-fetch
skips the fetch. It never asks for sudo, closes nothing, installs nothing and
stores no rollback copies. (twin-update run still refreshes and installs
with the system apt, as root.)
With --notify it sends a desktop notification on the machine it runs on
when updates are available, or a short "check incomplete" notice when the
other machine could not be reached or an error occurred. The same notice is
sent at most once per day (state in ~/.local/state/twin-update/).
Unreachable machines are reported in plain words: name lookup failed, ssh login refused, connection refused, Tailscale SSH asked for an interactive check, or timed out (off, asleep or offline).
Exit codes for check: 0 no updates, 10 updates available (also when
only one machine could be checked), 1 check incomplete (error or other
machine unreachable, and no updates found), 2 usage or config error.
Example user timer (no root needed; runs while you are logged in, or always with lingering enabled):
# ~/.config/systemd/user/twin-update-check.service
[Unit]
Description=Twin Update: daily read-only update check
[Service]
Type=oneshot
ExecStart=%h/.local/bin/twin-update check --notify
SuccessExitStatus=10
TimeoutStartSec=5min
Nice=10
IOSchedulingClass=idle
# ~/.config/systemd/user/twin-update-check.timer
[Unit]
Description=Twin Update: daily update check
[Timer]
OnCalendar=*-*-* 09:13:00
Persistent=true
[Install]
WantedBy=timers.target
systemctl --user daemon-reload && systemctl --user enable --now twin-update-check.timer
rollback checks the stored copy's sha256, closes the app gracefully, installs
it with apt-get install --allow-downgrades ./<deb>, sets apt-mark hold
(recorded as Twin Update's own hold; the next deliberate run releases it),
verifies, and reopens the app if it was open.
A rollback restores program files, not app data. If a new version migrated its settings or databases, keep your own data backups. Twin Update never opens, copies or modifies app databases.
Install (each machine)
pipx install twin-update # or: uv tool install twin-update
or the single-file zipapp from a release:
install -D -m 0755 twin-update.pyz ~/.local/bin/twin-update
Install it at the same path on both machines (remote_command in the config
points to it). Then create ~/.config/twin-update/config.toml (mode 0600)
from config.example.toml. The same file works on both machines. Check with
twin-update doctor, then twin-update run --dry-run.
Transport
The machine you run on reaches the other with plain ssh (BatchMode, host
keys checked, no password prompts). The remote side runs
twin-update local --serve for the length of the run. Two options:
Tailscale SSH. On each machine that should accept runs:
sudo tailscale set --ssh. "Shields up" blocks incoming connections, Tailscale
SSH included, so it must be off on those machines; let the policy do the
limiting instead. Example policy fragment with tags (merge it into your
policy; tagging a device makes it tag-owned rather than user-owned):
{
"tagOwners": {
"tag:twin-a": ["autogroup:admin"],
"tag:twin-b": ["autogroup:admin"]
},
"grants": [
{ "src": ["tag:twin-a"], "dst": ["tag:twin-b"], "ip": ["tcp:22"] },
{ "src": ["tag:twin-b"], "dst": ["tag:twin-a"], "ip": ["tcp:22"] }
],
"ssh": [
{ "action": "accept", "src": ["tag:twin-a"], "dst": ["tag:twin-b"], "users": ["alice"] },
{ "action": "accept", "src": ["tag:twin-b"], "dst": ["tag:twin-a"], "users": ["alice"] }
]
}
Use "action": "accept": "check" needs an interactive browser login and a
BatchMode run cannot answer it. If your policy still has an allow-all rule,
it also lets everything else in the tailnet reach these machines; narrow it.
Keep only the direction you need if you always start runs on the same machine.
Plain ssh key. A dedicated key without a passphrase prompt (or one held by
your agent) in the other machine's authorized_keys, ideally limited with
from="<the other machine's address>", and its host key already in
known_hosts.
Development
PYTHONPATH=src:tests python3 -m unittest discover -s tests
python3 tools/build_zipapp.py # -> dist/twin-update.pyz
Standard library only. Exit codes (check differs, see above): 0 ok / dry-run,
4 doctor found a problem (including an unreachable other machine; a busy
sync lock is only a warning), 1 failed, 2 usage or config,
3 aborted (nothing changed).
License
Apache License 2.0. See LICENSE.
Metadata
Release files for twin-update 0.2.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| twin_update-0.2.1.tar.gz | 57.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| twin_update-0.2.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 96.5 kB
Release files / twin_update-0.2.1.tar.gz
| Download URL | twin_update-0.2.1.tar.gz |
|---|---|
| Size | 57.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
a089569ec2f0bc811dbc1c8e4f733241ed4d8ea5a928861e18ed150711d9801e
|
|
BLAKE2b-256 checksum How to use checksums |
d453453ec7f5f007418c03fefe27757ba7a4e35181575b8b3e1f914a6df811fd
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 10, 2026.
Transparency logRelease files / twin_update-0.2.1-py3-none-any.whl
| Download URL | twin_update-0.2.1-py3-none-any.whl |
|---|---|
| Size | 39.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
a4cc39596606b7ab87a2d7cd566623823fdce314fafc133c5ca5335182c71fc0
|
|
BLAKE2b-256 checksum How to use checksums |
e697e2fe831441789995ca08f6fa1f8dee51d2759a5030994031550df2a9ac79
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 10, 2026.
Transparency log