Files
bryanpedini 3af159e09a fix(show): star marker, aligned gutter and smarter AUR merge
N?? / show marks the upstream candidate with "*" instead of ">" (the
prompt, which read as a command in logs), and every line but the note
gets a two-character gutter: "  " for the installed record, "* " for the
candidate, so keys and colons line up. The plain native fallback (zypper,
unmergeable records) and the not-installed candidate use the same gutter.

pacman records (-Qi with -Si or the AUR helper's -Sia) are now compared
the way they are meant:
- values unwrapped (continuation lines joined, runs of blanks as one):
  yay wraps Depends On / Optional Deps at 80 columns even without a
  terminal unless COLUMNS=0, which the helper now gets for -Qua and -Sia
- Depends On, Provides, Conflicts With, Make/Check Deps and the other
  lists as sets of exact tokens in any order (a constraint is a change)
- Optional Deps by package names ("name: description [installed]" in
  -Qi, bare names from the AUR)
- a differing value is one line like -Qi, Optional Deps one entry per
  line; candidate-only fields sit next to their counterpart (Repository
  after Version, AUR URL after URL, Maintainer after Packager, ...)

Verified on recorded claude-desktop records (paru 2.1.0, yay 13.0.1, the
installed 2.9939.4-1 from the AUR's own .SRCINFO); discovery/pacman.md
section 15.2.
2026-10-10 16:01:51 +02:00

38 KiB

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. On Arch Linux and its derivatives, AUR packages join the review when paru or yay is installed (see AUR packages).

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. On the pacman backend paru or yay is optional too (AUR packages); the helper is run as the user who invoked sudo, with runuser (util-linux, always there on Arch) or else sudo -u.

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          installed record merged with the upstream candidate (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)
  --aur-helper=NAME pacman backend: list and upgrade AUR packages with paru or yay, or
                    none (default: paru, else yay, when installed; other backends ignore it)
  --no-aur          same as --aur-helper=none
  -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); there an AUR helper simply runs as you.

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. The column right after the number is a * for an AUR package (pacman backend with paru or yay, see below) and a space on every other row.

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: the installed record, with the upstream candidate merged in as lines starting with * (see below); every line has a two-character gutter ( installed, * upstream). zypper shows zypper info --requires as it is
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).

N?? (and show PKG) prints the installed record in the package manager's own layout (dpkg-query -s, pacman -Qi, dnf info --installed), preceded by one # line saying how to read it. Every other line starts with a two-character gutter, so keys and colons line up: two spaces for the installed record, * for the upstream candidate. Right after each field whose value differs in the upstream candidate (apt-cache show, pacman -Si from the repository pacman would use, dnf info --upgrades) comes the candidate's line, marked * ; fields only the installed record has (install reason, conffiles, Required By) stay as they are, and identical fields are printed once. Fields only the candidate has (download size, checksums, repository) follow at the end the same way - on pacman next to their installed counterpart instead (Repository after Version, Download Size after Installed Size; for the AUR AUR URL after URL, Make Deps/Check Deps after Optional Deps, Maintainer after Packager, Last Modified and First Submitted after Build Date, the rest at the end). Multi-line values (descriptions, apt continuation lines) are compared as blocks and, when they differ, every line of the candidate's block gets the * . On pacman values are compared unwrapped (the AUR helpers wrap long lists at the terminal width), lists such as Depends On as sets of tokens in any order, Optional Deps by package names only (the installed record says name: description [installed], the AUR bare names); a differing value is printed on one line like pacman -Qi does, Optional Deps one entry per line. A package that is not installed shows the candidate alone, every line marked * . When the two records cannot be paired (zypper, whose rpm -qi and zypper info share almost no keys; no candidate; several installed architectures) the plain native output is shown, in the same gutter. On Debian 12, with su-dummy-b 1.0 installed and 2.0 available:

> 2??
# lines starting with * come from the upstream candidate: a differing value, or a field the installed record lacks
  Package: su-dummy-b
  Status: install ok installed
  Priority: optional
  Section: misc
  Maintainer: Skeptical Updater Fixture <su-fixture@example.invalid>
  Architecture: all
  Version: 1.0
