Files
skeptical-updater/discovery/pacman.md
T
bryanpedini 875c8294bf feat(show): merge the installed record with the upstream candidate
N?? and `show PKG` now print the installed record in the package
manager's own key/value layout, after one line explaining the format:

    # lines starting with > come from the upstream candidate: a differing
    value, or a field the installed record lacks

After each field whose candidate value differs comes the candidate's
line prefixed with "> "; fields only the candidate has are appended the
same way; installed-only fields stay; identical fields print once.
Multi-line values (pacman Optional Deps, dnf Description, RFC-822
continuation lines) are compared as blocks and a differing candidate
block gets "> " on every line. A package that is not installed shows the
candidate alone ("# not installed: this is the upstream candidate").

Merged where the backend gives comparable records for both sides:
- pacman: -Qi vs -Si REPO/PKG, REPO = the first repository -Sl lists
- apt: dpkg-query -s (Status, Conffiles, the long description; richer
  than apt-cache show PKG=<installed>, which gives the archive record
  when that version is still listed) vs apt-cache show --no-all-versions;
  Description-xx pairs with Description and a one-line candidate
  Description equal to the installed first line counts as identical
  (the archives ship the long text in Translation files)
- dnf: info --installed vs info --upgrades (--available lists other
  architectures first and nothing for an up-to-date package); not
  installed: --available; dnf5's differently padded keys are re-padded
zypper keeps `zypper info --requires`: rpm -qi and zypper info share
almost no keys and zypper cannot print the installed record itself.
No candidate, or not exactly one record per side, keeps the old output.

