feat(aur): list and upgrade AUR packages through paru or yay on Arch

On the pacman backend, an installed AUR helper (paru, else yay;
--aur-helper=paru|yay|none, --no-aur) adds the AUR updates to the review
list. The helper never runs as root: when the tool is root through sudo it
runs as SUDO_USER (runuser -u, else sudo -u, with that user's HOME, from
that home); unprivileged it runs as is; a root login gets no AUR, with a
warning. The banner names the helper ("+ AUR via paru").

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

paru 2.1.0 and yay 13.0.1 formats verified in discovery/pacman.md section
15 (proof: discovery/pacman/aur.sh). Unit tests replay the recorded output
(fake paru, yay, runuser, getent); the archlinux E2E run installs the
foreign su-dummy-aur and drives a fake paru through a fake sudo built on
passwordless su for wheel.
This commit is contained in:
bryanpedini committed 2026-10-10 15:06:18 +02:00
1 parent 875c8294bf
commit def537af58
39 files changed
+1903 -115

No files matched your search

+131 -14
View File
@@ -6,7 +6,9 @@ upgradable packages, lets the user interrogate each one, and only then runs the
native upgrade. Packages the user has explicitly acknowledged are remembered in a
statefile and never questioned again. It is one file with no runtime dependencies
beyond bash >= 4.3 and the distro's package manager, and it never reimplements
package management: every action shells out to the native tool.
package management: every action shells out to the native tool. On Arch Linux and
its derivatives, AUR packages join the review when `paru` or `yay` is installed
(see [AUR packages](#aur-packages-arch-linux-paru-or-yay)).
The specification is [docs/DESIGN.md](docs/DESIGN.md); the per-family command
research it is built on lives in [discovery/](discovery/).
@@ -27,7 +29,9 @@ on first use, not by `make install`; `make uninstall` leaves it in place.
Runtime needs: bash >= 4.3, the package manager, coreutils, sed and grep (plus awk,
which the apt and pacman backends use and which those distros always ship). `sudo`
is optional: when the tool needs root and you are not root, it re-runs itself
through `sudo` with the same arguments, or fails with a clear message.
through `sudo` with the same arguments, or fails with a clear message. On the pacman
backend `paru` or `yay` is optional too (AUR packages); the helper is run as the user
who invoked `sudo`, with `runuser` (util-linux, always there on Arch) or else `sudo -u`.
## Usage
@@ -51,6 +55,9 @@ options:
--dry-run print native commands instead of running mutating ones
--state-dir=DIR override /var/lib/skeptical-updater (env: SKEPTICAL_UPDATER_STATE_DIR)
--no-color disable ANSI colours (also honours NO_COLOR and non-tty stdout)
--aur-helper=NAME pacman backend: list and upgrade AUR packages with paru or yay, or
none (default: paru, else yay, when installed; other backends ignore it)
--no-aur same as --aur-helper=none
-h, --help this help
-V, --version print the version
```
@@ -60,7 +67,8 @@ otherwise the exit status of the package manager's upgrade step.
What needs root: the metadata refresh, the upgrade, a purge, and writing a state
directory you cannot write. `--dry-run`, `list --no-refresh`, `trusted`, `info` and
`show` work unprivileged (as long as the state directory is readable).
`show` work unprivileged (as long as the state directory is readable); there an AUR
helper simply runs as you.
### Example session
@@ -73,9 +81,9 @@ Backend: apt (Debian GNU/Linux 12 (bookworm))
Refreshing package lists... done.
3 upgradable, 0 trusted (hidden, use --all to show), 3 to review:
[1] su-dummy-a 1.0 -> 2.0 su-fixture:su-repo
[2] su-dummy-b 1.0 -> 2.0 su-fixture:su-repo
[3] su-dummy-c 1.0 -> 2.0 file:/srv/su-repo ./ [held]
[1] su-dummy-a 1.0 -> 2.0 su-fixture:su-repo
[2] su-dummy-b 1.0 -> 2.0 su-fixture:su-repo
[3] su-dummy-c 1.0 -> 2.0 file:/srv/su-repo ./ [held]
<enter> upgrade | N? what is it | N?? full info | N- exclude this run | Nx purge now | N+ trust forever
> 2?
@@ -89,7 +97,7 @@ excluded from this run: su-dummy-c
> l
3 upgradable, 2 trusted (hidden, use --all to show), 1 to review:
-[3] su-dummy-c 1.0 -> 2.0 file:/srv/su-repo ./ [held]
-[3] su-dummy-c 1.0 -> 2.0 file:/srv/su-repo ./ [held]
>
Excluded from this run: su-dummy-c
+ apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold -o DPkg::Lock::Timeout=60 --with-new-pkgs upgrade
@@ -115,7 +123,8 @@ upgrade; end of input without one aborts with exit status 2).
`[held]` marks packages the package manager will not upgrade on its own: apt
holds and "kept back" packages, pacman `IgnorePkg` (`[ignored]` in `pacman -Qu`).
dnf `exclude=`/versionlock and zypper locks hide packages from the native listing,
so they do not appear at all.
so they do not appear at all. The column right after the number is a `*` for an AUR
package (pacman backend with paru or yay, see below) and a space on every other row.
### Command grammar
@@ -188,6 +197,73 @@ replacements only the pacman transaction (`-Sup`) shows, packages dnf installs t
others, packages a Tumbleweed `dist-upgrade` removes. With no row but such work, the list
says "0 to review, but ... reports pending changes" and `<enter>` still runs the upgrade.
### AUR packages (Arch Linux: paru or yay)
On the pacman backend (Arch and its derivatives, `pman` included) AUR packages are reviewed
with the rest when an AUR helper is installed: `paru`, else `yay`. `--aur-helper=paru|yay`
picks one (an error if it is not installed), `--aur-helper=none` or `--no-aur` turns the AUR
off. The banner says which helper is in use, the AUR rows get a `*` right after the number
and `aur` as their repository:
```
$ skeptical-updater (as a normal user: it re-runs itself through sudo)
Backend: pacman (Arch Linux) + AUR via paru
Refreshing package lists... done.
4 upgradable, 0 trusted (hidden, use --all to show), 4 to review:
[1] su-dummy-a 1.0-1 -> 2.0-1 su-dummy
[2]* su-dummy-aur 1.0-1 -> 2.0-1 aur
[3] su-dummy-b 1.0-1 -> 2.0-1 su-dummy
[4] su-dummy-c 1.0-1 -> 2.0-1 su-dummy
<enter> upgrade | N? what is it | N?? full info | N- exclude this run | Nx purge now | N+ trust forever
> 3-
excluded from this run: su-dummy-b
>
Excluded from this run: su-dummy-b
Checking the reviewed transaction (pacman -Sup)... ok.
+ cp -p -- /tmp/skeptical-updater-db.4242.AbC123/sync/su-dummy.db /var/lib/pacman/sync/
+ pacman -Su --noconfirm --ignore su-dummy-b
...
+ runuser -u alice -- env HOME=/home/alice paru -Sua --ignore su-dummy-b
...
Upgrade finished.
```
(`pman` installed: `Backend: pman (Arch Linux) + AUR via paru`.)
- **Listing**: `paru -Qua` / `yay -Qua` (`name old -> new`). The helper asks the AUR every time,
so `--no-refresh` lists the AUR rows too; when it fails (no network, the AUR is down) its
message is shown with `aur: paru -Qua failed, AUR packages not listed` and the session goes on
with the repository rows. A package in `IgnorePkg` is listed `[held]` by paru and left out by
yay. Devel (`-git`) packages are listed when the helper checks them (paru does by default, yay
with `--devel`), with `latest-commit` as the new version. A name a repository has wins over an
AUR row (a package that moved from the AUR into a repository).
- **`N?`, `N+`, `Nx`** work as for any installed package, through pacman (`pacman -Qi`,
the statefile with backend `pacman`, `pacman -Rns`). **`N??`** merges `pacman -Qi` with the
AUR record (`paru -Sia` / `yay -Sia`), `>` lines as usual: `> Version`, `> Repository : aur`,
`> Maintainer`, `> Votes`, ... (`show PKG` does the same for a package in no repository).
- **`N-`** excludes an AUR row through the helper's `--ignore`.
- **`<enter>`** upgrades the repository packages first, exactly as without the AUR (pre-check,
the reviewed databases, `pacman -Su`), then, if AUR rows are left to upgrade, runs
`paru -Sua --ignore <every excluded row>` (or yay). The helper runs only after a successful
repository step; if it fails, its exit status is the tool's (`the AUR upgrade failed: paru
exited with status 1 (the repository packages were upgraded)`). When no repository row is
left to upgrade, pacman is not run at all, only the helper. `--dry-run` prints the helper's
command line.
- **Which user**: AUR helpers must not run as root (paru refuses to build as root, makepkg too).
When the tool is root because of its own `sudo` re-exec (or any `sudo`: `SUDO_USER` names a
non-root user), every helper command runs as that user: `runuser -u "$SUDO_USER" -- env
HOME=<that user's home> paru ...` from that home (`sudo -u` when `runuser` is missing). Run
unprivileged (`list --no-refresh`, `info`, `show`, `--dry-run`) the helper runs as you. A root
login without `SUDO_USER` gets no AUR, with a warning (`aur: paru is never run as root and
there is no invoking user ...`).
- **PKGBUILD review**: when stdin is a terminal the helper gets it and asks as usual - paru's
PKGBUILD review, yay's diff menu, and `sudo` asking for your password for its `pacman -U` if
the sudo timestamp has expired meanwhile. When stdin is not a terminal (a piped session) the
helper runs with `--noconfirm`: nothing is reviewed, and its `sudo pacman -U` must work without
a password.
### Line editing and history
When stdin is a terminal, the prompt is read with bash's readline and its defaults: Left/Right
@@ -263,12 +339,14 @@ Permanent holds belong to the package manager (`apt-mark hold`, `IgnorePkg`,
|---------|---------|---------|-------------------|-------|
| apt | `apt-get update -qq -o APT::Update::Error-Mode=any` | `apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold -o DPkg::Lock::Timeout=60 --with-new-pkgs upgrade` | temporary `apt-mark hold` of the excluded packages that are *not already held*, journalled in `holds.added` before holding; afterwards exactly those are released (also on failure, Ctrl-C, TERM, HUP, QUIT, a second signal during the restore, or a closed stdout; a journal left by `kill -9` is replayed at the start of the next root run). Holds you placed are never touched. `apt-mark` ignores `DPkg::Lock::Timeout`, so it is retried for up to 60 s while dpkg's lock is busy. | plan: `apt-get -s purge --auto-remove PKG` minus what `apt-get -s autoremove` already listed (`--auto-remove` is system-wide), then `apt-get -y purge` of exactly that set: PKG and the dependencies this purge orphans; no `autoremove` |
| pacman / pman | `pacman -Sy --dbpath <throwaway copy>` (with `local` linked to the real local database, seeded with the system's `*.db` and `*.db.sig`): listing, `q`, end of input and `--dry-run` never touch `/var/lib/pacman/sync`; errors pacman prints about it are shown | check `pacman -Sup --noconfirm --ignore ... --dbpath <throwaway>` (on failure: nothing changed, its exit status), install the reviewed `*.db`/`*.db.sig` into `/var/lib/pacman/sync` under `db.lck`, all-or-nothing, never over a newer system database (exit 2), then `pacman -Su --noconfirm` - exactly the reviewed metadata, no second sync; with `--no-refresh` plain `pacman -Su` | `--ignore a,b` | plan: `pacman -Rs --print`; then `pacman -Rns --noconfirm -- ...` |
| AUR (pacman backend, paru or yay) | none needed: `-Qua` asks the AUR every time | after the repository step: `paru -Sua` (`--noconfirm` when stdin is not a terminal), as the user who invoked sudo | the helper's `--ignore a,b` (all excluded rows) | through pacman, like any installed package |
| dnf / yum | `dnf -q --refresh --setopt=skip_if_unavailable=0 makecache` | `dnf -y --setopt=skip_if_unavailable=0 --setopt=*.metadata_expire=-1 upgrade` (the reviewed metadata; the repo glob is needed because a repo's own `metadata_expire` overrides the main one) | `--exclude=a,b` (adds to `exclude=` from the configuration) | plan: `dnf --assumeno remove PKG`; then `dnf -y remove ...` |
| zypper | `zypper --non-interactive --quiet refresh` | `zypper --non-interactive --no-refresh update` (Leap/SLE) or `dist-upgrade` (Tumbleweed/Slowroll, by `ID=` in os-release) | locks in a *private* `ZYPP_CONF`: the effective configuration (main zypp.conf plus the `zypp.conf.d` drop-ins of `/usr/etc` and `/etc`) with `locksfile.path` pointing at a copy of the effective lock file (also when a drop-in sets it), plus `addlock` of the exclusions; deleted afterwards: the real lock file is never written | plan: `zypper --xmlout remove --dry-run --clean-deps PKG`; then `zypper --non-interactive --no-refresh remove --clean-deps ...` |
Every package-manager call runs with `LC_ALL=C` (and `DEBIAN_FRONTEND=noninteractive`;
`COLUMNS=0` for pacman), with stdin from `/dev/null` so that it can never eat the
commands you pipe in. The upgradable list comes from `apt-get -s --with-new-pkgs
commands you pipe in - except an AUR helper's upgrade on a terminal, which gets the terminal for
its own prompts. The upgradable list comes from `apt-get -s --with-new-pkgs
upgrade` (Inst lines + the "kept back" block), `pacman -Qu` joined with `pacman -Sl`,
`dnf -q check-update` (+ `rpm -qa` for the installed versions) and
`zypper --xmlout list-updates`, or on Tumbleweed (as root) the real plan
@@ -303,8 +381,8 @@ runs a subset of distros; `E2E_FAIL_FAST=1` stops at the first failing distro.
No root, no network, no real package manager. The applet runs with a hermetic
`PATH`: the fakes in `tests/unit/fakes/<backend>/` (`apt-get`, `apt-cache`,
`dpkg-query`, `apt-mark`, `pacman`, `pman`, `dnf`, `yum`, `rpm`, `zypper`, `sudo`)
in front of symlinks to the few system tools it needs. The fakes replay the real
`dpkg-query`, `apt-mark`, `pacman`, `pman`, `dnf`, `yum`, `rpm`, `zypper`, `sudo`, and for the
AUR `paru`, `yay`, `runuser`, `getent`) in front of symlinks to the few system tools it needs. The fakes replay the real
outputs recorded in `discovery/*.md` (data in `tests/unit/data/`), keep their state
(holds, purged packages) under the test's temp dir and log every invocation, so the
tests assert the exact commands issued. Covered: CLI parsing and exit codes, backend
@@ -320,9 +398,14 @@ database install / `-Su` sequence and stale throwaway databases, statefile locki
the `mkdir` lock fallback (EPERM owner alive, no busy loop), an inaccessible state directory, the
sudo re-exec of the file itself and its loop guard), the `N??` merge on recorded pacman, apt, dnf4
and dnf5 records (changed, identical, installed-only, candidate-only and multi-line fields, apt's
short candidate descriptions, dnf5's padding, the fallbacks), and the prompt on a real pty (`script`):
cursor editing, Up/Down history recall, the stashed line, the bell at both ends, no history file.
About 30 seconds.
short candidate descriptions, dnf5's padding, the fallbacks), the prompt on a real pty (`script`):
cursor editing, Up/Down history recall, the stashed line, the bell at both ends, no history file,
and the AUR (`tests/unit/aur.bats`, on recorded paru 2.1.0 and yay 13.0.1 output): helper detection
and overrides, the user the helper runs as (`runuser -u` / `sudo -u` with `SUDO_USER`, unprivileged,
a root login), the `*` column on pacman and apt rows, `-Qua` parsing (`[ignored]`, yay's age suffix
and root warning, devel packages, failures), `N?`/`N??`/`N-`/`N+`/`Nx` on AUR rows, the upgrade order
and arguments, an AUR-only upgrade, a failing helper, `--dry-run`, and `--noconfirm` only without a
terminal. About 35 seconds.
### End-to-end tests (`tests/e2e/`)
@@ -354,6 +437,20 @@ review is not installed), apt
holds waiting for a busy dpkg lock, zypper honouring a lock file set in a drop-in and applying
the reviewed metadata although a newer package appeared after the review.
`tests/e2e/aur.bats` covers the AUR. On archlinux the fixture (`FIXTURE_AUR=1`) also installs
`su-dummy-aur` 1.0 with `pacman -U` (a foreign package: in no repository) and keeps its 2.0
package file, creates the user `su-tester` and makes `su` passwordless for the `wheel` group;
the tests install `fixtures/fake-paru.sh` as `/usr/local/bin/paru` (it emulates paru's `-Qua`,
`-Sia` and `-Sua` for that package, offline, refusing to install as root like paru) and
`fixtures/fake-sudo.sh` as `/usr/local/bin/sudo` (the image has no sudo: `su`, with `SUDO_USER`
set like sudo does). The applet runs as `su-tester` and re-executes itself through that sudo, or
as root with `SUDO_USER=su-tester`: the `*` row with repository `aur`, paru running as su-tester
with its HOME, `N?` and `N??` on it, `N-` keeping it at 1.0 while the repository packages upgrade,
a plain `<enter>` upgrading it to 2.0 after them, an AUR-only upgrade that leaves pacman alone,
`--dry-run`, an unprivileged `list --no-refresh`, a root login (warning, no AUR) and `--no-aur`.
On the other distros it checks the space column and that no AUR helper ever runs, even with
`--aur-helper=paru` (ignored with a warning).
The matrix (`tests/docker/images.txt`):
| label | image | backend |
@@ -442,6 +539,26 @@ From the discovery reports (`discovery/*.md`), all reproduced there:
the current closures of all your trusted packages wholesale. A package shared by
several trusted roots is recorded under the first one only, so `untrust` of that
root drops it until the next `refresh`.
- **AUR** (pacman backend with paru or yay):
- The helper is run as the user named by `SUDO_USER` only: a root login (no `SUDO_USER`, or
`doas`, `run0`, `pkexec`) lists and upgrades no AUR package, with a warning.
- A piped session upgrades AUR packages with `--noconfirm`: no PKGBUILD review, and the
helper's own `sudo pacman -U` must not ask for a password (NOPASSWD, or a sudo timestamp that
is still valid). Review AUR updates on a terminal, or exclude their rows. On a terminal the
helper may ask for your password again if its sudo timestamp expired during a long review.
- The AUR listing needs the network on every run (also with `--no-refresh`); without it the
session goes on with the repository rows (warning). `N??` on an AUR row asks the AUR too.
- The helper decides what is foreign from the system's sync database, not from the reviewed
throwaway copy; a package that moved from the AUR into a repository is listed from the
repository (its AUR row is dropped).
- What the helper reports is what you get: yay leaves `IgnorePkg` packages out (paru lists them
`[held]`), devel packages are listed only when the helper checks them (paru by default, yay
with `--devel`) and show `latest-commit` as the new version; the helper's own configuration
(`paru.conf`, `yay --save`) applies to its upgrade.
- The AUR upgrade is one helper run after `pacman -Su`: a helper failure leaves the repository
packages upgraded (reported, its exit status returned). The E2E suite drives a fake paru
(offline); the formats of real paru 2.1.0 and yay 13.0.1 are verified in
`discovery/pacman.md` section 15.
- The upgradable lists show what the native listing shows: new dependencies are rows on apt
and on Tumbleweed (`-` as the old version); on pacman and dnf new dependencies,
replacements and obsoletes only appear as a note, and removals only on Tumbleweed (note);