* Version: 2.0
  Conffiles:
   /etc/su-dummy/su-dummy-b.conf 607e2d3a119dfe832d937ce579ef1079
  Description: dummy package su-dummy-b for skeptical-updater tests
   This package only exists to exercise the package manager.
   .
   It ships one data file and one conffile (version 1.0).
* Description: dummy package su-dummy-b for skeptical-updater tests
*  This package only exists to exercise the package manager.
*  .
*  It ships one data file and one conffile (version 2.0).
* Description-md5: 27e6732d8f16131f7512e1d4f1855cc3
* Filename: ./su-dummy-b_2.0_all.deb
* Size: 824
* MD5sum: 62274690c5324af5419515e9f430226b
* SHA256: e1c87480693d98770356f1dc55616dc416f7e3c3f71dc3967e94dec77bb36c16

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.

AUR packages (Arch Linux: paru or yay)

On the pacman backend (Arch and its derivatives, pman included) AUR packages are reviewed with the rest when an AUR helper is installed: paru, else yay. --aur-helper=paru|yay picks one (an error if it is not installed), --aur-helper=none or --no-aur turns the AUR off. The banner says which helper is in use, the AUR rows get a * right after the number and aur as their repository:

$ skeptical-updater                      (as a normal user: it re-runs itself through sudo)
Backend: pacman (Arch Linux) + AUR via paru
Refreshing package lists... done.
4 upgradable, 0 trusted (hidden, use --all to show), 4 to review:

 [1]  su-dummy-a    1.0-1  ->  2.0-1  su-dummy
 [2]* su-dummy-aur  1.0-1  ->  2.0-1  aur
 [3]  su-dummy-b    1.0-1  ->  2.0-1  su-dummy
 [4]  su-dummy-c    1.0-1  ->  2.0-1  su-dummy

<enter> upgrade | N? what is it | N?? full info | N- exclude this run | Nx purge now | N+ trust forever
> 3-
excluded from this run: su-dummy-b
>
Excluded from this run: su-dummy-b
Checking the reviewed transaction (pacman -Sup)... ok.
+ cp -p -- /tmp/skeptical-updater-db.4242.AbC123/sync/su-dummy.db /var/lib/pacman/sync/
+ pacman -Su --noconfirm --ignore su-dummy-b
...
+ runuser -u alice -- env HOME=/home/alice paru -Sua --ignore su-dummy-b
...
Upgrade finished.

(pman installed: Backend: pman (Arch Linux) + AUR via paru.)

  • Listing: paru -Qua / yay -Qua (name old -> new). The helper asks the AUR every time, so --no-refresh lists the AUR rows too; when it fails (no network, the AUR is down) its message is shown with aur: paru -Qua failed, AUR packages not listed and the session goes on with the repository rows. A package in IgnorePkg is listed [held] by paru and left out by yay. Devel (-git) packages are listed when the helper checks them (paru does by default, yay with --devel), with latest-commit as the new version. A name a repository has wins over an AUR row (a package that moved from the AUR into a repository).
  • N?, N+, Nx work as for any installed package, through pacman (pacman -Qi, the statefile with backend pacman, pacman -Rns). N?? merges pacman -Qi with the AUR record (paru -Sia / yay -Sia), * lines as usual: * Version followed by * Repository : aur, * AUR URL under URL, * Maintainer under Packager, * Votes, ... (show PKG does the same for a package in no repository).
  • N- excludes an AUR row through the helper's --ignore.
  • <enter> upgrades the repository packages first, exactly as without the AUR (pre-check, the reviewed databases, pacman -Su), then, if AUR rows are left to upgrade, runs paru -Sua --ignore <every excluded row> (or yay). The helper runs only after a successful repository step; if it fails, its exit status is the tool's (the AUR upgrade failed: paru exited with status 1 (the repository packages were upgraded)). When no repository row is left to upgrade, pacman is not run at all, only the helper. --dry-run prints the helper's command line.
  • Which user: AUR helpers must not run as root (paru refuses to build as root, makepkg too). When the tool is root because of its own sudo re-exec (or any sudo: SUDO_USER names a non-root user), every helper command runs as that user: runuser -u "$SUDO_USER" -- env HOME=<that user's home> paru ... from that home (sudo -u when runuser is missing). Run unprivileged (list --no-refresh, info, show, --dry-run) the helper runs as you. A root login without SUDO_USER gets no AUR, with a warning (aur: paru is never run as root and there is no invoking user ...).
  • PKGBUILD review: when stdin is a terminal the helper gets it and asks as usual - paru's PKGBUILD review, yay's diff menu, and sudo asking for your password for its pacman -U if the sudo timestamp has expired meanwhile. When stdin is not a terminal (a piped session) the helper runs with --noconfirm: nothing is reviewed, and its sudo pacman -U must work without a password.