Formats verified in containers and recorded in discovery/*.md. Unit
tests replay recorded pacman, apt, dnf4 and dnf5 records; the E2E suite
checks 2?? on su-dummy-b on all 12 distros.
2026-10-10 14:24:39 +02:00

34 KiB

Discovery: pacman (Arch Linux) and pman

Everything below was run in throwaway containers (docker run --rm -i, LC_ALL=C) on 2026-10-09 against archlinux:latest (pacman 7.1.0 / libalpm 16.0.1, bash 5.3.20). Companion files (all in discovery/pacman/):

file purpose
fixture.sh E2E fixture: dummy packages su-dummy-{a,b,c} 1.0 installed, 2.0 offered by a local repo
lib.sh proven bash helpers (pm_upgradable, pm_field, pm_closure, pm_plan, ...)
checks.sh executable proof of this document (ALL CHECKS PASSED on archlinux:latest and archlinux:base-devel)

Re-run the proof:

docker run --rm -i -v "$PWD/discovery/pacman:/d:ro" archlinux:latest bash /d/fixture.sh
docker run --rm -i -v "$PWD/discovery/pacman:/d:ro" archlinux:latest bash /d/checks.sh

0. Summary / chosen commands

need command
refresh (root) pacman -Sy (then apply with pacman -Su, see section 1)
upgradable list pacman -Qu -> name old -> new [ignored]; repo from pacman -Sl (first repo with that name wins)
exact transaction (incl. new deps) pacman -Sup --noconfirm [--ignore a,b] --print-format '%r/%n %v'
description pacman -Qi -- name / pacman -Si -- repo/name, field Description
installed reverse deps pacman -Qi fields Required By / Optional For
dependency closure one pacman -Qi dump + awk BFS (pm_closure); pactree is not in the base image
per-run exclusion pacman -Su --noconfirm --ignore a,b (or repeated --ignore); globs work
upgrade pacman -Su --noconfirm (-Syu if you did not refresh before)
purge pacman -Rns --noconfirm -- pkg...

Top gotchas (details below):

  1. The fresh image has no sync databases: pacman -Si/-Sl/-Qu are useless (and -S bats says "not found") until pacman -Sy has run.
  2. pacman -Qu has no --ignore (error: invalid option '--ignore', exit 1). Per-run exclusions must be filtered by the tool itself; [ignored] is only shown for IgnorePkg/IgnoreGroup from the config.
  3. pacman -Qu exits 1 when nothing is upgradable (empty output); -Su/-Syu exit 0 with there is nothing to do.
  4. Excluding a package that another upgraded package needs at a higher version makes the whole transaction fail under --noconfirm (exit 1, nothing upgraded). Pre-check with pacman -Sup.
  5. -Qi/-Si wrap long values at $COLUMNS (even when piped) if COLUMNS is exported. Use COLUMNS=0 and a continuation-aware parser (lib.sh does both).
  6. pactree -l -u prints dependency strings (libncursesw.so=6-64, linux-api-headers>=4.10), not package names, and pacman-contrib is not installed.
  7. The image's /etc/pacman.conf ends with a second [options] block (NoExtract lines). Appending Include = /etc/pacman.d/ignorepkg at EOF works by accident here; on a stock Arch host the last section is [extra] and pacman just warns directive 'IgnorePkg' in section 'extra' not recognized. and ignores nothing.
  8. makepkg refuses root (exit 10); runuser -u <user> -- works, sudo is absent.

1. Refreshing metadata

$ pacman -Sy                 # root; image has only /var/lib/pacman/{local,sync(empty)} before this
:: Synchronizing package databases...
 core downloading...
 extra downloading...
exit=0

Timing: cold -Sy 0.21-0.25 s, warm 0.12 s (fast CDN; core.db 130 KB, extra.db 8.8 MB). A second -Sy prints the same "downloading..." lines (NoProgressBar), so the output cannot tell "up to date" from "refreshed".

On a fresh image (no -Sy):

$ pacman -Qu ; echo $?
warning: database file for 'core' does not exist (use '-Sy' to download)
warning: database file for 'extra' does not exist (use '-Sy' to download)
1
$ pacman -Si bats
error: package 'bats' was not found

How many upgrades does a fresh image have? Today only 6 (image built 5 days earlier):

$ pacman -Sy && pacman -Qu
ca-certificates-mozilla 3.130-1 -> 3.131-1
device-mapper 2.03.42-1 -> 2.03.43-2
expat 2.8.5-1 -> 2.9.0-1
glib2 2.88.3-1 -> 2.90.1-1
hwdata 0.411-1 -> 0.412-1        (+ libsysprof-capture)

The count is time-dependent; tests must not assert on it. Use FIXTURE_ISOLATE=1 (section 10) for an exact set.

Partial-upgrade concern

pacman -Sy alone moves the sync DB ahead of the installed packages. That is only dangerous if you then install something (pacman -S foo) without a full upgrade; a -Sy immediately followed by -Su is the same as -Syu. Two safe designs:

  • A (recommended, simple): pacman -Sy, list with -Qu, let the user exclude, then apply with pacman -Su --noconfirm --ignore ... (note -Su, not -Syu: it applies exactly the reviewed DB snapshot; -Syu would re-sync and could add newer, unreviewed versions). If the user aborts after the refresh, tell them the DB is refreshed (do not run pacman -S x without -u).

  • B (no side effects, root, no extra packages): temporary sync DB. Verified:

    tmp=$(mktemp -d /tmp/checkdb.XXXXXX); chmod 755 "$tmp"          # 755 is REQUIRED (see below)
    ln -s /var/lib/pacman/local "$tmp/local"
    pacman -Sy --dbpath "$tmp" --logfile /dev/null                  # exit 0
    pacman -Qu --dbpath "$tmp"                                      # same list as design A
    pacman -Sup --dbpath "$tmp" --print-format '%r/%n %v'           # same transaction view
    ls /var/lib/pacman/sync                                         # still empty: system DB untouched
    

    With the default mktemp -d mode 0700 it fails: error: could not open file /tmp/checkdb.X/sync/download-XXXX/core.db.part: Permission denied (the sandboxed download user alpm cannot traverse it); chmod 755 or --disable-sandbox fixes it. When applying, use -Syu (re-sync) since the real DB was not refreshed. lib.sh: pm_refresh_tmpdb sets PM_OPTS=(--dbpath ...).

  • checkupdates (pacman-contrib) is NOT in the base image (archlinux:latest and :base-devel both lack it and pactree), and it hard-requires fakeroot (Cannot find the fakeroot binary; also absent from latest, present in base-devel) even as root. After pacman -S pacman-contrib fakeroot it works: prints the same -Qu lines (drops [ignored] rows, per its source grep -v '\[.*\]'), exit 0 = updates (verified), 2 = none (verified), 1 = error (from the source: Cannot fetch updates). It is just B with fakeroot instead of chmod 755; prefer B.

pacman -Qu and pacman -Sup need no root (verified as a normal user, exit 0), only the refresh and the real upgrade do.

2. Machine-parsable upgradable list

pacman -Qu (piped: no colour even with Color in pacman.conf; add --color never to be safe):

$ pacman -Qu
su-dummy-a 1.0-1 -> 2.0-1
su-dummy-b 1.0-1 -> 2.0-1
su-dummy-c 1.0-1 -> 2.0-1
$ pacman -Qu -q          # names only

With IgnorePkg (from Include = /etc/pacman.d/ignorepkg inside [options], one IgnorePkg = x per line, several names per line allowed, globs allowed):

$ pacman -Qu
ca-certificates-mozilla 3.130-1 -> 3.131-1
expat 2.8.5-1 -> 2.9.0-1 [ignored]
glib2 2.88.3-1 -> 2.90.1-1 [ignored]      # after "IgnorePkg = glib2 hwdata"

Format: name SP oldver SP "->" SP newver [SP "[ignored]"]; versions contain no spaces (epochs look like 1:1.37.2-3). Exit 0 if >=1 line, exit 1 and empty output otherwise ([ignored] rows count as lines, so exit stays 0 when only ignored rows remain).

Repo of the candidate: -Qu does not print it. Use pacman -Sl (repo name version [installed[: oldver]]):

$ pacman -Sl | grep -E ' (expat|glib2) '
core expat 2.9.0-1 [installed: 2.8.5-1]
core glib2 2.90.1-1 [installed: 2.88.3-1]

A name can be in several repos. Verified with su-dummy-b 2.0-1 in [su-dummy] and 3.0-1 in a later [su-dummy2]: -Qu reports 1.0-1 -> 2.0-1 (the first repo in pacman.conf order wins), -Sl lists both rows in config order, and -Si su-dummy-b prints both blocks, so take the first -Sl row per name and use repo/name for -Si. Exact parse (this is lib.sh: pm_upgradable, output name<TAB>old<TAB>new<TAB>repo<TAB>ignored(0|1)):

awk '
  FNR == NR { if (!($2 in repo)) repo[$2] = $1; next }                    # pacman -Sl
  { printf "%s\t%s\t%s\t%s\t%d\n", $1, $2, $4, repo[$1], ($5 == "[ignored]") }   # pacman -Qu
' <(LC_ALL=C pacman -Sl) <(LC_ALL=C pacman -Qu)
su-dummy-a	1.0-1	2.0-1	su-dummy	0
ca-certificates-mozilla	3.130-1	3.131-1	core	0

Alternative that also yields the repo directly and is authoritative for what -Su will do: pacman -Sup --print-format '%r|%n|%v|%s|%l' (%s download size, %l URL; works as non-root; dependency order; includes new dependencies and skips ignored packages, which -Qu does not):

$ pacman -Sup --print-format '%r|%n|%v|%s'
core|ca-certificates-mozilla|3.131-1|391549
...

Verified with su-dummy-a 3.0 depending on a brand-new su-dummy-new: -Qu listed 3 packages, -Sup listed 4 (su-dummy-new 1.0-1 appears before su-dummy-a), and the new package is installed "as a dependency". So a skeptical UI should show -Qu rows plus "also installs: ..." from -Sup minus -Qu. Not covered by -Qu: packages pulled in via replaces.

3. Description and full info

$ pacman -Qi bash | head -5
Name            : bash
Version         : 5.3.20-1
Description     : The GNU Bourne Again shell
Architecture    : x86_64
URL             : https://www.gnu.org/software/bash/bash.html
$ pacman -Si su-dummy/su-dummy-a | head -4        # the candidate (new version)
Repository      : su-dummy
Name            : su-dummy-a
Version         : 2.0-1
Description     : Skeptical-updater dummy package A
  • Installed: pacman -Qi -- name; candidate: pacman -Si -- repo/name (always qualify with the repo, see the two-repo note above). -Si has Repository, Download Size, Build Date, new Depends On; it has no Required By/Install Reason (those only exist for -Qi).

  • Description only (simple, single target, piped, no COLUMNS exported): pacman -Qi -- bash | sed -n 's/^Description *: //p'.

  • Robust version (any number of targets, wrapped lines joined, field split at the first colon) is lib.sh: pm_field -Qi|-Si Field target... -> name<TAB>value:

    pm_field() { local op=$1 field=$2; shift 2
      LC_ALL=C COLUMNS=0 pacman "$op" -- "$@" 2>/dev/null | awk -v f="$field" '
        function flush() { if (name != "") print name "\t" val; name = ""; val = ""; infield = 0 }
        /^[ \t]*$/ { flush(); next }
        /^[^ \t]/ { infield = 0; i = index($0, ":"); k = substr($0, 1, i-1); sub(/[ \t]+$/, "", k)
                    v = substr($0, i+1); sub(/^[ \t]+/, "", v)
                    if (k == "Name") name = v; else if (k == f) { val = v; infield = 1 }; next }
        infield { sub(/^[ \t]+/, ""); val = val " " $0 }
        END { flush() }'; }
    

    pm_field -Qi Description bash -> bash<TAB>The GNU Bourne Again shell; also unaffected by COLUMNS=20 (verified: raw COLUMNS=20 pacman -Qi bash does wrap).

  • Full dump = the raw command output, never wrapped when COLUMNS=0 or unset and stdout is not a tty (wrapping is driven by the COLUMNS env var, not by a real tty: stty cols 100 + pipe did not wrap, COLUMNS=80 + pipe did). Optional Deps is the exception: one entry per line (continuation lines are new entries, not wraps):

    Optional Deps   : diffutils: for pacdiff
                      fakeroot: for checkupdates
    
  • Missing target: error: package 'x' was not found on stderr, exit 1, other targets still printed.

4. Installed reverse dependencies

pacman -Qi fields (names only, no versions, two-space separated, None when empty; computed over installed packages and already resolved through provides, e.g. bash is listed as a dependent of readline although it depends on libreadline.so=8-64):

$ pacman -Qi bash | grep -E '^(Required By|Optional For)'
Required By     : audit  base  bzip2  ca-certificates-utils  device-mapper  e2fsprogs  gawk  ...
Optional For    : ncurses  pcre2  tzdata
pm_required_by()  { pm_field -Qi 'Required By'  "$1" | cut -f2- | tr -s ' ' '\n' | grep -vxE 'None|' | sort -u; }
pm_optional_for() { pm_field -Qi 'Optional For' "$1" | cut -f2- | tr -s ' ' '\n' | grep -vxE 'None|' | sort -u; }

Proven: pm_required_by su-dummy-b -> su-dummy-a; pm_required_by su-dummy-a -> empty; pm_optional_for bash -> ncurses pcre2 tzdata; pm_required_by glibc | wc -l -> 111 (a 1021-char line). Note -Si does not have these fields; for the candidate, reverse deps = the installed ones.

5. Recursive dependency closure restricted to installed packages

Premise check: pacman -Qq/-Qi/-Q DO resolve provides and version-constrained dependency strings in pacman 7.1 (they use the satisfier lookup), so no special handling is needed on this version:

$ pacman -Qq sh                    -> bash          (exit 0)
$ pacman -Qq 'glibc>=2.38'         -> glibc         (exit 0)
$ pacman -Qq libreadline.so=8-64   -> readline      (exit 0)
$ pacman -Qq 'glibc>=99'           -> error: package 'glibc>=99' was not found   (exit 1)
$ pacman -T sh ; echo $?           -> (nothing) 0   ;  pacman -T nonexist -> "nonexist", exit 127
$ pacman -Qqs '^sh$'               -> bash          (-Qs also searches provides, but it is a regex search; do not use)

(Unquoted >= is a shell redirect, always quote dependency strings. I did not verify older pacman releases, so pm_closure below does not rely on this.)

pactree (pacman-contrib, absent from latest and base-devel): after installing it, pactree -l -u bash prints dependency strings, not names:

$ pactree -l -u bash
bash
readline
glibc
linux-api-headers>=4.10
tzdata
filesystem
iana-etc
libncursesw.so=6-64          <- provided name, the package "ncurses" itself never appears
libgcc
libstdc++
$ pactree -l -u pacman | wc -l     -> 108   (contains libcrypt.so=2-64, libp11-kit=0.26.5-1, ca-certificates-utils>=20181109-3, ...)

So to use it you must resolve every line: pactree -l -u P | xargs -r pacman -Qq -- | sort -u (works on 7.1). pactree -r -l -u P is the reverse closure. pactree -l -u nonexist -> error: package 'nonexist' not found, exit 1.

Pure-pacman implementation (lib.sh: pm_closure): a single pacman -Qi dump (all installed packages, ~10 ms) parsed by one awk process that builds name, Provides -> provider map, Depends On, Required By, Optional For, then BFS. A dependency string d is resolved as: strip the constraint (sub(/[<>=].*/,"")); if it is a real installed package use it; otherwise use every installed package whose Provides has that base name. Usage:

