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.
skeptical-updater
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. It is one file with no runtime dependencies beyond bash >= 4.3 and the distro's package manager, and it never reimplements package management: every action shells out to the native tool.
The specification is docs/DESIGN.md; the per-family command research it is built on lives in discovery/.
Install
sudo make install # /usr/local/bin/skeptical-updater
sudo make install PREFIX=/usr # /usr/bin/skeptical-updater
make install DESTDIR=/tmp/pkg PREFIX=/usr # staged install, e.g. for packaging
sudo make uninstall
Or just copy the skeptical-updater file somewhere in PATH and make it executable.
The statefile directory (/var/lib/skeptical-updater/) is created by the applet
on first use, not by make install; make uninstall leaves it in place.
Runtime needs: bash >= 4.3, the package manager, coreutils, sed and grep (plus awk,
which the apt and pacman backends use and which those distros always ship). sudo
is optional: when the tool needs root and you are not root, it re-runs itself
through sudo with the same arguments, or fails with a clear message.
Usage
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 description, install reason and installed dependents (as N?)
show PKG full native package information (as N??)
options:
--backend=NAME force apt|dnf|pacman|zypper (default: detect pacman > apt > dnf/yum > 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 (env: SKEPTICAL_UPDATER_STATE_DIR)
--no-color disable ANSI colours (also honours NO_COLOR and non-tty stdout)
-h, --help this help
-V, --version print the version
Exit status: 0 ok, 1 usage or backend error, 2 aborted (end of input),
otherwise the exit status of the package manager's upgrade step.
What needs root: the metadata refresh, the upgrade, a purge, and writing a state
directory you cannot write. --dry-run, list --no-refresh, trusted, info and
show work unprivileged (as long as the state directory is readable).
Example session
A Debian 12 box with three pending upgrades; su-dummy-c is on hold
(apt-mark hold) and su-dummy-a depends on su-dummy-b:
$ sudo skeptical-updater
Backend: apt (Debian GNU/Linux 12 (bookworm))
Refreshing package lists... done.
3 upgradable, 0 trusted (hidden, use --all to show), 3 to review:
[1] su-dummy-a 1.0 -> 2.0 su-fixture:su-repo
[2] su-dummy-b 1.0 -> 2.0 su-fixture:su-repo
[3] su-dummy-c 1.0 -> 2.0 file:/srv/su-repo ./ [held]
<enter> upgrade | N? what is it | N?? full info | N- exclude this run | Nx purge now | N+ trust forever
> 2?
su-dummy-b: dummy package su-dummy-b for skeptical-updater tests
reason: installed as a dependency
required by: su-dummy-a
> 1+
trusted su-dummy-a (+1 dependency: su-dummy-b)
> 3-
excluded from this run: su-dummy-c
> l
3 upgradable, 2 trusted (hidden, use --all to show), 1 to review:
-[3] su-dummy-c 1.0 -> 2.0 file:/srv/su-repo ./ [held]
>
Excluded from this run: su-dummy-c
+ apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold -o DPkg::Lock::Timeout=60 --with-new-pkgs upgrade
...
The following packages have been kept back:
su-dummy-c
The following packages will be upgraded:
su-dummy-a su-dummy-b
...
Upgrade finished.
$ skeptical-updater trusted
# skeptical-updater trusted packages. Columns: backend package reason added(UTC ISO-8601)
apt su-dummy-a user 2026-10-09T19:24:13Z
apt su-dummy-b dep:su-dummy-a 2026-10-09T19:24:13Z
Next time su-dummy-a and su-dummy-b are upgraded silently; only new packages
are questioned. The prompt reads stdin, so the same session can be scripted:
printf '3-\n\n' | sudo skeptical-updater (an empty line is what triggers the
upgrade; end of input without one aborts with exit status 2).
[held] marks packages the package manager will not upgrade on its own: apt
holds and "kept back" packages, pacman IgnorePkg ([ignored] in pacman -Qu).
dnf exclude=/versionlock and zypper locks hide packages from the native listing,
so they do not appear at all.
Command grammar
One line, whitespace-separated tokens; N is the number in brackets. Numbers are
stable for the whole session (rows that leave the list leave a gap). Every token
of a line is validated first: a line with an unknown token or a number that is not
listed prints an error, runs nothing, and the prompt comes back.
| token | action |
|---|---|
| empty line | proceed: run the native upgrade with the current exclusions |
N? |
what is it: the one-line description, reason: how it got installed (explicitly installed, installed as a dependency, unknown when the package manager cannot tell, not installed for a new dependency) and required by: the packages that depend on it, or nothing. That list covers installed packages only |
N?? |
full native info (apt-cache show, pacman -Qi + -Si, dnf info, zypper info --requires) |
N- |
exclude from this run (toggle; N- again re-includes); shown with a - marker |
Nx |
purge now, with its now-unneeded dependencies: the purge is simulated first and the prompt lists everything it removes (Purge su-dummy-a now? This also removes: su-dummy-b [y/N]); the answer is read from the same stdin; the rows of every removed package leave the list. x refuses when other installed packages depend on the target, like pacman -Rns; purge the dependents first (not purged: su-dummy-b is required by: su-dummy-a - purge the dependents first). Rows with old version - (new dependencies, not installed) cannot be purged |
N+ |
trust forever: write it and its installed dependency closure to the statefile; the row leaves the list. Rows this hides are re-included if they were excluded (a trusted package is upgraded) |
*+ / *- |
+ / - for every listed package (*- is an all-or-nothing toggle: when all are excluded it re-includes them) |
l |
redraw the list |
q |
quit without upgrading (exit 0) |
h, ? |
help |
| end of input | abort without upgrading (exit 2) |
Several tokens may share a line (1? 3- 4+); they run left to right. With --all
trusted packages are listed too, marked with =. When purges leave nothing to upgrade the
session ends by itself (exit 0).
Under the rows, Note: lines report pending work that is not a row: new dependencies or
replacements only the pacman transaction (-Sup) shows, packages dnf installs to obsolete
others, packages a Tumbleweed dist-upgrade removes. With no row but such work, the list
says "0 to review, but ... reports pending changes" and <enter> still runs the upgrade.
Dry run
--dry-run prints every mutating command as + <command> instead of running it:
the upgrade, purges, apt holds/unholds, zypper lock files, the installation of the reviewed
pacman databases and statefile writes. The
metadata refresh still runs when you are root (it is printed when you are not), and
read-only queries run normally, so the list is real:
$ skeptical-updater --dry-run <<<$'2-\n'
...
Excluded from this run: su-dummy-b
+ apt-mark hold su-dummy-b
+ apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold -o DPkg::Lock::Timeout=60 --with-new-pkgs upgrade
+ apt-mark unhold su-dummy-b
Dry run: nothing was changed.
Statefile
/var/lib/skeptical-updater/trusted.tsv (directory override: --state-dir=DIR or
SKEPTICAL_UPDATER_STATE_DIR). The directory is created 0755 on the first write.
# 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
- Tab-separated, one record per line, sorted and unique on (backend, package);
lines starting with
#are comments and are preserved (kept at the top). Malformed lines are kept verbatim with a warning. reasonisuserfor a package you confirmed (N+/trust), ordep:<root>for a package that was part of the installed dependency closure of<root>when you trusted it. Auserrow is never downgraded todep:.- A package is trusted when a row with the current backend and its name exists; the reason does not matter. Rows of other backends are kept untouched.
- Writes are atomic:
trusted.tsv.tmp.<pid>in the same directory, renamed over the old file. They run undertrusted.lock(flock, or an atomicmkdirlock with stale detection whereflockis missing): the file is re-read and this session's changes are replayed onto it, so atrustin another terminal during a session is kept. - A state directory you cannot enter, or a statefile you cannot read, is an error (never silently "nothing trusted").
untrust PKGremoves the rows forPKGand everydep:PKGrow;refreshrecomputes the closure of everyuserroot (adds new dependencies, drops rows that are no longer needed, keeps the dates of the others).
The other files are recovery aids that only exist while an upgrade runs (or after one was
killed with kill -9 or a power loss): holds.added, the apt hold journal (used under
holds.lock), and zypper-private, the path of the private zypper configuration. At the start
of every root run, before anything is listed, a left-over journal is replayed (the holds it
recorded are released, a message says which) and a left-over private configuration is removed.
How exclusions work
N- exclusions are per run and never persisted: you are asked again next time.
Permanent holds belong to the package manager (apt-mark hold, IgnorePkg,
zypper addlock, dnf exclude=), and skeptical-updater never changes them.
| backend | refresh | upgrade | per-run exclusion | purge |
|---|---|---|---|---|
| apt | apt-get update -qq -o APT::Update::Error-Mode=any |
apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold -o DPkg::Lock::Timeout=60 --with-new-pkgs upgrade |
temporary apt-mark hold of the excluded packages that are not already held, journalled in holds.added before holding; afterwards exactly those are released (also on failure, Ctrl-C, TERM, HUP, QUIT, a second signal during the restore, or a closed stdout; a journal left by kill -9 is replayed at the start of the next root run). Holds you placed are never touched. apt-mark ignores DPkg::Lock::Timeout, so it is retried for up to 60 s while dpkg's lock is busy. |
plan: apt-get -s purge --auto-remove PKG minus what apt-get -s autoremove already listed (--auto-remove is system-wide), then apt-get -y purge of exactly that set: PKG and the dependencies this purge orphans; no autoremove |
| pacman / pman | pacman -Sy --dbpath <throwaway copy> (with local linked to the real local database, seeded with the system's *.db and *.db.sig): listing, q, end of input and --dry-run never touch /var/lib/pacman/sync; errors pacman prints about it are shown |
check pacman -Sup --noconfirm --ignore ... --dbpath <throwaway> (on failure: nothing changed, its exit status), install the reviewed *.db/*.db.sig into /var/lib/pacman/sync under db.lck, all-or-nothing, never over a newer system database (exit 2), then pacman -Su --noconfirm - exactly the reviewed metadata, no second sync; with --no-refresh plain pacman -Su |
--ignore a,b |
plan: pacman -Rs --print; then pacman -Rns --noconfirm -- ... |
| dnf / yum | dnf -q --refresh --setopt=skip_if_unavailable=0 makecache |
dnf -y --setopt=skip_if_unavailable=0 --setopt=*.metadata_expire=-1 upgrade (the reviewed metadata; the repo glob is needed because a repo's own metadata_expire overrides the main one) |
--exclude=a,b (adds to exclude= from the configuration) |
plan: dnf --assumeno remove PKG; then dnf -y remove ... |
| zypper | zypper --non-interactive --quiet refresh |
zypper --non-interactive --no-refresh update (Leap/SLE) or dist-upgrade (Tumbleweed/Slowroll, by ID= in os-release) |
locks in a private ZYPP_CONF: the effective configuration (main zypp.conf plus the zypp.conf.d drop-ins of /usr/etc and /etc) with locksfile.path pointing at a copy of the effective lock file (also when a drop-in sets it), plus addlock of the exclusions; deleted afterwards: the real lock file is never written |
plan: zypper --xmlout remove --dry-run --clean-deps PKG; then zypper --non-interactive --no-refresh remove --clean-deps ... |
Every package-manager call runs with LC_ALL=C (and DEBIAN_FRONTEND=noninteractive;
COLUMNS=0 for pacman), with stdin from /dev/null so that it can never eat the
commands you pipe in. The upgradable list comes from apt-get -s --with-new-pkgs upgrade (Inst lines + the "kept back" block), pacman -Qu joined with pacman -Sl,
dnf -q check-update (+ rpm -qa for the installed versions) and
zypper --xmlout list-updates, or on Tumbleweed (as root) the real plan
zypper --xmlout --no-refresh dist-upgrade --dry-run. Reverse dependencies and dependency closures use
apt-cache rdepends / the dpkg database, pacman -Qi, and plain rpm queries on
dnf and zypper systems (see the discovery reports for why). The install reason N? shows comes
from apt-mark showauto / showmanual, the Install Reason of pacman -Qi,
dnf repoquery --installed --queryformat '%{reason}' (packages installed with plain rpm are
unknown to dnf4, user-installed to dnf5) and the Installed line of zypper info
(Yes (automatically); libzypp's /var/lib/zypp/AutoInstalled when zypper is locked).
Testing
Everything runs in Docker; the host only needs GNU make, bash, Docker and (once,
for make vendor) curl. Run make help for the full list of targets.
| target | what it does |
|---|---|
make vendor |
download the pinned bats-core, bats-support and bats-assert into tests/vendor/ (once; no network afterwards) |
make lint |
shellcheck the applet, the test scripts, fixtures and fakes (koalaman/shellcheck image; discovery/ is reference material and is not linted) |
make test-unit |
tests/unit/*.bats in a debian:12 container, against fake package managers |
make test-e2e |
tests/e2e/ inside every distro listed in tests/docker/images.txt, against the real package managers; logs go to tests/docker/logs/<label>.log |
make test |
snapshot, test-unit, test-e2e, then always docker-cleanup; exits with the tests' status |
make test-no-cleanup |
same as test but keeps the pulled images, for iterating |
make docker-cleanup-dry-run |
show what the cleanup would do |
make clean |
remove test logs and .bats-tmp/ |
Variables: make test-e2e E2E_ONLY=debian-12 (comma-separated list allowed)
runs a subset of distros; E2E_FAIL_FAST=1 stops at the first failing distro.
Unit tests (tests/unit/)
No root, no network, no real package manager. The applet runs with a hermetic
PATH: the fakes in tests/unit/fakes/<backend>/ (apt-get, apt-cache,
dpkg-query, apt-mark, pacman, pman, dnf, yum, rpm, zypper, sudo)
in front of symlinks to the few system tools it needs. The fakes replay the real
outputs recorded in discovery/*.md (data in tests/unit/data/), keep their state
(holds, purged packages) under the test's temp dir and log every invocation, so the
tests assert the exact commands issued. Covered: CLI parsing and exit codes, backend
detection and override, root handling and the sudo re-exec, every parser (install reasons
included: explicitly installed, dependency, unknown and not installed on each backend), the
statefile, the whole interactive grammar, the per-backend exclusion mechanics (apt
hold journal incl. crash recovery and SIGTERM, pacman --ignore, dnf --exclude,
zypper private lock file contents incl. drop-ins), the purge plans, the metadata freeze flags,
--dry-run, and the robustness fixes (hold restore with a closed stdout and with a second
signal during the exit handler, the holds lock kept while a restore failed, journal and private
zypper configuration replayed only when no other run holds their lock, the pacman pre-check /
database install / -Su sequence and stale throwaway databases, statefile locking and merge,
the mkdir lock fallback (EPERM owner alive, no busy loop), an inaccessible state directory, the
sudo re-exec of the file itself and its loop guard).
About 30 seconds.
End-to-end tests (tests/e2e/)
tests/docker/run-e2e.sh <label> <image> runs tests/e2e/entrypoint.sh as root in a
throwaway container (repository mounted read-only). The entrypoint installs the
applet, builds a fixture with tests/e2e/fixtures/{apt,pacman,rpm}.sh (copies of
the proven discovery/ fixtures): su-dummy-a (depends on su-dummy-b),
su-dummy-b and su-dummy-c installed at 1.0, 2.0 offered by a local repository,
plus su-dummy-orphan, an unrelated automatically-installed package nothing depends on,
with the distro's own repositories disabled (apt SU_FIXTURE_OFFLINE=1, pacman
FIXTURE_ISOLATE=1, rpm SU_FIXTURE_ISOLATE=1 after the build tools are installed),
so the review list is exactly the three dummies and the result does not depend on
mirror state. su-dummy-b is recorded as installed as a dependency on every backend (pacman
--asdeps, apt-mark auto, dnf mark remove, zypper's AutoInstalled) and su-dummy-a as
explicitly installed (dnf needs dnf mark install: it records plain rpm installs as
unknown). Then tests/e2e/*.bats exercise the real package manager: banner, list, N? (the
reason and required by: of both), N??, N+ and the statefile, trust/untrust/refresh,
excluding su-dummy-b (with and without a pre-existing hold/lock/IgnorePkg/exclude on
su-dummy-c, which must survive byte for byte), trusting re-including an excluded
dependency, full upgrade, Nx (refused for su-dummy-b, which su-dummy-a needs; su-dummy-a
taking its dependency su-dummy-b along, shown in the prompt, and the unrelated orphan surviving),
purging everything, q, end of input, --dry-run, the read-only commands as an unprivileged
user, plus per backend: pacman never touching the real sync database outside the upgrade and
installing exactly the reviewed database at <enter> (same hash, a package published after the
review is not installed), apt
holds waiting for a busy dpkg lock, zypper honouring a lock file set in a drop-in and applying
the reviewed metadata although a newer package appeared after the review.
The matrix (tests/docker/images.txt):
| label | image | backend |
|---|---|---|
| debian-12, debian-13 | debian:12, debian:13 |
apt |
| ubuntu-22.04, ubuntu-24.04, ubuntu-latest | ubuntu:22.04, ubuntu:24.04, ubuntu:latest |
apt |
| archlinux | archlinux:latest |
pacman |
| almalinux-9, almalinux-10 | almalinux:9, almalinux:10 |
dnf (dnf4 on both) |
| rockylinux-9, rockylinux-10 | rockylinux/rockylinux:9, rockylinux/rockylinux:10 |
dnf (dnf4) |
| opensuse-tumbleweed, opensuse-leap | opensuse/tumbleweed:latest, opensuse/leap:latest |
zypper (dist-upgrade / update) |
The Rocky images come from the rockylinux/ namespace: the Docker Hub library
rockylinux:9 is a stale Rocky 9.3 build and rockylinux:10 does not exist.
The rpm fixtures install rpm-build and createrepo_c from the distro mirrors, so
the rpm-family runs need network for that step (everything after it is offline).
Docker image hygiene
The E2E matrix pulls a dozen distro images. To leave the host as it was:
tests/docker/snapshot.sh(make docker-snapshot) records the image set intests/docker/images-before.tsvandtests/docker/image-ids-before.txt. It refuses to overwrite an existing snapshot, so a secondmake testcannot sneak the images pulled by the first run into the baseline.tests/docker/cleanup.sh(make docker-cleanup) restores that set: it points pre-existing tags that a newer pull took over (e.g.archlinux:latest) back at their original image, untags tags that are new, and deletes image ids that are new. It never stops or removes containers; images still used by a container are left in place and reported. It is idempotent and exits non-zero if a pre-existing tag could not be restored.--dry-runprints the plan.
make test always runs the cleanup at the end; use make test-no-cleanup (or the
individual test-unit / test-e2e targets) while iterating and make docker-cleanup
when done. To re-baseline on purpose (for example after pulling images you want to
keep), run make docker-cleanup first if a test run happened since the last
snapshot, then:
tests/docker/snapshot.sh --force
Known limitations
From the discovery reports (discovery/*.md), all reproduced there:
- pacman: excluding a package that another upgrade needs at a newer version
(e.g.
a2.0 depends onb>=2.0,bexcluded) makespacman -Su --noconfirmabort the whole transaction (exit 1, nothing upgraded):--noconfirmanswers "skip the unresolvable package?" with no. Exclude the dependants too. - dnf4 (EL9/EL10): the same situation is an error (exit 1) unless
--nobestis used; dnf5 silently skips the dependant (exit 0). The tool prints a hint, it does not retry with--nobest. - zypper on Tumbleweed:
dist-upgradewith a vendor change (or another problem the solver cannot decide alone) is cancelled by--non-interactivewith exit 4; runzypper dist-upgradeby hand to pick a solution. - apt: holding an excluded package also keeps back everything that needs a newer version of it (shown in apt's own "kept back" output).
- pacman: the upgrade applies exactly the reviewed metadata: the reviewed transaction is
checked first (
pacman -Supagainst the throwaway database; if it fails, e.g. the ignored- dependency trap above, nothing on the system changes and the tool exits with pacman's status), then the reviewed databases are installed into/var/lib/pacman/sync(under pacman's owndb.lck, all-or-nothing) andpacman -Suruns. If a system database changed since the review and is newer (you ranpacman -Symeanwhile), nothing is changed and the tool exits with 2: run it again. If thatpacman -Sustill fails or is interrupted (a file conflict, Ctrl-C, a full disk), the system's sync database is newer than its installed packages - the tool warns; finish withpacman -Suor run it again before anypacman -S.--no-refreshapplies the existing databases with-Su. Throwaway databases left bykill -9are removed at the start of the next root run. - dnf:
--setopt=*.metadata_expire=-1freezes cached metadata (verified on dnf4 and dnf5 against an HTTP repository), but libdnf reads localfile://repositories live, so those can change between review and upgrade. dnf5 is only exercised by the unit tests (with recorded dnf5 output); the EL images of the E2E matrix all run dnf4. - zypper: the private configuration assumes libzypp's layering (main file from
/etc, else/usr/etc; drop-ins of bothzypp.conf.ddirectories,/etcwinning on equal names, in name order; nothing else whenZYPP_CONFis set). Verified on Tumbleweed and Leap 16 for an/etcdrop-in. On Leap the review list islist-updates(new dependencies are not rows); on Tumbleweed the plan needs root, so unprivileged runs showlist-updateswith a note. - Purges follow pacman's
-Rnsprinciple on every backend: a package that other installed packages depend on is not purged (they are listed; purge them first), and only dependencies that were installed automatically and that nothing else needs go along with the target - dependencies installed by hand stay. The reverse-dependency query is the oneN?uses (apt:apt-cache rdepends --installed, which also counts a dependent whose candidate, not installed, version needs the package; rpm: provides + files); a dependent it misses is still caught by the check that the simulated removal holds nothing outside the target's dependency closure. - Closures are computed at trust time. A dependency that appears later is new
to the system and is questioned once; run
skeptical-updater refreshto re-trust the current closures of all your trusted packages wholesale. A package shared by several trusted roots is recorded under the first one only, sountrustof that root drops it until the nextrefresh. - The upgradable lists show what the native listing shows: new dependencies are rows on apt
and on Tumbleweed (
-as the old version); on pacman and dnf new dependencies, replacements and obsoletes only appear as a note, and removals only on Tumbleweed (note); everything else the transaction does is visible in the package manager's own output.
License
GPL-2.0-or-later, see LICENSE.