Line editing and history

When stdin is a terminal, the prompt is read with bash's readline and its defaults: Left/Right move the cursor, Home/End or Ctrl-A/Ctrl-E jump to the ends, Backspace/Delete edit, and Up/Down walk the lines entered in this session. Down past the newest entry brings back what you had typed before pressing Up; Up at the oldest entry, and Down when already back on the new line, ring the terminal bell. Empty lines and the y/N answer of Nx are not recorded. The history lives in memory only and is never read from or written to a file (HISTFILE is ignored), so every run starts with an empty one. As in bash, readline draws the prompt and the line on stderr and honours your ~/.inputrc. Piped or redirected stdin keeps the plain line reader: every line is echoed after the prompt, and end of input aborts (exit 2).

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.
  • reason is user for a package you confirmed (N+ / trust), or dep:<root> for a package that was part of the installed dependency closure of <root> when you trusted it. A user row is never downgraded to dep:.
  • 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 under trusted.lock (flock, or an atomic mkdir lock with stale detection where flock is missing): the file is re-read and this session's changes are replayed onto it, so a trust in 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 PKG removes the rows for PKG and every dep:PKG row; refresh recomputes the closure of every user root (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 -- ...
AUR (pacman backend, paru or yay) none needed: -Qua asks the AUR every time after the repository step: paru -Sua (--noconfirm when stdin is not a terminal), as the user who invoked sudo the helper's --ignore a,b (all excluded rows) through pacman, like any installed package
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 - except an AUR helper's upgrade on a terminal, which gets the terminal for its own prompts. 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, and for the AUR paru, yay, runuser, getent) 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), the N?? merge on recorded pacman, apt, dnf4 and dnf5 records (changed, identical, installed-only, candidate-only and multi-line fields, apt's short candidate descriptions, dnf5's padding, the fallbacks, the two-character gutter; on pacman the unwrapped comparison, token sets, Optional Deps name sets and the placement of candidate-only fields, on the recorded claude-desktop records of paru and of yay, which wraps), the prompt on a real pty (script): cursor editing, Up/Down history recall, the stashed line, the bell at both ends, no history file, and the AUR (tests/unit/aur.bats, on recorded paru 2.1.0 and yay 13.0.1 output): helper detection and overrides, the user the helper runs as (runuser -u / sudo -u with SUDO_USER, unprivileged, a root login), the * column on pacman and apt rows, -Qua parsing ([ignored], yay's age suffix and root warning, devel packages, failures), N?/N??/N-/N+/Nx on AUR rows, the upgrade order and arguments, an AUR-only upgrade, a failing helper, --dry-run, and --noconfirm only without a terminal. About 35 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?? (su-dummy-b's installed 1.0 record with * Version, and * Repository on pacman and dnf, every line in the gutter; zypper's plain zypper info), 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, a session on a pty where Up recalls the previous line (skipped where the image has no script), --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.