pm_closure bash            # forward closure, roots excluded, sorted, one real name per line
pm_closure -r glibc        # reverse closure via "Required By" (add -o to follow "Optional For" too)
pm_closure sh              # roots may be provided names  -> same as bash

Sample: pm_closure bash -> filesystem glibc iana-etc libgcc libstdc++ linux-api-headers ncurses readline tzdata (9 packages; pactree printed 10 strings, including libncursesw.so=6-64 for ncurses). Proof (checks.sh, all PASS): for bash (9), pacman (107) and su-dummy-a (1), the output equals pactree -l -u P | xargs -r pacman -Qq -- | sort -u minus the root; reverse closures of glibc and bash equal pactree -r -l -u. If several installed packages provide the same name the closure is a superset of pactree's single pick (does not happen in the base image). Optional deps are excluded (as in pactree's default).

6. Per-run exclusion

Both forms work (verified, --noconfirm, fixture):

pacman -Su --noconfirm --ignore su-dummy-a,su-dummy-c            # comma separated
pacman -Su --noconfirm --ignore su-dummy-a --ignore su-dummy-c   # repeated
pacman -Su --noconfirm --ignore 'su-dummy-[ab]'                  # globs work, like IgnorePkg

Result: exit=0, only su-dummy-b upgraded; the warning goes to stderr:

