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.
570 lines
37 KiB
Markdown
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).
|