tests/e2e/aur.bats covers the AUR. On archlinux the fixture (FIXTURE_AUR=1) also installs su-dummy-aur 1.0 with pacman -U (a foreign package: in no repository) and keeps its 2.0 package file, creates the user su-tester and makes su passwordless for the wheel group; the tests install fixtures/fake-paru.sh as /usr/local/bin/paru (it emulates paru's -Qua, -Sia and -Sua for that package, offline, refusing to install as root like paru) and fixtures/fake-sudo.sh as /usr/local/bin/sudo (the image has no sudo: su, with SUDO_USER set like sudo does). The applet runs as su-tester and re-executes itself through that sudo, or as root with SUDO_USER=su-tester: the * row with repository aur, paru running as su-tester with its HOME, N? and N?? on it, N- keeping it at 1.0 while the repository packages upgrade, a plain <enter> upgrading it to 2.0 after them, an AUR-only upgrade that leaves pacman alone, --dry-run, an unprivileged list --no-refresh, a root login (warning, no AUR) and --no-aur. On the other distros it checks the space column and that no AUR helper ever runs, even with --aur-helper=paru (ignored with a warning).

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 in tests/docker/images-before.tsv and tests/docker/image-ids-before.txt. It refuses to overwrite an existing snapshot, so a second make test cannot 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-run prints 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. a 2.0 depends on b>=2.0, b excluded) makes pacman -Su --noconfirm abort the whole transaction (exit 1, nothing upgraded): --noconfirm answers "skip the unresolvable package?" with no. Exclude the dependants too.
  • dnf4 (EL9/EL10): the same situation is an error (exit 1) unless --nobest is used; dnf5 silently skips the dependant (exit 0). The tool prints a hint, it does not retry with --nobest.
  • zypper on Tumbleweed: dist-upgrade with a vendor change (or another problem the solver cannot decide alone) is cancelled by --non-interactive with exit 4; run zypper dist-upgrade by 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 -Sup against 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 own db.lck, all-or-nothing) and pacman -Su runs. If a system database changed since the review and is newer (you ran pacman -Sy meanwhile), nothing is changed and the tool exits with 2: run it again. If that pacman -Su still 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 with pacman -Su or run it again before any pacman -S. --no-refresh applies the existing databases with -Su. Throwaway databases left by kill -9 are removed at the start of the next root run.
  • dnf: --setopt=*.metadata_expire=-1 freezes cached metadata (verified on dnf4 and dnf5 against an HTTP repository), but libdnf reads local file:// 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 both zypp.conf.d directories, /etc winning on equal names, in name order; nothing else when ZYPP_CONF is set). Verified on Tumbleweed and Leap 16 for an /etc drop-in. On Leap the review list is list-updates (new dependencies are not rows); on Tumbleweed the plan needs root, so unprivileged runs show list-updates with a note.
  • Purges follow pacman's -Rns principle 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 one N? 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 refresh to 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, so untrust of that root drops it until the next refresh.
  • AUR (pacman backend with paru or yay):
    • The helper is run as the user named by SUDO_USER only: a root login (no SUDO_USER, or doas, run0, pkexec) lists and upgrades no AUR package, with a warning.
    • A piped session upgrades AUR packages with --noconfirm: no PKGBUILD review, and the helper's own sudo pacman -U must not ask for a password (NOPASSWD, or a sudo timestamp that is still valid). Review AUR updates on a terminal, or exclude their rows. On a terminal the helper may ask for your password again if its sudo timestamp expired during a long review.
    • The AUR listing needs the network on every run (also with --no-refresh); without it the session goes on with the repository rows (warning). N?? on an AUR row asks the AUR too.
    • The helper decides what is foreign from the system's sync database, not from the reviewed throwaway copy; a package that moved from the AUR into a repository is listed from the repository (its AUR row is dropped).
    • What the helper reports is what you get: yay leaves IgnorePkg packages out (paru lists them [held]), devel packages are listed only when the helper checks them (paru by default, yay with --devel) and show latest-commit as the new version; the helper's own configuration (paru.conf, yay --save) applies to its upgrade.
    • The AUR upgrade is one helper run after pacman -Su: a helper failure leaves the repository packages upgraded (reported, its exit status returned). The E2E suite drives a fake paru (offline); the formats of real paru 2.1.0 and yay 13.0.1 are verified in discovery/pacman.md section 15.
  • 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.