warning: su-dummy-a: ignoring package upgrade (1.0-1 => 2.0-1)
warning: su-dummy-c: ignoring package upgrade (1.0-1 => 2.0-1)

Ignoring everything: stdout :: Starting full system upgrade... + there is nothing to do, warnings on stderr, exit 0.

  • pacman -Qu --ignore x is invalid (error: invalid option '--ignore', exit 1): [ignored] marks only IgnorePkg/IgnoreGroup from the configuration. Effective list wherever it was configured: pacman-conf IgnorePkg (one pattern per line; IgnorePkg = glib2 hwdata -> two lines).

  • Include placement: Include = /etc/pacman.d/ignorepkg must be inside [options]. Inserted right after the first header with sed -i '0,/^\[options\]/s||[options]\nInclude = /etc/pacman.d/ignorepkg|' /etc/pacman.conf (that is what fixture.sh does). Appended at EOF of a stock config (last section [extra]): warning: config file /etc/pacman.d/ignorepkg, line 1: directive 'IgnorePkg' in section 'extra' not recognized. and the package is still upgraded. After writing, verify with pacman-conf IgnorePkg.

  • Dependency trap (FIXTURE_STRICT_DEP=1: su-dummy-a 2.0 needs su-dummy-b>=2.0), --ignore su-dummy-b (or IgnorePkg = su-dummy-b):

    warning: su-dummy-b: ignoring package upgrade (1.0-1 => 2.0-1)
    warning: cannot resolve "su-dummy-b>=2.0", a dependency of "su-dummy-a"
    :: The following package cannot be upgraded due to unresolvable dependencies:
          su-dummy-a
    :: Do you want to skip the above package for this upgrade? [y/N]
    :: unable to satisfy dependency 'su-dummy-b>=2.0' required by su-dummy-a
    error: failed to prepare transaction (could not satisfy dependencies)       exit=1
    

    With --noconfirm the default answer is N: the whole run aborts and nothing (not even su-dummy-c) is upgraded. Same for excluding a new dependency (--ignore su-dummy-new). Pre-check without root/side effects: pacman -Sup --noconfirm --ignore su-dummy-b --print-format '%r/%n %v' </dev/null -> exit 1, stdout :: unable to satisfy dependency ..., stderr error: failed to prepare transaction ... (lib.sh: pm_plan [skip...]; exit 0 and an empty list when everything is ignored). --ask 16 (verified) answers that prompt "yes": pacman -Su --noconfirm --ask 16 --ignore su-dummy-b exits 0, upgrades only su-dummy-c and silently leaves su-dummy-a behind. Probably not what a skeptical user wants; at most offer it as an explicit choice after showing the pm_plan failure.

