N? and `info PKG` now print three lines, none indented:
PKG: <description>
reason: explicitly installed | installed as a dependency | unknown | not installed
required by: <installed dependents, comma separated> | nothing
The reason comes from a new <be>_reason backend function: apt-mark
showauto / showmanual (dpkg decides for a package in neither list), the
Install Reason of pacman -Qi, dnf repoquery --installed %{reason} (dnf4
and dnf5 words) and the Installed line of zypper info (rpm plus libzypp's
AutoInstalled when zypper is locked). A row whose old version is "-" is
not installed and the backend is not asked. The "(installed)" qualifier
moves to --help, README and DESIGN.
The rpm E2E fixture and e2e_reset now also mark su-dummy-a and su-dummy-c
as user-installed on dnf, which records plain rpm installs as "unknown",
so 1? and 2? show both reasons on all 12 distros. The verified commands
are recorded in discovery/{apt,pacman,rpm}.md.
394 lines
25 KiB
Markdown
394 lines
25 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.
|
|
|
|
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.
|
|
|
|
## Usage
|
|
|
|
```
|
|
skeptical-updater [options] [command]
|
|
|
|
commands:
|
|
(none) interactive review + upgrade
|
|
list print the review list and exit (no prompt)
|
|
trusted print the statefile
|
|
trust PKG... add PKG and its installed dependency closure to the statefile
|
|
untrust PKG... remove rows for PKG (and rows whose reason is dep:PKG)
|
|
refresh recompute dependency closures for every 'user' root
|
|
info PKG description, install reason and installed dependents (as N?)
|
|
show PKG full native package information (as N??)
|
|
|
|
options:
|
|
--backend=NAME force apt|dnf|pacman|zypper (default: detect pacman > apt > dnf/yum > zypper)
|
|
--all also list trusted packages (marked with '=')
|
|
--no-refresh skip the metadata refresh step
|
|
--dry-run print native commands instead of running mutating ones
|
|
--state-dir=DIR override /var/lib/skeptical-updater (env: SKEPTICAL_UPDATER_STATE_DIR)
|
|
--no-color disable ANSI colours (also honours NO_COLOR and non-tty stdout)
|
|
-h, --help this help
|
|
-V, --version print the version
|
|
```
|
|
|
|
Exit status: `0` ok, `1` usage or backend error, `2` aborted (end of input),
|
|
otherwise the exit status of the package manager's upgrade step.
|
|
|
|
What needs root: the metadata refresh, the upgrade, a purge, and writing a state
|
|
directory you cannot write. `--dry-run`, `list --no-refresh`, `trusted`, `info` and
|
|
`show` work unprivileged (as long as the state directory is readable).
|
|
|
|
### Example session
|
|
|
|
A Debian 12 box with three pending upgrades; `su-dummy-c` is on hold
|
|
(`apt-mark hold`) and `su-dummy-a` depends on `su-dummy-b`:
|
|
|
|
```
|
|
$ sudo skeptical-updater
|
|
Backend: apt (Debian GNU/Linux 12 (bookworm))
|
|
Refreshing package lists... done.
|
|
3 upgradable, 0 trusted (hidden, use --all to show), 3 to review:
|
|
|
|
[1] su-dummy-a 1.0 -> 2.0 su-fixture:su-repo
|
|
[2] su-dummy-b 1.0 -> 2.0 su-fixture:su-repo
|
|
[3] su-dummy-c 1.0 -> 2.0 file:/srv/su-repo ./ [held]
|
|
|
|
<enter> upgrade | N? what is it | N?? full info | N- exclude this run | Nx purge now | N+ trust forever
|
|
> 2?
|
|
su-dummy-b: dummy package su-dummy-b for skeptical-updater tests
|
|
reason: installed as a dependency
|
|
required by: su-dummy-a
|
|
> 1+
|
|
trusted su-dummy-a (+1 dependency: su-dummy-b)
|
|
> 3-
|
|
excluded from this run: su-dummy-c
|
|
> l
|
|
3 upgradable, 2 trusted (hidden, use --all to show), 1 to review:
|
|
|
|
-[3] su-dummy-c 1.0 -> 2.0 file:/srv/su-repo ./ [held]
|
|
>
|
|
Excluded from this run: su-dummy-c
|
|
+ apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold -o DPkg::Lock::Timeout=60 --with-new-pkgs upgrade
|
|
...
|
|
The following packages have been kept back:
|
|
su-dummy-c
|
|
The following packages will be upgraded:
|
|
su-dummy-a su-dummy-b
|
|
...
|
|
Upgrade finished.
|
|
|
|
$ skeptical-updater trusted
|
|
# skeptical-updater trusted packages. Columns: backend package reason added(UTC ISO-8601)
|
|
apt su-dummy-a user 2026-10-09T19:24:13Z
|
|
apt su-dummy-b dep:su-dummy-a 2026-10-09T19:24:13Z
|
|
```
|
|
|
|
Next time `su-dummy-a` and `su-dummy-b` are upgraded silently; only new packages
|
|
are questioned. The prompt reads stdin, so the same session can be scripted:
|
|
`printf '3-\n\n' | sudo skeptical-updater` (an empty line is what triggers the
|
|
upgrade; end of input without one aborts with exit status 2).
|
|
|
|
`[held]` marks packages the package manager will not upgrade on its own: apt
|
|
holds and "kept back" packages, pacman `IgnorePkg` (`[ignored]` in `pacman -Qu`).
|
|
dnf `exclude=`/versionlock and zypper locks hide packages from the native listing,
|
|
so they do not appear at all.
|
|
|
|
### Command grammar
|
|
|
|
One line, whitespace-separated tokens; `N` is the number in brackets. Numbers are
|
|
stable for the whole session (rows that leave the list leave a gap). Every token
|
|
of a line is validated first: a line with an unknown token or a number that is not
|
|
listed prints an error, runs nothing, and the prompt comes back.
|
|
|
|
| token | action |
|
|
|-------|--------|
|
|
| empty line | proceed: run the native upgrade with the current exclusions |
|
|
| `N?` | what is it: the one-line description, `reason:` how it got installed (`explicitly installed`, `installed as a dependency`, `unknown` when the package manager cannot tell, `not installed` for a new dependency) and `required by:` the packages that depend on it, or `nothing`. That list covers installed packages only |
|
|
| `N??` | full native info (`apt-cache show`, `pacman -Qi` + `-Si`, `dnf info`, `zypper info --requires`) |
|
|
| `N-` | exclude from this run (toggle; `N-` again re-includes); shown with a `-` marker |
|
|
| `Nx` | purge now, with its now-unneeded dependencies: the purge is simulated first and the prompt lists everything it removes (`Purge su-dummy-a now? This also removes: su-dummy-b [y/N]`); the answer is read from the same stdin; the rows of every removed package leave the list. `x` refuses when other installed packages depend on the target, like `pacman -Rns`; purge the dependents first (`not purged: su-dummy-b is required by: su-dummy-a - purge the dependents first`). Rows with old version `-` (new dependencies, not installed) cannot be purged |
|
|
| `N+` | trust forever: write it and its installed dependency closure to the statefile; the row leaves the list. Rows this hides are re-included if they were excluded (a trusted package is upgraded) |
|
|
| `*+` / `*-` | `+` / `-` for every listed package (`*-` is an all-or-nothing toggle: when all are excluded it re-includes them) |
|
|
| `l` | redraw the list |
|
|
| `q` | quit without upgrading (exit 0) |
|
|
| `h`, `?` | help |
|
|
| end of input | abort without upgrading (exit 2) |
|
|
|
|
Several tokens may share a line (`1? 3- 4+`); they run left to right. With `--all`
|
|
trusted packages are listed too, marked with `=`. When purges leave nothing to upgrade the
|
|
session ends by itself (exit 0).
|
|
|
|
Under the rows, `Note:` lines report pending work that is not a row: new dependencies or
|
|
replacements only the pacman transaction (`-Sup`) shows, packages dnf installs to obsolete
|
|
others, packages a Tumbleweed `dist-upgrade` removes. With no row but such work, the list
|
|
says "0 to review, but ... reports pending changes" and `<enter>` still runs the upgrade.
|
|
|
|
### Dry run
|
|
|
|
`--dry-run` prints every mutating command as `+ <command>` instead of running it:
|
|
the upgrade, purges, apt holds/unholds, zypper lock files, the installation of the reviewed
|
|
pacman databases and statefile writes. The
|
|
metadata refresh still runs when you are root (it is printed when you are not), and
|
|
read-only queries run normally, so the list is real:
|
|
|
|
```
|
|
$ skeptical-updater --dry-run <<<$'2-\n'
|
|
...
|
|
Excluded from this run: su-dummy-b
|
|
+ apt-mark hold su-dummy-b
|
|
+ apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold -o DPkg::Lock::Timeout=60 --with-new-pkgs upgrade
|
|
+ apt-mark unhold su-dummy-b
|
|
Dry run: nothing was changed.
|
|
```
|
|
|
|
## Statefile
|
|
|
|
`/var/lib/skeptical-updater/trusted.tsv` (directory override: `--state-dir=DIR` or
|
|
`SKEPTICAL_UPDATER_STATE_DIR`). The directory is created `0755` on the first write.
|
|
|
|
```
|
|
# skeptical-updater trusted packages. Columns: backend package reason added(UTC ISO-8601)
|
|
apt nodejs user 2026-10-09T09:40:12Z
|
|
apt libnode115 dep:nodejs 2026-10-09T09:40:12Z
|
|
```
|
|
|
|
- Tab-separated, one record per line, sorted and unique on (backend, package);
|
|
lines starting with `#` are comments and are preserved (kept at the top).
|
|
Malformed lines are kept verbatim with a warning.
|
|
- `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 -- ...` |
|
|
| dnf / yum | `dnf -q --refresh --setopt=skip_if_unavailable=0 makecache` | `dnf -y --setopt=skip_if_unavailable=0 --setopt=*.metadata_expire=-1 upgrade` (the reviewed metadata; the repo glob is needed because a repo's own `metadata_expire` overrides the main one) | `--exclude=a,b` (adds to `exclude=` from the configuration) | plan: `dnf --assumeno remove PKG`; then `dnf -y remove ...` |
|
|
| zypper | `zypper --non-interactive --quiet refresh` | `zypper --non-interactive --no-refresh update` (Leap/SLE) or `dist-upgrade` (Tumbleweed/Slowroll, by `ID=` in os-release) | locks in a *private* `ZYPP_CONF`: the effective configuration (main zypp.conf plus the `zypp.conf.d` drop-ins of `/usr/etc` and `/etc`) with `locksfile.path` pointing at a copy of the effective lock file (also when a drop-in sets it), plus `addlock` of the exclusions; deleted afterwards: the real lock file is never written | plan: `zypper --xmlout remove --dry-run --clean-deps PKG`; then `zypper --non-interactive --no-refresh remove --clean-deps ...` |
|
|
|
|
Every package-manager call runs with `LC_ALL=C` (and `DEBIAN_FRONTEND=noninteractive`;
|
|
`COLUMNS=0` for pacman), with stdin from `/dev/null` so that it can never eat the
|
|
commands you pipe in. The upgradable list comes from `apt-get -s --with-new-pkgs
|
|
upgrade` (Inst lines + the "kept back" block), `pacman -Qu` joined with `pacman -Sl`,
|
|
`dnf -q check-update` (+ `rpm -qa` for the installed versions) and
|
|
`zypper --xmlout list-updates`, or on Tumbleweed (as root) the real plan
|
|
`zypper --xmlout --no-refresh dist-upgrade --dry-run`. Reverse dependencies and dependency closures use
|
|
`apt-cache rdepends` / the dpkg database, `pacman -Qi`, and plain `rpm` queries on
|
|
dnf and zypper systems (see the discovery reports for why). The install reason `N?` shows comes
|
|
from `apt-mark showauto` / `showmanual`, the `Install Reason` of `pacman -Qi`,
|
|
`dnf repoquery --installed --queryformat '%{reason}'` (packages installed with plain `rpm` are
|
|
`unknown` to dnf4, user-installed to dnf5) and the `Installed` line of `zypper info`
|
|
(`Yes (automatically)`; libzypp's `/var/lib/zypp/AutoInstalled` when zypper is locked).
|
|
|
|
## Testing
|
|
|
|
Everything runs in Docker; the host only needs GNU make, bash, Docker and (once,
|
|
for `make vendor`) curl. Run `make help` for the full list of targets.
|
|
|
|
| target | what it does |
|
|
|--------|--------------|
|
|
| `make vendor` | download the pinned bats-core, bats-support and bats-assert into `tests/vendor/` (once; no network afterwards) |
|
|
| `make lint` | shellcheck the applet, the test scripts, fixtures and fakes (`koalaman/shellcheck` image; `discovery/` is reference material and is not linted) |
|
|
| `make test-unit` | `tests/unit/*.bats` in a `debian:12` container, against fake package managers |
|
|
| `make test-e2e` | `tests/e2e/` inside every distro listed in `tests/docker/images.txt`, against the real package managers; logs go to `tests/docker/logs/<label>.log` |
|
|
| `make test` | snapshot, `test-unit`, `test-e2e`, then **always** `docker-cleanup`; exits with the tests' status |
|
|
| `make test-no-cleanup` | same as `test` but keeps the pulled images, for iterating |
|
|
| `make docker-cleanup-dry-run` | show what the cleanup would do |
|
|
| `make clean` | remove test logs and `.bats-tmp/` |
|
|
|
|
Variables: `make test-e2e E2E_ONLY=debian-12` (comma-separated list allowed)
|
|
runs a subset of distros; `E2E_FAIL_FAST=1` stops at the first failing distro.
|
|
|
|
### Unit tests (`tests/unit/`)
|
|
|
|
No root, no network, no real package manager. The applet runs with a hermetic
|
|
`PATH`: the fakes in `tests/unit/fakes/<backend>/` (`apt-get`, `apt-cache`,
|
|
`dpkg-query`, `apt-mark`, `pacman`, `pman`, `dnf`, `yum`, `rpm`, `zypper`, `sudo`)
|
|
in front of symlinks to the few system tools it needs. The fakes replay the real
|
|
outputs recorded in `discovery/*.md` (data in `tests/unit/data/`), keep their state
|
|
(holds, purged packages) under the test's temp dir and log every invocation, so the
|
|
tests assert the exact commands issued. Covered: CLI parsing and exit codes, backend
|
|
detection and override, root handling and the sudo re-exec, every parser (install reasons
|
|
included: explicitly installed, dependency, unknown and not installed on each backend), the
|
|
statefile, the whole interactive grammar, the per-backend exclusion mechanics (apt
|
|
hold journal incl. crash recovery and SIGTERM, pacman `--ignore`, dnf `--exclude`,
|
|
zypper private lock file contents incl. drop-ins), the purge plans, the metadata freeze flags,
|
|
`--dry-run`, and the robustness fixes (hold restore with a closed stdout and with a second
|
|
signal during the exit handler, the holds lock kept while a restore failed, journal and private
|
|
zypper configuration replayed only when no other run holds their lock, the pacman pre-check /
|
|
database install / `-Su` sequence and stale throwaway databases, statefile locking and merge,
|
|
the `mkdir` lock fallback (EPERM owner alive, no busy loop), an inaccessible state directory, the
|
|
sudo re-exec of the file itself and its loop guard).
|
|
About 30 seconds.
|
|
|
|
### End-to-end tests (`tests/e2e/`)
|
|
|
|
`tests/docker/run-e2e.sh <label> <image>` runs `tests/e2e/entrypoint.sh` as root in a
|
|
throwaway container (repository mounted read-only). The entrypoint installs the
|
|
applet, builds a fixture with `tests/e2e/fixtures/{apt,pacman,rpm}.sh` (copies of
|
|
the proven `discovery/` fixtures): `su-dummy-a` (depends on `su-dummy-b`),
|
|
`su-dummy-b` and `su-dummy-c` installed at 1.0, 2.0 offered by a local repository,
|
|
plus `su-dummy-orphan`, an unrelated automatically-installed package nothing depends on,
|
|
with the distro's own repositories disabled (apt `SU_FIXTURE_OFFLINE=1`, pacman
|
|
`FIXTURE_ISOLATE=1`, rpm `SU_FIXTURE_ISOLATE=1` after the build tools are installed),
|
|
so the review list is exactly the three dummies and the result does not depend on
|
|
mirror state. `su-dummy-b` is recorded as installed as a dependency on every backend (pacman
|
|
`--asdeps`, `apt-mark auto`, `dnf mark remove`, zypper's `AutoInstalled`) and `su-dummy-a` as
|
|
explicitly installed (dnf needs `dnf mark install`: it records plain `rpm` installs as
|
|
`unknown`). Then `tests/e2e/*.bats` exercise the real package manager: banner, list, `N?` (the
|
|
reason and `required by:` of both), `N??`, `N+` and the statefile, `trust`/`untrust`/`refresh`,
|
|
excluding `su-dummy-b` (with and without a pre-existing hold/lock/IgnorePkg/exclude on
|
|
`su-dummy-c`, which must survive byte for byte), trusting re-including an excluded
|
|
dependency, full upgrade, `Nx` (refused for su-dummy-b, which su-dummy-a needs; su-dummy-a
|
|
taking its dependency su-dummy-b along, shown in the prompt, and the unrelated orphan surviving),
|
|
purging everything, `q`, end of input, `--dry-run`, the read-only commands as an unprivileged
|
|
user, plus per backend: pacman never touching the real sync database outside the upgrade and
|
|
installing exactly the reviewed database at `<enter>` (same hash, a package published after the
|
|
review is not installed), apt
|
|
holds waiting for a busy dpkg lock, zypper honouring a lock file set in a drop-in and applying
|
|
the reviewed metadata although a newer package appeared after the review.
|
|
|
|
The matrix (`tests/docker/images.txt`):
|
|
|
|
| label | image | backend |
|
|
|-------|-------|---------|
|
|
| debian-12, debian-13 | `debian:12`, `debian:13` | apt |
|
|
| ubuntu-22.04, ubuntu-24.04, ubuntu-latest | `ubuntu:22.04`, `ubuntu:24.04`, `ubuntu:latest` | apt |
|
|
| archlinux | `archlinux:latest` | pacman |
|
|
| almalinux-9, almalinux-10 | `almalinux:9`, `almalinux:10` | dnf (dnf4 on both) |
|
|
| rockylinux-9, rockylinux-10 | `rockylinux/rockylinux:9`, `rockylinux/rockylinux:10` | dnf (dnf4) |
|
|
| opensuse-tumbleweed, opensuse-leap | `opensuse/tumbleweed:latest`, `opensuse/leap:latest` | zypper (`dist-upgrade` / `update`) |
|
|
|
|
The Rocky images come from the `rockylinux/` namespace: the Docker Hub library
|
|
`rockylinux:9` is a stale Rocky 9.3 build and `rockylinux:10` does not exist.
|
|
The rpm fixtures install `rpm-build` and `createrepo_c` from the distro mirrors, so
|
|
the rpm-family runs need network for that step (everything after it is offline).
|
|
|
|
### Docker image hygiene
|
|
|
|
The E2E matrix pulls a dozen distro images. To leave the host as it was:
|
|
|
|
- `tests/docker/snapshot.sh` (`make docker-snapshot`) records the image set 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`.
|
|
- 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).
|