N? and `info PKG` now print three lines, none indented:
PKG: <description>
reason: explicitly installed | installed as a dependency | unknown | not installed
required by: <installed dependents, comma separated> | nothing
The reason comes from a new <be>_reason backend function: apt-mark
showauto / showmanual (dpkg decides for a package in neither list), the
Install Reason of pacman -Qi, dnf repoquery --installed %{reason} (dnf4
and dnf5 words) and the Installed line of zypper info (rpm plus libzypp's
AutoInstalled when zypper is locked). A row whose old version is "-" is
not installed and the backend is not asked. The "(installed)" qualifier
moves to --help, README and DESIGN.
The rpm E2E fixture and e2e_reset now also mark su-dummy-a and su-dummy-c
as user-installed on dnf, which records plain rpm installs as "unknown",
so 1? and 2? show both reasons on all 12 distros. The verified commands
are recorded in discovery/{apt,pacman,rpm}.md.
15 KiB
skeptical-updater — design
A distro-agnostic, single-file bash applet for a manual, conscious update cycle. It wraps the native package manager (apt, dnf/yum, pacman/pman, zypper), lists upgradable packages, lets the user interrogate each one, and only then runs the native upgrade. Packages the user has explicitly acknowledged are remembered in a statefile and never questioned again.
Goals
- One file, no runtime dependencies beyond bash >= 4.3 and the distro's package manager.
- Never reimplement package management: every action shells out to the native tool.
- Deterministic, testable:
--dry-runprints the commands it would run; stdin is the command channel (works piped, no TTY needed). - Statefile is plain text, hand-editable, atomic writes.
Backend detection
Order (first match wins, overridable with --backend=<name>):
| backend | detected by | notes |
|---|---|---|
pacman |
pacman in PATH |
pman present => label shown as pman, same commands; honor pacman IgnorePkg (pacman -Qu marks them [ignored]); the refresh syncs a throwaway --dbpath copy; at <enter> its reviewed databases are installed and applied with pacman -Su |
apt |
apt-get in PATH |
Debian/Ubuntu |
dnf |
dnf in PATH (else yum) |
dnf4 (EL9) and dnf5 (EL10) differences hidden in the backend |
zypper |
zypper in PATH |
dist-upgrade on Tumbleweed (ID=opensuse-tumbleweed), update on Leap; on Tumbleweed the review list is the dist-upgrade --dry-run plan |
Each backend implements the same function set (see "Backend contract").
Statefile
Directory: /var/lib/skeptical-updater/ (override: SKEPTICAL_UPDATER_STATE_DIR).
Created 0755 root:root on first write. The tool runs as root (it must, to upgrade);
when not root it re-execs itself through sudo if available.
File: trusted.tsv — tab-separated, UTF-8, LF, one record per line, sorted and
unique on (backend, package). Lines starting with # are comments and are kept.
# skeptical-updater trusted packages. Columns: backend package reason added(UTC ISO-8601)
apt nodejs user 2026-10-09T09:40:12Z
apt libnode115 dep:nodejs 2026-10-09T09:40:12Z
reasonisuserfor a package the user confirmed withN+, ordep:<root>for a package pulled in as part of the installed dependency closure of<root>at the time of confirmation.backendis recorded so the file stays correct on hosts with more than one package manager and stays readable when copied between hosts.- Writes are atomic: write to
trusted.tsv.tmp.$$in the same directory,mvover. - Writes happen under a lock (
trusted.lock:flock(1)when available, else an atomicmkdirlock: a directory created under a temporary name with the owner's pid inside and renamed into place; a lock whose owner is gone - a process of another user counts as alive - is taken over by renaming it away first and checking its pid again there, so two waiters can never remove a live lock; never busy-waited on; leftovers of crashed acquirers are removed). At save time the file is re-read and this session's additions/removals are replayed onto it, so atrustrun in another terminal meanwhile is not lost. - Comment lines are kept, in their order, at the top of the file when it is rewritten; malformed lines are kept verbatim (with a warning) in the same block.
- A package is "trusted" if a row with the current backend and that name exists. Reason does not matter for filtering.
Why a stored closure instead of computing it every run: closure computation across
40+ upgradable packages on every run is slow on apt/rpm; computing it once at N+
time and storing dep:<root> rows keeps every run O(upgradable). A new dependency
that appears later is new to the system and deserves to be questioned once;
skeptical-updater refresh recomputes closures for all user roots when the user
wants to re-trust them wholesale.
No other state, except two crash-recovery files that only exist while an upgrade runs (or
after one was killed): holds.added, the apt hold journal (used under holds.lock, which is
kept until the holds are really released), and zypper-private, the path of the private zypper
configuration (used under zypper.lock). Both are replayed/removed at the start of the next root
run, before anything is listed - only when their lock is free, i.e. not while another run uses
them. Per-run exclusions (N-) are
intentionally not persisted: a skeptical user wants to be asked again next time. Permanent holds belong to the
package manager (apt-mark hold, IgnorePkg, zypper addlock, dnf exclude=).
Interactive session
$ sudo skeptical-updater
Backend: apt (Debian GNU/Linux 12)
Refreshing package lists... done.
12 upgradable, 9 trusted (hidden, use --all to show), 3 to review:
[1] nodejs 18.19.0+dfsg-6 -> 18.20.4+dfsg-1 bookworm-security
[2] libnode108 18.19.0+dfsg-6 -> 18.20.4+dfsg-1 bookworm-security
[3] zsh 5.9-4 -> 5.9-4+deb12u1 bookworm
<enter> upgrade | N? what is it | N?? full info | N- exclude this run | Nx purge now | N+ trust forever
> 1?
Command grammar (one line, whitespace-separated tokens, each N is the number in brackets):
| token | action |
|---|---|
| empty line | proceed: run the native upgrade with the current exclusions |
N? |
print three lines, none indented: PKG: <one-line description>, reason: <r> with <r> = explicitly installed, installed as a dependency, unknown (the backend cannot tell) or not installed (also, without asking the backend, for a row whose old version is -), and required by: <a, b, ...> - the reverse dependencies, installed packages only - or required by: nothing |
N?? |
print the full native info dump (apt-cache show / pacman -Qi / dnf info / zypper info --requires) |
N- |
exclude from this run (toggle; N- again re-includes); shown with - marker |
Nx |
purge it now, following pacman's -Rns principle on every backend: if any installed package still depends on it, refuse and print who (the reverse dependencies N? shows) - never cascade; otherwise simulate the native purge of it together with its now-unneeded dependencies, show that removal set ("This also removes: ...") and ask y/N. As a safety check, a simulated set containing anything that is neither the target nor in its installed dependency closure is refused too. Only a purge that ran removes rows (of every package it removed). A row whose old version is - (a new dependency, not installed) cannot be purged |
N+ |
trust it: write it and its installed dependency closure to the statefile; the row leaves the list (with --all it stays, marked =). Rows that this trust hides are re-included if they were excluded |
*+ / *- |
apply + / - to every listed package; *- is an all-or-nothing toggle: when every listed row is already excluded it re-includes them all |
l |
redraw the list |
q |
quit without upgrading (exit 0) |
h, ? |
help |
Numbers are stable for the whole session (removed rows leave a gap, never renumber). All tokens of a line are validated first: an unknown token or a number that is not listed prints an error, runs nothing of that line and re-prompts. EOF on stdin aborts without upgrading (exit 2) so a piped run can never upgrade by accident unless it sends an empty line. When purges leave no row at all, the session ends ("Nothing left to upgrade.", exit 0).
Flags shown after a row: [held] = the package manager will not upgrade it on its own: on apt
a hold (apt-mark hold) or a package apt keeps back (the "kept back" block of
apt-get -s --with-new-pkgs upgrade), on pacman an IgnorePkg ([ignored] in pacman -Qu).
dnf excludes/versionlocks and zypper locks hide the package from the native listing instead.
Notes after the rows report what the backend says beyond them (new dependencies or
replacements the upgrade installs, packages a Tumbleweed dist-upgrade removes); with no row
but such pending work the prompt is still offered ("0 to review, but ... reports pending
changes").
After <enter>:
- Build the exclusion set =
N-rows. - Apply native per-run exclusion (
pacman --ignore a,b,dnf --exclude, apt temporaryapt-mark holdof the packages not already held, journalled first and released afterwards - also on failure, Ctrl-C, TERM, HUP, QUIT, a closed stdout - so the hold set is exactly the prior one; zypperaddlockin a private configuration whose lock file is a copy of the effective one, so the real lock set is never written). - Run the native upgrade non-interactively, streaming its output, on exactly the metadata that
was reviewed: dnf
--setopt=*.metadata_expire=-1, zypper--no-refresh; apt never refreshes on upgrade. pacman: (a)pacman -Sup --noconfirm --ignore ... --dbpath <throwaway>checks the reviewed transaction - if it fails (an ignored package another upgrade needs, a conflict) its output is shown and the tool exits with its status, nothing changed; (b) the throwaway*.dband*.db.sigfiles are installed into the system's sync directory under pacman's own lock (db.lck, created atomically; an existing one = another pacman: nothing changed). A system database that changed since the review and is newer than the reviewed copy is never overwritten: the tool refuses ("package databases changed since the review ()") with exit status 2 and changes nothing; identical ones are skipped. The install is all-or-nothing: everything is copied to temporary names first, then all renames happen with the signals ignored, each.db.sigright before its.db;db.lckis removed right before (c)pacman -Su --noconfirm --ignore ...applies them, no further sync. If (c) fails or is interrupted the system's sync database is newer than its installed packages: the tool (or its exit handler) says so; finish withpacman -Su(or run the tool again) before anypacman -S. With--no-refresh(or a failed refresh) plainpacman -Suapplies the existing databases. Throwaway databases carry their owner's pid in the name (/tmp/skeptical-updater-db.<pid>.XXXXXX); those of runs that are gone are removed at the start of the next root run. - Restore holds/locks, exit with the package manager's exit code.
Trusted packages are upgraded silently along with everything else; they are only hidden from the questioning list.
CLI
skeptical-updater [options] [command]
commands:
(none) interactive review + upgrade
list print the review list and exit (no prompt)
trusted print the statefile
trust PKG... add PKG and its installed dependency closure to the statefile
untrust PKG... remove rows for PKG (and rows whose reason is dep:PKG)
refresh recompute dependency closures for every `user` root
info PKG same as N?
show PKG same as N??
options:
--backend=NAME force apt|dnf|pacman|zypper
--all also list trusted packages (marked with `=`)
--no-refresh skip the metadata refresh step
--dry-run print native commands instead of running mutating ones
--state-dir=DIR override /var/lib/skeptical-updater
--no-color disable ANSI colours (also honours NO_COLOR, non-tty stdout)
--version, --help
Exit codes: 0 ok (also q), 1 usage/backend error, 2 aborted (end of input),
otherwise the native package manager's exit code from the upgrade step.
When root is needed (refresh, upgrade, purge, writing a state directory the user cannot
write) and the user is not root, the tool re-runs itself as
sudo -- /absolute/path/to/itself --sudo-reexec ARGS... - the file itself, so a sudoers rule
for it keeps matching; only a copy that is not executable is run as
sudo -- "$BASH" /absolute/path --sudo-reexec ARGS.... The hidden --sudo-reexec (accepted
only as the first argument, never forwarded) makes a re-run that is still not root fail instead
of looping. Without sudo it fails with a clear message.
Backend contract
Every backend is a set of bash functions with a common prefix. All output is
LC_ALL=C. All functions must work without a TTY.
<be>_refresh # update metadata; non-fatal on failure (warn)
<be>_upgradable # stdout: name<TAB>old<TAB>new<TAB>repo<TAB>flags (flags: "" | "held"; old "-" = new package)
# plus "#<note>" lines for pending work that is not a row
<be>_describe PKG # stdout: one-line summary
<be>_full_info PKG # stdout: native full dump (candidate version if available)
<be>_rdepends PKG # stdout: installed packages requiring PKG, one per line
<be>_reason PKG # stdout: explicit | dependency | unknown | not-installed (how PKG got installed)
<be>_closure PKG # stdout: installed recursive dependency closure of PKG (excluding PKG), one per line
<be>_purge_plan PKG # stdout: every package a purge of PKG and its now-unneeded dependencies removes
# (simulation, no cascade); non-zero = refused
<be>_purge PKG... # non-interactive native purge/remove of exactly the planned packages
<be>_upgrade EXCLUDED... # non-interactive native upgrade skipping EXCLUDED (handles hold/lock save+restore)
<be>_label # "apt" / "pman" / ...
Mutating calls go through run_cmd which honours --dry-run (prints + cmd to
stdout instead of executing).
<be>_reason sources (read-only, unprivileged, verified in discovery/*.md): apt
apt-mark showauto PKG (dependency) / apt-mark showmanual PKG (explicit), a package in neither
list is explicit when dpkg has it installed; pacman the Install Reason of pacman -Qi; dnf
repoquery --installed --queryformat '%{reason}' (user/group/dnf5 External User = explicit,
dependency/weak-dependency = dependency, dnf4 unknown - plain rpm -i - stays unknown, no
output = not installed); zypper the Installed line of zypper --no-refresh info
(Yes (automatically) / Yes / No), and when zypper gives no answer (its lock is busy) rpm -q
plus libzypp's /var/lib/zypp/AutoInstalled.
Layout
skeptical-updater the applet (single bash file)
makefile install / lint / test / docker targets (no LLM needed)
docs/DESIGN.md this file
discovery/ verified per-family command notes and fixture scripts
tests/unit/*.bats bats-core tests with a fake backend (no root, no docker)
tests/e2e/*.bats bats-core tests run inside each distro container against real package managers
tests/docker/ images list, snapshot/cleanup scripts, per-distro Dockerfiles