7. Non-interactive upgrade, exit codes

$ pacman -Syu --noconfirm; echo $?        # upgrades; prints the ":: Proceed with installation? [Y/n]" line but auto-answers
0
$ pacman -Syu --noconfirm                 # nothing left
:: Synchronizing package databases...
 su-dummy downloading...
:: Starting full system upgrade...
 there is nothing to do                   <- leading space; exit 0
$ pacman -Qu; echo $?                     # nothing upgradable: no output
1
situation exit message
upgrade ok / nothing to do 0 there is nothing to do (stdout)
pacman -Qu nothing upgradable 1 (empty)
not root (-Sy, -Su, -Rns) 1 error: you cannot perform this operation unless you are root.
db locked (/var/lib/pacman/db.lck) 1 error: failed to synchronize all databases (unable to lock database) (-Qu/-Qi still work, exit 0)
excluded dep unsatisfiable 1 see section 6
checkupdates 0 updates / 2 none / 1 error

--noconfirm answers every question with its default: install prompts are Y, but "skip unresolvable package?", conflict removal and HoldPkg prompts are N (they abort with exit 1). Messages are split over stdout and stderr (warning:/error: -> stderr, :: unable to satisfy ... -> stdout), so capture both.

8. Purge

$ pacman -Rns --noconfirm su-dummy-a ; echo $?     # a explicit, b installed --asdeps and only needed by a
removing su-dummy-a...
removing su-dummy-b...                              # -s also removed the now-unneeded dependency
0
$ pacman -Rns --noconfirm su-dummy-b ; echo $?      # required by a
checking dependencies...                            (stdout)
:: removing su-dummy-b breaks dependency 'su-dummy-b' required by su-dummy-a
error: failed to prepare transaction (could not satisfy dependencies)       (stderr)
1                                                   # nothing removed
$ pacman -Rns --noconfirm nope ; echo $?            -> error: target not found: nope      1
$ pacman -Rns --noconfirm su-dummy-c nope           -> same error, exit 1, su-dummy-c NOT removed (all or nothing)
$ pacman -Rns --noconfirm su-dummy-b su-dummy-a     -> 0 (order irrelevant, both removed)
$ pacman -Rns --noconfirm pacman                    -> breaks dependency 'pacman' required by base, exit 1
$ pacman -Rdd --noconfirm pacman                    -> ":: HoldPkg was found in target list. Do you want to continue? [y/N]" exit 1

