63 KiB
RPM family discovery: DNF (AlmaLinux / Rocky / Fedora) and ZYPPER (openSUSE)
Everything below was run in throwaway docker run --rm -i containers (Docker 29.8.2 on the host, 2026-10-09).
All outputs are real; "exit=N" is the real exit status. All commands run with LC_ALL=C.
Companion files (all in discovery/rpm/, all proven on every image listed in section 0):
| file | purpose |
|---|---|
fixture.sh |
E2E fixture: builds 3 dummy RPMs (+ optional extras), local repo, 1.0 installed / 2.0 offered. dnf vs zypper auto-detected |
lib-rpm.sh |
the proven bash building blocks quoted in this document (su_refresh, su_upgradable, su_describe, su_rdeps, su_closure, su_upgrade, su_plan, su_remove, ...) |
proof.sh |
runs the fixture + every lib-rpm.sh function and asserts the results (43-53 PASS lines per image) |
Reproduce on any image: docker run --rm -i -v <project>/discovery/rpm:/d:ro <image> bash /d/fixture.sh or ... bash /d/proof.sh.
0. Verdict, images, and the surprises
0.1 Chosen commands (one line each)
| need | DNF (dnf4 and dnf5, yum is the same binary) |
ZYPPER |
|---|---|---|
| refresh | dnf -q --refresh --setopt=skip_if_unavailable=0 makecache >/dev/null (exit 0/1) |
zypper --non-interactive --quiet refresh (exit 0; 4 repo error; 3 unknown repo; 6 no repos) |
| upgradable list | dnf -q check-update --setopt=skip_if_unavailable=0 (exit 100 = updates, 0 = none, 1 = error), parse 3 columns name.arch evr repo, old version from rpm -qa |
zypper --non-interactive --xmlout list-updates (exit 0 always), parse <update ... name edition edition-old arch> + <source alias> |
| summary/description | dnf -q repoquery --upgrades --latest-limit=1 --queryformat $'%{summary}\n%{description}\n' PKG, fallback rpm -q --qf '%{SUMMARY}\n%{DESCRIPTION}\n' PKG |
zypper -q -n info PKG (shows candidate, else installed), parse Summary : / indented Description : |
| installed reverse deps | pure rpm: provides + owned files joined against all REQUIRENAMEs (su_rdeps); == dnf repoquery --installed --whatrequires |
same pure-rpm function (identical on both families) |
| recursive installed closure | pure rpm BFS su_closure (rpm -q REQUIRENAME + rpm -q --whatprovides); == dnf4 repoquery --installed --requires --resolve --recursive; dnf5 has no --resolve |
same function |
| per-run exclusion | dnf -y upgrade --exclude=a,b (comma list; repeated flags also work; dnf4 and dnf5) |
no --exclude. Chosen: private ZYPP_CONF with its own locksfile.path (su_zypper_excluding). Alternative with exact restore: snapshot/restore /etc/zypp/locks + trap (su_zypper_excluding_snapshot) |
| non-interactive upgrade | dnf -y upgrade |
Leap/SLE: zypper --non-interactive update; Tumbleweed (os-release ID=opensuse-tumbleweed): zypper --non-interactive dup |
| remove + unneeded deps | dnf -y remove PKG (clean_requirements_on_remove=1 is the default on dnf4 and dnf5) |
zypper --non-interactive remove --clean-deps PKG |
| plan preview (new deps, removals, obsoletes) | dnf --assumeno upgrade [--exclude=..] + table parse (su_plan_dnf) |
zypper --non-interactive --xmlout update or dup plus --dry-run (<install-summary><to-upgrade>/<to-install>/<to-remove>) |
0.2 Correct image names
| asked | result |
|---|---|
almalinux:9 |
exists. AlmaLinux 9.8, dnf 4.14.0, rpm 4.16.1.3, bash 5.1.8 |
almalinux:10 |
exists. AlmaLinux 10.2, dnf 4.20.0 (NOT dnf5), rpm 4.19.1.1, bash 5.2.26 |
rockylinux:9 |
exists but stale: Docker "official" library image = Rocky 9.3 created 2023-11-28 (96 pending upgrades on day one), dnf 4.14.0. Fresh equivalent: rockylinux/rockylinux:9 or quay.io/rockylinux/rockylinux:9 = Rocky 9.8 (same image id 686e4558d0a5) |
rockylinux:10 |
does not exist: docker manifest inspect rockylinux:10 -> "no such manifest" (also :10.0, :10.1, :10-minimal missing). Correct: rockylinux/rockylinux:10 (Docker Hub) or quay.io/rockylinux/rockylinux:10 |
| quay vs Docker Hub Rocky 10 | same image: both resolve to id sha256:0b5329d806d0..., repo digest sha256:827d37bc128288ccf160ee318bb3cb92d591164cb217e92f8bc61e3982ae1834. Rocky 10.2, dnf 4.20.0, rpm 4.19.1.1. (fixture.sh also passes on the quay tag, 8 s.) |
opensuse/tumbleweed:latest |
openSUSE Tumbleweed (os-release ID=opensuse-tumbleweed, VERSION_ID=20261007), zypper 1.14.101, rpm 4.20.1, bash 5.3.20 |
opensuse/leap:latest |
Leap 16.0 (ID=opensuse-leap), image created 2025-08-26, zypper 1.14.101, rpm 4.20.1, bash 5.2.37 |
| extra, for dnf5 only | fedora:latest = Fedora 44, dnf5 5.4.3.0, rpm 6.0.2 (pulled only to verify dnf5 syntax; not a target) |
0.3 Surprises that change the design
- EL10 is dnf4 (4.20.0), not dnf5. AlmaLinux 10 has no
dnf5package anywhere (BaseOS/AppStream/CRB/Extras/EPEL:dnf -q --enablerepo='*' list --available dnf5-> "No matching Packages"; onlymicrodnf 3.10.1). Rocky 10 shipsdnf5 5.1.12only in the disableddevelrepo. So dnf5 is only met on Fedora (or a user-installed dnf5). The tool must support dnf4 and dnf5 from one code path; the only target images that run dnf5 are not in the requested list, so dnf5 was verified onfedora:latest. dnf5silently ignores a dead repo (exit 0,-qhides the warning) unless--setopt=skip_if_unavailable=0. dnf4 errors by default (exit 1).rpm -qa --qf '[%{REQUIRENAME}\t%{NAME}\n]'is broken (error: incorrect format: array iterator used with different sized arrays, ~50% of packages) on every rpm 4.16..6.0; the scalar tag must be written%{=NAME}inside[...].- Missing tools in base images: no
awk/find/xargs/hostname/python3/xmllinton Tumbleweed; nofind/xargs/python3/xmllinton Leap; nodiff/cmp/ps/which/sudoalmost everywhere. All proven code uses only bash + rpm + sed/grep/tr/sort/join/cut/mktemp. rpm -q --whatrequires bashreturns 1 package on EL9 wherednf repoquery --installed --whatrequires bashandsu_rdeps bashreturn 52 (file deps/bin/sh,/usr/bin/bash, sonames).zypper removelockis not an undo (deletes all matching locks, leaves an empty/etc/zypp/locksfile even if there was none).dnf list --upgradesand (dnf4)repoquery --upgradesignore the versionlock plugin; onlycheck-updatehonours it on both majors.
0.4 Base image facts (this is item 9 of the brief; repeated as a pointer in section 9)
| alma:9 | alma:10 | rocky:9 (9.3 lib) | rocky 10 | tumbleweed | leap 16.0 | |
|---|---|---|---|---|---|---|
| package mgr | dnf 4.14.0 | dnf 4.20.0 | dnf 4.14.0 | dnf 4.20.0 | zypper 1.14.101 | zypper 1.14.101 |
yum |
symlink -> dnf-3 |
dnf-3 |
dnf-3 |
dnf-3 |
absent | absent |
| rpm | 4.16.1.3 | 4.19.1.1 | 4.16.1.3 | 4.19.1.1 | 4.20.1 | 4.20.1 |
| bash | 5.1.8 | 5.2.26 | 5.1.8 | 5.2.26 | 5.3.20 | 5.2.37 |
sudo |
no (installable, baseos) | no | no | no | no (installable) | no (installable) |
su |
yes | no | yes | yes | yes | yes |
python3 |
yes | yes | yes | yes | no | no |
| awk / find / xargs | yes | yes | yes | yes | no / no / no | awk yes, find, xargs no |
| xmllint | yes | yes | yes | yes | no | no |
| runs as | root | root | root | root | root | root |
bats:
- EL9/EL10 (Alma and Rocky): not in BaseOS/AppStream/CRB; needs EPEL:
dnf -y install epel-release && dnf -y install bats(epel-release is inextras, enabled by default). Verified: EL9 ->Bats 1.8.0(bats-1.8.0-1.el9), EL10 ->Bats 1.11.1(bats-1.11.1-1.el10_0).batsinstall itself took 3-5 s;epel-release1-12 s (measured on all four EL images). - Tumbleweed:
zypper -n in bats->Bats 1.14.0(bats-1.14.0-1.1), main OSS repo, no extra repo. Leap 16.0:Bats 1.11.1(bats-1.11.1-160000.2.2), main repo. (install of bats+sudo: 8-9 s.) sudoinstallable everywhere: EL baseos1.9.17p2/1.9.17-10.p2.el10_2.7; Tumbleweed1.9.17p2; Leap1.9.17p1.python3on openSUSE: Leap haspython3/python313; Tumbleweed has versionedpython311/python312/... (no unversioned package).rpm-buildandcreaterepo_care installable on all six images (BaseOS/AppStream on EL, main OSS on openSUSE). EL9: rpm-build 4.16.1.3-40.el9 + createrepo_c 0.20.1-4.el9; EL10: rpm-build 4.19.1.1-23.el10 + createrepo_c 1.1.2-4.el10; Tumbleweed: rpm-build 4.20.1-11.1 + createrepo_c 1.2.3-1.4; Leap 16: rpm-build 4.20.1-160000.3.1 + createrepo_c 1.1.0-160000.2.2. No EPEL needed for them.
1. Refresh metadata non-interactively
DNF
$ dnf makecache # alma9 (dnf4), cold cache, 9.0 s
AlmaLinux 9 - AppStream 23 MB/s | 28 MB 00:01
AlmaLinux 9 - BaseOS 32 MB/s | 45 MB 00:01
AlmaLinux 9 - Extras 52 kB/s | 33 kB 00:00
Last metadata expiration check: 0:00:01 ago on Fri Oct 9 08:20:43 2026.
Metadata cache created. exit=0
Variants tested (stdout lines / stderr lines / exit):
| command | dnf4 (alma9/10) | dnf5 (fedora 44) |
|---|---|---|
dnf makecache |
5 / 0 / 0 (progress + "Metadata cache created." on stdout) | 1 / 3 / 0 (progress "Updating and loading repositories:" ... "Repositories loaded." on stderr, "Metadata cache created." on stdout) |
dnf -q makecache |
0 / 0 / 0 (silent) | 1 / 0 / 0 (still prints "Metadata cache created." to stdout) -> >/dev/null |
dnf --refresh makecache and dnf makecache --refresh |
both accepted, forces re-download | both accepted |
plain makecache (no --refresh) |
no re-download while the cache is younger than metadata_expire (dnf4 dump on alma9: metadata_expire = 172800 = 48 h; ~1-2 s of revalidation measured) |
same |
--refresh(= "set metadata as expired") is the right switch for a skeptical tool:dnf -q --refresh makecachere-downloads every enabled repo (alma9 1.6-7 s, alma10 2.5-7 s, rocky 1-1.4 s, fedora 1.7 s measured warm; cold 3-9.5 s, section 11).dnf check-updateexit codes (verified on dnf4 and dnf5): 100 = updates available, 0 = nothing (also 0 when a name pattern matches nothing:dnf -q check-update 'nonexist*'-> exit=0), 1 = error.- Dead repo handling differs (repodata removed from the local repo):
| command | dnf4 alma10 | dnf5 fedora |
|---|---|---|
dnf -q --refresh makecache |
exit 1: Error: Failed to download metadata for repo 'su-dummy': Cannot download repomd.xml: Cannot download repodata/repomd.xml: All mirrors were tried |
exit 0, no message (repo silently ignored, default skip_if_unavailable=true for repos that do not set it) |
same + --setopt=skip_if_unavailable=0 |
exit 1 (same message) | exit 1: Failed to download metadata (baseurl: "file:///var/lib/su-dummy-repo") for repository "su-dummy": Usable URL not found |
dnf -y upgrade su-dummy-a |
exit 1 | exit 0, prints Nothing to do.; su-dummy-a stays 1.0 (the dead repo is only a stderr warning >>> Curl error (37) ..., absent with -q) unless skip_if_unavailable=0 |
So always pass --setopt=skip_if_unavailable=0 (harmless on dnf4) and never rely on -q to see problems.
- Oddity seen on dnf 4.14.0 (EL9): with the repo gone,
dnf -q check-updatestill returned 100 from stale cache, whilemakecache --refresh,repoqueryandupgradefailed with exit 1. => run the explicit refresh first and abort on its failure, do not infer repo health from check-update. - Real-world flake observed once on
rockylinux:9:Error: Failed to download metadata for repo 'extras': repomd.xml parser error: Parse error at line: 1 (Extra content at the end of the document(mirror served garbage), exit 1; re-run was clean. The refresh step correctly failed the run; the tool should surface this and let the user retry. yum:/usr/bin/yum -> dnf-3on all EL images (prints4.14.0/4.20.0);/usr/bin/yum -> dnf5on Fedora. Detection:dnf --version | head -1starts withdnf5 versionon dnf5, with a bare version (4.14.0) on dnf4.
ZYPPER
$ zypper --non-interactive refresh # tumbleweed, metadata fresh, 0.6 s
Repository 'openSUSE-Tumbleweed-Update' is up to date.
Repository 'openSUSE-Tumbleweed-Non-Oss' is up to date.
Repository 'Open H.264 Codec (openSUSE Tumbleweed)' is up to date.
Repository 'openSUSE-Tumbleweed-Oss' is up to date.
Repository 'su-dummy' is up to date.
All repositories have been refreshed. exit=0
-qsilences it; exit 0 either way.zypper refresh --force su-dummyre-downloads ("Forcing raw metadata refresh ... Building repository 'su-dummy' cache [....done]", exit 0).- Normal
refreshonly checks whether the repo changed (cheap); it is also whatzypper lu/up/dupdo implicitly (autorefresh=on). - Broken repo:
Retrieving repository 'su-dummy' metadata [.error]...Could not refresh the repositories because of errors.exit=4. Unknown name (refresh nonexist):Repository 'nonexist' not found by its alias, number, or URI.exit=3. - Leap prints
Refreshing service 'openSUSE'.as first line of nearly every command (it is a service, not a repo) - in-qmode it disappears, in--xmloutmode it is a<message type="info">. - GPG: the test repo is
--no-gpgcheck. For real third-party repos--non-interactivewould refuse unknown keys;--gpg-auto-import-keysis the (security-relevant) opt-in. Not used by the tool.
2. Machine-parsable list of upgradable packages (name<TAB>old<TAB>new<TAB>repo)
2.1 DNF: candidates compared (alma9/alma10 = dnf4, fedora = dnf5)
dnf -q check-update |
dnf -q repoquery --upgrades --queryformat ... |
dnf -q list --upgrades |
|
|---|---|---|---|
| first line(s) | dnf4: one blank line; dnf5: a header line Upgrades (printed even with -q) |
none | Available Upgrades (dnf4) / Available upgrades (dnf5) |
| columns | 3: name.arch evr repo (arch suffix attached, epoch shown as 2:... when non-zero) |
whatever you ask for | same 3 columns |
honours --exclude / excludepkgs |
yes (dnf4, dnf5) | yes | yes |
| honours versionlock plugin | yes (dnf4 and dnf5) | dnf4: no (locked pkg still listed), dnf5: yes | no on both |
same code path as dnf upgrade |
yes | no | no |
| "Obsoleting Packages" | dnf4: trailing section Obsoleting Packages (+ indented old pkg @System); dnf5: not listed |
not listed | not listed |
| exit | 100 / 0 / 1 | 0 | 0 |
| queryformat escapes | - | dnf4 expands \t \n and appends its own newline (blank line after each record); dnf5 does not expand \t (prints literal \t), expands \n, appends nothing |
- |
| old (installed) version | not given | not given (no installed-version tag) | not given |
Samples (cat -A; EL9 shown, EL10 identical in shape):
$ dnf -q check-update | cat -A # dnf4, alma9, exit=100
$
gnutls.x86_64 3.8.10-9.el9_8 baseos $
su-dummy-a.noarch 2.0-1 su-dummy$
su-dummy-b.noarch 2.0-1 su-dummy$
su-dummy-c.x86_64 2.0-1 su-dummy$
vim-minimal.x86_64 2:8.2.2637-26.el9_8.23 baseos $
$ dnf -q check-update | head -3 # dnf5, fedora 44, exit=100
Upgrades
ca-certificates.noarch 2026.2.90_v9.0.317-1.fc44 updates
curl.x86_64 8.18.0-12.fc44 updates
$ dnf -q check-update 'nonexist*' ; echo exit=$? # both: nothing printed, exit=0
$ dnf -q repoquery --upgrades --queryformat '%{name}\t%{evr}\t%{repoid}\n' 'su-dummy-*'
su-dummy-a 2.0-1 su-dummy # dnf4: ...followed by a BLANK line after every record
su-dummy-a\t2.0-1\tsu-dummy # dnf5: literal "\t" (not expanded!)
$ dnf -q repoquery --upgrades --queryformat $'%{name}\t%{arch}\t%{evr}\t%{repoid}\n' 'su-dummy-*' | cat -A
su-dummy-a^Inoarch^I2.0-1^Isu-dummy$ # dnf4 and dnf5 identical when real TAB chars are passed
$ dnf -q repoquery --upgrades # default format on both: name-epoch:ver-rel.arch
su-dummy-a-0:2.0-1.noarch
Other tag differences: %{reponame} is the repo id on dnf4 but the human name on dnf5 (skeptical-updater dummy packages); %{repoid} is the id on both. %{evr} omits epoch 0 on both, includes 1: when non-zero (su-dummy-e with Epoch: 1 -> 1:2.0-1).
dnf4 long names do not wrap in a non-tty even at COLUMNS=30 (a 108-character package name stays on one line, padded): the 3-field read is safe; the same holds on dnf5.
Decision: parse dnf -q check-update (only listing that honours excludes and versionlock on both majors and is the same solver view as dnf upgrade), take the old version from rpm -qa, and use repoquery only for summary/description. Parse (from lib-rpm.sh, proven on 5 dnf images):
_su_installed_evr() { # "name.arch<TAB>evr", ascending so the highest version (installonly pkgs have several) wins
rpm -qa --qf '%{NAME}.%{ARCH}\t%|EPOCH?{%{EPOCH}:}|%{VERSION}-%{RELEASE}\n' | LC_ALL=C sort -t $'\t' -k1,1 -k2,2V
}
su_upgradable_dnf() {
local out rc na ver repo rest name arch k evr; local -A old=() oldn=()
out=$(dnf -q check-update --setopt=skip_if_unavailable=0); rc=$?
case $rc in 0) return 0 ;; 100) ;; *) return "$rc" ;; esac
while IFS=$'\t' read -r k evr; do evr=${evr#0:}; old[$k]=$evr; oldn[${k%.*}]=$evr; done < <(_su_installed_evr)
while read -r na ver repo rest; do
[ "$na" = Obsoleting ] && break # dnf4 trailing "Obsoleting Packages" section
[ -n "$repo" ] && [ -z "$rest" ] || continue # drops the blank line (dnf4), the "Upgrades" header (dnf5), anything not 3 fields
name=${na%.*}; arch=${na##*.} # plain name; the arch never contains a dot
printf '%s\t%s\t%s\t%s\t%s\n' "$name" "${old[$na]:-${oldn[$name]:-?}}" "$ver" "$repo" "$arch"
done <<<"$out" | LC_ALL=C sort
}
Output from proof.sh (alma10, tab shown as ^I):
gnutls^I3.8.10-4.el10_2^I3.8.10-5.el10_2^Ibaseos^Ix86_64$
rpm-sequoia^I1.10.1.1-2.el10^I1.10.2.1-1.el10_2^Ibaseos^Ix86_64$
su-dummy-a^I1.0-1^I2.0-1^Isu-dummy^Inoarch$
su-dummy-b^I1.0-1^I2.0-1^Isu-dummy^Inoarch$
su-dummy-c^I1.0-1^I2.0-1^Isu-dummy^Ix86_64$
Epoch example (alma9): vim-minimal 2:8.2.2637-26.el9_8.21 2:8.2.2637-26.el9_8.23 baseos x86_64.
Excluded / locked packages: both are removed from the sack, so they simply do not appear in check-update/repoquery (with --exclude=a,b: only su-dummy-c remains). To see them anyway: dnf4 --disableexcludes=all; dnf5 has no such option (Unknown argument "--disableexcludes=all" for command "repoquery", also no --disable-excludes/--no-excludes). Versionlock: dnf4 python3-dnf-plugin-versionlock (/etc/dnf/plugins/versionlock.list, line su-dummy-a-0:1.0-1.*), dnf5 dnf5-plugins (/etc/dnf/versionlock.toml); after versionlock add su-dummy-a, check-update lists only b and c on both.
Blind spot - obsoletes: a package that obsoletes an installed one (su-dummy-new obsoleting su-dummy-old) is listed only by dnf4 check-update (in the trailing section, which the parser drops) and by no repoquery --upgrades/dnf5 listing, but dnf upgrade will install it and remove the old one:
$ dnf --assumeno upgrade # dnf4
Installing:
su-dummy-new noarch 1.0-1 su-dummy 6.5 k
replacing su-dummy-old.noarch 1.0-1
=> use the plan preview of section 7 (su_plan) when the user must see everything that will change.
2.2 ZYPPER: candidates compared
zypper -n --xmlout list-updates |
zypper -q -n list-updates (table) |
... lu --all |
|
|---|---|---|---|
| exit | 0 (also with updates; there is no 100) | 0 | 0 |
| noise | <message> tags (Loading repository data..., Leap: Refreshing service 'openSUSE'.) |
-q removes all non-table lines |
|
| fields | name, edition (new), edition-old, arch, summary, description, <source alias=...> |
S | Repository | Name | Current Version | Available Version | Arch (repo = display name, e.g. openSUSE-Tumbleweed-Update) |
same table, locked rows get status vl |
| locked packages | hidden | hidden | shown with status vl |
| vendor-change candidates | hidden | hidden | shown (v) |
| when nothing | empty <update-list> |
prints nothing (even no header) |
$ zypper -q -n list-updates # tumbleweed
S | Repository | Name | Current Version | Available Version | Arch
---+----------------------------+--------------------+-----------------+-------------------+-------
v | openSUSE-Tumbleweed-Update | openSUSE-build-key | 1.0-68.1 | 1.0-69.1 | x86_64
v | su-dummy | su-dummy-a | 1.0-1 | 2.0-1 | noarch
v | su-dummy | su-dummy-b | 1.0-1 | 2.0-1 | noarch
v | su-dummy | su-dummy-c | 1.0-1 | 2.0-1 | x86_64
$ zypper -n --xmlout list-updates # one <update> element per package; attributes + summary + description (candidate!) + source
<update-list>
<update kind="package" name="su-dummy-a" edition="2.0-1" arch="noarch" edition-old="1.0-1"><summary>skeptical-updater dummy su-dummy-a (v2.0)</summary><description>Dummy package su-dummy-a version 2.0 for skeptical-updater tests.
...</description><license/><source url="file:/var/lib/su-dummy-repo" alias="su-dummy"/></update><update kind="package" ...
Note the XML puts all <update> elements on one physical line and descriptions span several lines, xmllint does not exist on SUSE images, python3 neither. Parse with tr+sed+bash (proven on Tumbleweed and Leap):
_su_xattr() { # _su_xattr '<tag text>' attr -> $REPLY (XML-unescaped); rc 1 if absent. ' edition="' never matches ' edition-old="'
local v=${1#* $2=\"}; if [ "$v" = "$1" ]; then REPLY=; return 1; fi
v=${v%%\"*}; v=${v//</<}; v=${v//>/>}; v=${v//"/\"}; v=${v//'/\'}; v=${v//&/&}; REPLY=$v
}
su_upgradable_zypper() {
local xml rc line tag name new old arch src alias
xml=$(zypper --non-interactive --xmlout list-updates); rc=$?; [ "$rc" -eq 0 ] || return "$rc"
# tr leaves no trailing newline and GNU sed keeps it that way: without `|| [ -n "$line" ]` the LAST package is silently dropped
printf '%s\n' "$xml" | tr '\n' ' ' | sed 's/<update /\n<update /g' | while IFS= read -r line || [ -n "$line" ]; do
case $line in '<update kind="package" '*) ;; *) continue ;; esac
tag=${line%%>*}
_su_xattr "$tag" name; name=$REPLY; _su_xattr "$tag" edition; new=$REPLY
_su_xattr "$tag" edition-old; old=$REPLY; _su_xattr "$tag" arch; arch=$REPLY
src=${line#*<source }; alias=; [ "$src" != "$line" ] && { _su_xattr "$src" alias && alias=$REPLY; }
printf '%s\t%s\t%s\t%s\t%s\n' "$name" "$old" "$new" "$alias" "$arch"
done | LC_ALL=C sort
}
Output (tumbleweed): openSUSE-build-key^I1.0-68.1^I1.0-69.1^Irepo-update^Ix86_64$, su-dummy-a^I1.0-1^I2.0-1^Isu-dummy^Inoarch$ ... The repo column is the stable alias (repo-update, Leap: openSUSE:repo-oss).
Table fallback (also proven; repo = display name, may contain spaces, e.g. repo-oss (16.0)):
trim() { local v=$1; v=${v#"${v%%[![:space:]]*}"}; v=${v%"${v##*[![:space:]]}"}; REPLY=$v; }
zypper -q -n list-updates | while IFS='|' read -r s repo name cur avail arch; do
case $s in v*) ;; *) continue ;; esac # data rows only (header/separator rows do not start with "v")
trim "$repo"; repo=$REPLY; trim "$name"; name=$REPLY; trim "$cur"; cur=$REPLY; trim "$avail"; avail=$REPLY; trim "$arch"; arch=$REPLY
printf '%s\t%s\t%s\t%s\t%s\n' "$name" "$cur" "$avail" "$repo" "$arch"
done
zypper already gives plain names (arch is a separate attribute/column).
Locks (see section 6): after zypper addlock su-dummy-a su-dummy-b, lu shows only su-dummy-c; lu --all shows
vl | su-dummy | su-dummy-a | 1.0-1 | 2.0-1 | noarch
vl | su-dummy | su-dummy-b | 1.0-1 | 2.0-1 | noarch
v | su-dummy | su-dummy-c | 1.0-1 | 2.0-1 | x86_64
Vendor change: package whose candidate has another vendor (Vendor: FooCorp installed, BarCorp in repo) is hidden by lu, shown by lu --all, and zypper up prints There is an update candidate for 'su-dummy-v' from vendor 'BarCorp', while the current vendor is 'FooCorp'. Use 'zypper install su-dummy-v-2.0-1.noarch' to install this candidate. followed by Nothing to do. (the update is skipped).
3. One-line description and full info dump
Fixture packages carry Summary: skeptical-updater dummy su-dummy-a (v1.0|v2.0) and a two-paragraph description (blank line between paragraphs) so installed vs candidate and paragraph handling are distinguishable.
3.1 Installed version (all backends): rpm
$ rpm -q --qf '%{SUMMARY}\n%{DESCRIPTION}\n' su-dummy-a
skeptical-updater dummy su-dummy-a (v1.0)
Dummy package su-dummy-a version 1.0 for skeptical-updater tests.
Second paragraph of su-dummy-a 1.0 (blank line above on purpose).
$ rpm -qi su-dummy-a # Name/Version/Release/Architecture/Install Date/Group/Size/License/Signature/Source RPM/Build Date/Build Host/Summary/Description/Distribution
3.2 Candidate (new) version
DNF: dnf info prints both an Installed Packages and an Available Packages block (dnf4 Description : first line + : continuation lines; dnf5 info adds Epoch, From repository : <unknown>, Vendor). dnf info --upgrades PKG (dnf4 and dnf5) prints only the candidate block (Available Upgrades / Available upgrades), as does dnf repoquery -i --upgrades PKG. For machine use take repoquery with a queryformat (same on dnf4/dnf5):
dnf -q repoquery --upgrades --latest-limit=1 --queryformat $'%{summary}\n%{description}\n' su-dummy-a
# -> line 1 summary, then the description with paragraph breaks intact; empty output => no candidate => fall back to rpm -q
dnf4 appends one extra blank line (harmless; $(...) strips it). Verified output (both dnf4 and dnf5): skeptical-updater dummy su-dummy-a (v2.0) / Dummy package su-dummy-a version 2.0 ... / blank / Second paragraph of su-dummy-a 2.0 ....
Caveat: dnf4 repoquery --upgrades does not honour versionlock, so the "candidate" may be one the lock blocks; for the summary only this is irrelevant.
ZYPPER: zypper info PKG already shows the best candidate (and marks the installed state), falling back to the installed package when no repo has it:
$ zypper -q -n info su-dummy-a
Information for package su-dummy-a:
-----------------------------------
Repository : su-dummy
Name : su-dummy-a
Version : 2.0-1
Arch : noarch
Vendor :
Installed Size : 15 B
Installed : Yes
Status : out-of-date (version 1.0-1 installed)
Source package : su-dummy-a-2.0-1.src
Summary : skeptical-updater dummy su-dummy-a (v2.0)
Description :
Dummy package su-dummy-a version 2.0 for skeptical-updater tests.
Second paragraph of su-dummy-a 2.0 (blank line above on purpose).
zypper info nonexistentpkg -> package 'nonexistentpkg' not found. exit=104. --xmlout info is useless (one big <message> blob). -q does not remove the Information for package banner. Description lines are indented 4 spaces; paragraph breaks are real empty lines and the block also ends with an empty line (and a second empty line before the next Information for package banner when several names are given), so a plain /^Description/,/^$/ sed range truncates after the first paragraph. State machine (proven):
zypper --non-interactive --quiet info "$pkg" 2>/dev/null | {
indesc=0; pend=
while IFS= read -r l; do
if [ $indesc = 1 ]; then
case $l in ' '*) printf '%s%s\n' "$pend" "${l# }"; pend= ;; # text line: flush held blank lines first
'') pend+=$'\n' ;; # blank: paragraph break OR end of block - decide on next line
*) break ;; esac # anything else = next block
else
case $l in Summary*:*) [ -z "${sum:-}" ] && { sum=${l#*: }; printf '%s\n' "$sum"; } ;; Description*:*) indesc=1 ;; esac
fi
done; }
# if zypper info exits non-zero (104) fall back to: rpm -q --qf '%{SUMMARY}\n%{DESCRIPTION}\n' "$pkg"
The XML of list-updates/update --dry-run (sections 2.2 and 7) also carries the candidate summary+description for upgradable packages.
su_describe PKG in lib-rpm.sh wraps all of this; proof.sh asserts candidate summary skeptical-updater dummy su-dummy-a (v2.0), the preserved blank line, and installed-only su-dummy-d (v1.0) on all 7 images.
4. Installed reverse dependencies
Candidates and what they really do:
| command | result on EL9 for bash |
verdict |
|---|---|---|
rpm -q --whatrequires bash |
1 package (ca-certificates) - matches only a Requires entry literally named bash |
misses file deps (/bin/sh, /usr/bin/bash), sonames, virtual provides |
dnf repoquery --installed --whatrequires bash |
52 (dnf4 default = --alldeps: all provides and files; --exactdeps drops to 1) |
correct, dnf only; works on dnf4 and dnf5 (fedora: 42) |
zypper search --installed-only -t package --requires bash |
= naive (name only); --requires-pkg bash uses all provides but over-reports on Tumbleweed (43 hits vs 4 direct: includes packages that depend via /bin/sh -> bash-sh -> bash) |
not the reference |
pure rpm su_rdeps (below) |
EL9 52, EL10 40, Fedora 42, Tumbleweed 4 direct (69 recursive), Leap 5 direct (67 recursive) | chosen; identical to the dnf answer wherever dnf can answer |
Why name != capability: on Tumbleweed /bin/sh is provided by bash-sh, not bash (rpm -q --whatprovides /bin/sh -> bash-sh), so aaa_base, binutils, ... are direct dependents of bash-sh and only transitive dependents of bash; su_rdeps_recursive bash (69) is a superset of zypper's 43 (comm -23 empty).
Algorithm: (1) the set of names the package is known by = [%{PROVIDENAME}\n] + rpm -ql (owned files, so file deps like /bin/sh match); (2) one table of every installed package's requirements capability<TAB>package from rpm -qa --qf '[%{REQUIRENAME}\t%{=NAME}\n]' with rpmlib(...) dropped and rich deps expanded; (3) join the two on the capability, drop the package itself. Mandatory trick %{=NAME} (a bare %{NAME} inside [...] makes rpm print error: incorrect format: array iterator used with different sized arrays for roughly half of the packages; reproduced on rpm 4.16.1.3, 4.19.1.1, 4.20.1, 6.0.2 - and the same error appears for [%{PROVIDENAME}\t%{NAME}\n]).
su_req_table() { # "capability<TAB>requiring-package", sorted; rich deps expanded via su_cap_names (section 5)
local out=$1 cap name t all; all=$(mktemp)
rpm -qa --qf '[%{REQUIRENAME}\t%{=NAME}\n]' > "$all"
{ grep -v -e '^rpmlib(' -e '^(' "$all" || true
{ grep -e '^(' "$all" || true; } | while IFS=$'\t' read -r cap name; do
su_cap_names <<<"$cap" | while IFS= read -r t; do printf '%s\t%s\n' "$t" "$name"; done
done
} | LC_ALL=C sort -u > "$out"; rm -f "$all"
}
su_rdeps() { # direct installed dependents of PKG, plain names, sorted
local pkg=$1 tab caps own=0
if [ -n "${SU_REQ_TAB:-}" ] && [ -s "$SU_REQ_TAB" ]; then tab=$SU_REQ_TAB; else tab=$(mktemp); su_req_table "$tab"; own=1; fi
caps=$(mktemp)
{ rpm -q --qf '[%{PROVIDENAME}\n]' "$pkg"; rpm -ql "$pkg" | grep -v -e '^(contains no files)$'; } | LC_ALL=C sort -u > "$caps"
LC_ALL=C join -t $'\t' "$caps" "$tab" | cut -f2 | LC_ALL=C sort -u | grep -v -x -F -e "$pkg" || true
rm -f "$caps"; [ "$own" = 1 ] && rm -f "$tab"; return 0
}
su_rdeps_recursive PKG... (also in the lib) loops su_rdeps with one shared table. Proof assertions: su_rdeps su-dummy-b = su-dummy-a su-dummy-d; su_rdeps su-dummy-c = su-dummy-d (c is only required through the rich dep (su-dummy-c if su-dummy-b)); su_rdeps su-dummy-d = empty; su_rdeps(bash) equals dnf repoquery --installed --whatrequires bash (EL9: 52 = 52, EL10: 40 = 40, Fedora/dnf5: 42 = 42). Cost: ~60-130 ms on these minimal images.
Plain names: %{NAME} has no arch/version; multilib duplicates collapse through sort -u. dnf repoquery ... --queryformat $'%{name}\n' | sed '/^$/d' is the dnf-native equivalent (dnf4 blank lines).
5. Recursive dependency closure restricted to installed packages
dnf repoquery --installed --requires --resolve --recursive --queryformat $'%{name}\n' PKG: works on dnf4 (alma9/alma10); dnf5 rejects it:Unknown argument "--resolve" for command "repoquery". ... The argument is available for commands: download(rc=2). Also dnf-only.- Chosen: pure-rpm breadth-first search, identical on dnf and zypper distros. Per BFS level:
rpm -q --qf '[%{REQUIRENAME}\n]' <frontier>-> filter ->rpm -q --whatprovides --qf '%{NAME}\n' <caps>. Input handling (all verified):
| dependency kind | example | handling |
|---|---|---|
rpmlib(...) |
rpmlib(CompressedFileNames) |
dropped (not a package; --whatprovides would just say "no package provides") |
| file deps | /bin/sh, /usr/bin/perl |
looked up with rpm -q --whatprovides /bin/sh (file owner or explicit Provides: bash on EL, bash-sh on SUSE) |
| soname / virtual | libc.so.6(GLIBC_2.34)(64bit), perl(Foo::Bar), config(bash) |
kept verbatim (REQUIRENAME already carries no version); contain parentheses but do not start with ( |
| versioned | su-dummy-b >= 1.0 |
%{REQUIRENAME} returns only su-dummy-b; the constraint is ignored (installed state is already consistent) |
| rich / boolean | (su-dummy-c if su-dummy-b), (glibc-gconv-extra(x86-64) = 2.34-275.el9_8 if redhat-rpm-config), (account-utils or shadow-pw-mgmt = 4.20.3), (annobin if (gcc or clang)) |
su_cap_names tokenises with paren-depth tracking: members kept, comparison operators and the version token after them dropped, and/or/with/without ignored, the condition after if/unless dropped (until else or the closing paren). Conditions are not evaluated -> safe superset |
| unresolvable | nothing installed provides it | no package provides X lines are filtered out (rpm prints them on stdout, exit 1) |
su_cap_names unit results (proven, EL9):
rpmlib(CompressedFileNames) =>
/bin/sh => /bin/sh
libc.so.6(GLIBC_2.34)(64bit) => libc.so.6(GLIBC_2.34)(64bit)
(su-dummy-c if su-dummy-b) => su-dummy-c
(glibc-gconv-extra(x86-64) = 2.34-275.el9_8 if redhat-rpm-config) => glibc-gconv-extra(x86-64)
(foo >= 1.0-2 or (bar and baz)) => foo bar baz
((a or b) if c) => a b
(a if b else c) => a c
(a unless (b and c) else d) => a d
(libx.so.1()(64bit) if foo) => libx.so.1()(64bit)
(annobin if (gcc or clang)) => annobin
(account-utils or shadow-pw-mgmt = 4.20.3) => account-utils shadow-pw-mgmt
Why the condition must be dropped: with the naive "all tokens" version, glibc on EL9 (rich dep (glibc-gconv-extra ... if redhat-rpm-config)) appeared to depend on redhat-rpm-config, which dragged rpm, rpm-build, binutils, gdb-minimal and the perl modules into the closure of filesystem (167 packages instead of ~15).
su_closure() { # su_closure PKG... -> installed packages PKG... need transitively (plain names, sorted, roots excluded)
local -A seen=(); local -a frontier=("$@") caps provs next out=(); local p
for p in "$@"; do seen[$p]=1; done
while [ "${#frontier[@]}" -gt 0 ]; do
mapfile -t caps < <(rpm -q --qf '[%{REQUIRENAME}\n]' "${frontier[@]}" 2>/dev/null | sort -u | su_cap_names | sort -u)
[ "${#caps[@]}" -gt 0 ] || break
mapfile -t provs < <(rpm -q --whatprovides --qf '%{NAME}\n' "${caps[@]}" 2>/dev/null | grep -v -e '^no package provides ' | sort -u)
next=()
for p in "${provs[@]}"; do [ -n "$p" ] && [ -z "${seen[$p]:-}" ] && { seen[$p]=1; next+=("$p"); out+=("$p"); }; done
frontier=("${next[@]}")
done
[ "${#out[@]}" -gt 0 ] && printf '%s\n' "${out[@]}" | sort -u; return 0
}
Proof (proof.sh, every image): su_closure su-dummy-a = su-dummy-b; su_closure su-dummy-d contains su-dummy-a su-dummy-b su-dummy-c glibc and the provider of /bin/sh (bash on EL, bash-sh on SUSE), no rpmlib, not the root. On dnf4 the result equals dnf repoquery --installed --requires --resolve --recursive: alma9 18 = 18, alma10 17 = 17 (dnf lists bash in its own closure because of the filesystem <-> bash cycle; ours excludes roots by design). Sample (alma10): almalinux-gpg-keys almalinux-release almalinux-repos basesystem bash filesystem glibc glibc-common glibc-gconv-extra glibc-minimal-langpack libgcc ncurses-base ncurses-libs setup su-dummy-a su-dummy-b su-dummy-c tzdata; Tumbleweed closure(su-dummy-d) = bash bash-sh filesystem glibc libncurses6 libreadline8 su-dummy-a su-dummy-b su-dummy-c system-user-root terminfo-base. Timing: 70-130 ms.
6. Per-run exclusion
6.1 DNF (--exclude, verified on dnf4 4.14/4.20 and dnf5 5.4.3)
| form | dnf4 | dnf5 |
|---|---|---|
dnf -y upgrade --exclude=su-dummy-a,su-dummy-b (comma list) |
ok | ok (documented as -x package,..., --exclude=package,..., global option) |
--exclude=a --exclude=b (repeated) |
ok | ok |
-x a -x b |
ok | ok |
globs --exclude='su-dummy-[ab]' |
ok | ok |
space separated in one value --exclude='a b' |
ok (repoquery) | ok (repoquery) |
works on check-update, repoquery, upgrade |
yes | yes |
--disableexcludes=all to see them again |
yes | not available |
Result of dnf -y upgrade --exclude=su-dummy-a,su-dummy-b 'su-dummy-*' (dnf4 and dnf5): su-dummy-a=1.0 su-dummy-b=1.0 su-dummy-c=2.0 (both syntaxes). Excluded packages disappear from check-update too (only su-dummy-c left).
Edge cases (verified):
- all packages matched by an explicit pattern are excluded: dnf4
No match for argument: su-dummy-*/Error: No packages marked for upgrade.exit 1; dnf5Argument 'su-dummy-*' matches only excluded packages.exit 1. With no package arguments and nothing to do both printNothing to do.exit 0. - an excluded dependency blocks the upgrade of its dependant (
su-dummy-p2.0Requires: su-dummy-q >= 2.0,--exclude=su-dummy-q):- dnf4 (EL default
best = 1,skip_if_unavailable = 0in the dnf4 config dump):Error: Problem: package su-dummy-p-2.0-1.noarch from su-dummy requires su-dummy-q >= 2.0, but none of the providers can be installed ... package su-dummy-q-2.0-1.noarch from su-dummy is filtered out by exclude filteringexit 1;--skip-brokendoes not help (same error);--nobest->Skip 1 Package ... Nothing to do.exit 0. - dnf5:
Skipping packages with broken dependencies: su-dummy-p noarch 2.0-1 su-dummythenNothing to do.exit 0 (no flag needed;--skip-brokenis rejected onupgrade:Unknown argument "--skip-broken" for command "upgrade"). => exit codes cannot tell "everything upgraded" from "some skipped because of the exclusion": re-runsu_upgradableafterwards (the proof does) and report what is left.
- dnf4 (EL default
6.2 ZYPPER (no --exclude)
Findings that drive the recipe:
zypper update/duphave no exclude option. Locks (zypper addlock NAME..., glob via*/?) are honoured by the solver: a fullzypper update(no package args) silently skips locked packages; a lock ona,b-> onlycis upgraded.- An explicit
zypper update LOCKEDor an update whose dependency is locked ends in an interactive "Problem" which--non-interactiveanswers with cancel: exit 4:while a full update with a locked dependency of a newer package just skips both:Problem: 1: Detailed information: - conflicting requests Solution 1: Following actions will be done: remove lock to allow installation of su-dummy-a-2.0-1.noarch[su-dummy] ... Solution 2: do not ask to install a solvable providing su-dummy-a.noarch = 2.0-1 Choose from above solutions by number or skip, retry or cancel [1/2/s/r/c/d/?] (c): c exit=4The following 2 package updates will NOT be installed: su-dummy-p su-dummy-q(exit 0),p/qstay 1.0. - Naming the packages explicitly instead (
zypper update a b c) is not an exclusion mechanism:zypper update su-dummy-p(p 2.0 needs q >= 2.0) also upgradedsu-dummy-q(dependency pull) - verified. - Lock file
/etc/zypp/locks(path =locksfile.pathin zypp.conf; created on firstaddlock; on Tumbleweed/etc/zypp/zypp.confdoes not exist at all, only/usr/etc/zypp/zypp.conf):$ cat -A /etc/zypp/locks $ type: package$ match_type: glob$ case_sensitive: on$ solvable_name: su-dummy-a$ $zypper lockstable:# | Name | Type | Repository | Comment;zypper -q llprints the same. zypper removelock NAMEis not a safe undo: it deletes every lock that matches NAME (a pre-existing user lock of the same name is destroyed - verified), and it leaves a 0-byte/etc/zypp/locksbehind even when no file existed before (verified:-rw-r--r-- 1 root root 0).
Recipe A (chosen): private zypp.conf, nothing persisted - su_zypper_excluding
libzypp reads $ZYPP_CONF instead of /etc/zypp/zypp.conf. Copy the current config (fall back to /usr/etc/zypp/zypp.conf on Tumbleweed), append [main] locksfile.path = <tmp>/locks, seed <tmp>/locks with a copy of the real locks, addlock the exclusions there, run, rm -rf the temp dir. The real lock set is never opened for writing, so there is nothing to restore (Ctrl-C safe, concurrent-run safe).
su_zypper_excluding() { # su_zypper_excluding NAME... -- zypper-args... e.g. ... a b -- update | ... a -- --xmlout dup --dry-run
local -a ex=(); local d src locks rc
while [ $# -gt 0 ] && [ "$1" != -- ]; do ex+=("$1"); shift; done; [ "${1:-}" = -- ] && shift
d=$(mktemp -d) || return 1
src=/etc/zypp/zypp.conf; [ -r "$src" ] || src=/usr/etc/zypp/zypp.conf
locks=$(sed -n 's/^[[:space:]]*locksfile\.path[[:space:]]*=[[:space:]]*//p' "$src" | tail -n1); : "${locks:=/etc/zypp/locks}"
cp "$src" "$d/zypp.conf" || { rm -rf "$d"; return 1; }
if [ -e "$locks" ]; then cp "$locks" "$d/locks"; else : > "$d/locks"; fi
printf '\n[main]\nlocksfile.path = %s\n' "$d/locks" >> "$d/zypp.conf" # second [main] block: last assignment wins (verified)
if [ "${#ex[@]}" -gt 0 ]; then
ZYPP_CONF=$d/zypp.conf zypper --non-interactive --quiet addlock "${ex[@]}" >/dev/null || { rm -rf "$d"; return 1; }
fi
ZYPP_CONF=$d/zypp.conf zypper --non-interactive "$@"; rc=$?
rm -rf "$d"; return $rc
}
Proof (Tumbleweed dup and Leap update, proof.sh section 6): su_upgrade --exclude su-dummy-a --exclude su-dummy-b -> exit 0, su-dummy-a=1.0 su-dummy-b=1.0 su-dummy-c=2.0; /etc/zypp/locks unchanged (absent before, absent after); no leftover /tmp/tmp.*; su_upgradable still lists a and b afterwards; the following unrestricted su_upgrade upgrades them (a=2.0 b=2.0 c=2.0).
Recipe B (alternative with exact restore of the real locks file) - su_zypper_excluding_snapshot
Use this only if the real locks must be the ones libzypp sees (e.g. another tool reads /etc/zypp/locks concurrently - note it is then visible to that tool for the duration).
su_zypper_excluding_snapshot() ( # subshell => traps are local
ex=(); while [ $# -gt 0 ] && [ "$1" != -- ]; do ex+=("$1"); shift; done; [ "${1:-}" = -- ] && shift
src=/etc/zypp/zypp.conf; [ -r "$src" ] || src=/usr/etc/zypp/zypp.conf
locks=$(sed -n 's/^[[:space:]]*locksfile\.path[[:space:]]*=[[:space:]]*//p' "$src" | tail -n1); : "${locks:=/etc/zypp/locks}"
bak=$(mktemp) || exit 1; had=0; restored=0
if [ -e "$locks" ]; then cp -p -- "$locks" "$bak" || exit 1; had=1; fi # snapshot: bytes + mode + mtime, or "absent"
_restore() { [ "$restored" = 1 ] && return 0; restored=1
if [ "$had" = 1 ]; then cp -p -- "$bak" "$locks"; else rm -f -- "$locks"; fi; rm -f -- "$bak"; }
trap '_restore' EXIT; trap '_restore; exit 130' INT; trap '_restore; exit 143' TERM HUP
if [ "${#ex[@]}" -gt 0 ]; then zypper --non-interactive --quiet addlock "${ex[@]}" >/dev/null || exit 1; fi
zypper --non-interactive "$@"
)
Proof (proof.sh section 10, Tumbleweed; Leap likewise): with a pre-existing user lock on su-dummy-d (comment "user lock"): cksum+mtime identical after a run (834034310 97 1791570407 before and after); user lock still present, temporary locks gone; a user lock on the same name as an exclusion survives (zypper removelock would have deleted it - asserted); file absent before => absent after; run killed with SIGTERM mid-way (shadowed zypper that kills its parent subshell) -> exit 143 and locks restored byte-identical.
Recipe C (rejected): explicit package list
zypper update <list of everything except X>: dependencies pull X anyway (shown above) and any name that is already newest prints No update candidate for .... Rejected.
7. Non-interactive upgrade, exit codes, plan preview
7.1 DNF
dnf -y upgrade [--exclude=a,b] [--setopt=skip_if_unavailable=0]- Exit codes (4 and 5): 0 success and "Nothing to do." (also
Dependencies resolved. Nothing to do. Complete!on dnf4); 1 any error (unknown package argumentNo match for argument: X+Error: No packages marked for upgrade., dependency problem, dead repo withskip_if_unavailable=0); 100 exists only forcheck-update;--assumeno(dry run) ends with exit 1 (Operation aborted./Operation aborted by the user.) after printing the transaction table. -qremoves the progress chatter but dnf5 still printsNothing to do.; dnf5 printsWarning: skipped OpenPGP checks for 3 packages from repository: su-dummyfor gpgcheck=0 repos.- On the stale
rockylinux:9(9.3) the first-ytransaction imports the Rocky 9 GPG key automatically (Importing GPG key 0x350D275D: ... From : /etc/pki/rpm-gpg/RPM-GPG-KEY-Rocky-9) - harmless with-y, but a non--ypath would prompt. - There is no machine-readable transaction output in dnf4.
dnf5 check-upgrade --jsonexists (dnf5 only), not used.
7.2 ZYPPER
Detection (verified): /etc/os-release ID=opensuse-tumbleweed (Tumbleweed) -> dup; ID=opensuse-leap (Leap 16.0) -> update; ID_LIKE is opensuse suse / suse opensuse on both, so test ID, not ID_LIKE. (opensuse-slowroll is rolling too and treated like Tumbleweed in the lib - not tested; MicroOS/Aeon/Leap Micro are transactional and out of scope.)
su_zypper_verb() { case "$(su_os_id)" in opensuse-tumbleweed|opensuse-slowroll) echo dup ;; *) echo update ;; esac; }
zypper --non-interactive dup # prints: Warning: You are about to do a distribution upgrade with all enabled repositories. ... Computing distribution upgrade...
zypper --non-interactive update
Observed exit codes:
| situation | exit |
|---|---|
success / "Nothing to do." (update and dup; No update candidate for 'su-dummy-a-2.0-1.noarch'. The highest available version is already installed. for explicit names) |
0 |
list-updates with or without updates; patch-check with 0 patches |
0 |
repo cannot be refreshed (Could not refresh the repositories because of errors.) |
4 |
| unknown repository name | 3 |
unresolved Problem answered by --non-interactive (default = cancel): explicit update of a locked package; dup with a vendor/"distupgrade repository" problem (the installed su-dummy-v-1.0-1.noarch does not belong to a distupgrade repository and must be replaced / Solution 1 install from vendor BarCorp / Solution 2 keep obsolete) |
4 |
info/remove of a name nothing provides (No provider of 'nonexistent-xx' found.) |
104 |
update --dry-run / dup --dry-run (even with Continue? [y/n/...] (y): y echoed) |
0 (dry run, nothing changed) |
update of a package listed in /etc/zypp/needreboot(.d) |
0, plus the text Since the last system boot core libraries or services have been updated. Reboot is suggested to ensure that your system benefits from these updates. |
Not reproduced here (documented zypper(8) semantics, no fixture can trigger them without real SUSE patches): 100/101 (patch-check: updates / security updates needed), 102 (reboot needed after a patch with the reboot flag), 103 (zypper itself/libzypp was updated, restart zypper), 106 (some repos skipped), 107 (an rpm scriptlet failed). The tool should treat any non-zero exit as failure to be shown, 0 as success, and treat 100-103 as "success with advice" if it ever sees them.
dup vs update on Tumbleweed: in this snapshot update --dry-run and dup --dry-run computed the same plan (4 upgrades openSUSE-build-key su-dummy-a su-dummy-b su-dummy-c, 11 new packages krb5 libbrotlicommon1 ... libverto1, 1 removal libcurl-mini4 - i.e. a package swap that list-updates never shows). The documented difference shows with vendor changes: list-updates hides the candidate, update skips it with a message, dup raises the Problem above (exit 4 under -n) - so Tumbleweed must use dup, and a skeptical tool must expect exit 4 there and show the Problem text (re-run without -n... or --dry-run text) instead of retrying.
7.3 Plan preview (what would REALLY change: new deps, removals, obsoletes)
list-updates/check-update never show newly installed dependencies, removals or obsolete-replacements. su_plan [--exclude N]... (in lib-rpm.sh) prints section<TAB>name<TAB>old<TAB>new<TAB>repo<TAB>arch<TAB>replaces, honouring exclusions:
- zypper:
zypper --non-interactive --xmlout dup|update --dry-runyields exact data (no table parsing):(the<install-summary download-size="19047" space-usage-diff="0" ... packages-to-change="3" need-restart="0" need-reboot="0"> <to-upgrade> <solvable type="package" name="su-dummy-a" edition="2.0-1" arch="noarch" repository="su-dummy" edition-old="1.0-1" arch-old="noarch" summary="..."><description>...</description></solvable> ...</to-upgrade><to-install>...</to-install><to-remove>...</to-remove></install-summary><prompt>and<progress>elements that follow are ignored;need-reboot/need-restartattributes are available). - dnf:
dnf --assumeno upgradetable parse - section headers are col-0 lines ending in:(Upgrading:,Installing:,Installing dependencies:,Installing weak dependencies:,Removing:,Removing unused dependencies:,Skipping packages with broken dependencies:...), rows start with one space, parsing stops atTransaction Summary. dnf4 prints noreplacingline for plain upgrades (old version then comes fromrpm -q), dnf5 printsreplacingrows with0:-prefixed epochs.
Proof section 9 (builds su-dummy-new with Obsoletes: su-dummy-old < 2 + Provides: su-dummy-old = 2): with --exclude su-dummy-a:
zypper (tumbleweed) dnf4 (alma9) dnf5 (fedora)
upgrade su-dummy-b 1.0-1 2.0-1 su-dummy noarch - install su-dummy-new - 1.0-1 su-dummy noarch su-dummy-old upgrade su-dummy-b 1.0-1 2.0-1 su-dummy noarch -
upgrade su-dummy-c 1.0-1 2.0-1 su-dummy x86_64 - upgrade su-dummy-b 1.0-1 2.0-1 su-dummy noarch - upgrade su-dummy-c 1.0-1 2.0-1 su-dummy x86_64 -
install su-dummy-new - 1.0-1 su-dummy noarch - upgrade su-dummy-c 1.0-1 2.0-1 su-dummy x86_64 - install su-dummy-new - 1.0-1 su-dummy noarch su-dummy-old
remove su-dummy-old - 1.0-1 @System noarch -
and su_upgradable shows 0 rows for su-dummy-new on all of them (the blind spot).
8. Remove / purge
DNF (dnf4 and dnf5 identical in effect)
clean_requirements_on_removedefault 1 on both (dnf4:dnf config-manager --dump->clean_requirements_on_remove = 1; dnf5 behaves the same: its plan showsRemoving unused dependencies: su-dummy-s noarch 0:1.0-1 su-dummy).$ dnf -y install su-dummy-r # r Requires s; both installed by dnf $ dnf -y remove su-dummy-r # dnf4: "Erasing: su-dummy-r-1.0-1.noarch ... Erasing: su-dummy-s-1.0-1.noarch" exit=0 (both removed) $ dnf -y remove --setopt=clean_requirements_on_remove=0 su-dummy-r # only r removed; s stays $ dnf -y autoremove # then removes the leftover s- Only dependencies installed by dnf as dependencies are cleaned.
su-dummy-b(installed by plainrpm -Uvh) stays afterdnf -y remove su-dummy-aon both majors. dnf remove nonexistent-xxexits 0 on dnf4 (Nothing to do.) and dnf5 (No packages to remove for argument: nonexistent-xx/Nothing to do.) -> confirm withrpm -qafterwards.- dnf4 refuses protected packages:
dnf -y remove dnf->Error: Problem: The operation would result in broken dependencies for the following protected packages: yum ...exit 1. - There is no
apt purgeequivalent in the rpm world;%confighandling on removal (*.rpmsave) was not exercised here.
ZYPPER
zypper --non-interactive remove --clean-deps PKG(-u). Plainremoveleaves the dependency: afterzypper -n install su-dummy-r(pullssu-dummy-s),zypper -n remove su-dummy-r-> only r removed,rpm -q su-dummy-sstill present; with--clean-deps->2 packages to remove.(1/2) Removing: su-dummy-r ... (2/2) Removing: su-dummy-s, exit 0.- Which deps are "unneeded" is tracked by zypper in
/var/lib/zypp/AutoInstalled; packages installed with plainrpm(the fixture'ssu-dummy-b) are never cleaned:remove --clean-deps su-dummy-aleftsu-dummy-binstalled. - Unknown name:
'nonexistent-xx' not found in package names. Trying capabilities....No provider of 'nonexistent-xx' found./Nothing to do.exit 104. proof.shsection 7 asserts all of the above throughsu_removeon every image.
9. Base image facts
See section 0.4 for the table (bash versions, sudo/su/python3/awk/find/xmllint presence), bats (EPEL on EL9/EL10; main repos on openSUSE) and rpm-build/createrepo_c availability. Short version: bash is present everywhere (5.1-5.3), sudo nowhere, python3 only on EL, awk/find/xargs missing on Tumbleweed, find/xargs missing on Leap, su missing on alma10; bats needs EPEL on Alma/Rocky; rpm-build and createrepo_c install from the base repos everywhere.
10. E2E fixture - discovery/rpm/fixture.sh
docker run --rm -i -v <project>/discovery/rpm:/d:ro <image> bash /d/fixture.sh # SU_FIXTURE_EXTRAS=1 for the extras
What it does (single script, detects zypper first, then dnf/yum; refuses to run as non-root):
- installs
rpm-build+createrepo_c(dnf:dnf -y -q install --setopt=install_weak_deps=False ...; zypper:zypper --non-interactive --quiet install --no-recommends ...) - no EPEL/extra repo needed anywhere; - generates minimal specs and builds with
rpmbuild -bb --define "_topdir /var/tmp/su-fixture":su-dummy-a(noarch,Requires: su-dummy-b),su-dummy-b(noarch),su-dummy-c(arch-specific,%global debug_package %{nil}, so the.x86_64suffix path is exercised), versions 1.0-1 and 2.0-1, one file each,Summary: skeptical-updater dummy <name> (v<ver>), two-paragraph%description; - installs the three 1.0 packages with
rpm -Uvh --oldpackage --replacepkgs(distro-agnostic; re-running the script resets to the 1.0 state); - publishes only the 2.0 packages in
/var/lib/su-dummy-repoand runscreaterepo_c; - registers the repo: dnf
/etc/yum.repos.d/su-dummy.repo(baseurl=file:///var/lib/su-dummy-repo,gpgcheck=0,repo_gpgcheck=0,metadata_expire=0) +dnf -q makecache --repo su-dummy; zypperzypper addrepo --no-gpgcheck --no-check --refresh file:///var/lib/su-dummy-repo su-dummy+zypper refresh su-dummy; - verifies that
check-update/list-updateslistssu-dummy-a,-b,-c, else exits 1.SU_FIXTURE_EXTRAS=1additionally installssu-dummy-d1.0 (not upgradable;Requires: su-dummy-a,/bin/sh,su-dummy-b >= 1.0,(su-dummy-c if su-dummy-b),rpmlib(CompressedFileNames)) for rdeps/closure tests and publishes (not installs)su-dummy-r(Requiressu-dummy-s) /su-dummy-sfor remove tests.
Side effect worth knowing: installing rpm-build pulls redhat-rpm-config etc. on EL, which flips the condition of rich dependencies such as glibc's (glibc-gconv-extra if redhat-rpm-config); the closure code must therefore not treat conditions as dependencies (it does not).
Real output (fixture.sh standalone, each run exit 0):
[fixture] backend=dnf # almalinux:9
[fixture] build tools installed in 14s
[fixture] rpmbuild done in 0s
[fixture] repo refresh in 0s
[fixture] OK: su-dummy-{a,b,c} 1.0 installed and 2.0 offered by repo 'su-dummy' (total 15s, since start 15s)
and the same OK line (totals 8-16 s) on almalinux:10, rockylinux:9, rockylinux/rockylinux:10, quay.io/rockylinux/rockylinux:10, opensuse/tumbleweed (backend=zypper, 13 s), opensuse/leap (backend=zypper, 14 s), and fedora:latest (dnf5, 16 s). Also verified: a second run on the same container is a clean reset (proof.sh section 8).
proof.sh results (final run, all rc=0): almalinux:9 44 PASS, almalinux:10 44, rockylinux:9 44, rockylinux/rockylinux:10 44, opensuse/tumbleweed 53, opensuse/leap 53, fedora:latest 43 (dnf5). (zypper images run two extra sections: lock snapshot/restore.) One transient failure was seen and is not a code issue: the first rockylinux:9 run failed section 1 (su_refresh exit 1: Rocky extras mirror returned a broken repomd.xml); re-run passed (the refresh gate did exactly what it is for).
11. Rough timings (host with fast network, Docker 29.8.2; wall clock, one run each, sequential)
| image | docker pull |
cold metadata refresh (first dnf makecache / zypper refresh in the container) |
warm refresh | fixture.sh wall (of which tool install) |
proof.sh wall |
|---|---|---|---|---|---|
| almalinux:9 | 4.7 s | 9.5 s | 1.6 s | 15.1 s (14 s) | 29.5 s |
| almalinux:10 | 4.5 s | 5.9 s | 1.9 s | 11.5 s (9 s) | 25.5 s |
| rockylinux:9 (9.3 lib image, 96 pending upgrades) | 6.1 s | 8.4 s | 1.1 s | 16.6 s (15 s) | 29.8 s |
| rockylinux/rockylinux:10 | 5.9 s | 3.3 s | 1.6 s | 10.5 s (8 s) | 21.0 s |
| quay.io/rockylinux/rockylinux:10 | 1.0 s (layers cached) | 4.2 s | - | 8.1 s (7 s) | not run (same image) |
| opensuse/tumbleweed | 3.4 s | 5.9 s | 0.9 s | 14.3 s (12 s) | 32.7 s |
| opensuse/leap (16.0) | 3.5 s | 6.3 s | 0.6 s | 14.4 s (12 s) | 34.3 s |
| fedora:latest (dnf5, reference) | n/a | 5.3 s | 0.3 s | 16.0 s (15 s) | 29.2 s |
Inside proof.sh (warm containers): forced refresh (su_refresh) 0.5-2.6 s (up to 7 s on alma when the mirror is slow), su_upgradable 0.3-0.8 s, su_closure 0.07-0.13 s, su_rdeps ~0.06-0.13 s, dummy upgrade 0.1-0.3 s (dnf) / 1.6-7 s (zypper, because a full update/dup also upgrades real packages). "Warm refresh" for dnf is a plain dnf makecache without --refresh (it still revalidates); --refresh forces a full download and is the number to budget for a skeptical run (3-9 s cold).
12. Gotcha checklist for the implementer
- Never assume dnf5 on EL10; support dnf4 + dnf5 through one parser (
check-update, 3 fields, tolerate blank line,Upgradesheader,Obsoleting Packagessection). - Always
--setopt=skip_if_unavailable=0(dnf5 otherwise hides dead repos,-qhides the warning) and gate on the refresh exit status; do not trustcheck-updatealone on dnf 4.14. - dnf
repoquery --queryformat: pass real TAB/newline ($'%{name}\t%{evr}\n'), thensed '/^$/d'(dnf4 adds blank lines, dnf5 expands only\n,\tstays literal).--qfand--queryformatboth work on both majors. - dnf4
repoquery --upgradesanddnf list --upgradesignore versionlock;check-updatedoes not. dnf5 has no--disableexcludes. check-update/list-updateshide obsoleters, new dependencies and removals. Usesu_planwhen the user must see the real transaction; compare list vs result afterwards because exit codes do not reveal skipped packages (dnf5 skips broken-dependency packages with exit 0; dnf4 fails with exit 1 unless--nobest).rpm --qf '[%{=NAME}...]', never%{NAME}inside[...].rpm -q --whatrequires pkgis name-only; use provides + owned files (su_rdeps). Rich deps: members only, conditions dropped.- zypper XML: all
<update>elements are on one line, descriptions multi-line, no xmllint/python on the images, trailing-newline trap withtr+sed+read. - zypper
list-updatesexits 0 even with updates; never look for 100. Locked and vendor-change candidates are hidden (lu --allshows them). - zypper has no
--exclude: useZYPP_CONFprivate locks (or snapshot/restore of the real locks file;removelockis not an undo). Tumbleweed has no/etc/zypp/zypp.conf, only/usr/etc/zypp/zypp.conf. - Tumbleweed ->
dup, Leap ->update, decided fromID=in/etc/os-release;dup/updateunder--non-interactiveturn unresolved Problems into exit 4 (cancel) - show the Problem, do not blindly retry. - Base images lack
awk,find,xargs,python3,xmllint,sudo,hostname,diff;rockylinux:9is a stale 9.3 image (userockylinux/rockylinux:9);rockylinux:10does not exist. dnf removeof a non-existent package exits 0; zypper exits 104. Verify withrpm -q.- Packages installed with plain
rpm -iare never auto-removed bydnf remove/zypper remove --clean-deps.
13. Not verified / limits
- dnf5 was verified on
fedora:latest(Fedora 44, dnf5 5.4.3.0 - an image outside the requested list), not on an EL10 userland (no dnf5 package in Alma 10; Rocky 10 hasdnf5 5.1.12only in the disableddevelrepo and was not exercised). - zypper exit codes 100-103, 106, 107 were not reproduced (see 7.2).
opensuse-slowroll, MicroOS/Aeon (transactional), SLES were not tested. - Multi-arch (x86_64 + i686) and installonly kernels were not exercised; the parser keys on
name.archand keeps the highest installedevrper key for the "old" column. - Docker images pulled during this work (all kept, none removed or re-tagged):
almalinux:9,almalinux:10,rockylinux:9,rockylinux/rockylinux:9,rockylinux/rockylinux:10,quay.io/rockylinux/rockylinux:9,quay.io/rockylinux/rockylinux:10,opensuse/tumbleweed:latest,opensuse/leap:latest,fedora:latest.