Files
skeptical-updater/README.md
T
bryanpedini def537af58 feat(aur): list and upgrade AUR packages through paru or yay on Arch
On the pacman backend, an installed AUR helper (paru, else yay;
--aur-helper=paru|yay|none, --no-aur) adds the AUR updates to the review
list. The helper never runs as root: when the tool is root through sudo it
runs as SUDO_USER (runuser -u, else sudo -u, with that user's HOME, from
that home); unprivileged it runs as is; a root login gets no AUR, with a
warning. The banner names the helper ("+ AUR via paru").

- listing: <helper> -Qua (also with --no-refresh), rows with repository
  "aur"; [ignored] is shown [held]; a failing -Qua warns and the session
  goes on with the repository rows
- every row has a marker column after the number: "*" for AUR rows, a
  space otherwise (on every backend)
- N?? merges pacman -Qi with <helper> -Sia; N?, N+, Nx stay with pacman;
  N- excludes through the helper's --ignore
- <enter>: the repository step exactly as before, then <helper> -Sua
  --ignore <excluded> as the invoking user (--noconfirm only when stdin is
  not a terminal); pacman is skipped when only AUR rows are left; a helper
  failure is reported and its status returned; --dry-run prints it
- other backends never run a helper; --aur-helper is ignored with a warning

paru 2.1.0 and yay 13.0.1 formats verified in discovery/pacman.md section
15 (proof: discovery/pacman/aur.sh). Unit tests replay the recorded output
(fake paru, yay, runuser, getent); the archlinux E2E run installs the
foreign su-dummy-aur and drives a fake paru through a fake sudo built on
passwordless su for wheel.
2026-10-10 15:06:18 +02:00

570 lines
37 KiB
Markdown

# 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](#aur-packages-arch-linux-paru-or-yay)).
The specification is [docs/DESIGN.md](docs/DESIGN.md); the per-family command
research it is built on lives in [discovery/](discovery/).
## Install
```sh
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). 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. 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,
prefixed with `>`; fields only the candidate has (download size, checksums, repository) follow at the
end the same way, and fields only the installed record has (install reason, conffiles,
`Required By`) stay as they are. Identical fields are printed once; multi-line values (descriptions, pacman
`Optional Deps`, apt continuation lines) are compared as blocks and, when they differ, every line of
the candidate's block gets the `>`. A package that is not installed shows the candidate alone. 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. 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`, `> Repository : aur`,
`> Maintainer`, `> 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 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; 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:
```sh
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](LICENSE).