-R without -s leaves the dependency as an orphan (pacman -Qdtq -> su-dummy-b).

9. Base image facts

item archlinux:latest (= archlinux:base, same manifest digest) archlinux:base-devel
exists yes; also archlinux:multilib-devel yes (pulled for this test, 950 MB vs 399 MB)
bash 5.3.20 5.3.20
pacman 7.1.0 (libalpm 16.0.1) 7.1.0
installed packages 137 170
sudo no yes (/etc/sudoers has only root ALL=(ALL:ALL) ALL)
python3 no (core/python 3.14.7-1, 13.5 MB download) yes
fakeroot no (core/fakeroot 1:1.37.2-3, 77 KB) yes
makepkg yes (/usr/sbin/makepkg) but unusable without fakeroot yes, works
gcc/make/debugedit no yes
pacman-contrib (checkupdates, pactree) no (extra/pacman-contrib 1.13.1-1) no
bats / git no / no no / no
su, runuser, bsdtar, repo-add, pacman-conf, vercmp, curl, awk(gawk) yes yes
default $LANG C.UTF-8 (set LC_ALL=C anyway) same
sync DBs absent until pacman -Sy absent

which is not installed; use command -v. All binaries live in /usr/bin (/usr/sbin is a symlink).

bats: extra/bats 1.14.0-1 (installs parallel, perl, db5.3 as deps). After pacman -Sy: pacman -S --noconfirm bats took 6.3 s, bats --version -> Bats 1.14.0, a smoke test printed 1..1 / ok 1 hello. Without a prior -Sy: pacman -Si bats -> error: package 'bats' was not found. (Installing straight after -Sy works today; the proper form on a stale image is pacman -Syu first.)

makepkg as root (exit 10):

==> ERROR: Running makepkg as root is not allowed as it can cause permanent,
catastrophic damage to your system.

(--asroot no longer exists: makepkg: invalid option '--asroot'.) Recipe, verified in both images (on latest first pacman -Sy --noconfirm fakeroot; the base-devel image already has it):

useradd -m builder
runuser -u builder -- bash -c 'cd /home/builder/pkg && makepkg --force --nodeps --noconfirm'     # exit 0
su -s /bin/bash builder -c 'cd /home/builder/pkg && makepkg --force --nodeps'                    # also works
sudo -u builder ...                                                                              # works in base-devel (verified); sudo does not exist in archlinux:latest

Use PKGDEST=/dir (writable by the build user!) to choose where the .pkg.tar.zst goes and options=('!debug' '!strip') in the PKGBUILD for arch=('any') payloads: without it makepkg 7 aborts on latest with ==> ERROR: Cannot find the debugedit binary required for including source files in debug packages. (debugedit is only in base-devel). --nodeps skips the dependency check (no sudo needed).

10. E2E fixture (discovery/pacman/fixture.sh)

docker run --rm -i -v "$PWD/discovery/pacman:/d:ro" archlinux:latest bash /d/fixture.sh
fixture: builder=tar isolate=0 repo=/srv/su-repo
fixture: installed: su-dummy-a 1.0-1;su-dummy-b 1.0-1;su-dummy-c 1.0-1;
fixture: repo list: core extra su-dummy
exit 0

What it does (root, inside the container):

  1. Builds su-dummy-{a,b,c} at 1.0-1 (to install) and 2.0-1 (to offer). su-dummy-a has depend = su-dummy-b. Each ships /usr/share/su-dummy/<name>/VERSION (contains the version) so tests can check an upgrade really happened. Descriptions: Skeptical-updater dummy package A (B has "(dependency of A)", C "(independent)").
  2. Default builder = tar: hand-made .pkg.tar.zst containing only .PKGINFO + payload (bsdtar --zstd -cf ... .PKGINFO usr). Verified that pacman -U, repo-add, -Qi, -Qk and -Syu accept it without .MTREE/.BUILDINFO. No network, no fakeroot, no build user. FIXTURE_BUILDER=makepkg uses real makepkg as user su-builder via runuser (installs fakeroot with pacman -Sy fakeroot if missing, so network; sets PKGDEST, SOURCE_DATE_EPOCH, options=('!debug' '!strip')). Both verified on latest and base-devel; both produce identical -Qu results.
  3. repo-add creates /srv/su-repo/su-dummy.db.tar.gz (+.db symlink) containing the 2.0 packages only; appends to /etc/pacman.conf:
    [su-dummy]
    SigLevel = Optional TrustAll
    Server = file:///srv/su-repo
    
  4. Installs the 1.0 packages with pacman -U, then pacman -D --asdeps su-dummy-b (a and c explicit, b "Installed as a dependency for another package", so pacman -Rns su-dummy-a removes b too).
  5. Creates an empty /etc/pacman.d/ignorepkg and inserts Include = /etc/pacman.d/ignorepkg after the first [options] header (pman layout).
  6. Does not sync (unless FIXTURE_SYNC=1), so the tool under test performs the refresh itself.

Result after pacman -Sy:

$ pacman -Qu | tail -3
su-dummy-a 1.0-1 -> 2.0-1
su-dummy-b 1.0-1 -> 2.0-1
su-dummy-c 1.0-1 -> 2.0-1
$ pacman -Sl | grep dummy
su-dummy su-dummy-a 2.0-1 [installed: 1.0-1]

Knobs: FIXTURE_ISOLATE=1 comments out [core]/[extra] so -Qu lists exactly the 3 dummies, works offline and is deterministic (default mode additionally lists whatever real upgrades the image has, 6 today; filter with grep '^su-dummy-'); FIXTURE_STRICT_DEP=1 makes a-2.0 depend on su-dummy-b>=2.0 (the section 6 trap); FIXTURE_REPO, FIXTURE_SYNC. The file can be sourced: it defines fx_build_tar <outdir> <name> <ver> <desc> [dep...] and does not enable set -e in the caller (used to publish extra packages, e.g. the su-dummy-new and two-repo experiments above).

Typical flow in a test: FIXTURE_ISOLATE=1 bash fixture.sh; pacman -Sy; <tool>; pacman -Q su-dummy-a -> su-dummy-a 2.0-1, cat /usr/share/su-dummy/su-dummy-a/VERSION -> 2.0. Re-running the fixture in the same container is safe (verified): [su-dummy] and the Include are not duplicated, the repo is rebuilt, and if the packages were already upgraded to 2.0 they are downgraded back to 1.0 by pacman -U (so it also works as a reset between test cases).

11. Timings (this host, fast CDN, three runs agree within 0.05 s)

step time
container start (docker run --rm archlinux:latest true) 0.30 s
pacman -Sy cold / warm 0.21-0.25 s / 0.12 s
fixture (tar builder, includes 3 installs + repo-add) 0.23 s
fixture (makepkg builder, includes pacman -Sy fakeroot, 6 makepkg runs) 7.3-7.7 s (one cold run: 12.9 s)
pm_upgradable (-Sl + -Qu + awk) 0.15 s
pm_closure pacman (107 packages) 0.02 s
pacman -S --noconfirm bats 6.3 s
pacman -S pacman-contrib 0.4 s
whole checks.sh incl. container start, contrib install and a full real -Su ~5-8 s
image pull archlinux:latest ~10 s (399 MB); base-devel 950 MB

12. pman mapping and bugs found

pman (/home/bryanpedini/projects/sources/pman/pman.sh) is a bash case over verbs; it prepends sudo when not root and the verb needs it (config, install, update, upgrade, remove, purge, clean, provides, providefiles, reflector, ignore, unignore when $UID != 0; it exits 1 if sudo is absent, as in the base image when not root: verified pman upgrade as an unprivileged user in archlinux:latest -> exit 1; in that container the sudo command needed but not found message itself was swallowed by echo ... > /dev/stderr failing with Permission denied, a side effect of runuser + docker's root-owned stderr pipe).

pman verb runs skeptical-updater uses
update pacman -Sy then prints N packages are available for update (pacman -Qu | wc -l, counts [ignored] rows) refresh
upgradable pacman -Qu list
localinfo / info pacman -Qi / -Si info (info shows both repos if duplicated)
upgrade pacman -Syu + extra args (pman upgrade --noconfirm --ignore a,b works, verified) apply (prefer -Su after a refresh)
purge / remove pacman -Rns / -Rs purge
ignored echo "$(sed 's/IgnorePkg = //' /etc/pacman.d/ignorepkg | sort)" read
ignore p... appends IgnorePkg = p to /etc/pacman.d/ignorepkg write
unignore p... sed -i "/IgnorePkg = p/d" write

Verified end to end with the fixture as root: pman update -> 3 packages are available for update; pman ignore su-dummy-c + pman upgradable -> su-dummy-c 1.0-1 -> 2.0-1 [ignored]; pman upgrade --noconfirm printed warning: su-dummy-c: ignoring package upgrade (1.0-1 => 2.0-1) and upgraded a and b (exit 0); pman purge --noconfirm su-dummy-a removed a and b.

pman bugs (verified; relevant if the tool reuses /etc/pacman.d/ignorepkg):

  • pman ignore foo after pman ignore barfoo prints foo is already ignored, skipping... (the check is grep -e "$pkg$", an unanchored suffix match) and foo is never added.
  • pman unignore su-dummy deletes the line IgnorePkg = su-dummy-c (sed "/IgnorePkg = su-dummy/d" is an unanchored prefix/substring match); regex characters in names are not escaped either.
  • ignored/ignore/unignore assume one package per line in IgnorePkg = <pkg> form and never check that pacman.conf actually includes the file in [options] (silently ineffective, section 6).
  • The tool should read the effective list with pacman-conf IgnorePkg and write with anchored matches (grep -qxF "IgnorePkg = $pkg", sed -i "/^IgnorePkg = ${pkg}\$/d" after escaping).

13. Install reason (N? / info): Install Reason of pacman -Qi

Verified 2026-10-10 on archlinux:latest (pacman 7.1.0), fixture FIXTURE_ISOLATE=1 FIXTURE_ORPHAN=1 FIXTURE_SYNC=1, COLUMNS=0 LC_ALL=C pacman -Qi -- PKG:

su-dummy-a       Install Reason  : Explicitly installed                              rc 0
su-dummy-b       Install Reason  : Installed as a dependency for another package     rc 0   (-D --asdeps)
su-dummy-c       Install Reason  : Explicitly installed                              rc 0
su-dummy-orphan  Install Reason  : Installed as a dependency for another package     rc 0   (-U --asdeps)
no-such-pkg      error: package 'no-such-pkg' was not found                          rc 1   (also for a package only in a sync db)
  • pacman -D --asexplicit su-dummy-b / --asdeps flip the line at once. Works as nobody and with db.lck present (-Qi takes no lock). One line, no wrapping with COLUMNS=0.
  • pacman -Qi sh answers for the provider (bash), like the description and Required By already do.
  • Mapping: Explicitly installed -> explicit, Installed as a dependency for another package -> dependency, -Qi failing -> not installed, anything else (or no such line) -> unknown.
  • Fixture: su-dummy-b is installed --asdeps, a/c explicitly; e2e_reset re-applies both after its pacman -U. No change needed.

14. Full info (N?? / show): -Qi merged with -Si REPO/PKG

Verified 2026-10-10 on archlinux:latest (pacman 7.1.0), COLUMNS=0 LC_ALL=C: the fixture (FIXTURE_ISOLATE=1 FIXTURE_ORPHAN=1 FIXTURE_SYNC=1) and a real upgradable package after pacman -Sy (glib2 2.88.3-1 installed, core 2.90.1-1).

$ pacman -Qi su-dummy-b                          $ pacman -Si su-dummy/su-dummy-b
Name            : su-dummy-b                     Repository      : su-dummy
Version         : 1.0-1                          Name            : su-dummy-b
Description     : Skeptical-updater dummy ...    Version         : 2.0-1
Architecture    : any                            Description     : Skeptical-updater dummy ...
URL             : https://example.invalid/...    Architecture    : any
Licenses        : custom                         URL / Licenses / Groups / Provides / Depends On /
Groups / Provides / Depends On / Optional Deps   Optional Deps / Conflicts With / Replaces
Required By     : su-dummy-a                     Download Size   : 388.00 B
Optional For    : None                           Installed Size  : 4.00 B
Conflicts With / Replaces                        Packager        : su-fixture <...>
Installed Size  : 4.00 B                         Build Date      : Tue Nov 14 22:13:20 2023
Packager        : su-fixture <...>               Validated By    : SHA-256 Sum
Build Date      : Tue Nov 14 22:13:20 2023
Install Date    : Sat Oct 10 11:55:39 2026
Install Reason  : Installed as a dependency for another package
Install Script  : No
Validated By    : None
  • Both are one record per target (blank line after it), every key padded to the same column (: at column 17 in both, C locale), so the keys pair one to one. -Qi only: Required By, Optional For, Install Date, Install Reason, Install Script. -Si only: Repository, Download Size. Both, usually different: Version, Installed Size, Build Date, Validated By (None/Signature installed, SHA-256 Sum Signature in the repo), Depends On/Provides/Optional Deps when they changed.
  • Multi-line: only Optional Deps (continuation lines indented to the value column, one entry each - glib2: dconf: ... / glib2-devel: ... / gvfs: ...); with COLUMNS=0 nothing else wraps (Provides of glib2, 6 entries, stays one line).
  • The candidate is -Si REPO/PKG with REPO = the first repository -Sl lists PKG in (pacman's choice, the join pacman_upgradable uses); plain -Si PKG prints one record per repository that has PKG.
  • Missing sides: -Qi of a package only in a sync database fails (not installed: the candidate alone); a local-only package has no -Si record (the plain -Qi stays).