diff --git a/bin/omarchy-update-system-pkgs b/bin/omarchy-update-system-pkgs index 7b197504..15b028d5 100755 --- a/bin/omarchy-update-system-pkgs +++ b/bin/omarchy-update-system-pkgs @@ -7,42 +7,25 @@ set -e echo -e "\e[32m\nUpdate system packages\e[0m" -# Transition --overwrite: previous script-installed Omarchy wrote these paths -# as unowned files; pacman would refuse the omarchy-settings upgrade on first -# encounter. Drop each entry once the transition release is the baseline. +errors=$(mktemp) +trap 'rm -f "$errors"' EXIT + +# /usr/share/omarchy is wholly Omarchy's; files can land there unowned and would +# otherwise abort the upgrade. Packaged content wins there. # -# An entry has to ship at least one release before the package starts owning the -# path. pacman checks file conflicts during transaction prepare, so the copy of -# this script running the upgrade is the one already on disk. -# -# The /usr/share/omarchy/* entry is permanent: that tree is wholly owned by -# the omarchy packages, but files can land there unowned (in-place extension -# work, script-written files), which would abort the whole upgrade. Packaged -# content always wins there. -sudo env OMARCHY_UPDATE_PACMAN=1 pacman -Syu --noconfirm \ - --overwrite '/etc/docker/daemon.json' \ - --overwrite '/etc/gnupg/dirmngr.conf' \ - --overwrite '/etc/mkinitcpio.conf.d/omarchy_hooks.conf' \ - --overwrite '/etc/mkinitcpio.conf.d/thunderbolt_module.conf' \ - --overwrite '/etc/modprobe.d/omarchy-usb-autosuspend.conf' \ - --overwrite '/etc/sddm.conf.d/10-theme.conf' \ - --overwrite '/etc/sddm.conf.d/10-wayland.conf' \ - --overwrite '/etc/sudoers.d/omarchy-asdcontrol' \ - --overwrite '/etc/sudoers.d/omarchy-passwd-tries' \ - --overwrite '/etc/sudoers.d/omarchy-tzupdate' \ - --overwrite '/etc/sysctl.d/90-omarchy-file-watchers.conf' \ - --overwrite '/etc/sysctl.d/99-omarchy-sysctl.conf' \ - --overwrite '/etc/systemd/logind.conf.d/10-ignore-power-button.conf' \ - --overwrite '/etc/systemd/resolved.conf.d/10-disable-multicast.conf' \ - --overwrite '/etc/systemd/resolved.conf.d/20-docker-dns.conf' \ - --overwrite '/etc/systemd/system.conf.d/10-faster-shutdown.conf' \ - --overwrite '/etc/systemd/system/user@.service.d/10-faster-shutdown.conf' \ - --overwrite '/etc/systemd/system/docker.service.d/no-block-boot.conf' \ - --overwrite '/etc/systemd/system/plocate-updatedb.service.d/ac-only.conf' \ - --overwrite '/etc/systemd/system.conf.d/20-omarchy-nofile.conf' \ - --overwrite '/etc/systemd/user.conf.d/20-omarchy-nofile.conf' \ - --overwrite '/usr/lib/systemd/system-sleep/unmount-fuse' \ - --overwrite '/usr/share/plymouth/themes/omarchy/*' \ - --overwrite '/usr/share/sddm/hyprland.lua' \ - --overwrite '/usr/share/sddm/themes/omarchy/*' \ - --overwrite '/usr/share/omarchy/*' +# Progress bars stay on stdout. Errors are on stderr, kept for the conflict +# handler below; LC_ALL=C is what keeps them parseable in any locale. +if sudo env LC_ALL=C OMARCHY_UPDATE_PACMAN=1 pacman -Syu --noconfirm \ + --overwrite '/usr/share/omarchy/*' 2>"$errors"; then + cat "$errors" >&2 + exit 0 +fi +cat "$errors" >&2 + +# An upgrade blocked only by files pacman doesn't own yet is the one failure +# worth retrying: the handler clears them and runs this again. Anything else, +# including a second failure, is for a human. +[[ ${OMARCHY_UPDATE_RETRY:-} != 1 ]] || exit 1 +# exec, so the EXIT trap above does not fire and the handler can still read the +# report. It takes over deleting it. +exec env OMARCHY_UPDATE_CONFLICT=1 omarchy-update-system-pkgs-when-conflicted "$errors" diff --git a/bin/omarchy-update-system-pkgs-when-conflicted b/bin/omarchy-update-system-pkgs-when-conflicted new file mode 100755 index 00000000..7b071e2c --- /dev/null +++ b/bin/omarchy-update-system-pkgs-when-conflicted @@ -0,0 +1,88 @@ +#!/bin/bash + +# Internal to omarchy-update-system-pkgs. Not a command to run by hand: it acts +# on a pacman error report, and an old or hand-written one would move live files +# out of the way for an upgrade that is not happening. +# +# Paths written by a script rather than installed by a package -- an earlier +# release's installer, an in-place edit -- belong to nobody, and pacman refuses +# to install over a file it doesn't own. This clears them and runs the upgrade +# again. + +set -e + +[[ ${OMARCHY_UPDATE_CONFLICT:-} == 1 ]] || { + echo "omarchy-update-system-pkgs-when-conflicted runs as part of omarchy update" >&2 + exit 1 +} + +errors=${1:?usage: omarchy-update-system-pkgs-when-conflicted } + +# Where a file pacman is taking over gets moved, mirroring its full path. Kept +# out of the directory it came from: SDDM reads every file in sddm.conf.d +# whatever its extension, and systemd-sleep runs every executable in +# system-sleep, so a copy left beside the original would still be live. +replaced=${OMARCHY_REPLACED_DIR:-/var/lib/omarchy/replaced} + +# Anything moved that the upgrade didn't end up installing goes back, whether +# the retry failed or the move loop itself was interrupted partway. Nothing here +# should leave configuration inactive that was live when it started. +# +# -e follows symlinks, so a dangling one reads as absent. -L catches those: a +# link pacman installed, and a relative link that no longer resolves from inside +# the quarantine. +moved=() +restore_moved() { + local path restorable=() + + for path in "${moved[@]}"; do + if [[ ! -e $path && ! -L $path ]] && [[ -e $replaced$path || -L $replaced$path ]]; then + restorable+=("$path") + fi + done + ((${#restorable[@]})) || return 0 + + echo -e "\e[33m\nPutting back what the upgrade didn't take:\e[0m" + for path in "${restorable[@]}"; do + sudo mv -T "$replaced$path" "$path" + echo " $path" + done +} + +# exec'd into, so the caller's EXIT trap never ran and the report is still here. +# Cleaning it up is this script's job from now on. +trap 'restore_moved; rm -f "$errors"' EXIT +trap 'exit 1' INT TERM + +# Paths one of these packages installs that pacman doesn't own. Moving them out +# of the way is what lets the upgrade through, and works on a leftover directory +# as well as a file. +# +# "pkg: /path exists in filesystem" is unowned; the same line plus "(owned by +# x)" is not, so the end anchor takes only ours. -Qo re-checks the live database +# before anything moves. +mapfile -t leftovers < <( + grep -oP '^omarchy(-dev|-settings|-settings-dev)?: \K.+(?= exists in filesystem$)' "$errors" | + while IFS= read -r path; do pacman -Qo "$path" &>/dev/null || echo "$path"; done +) +((${#leftovers[@]})) || exit 1 + +# All of them or none: moving a subset leaves the retry blocked by the rest, and +# the moved files inactive for nothing. +((${#leftovers[@]} == $(grep -c ' exists in filesystem' "$errors"))) || exit 1 + +echo -e "\e[33m\nTaking over files pacman doesn't own yet:\e[0m" +for path in "${leftovers[@]}"; do + sudo mkdir -p "$replaced${path%/*}" + # -T so an existing directory at the destination is replaced, not moved into. + sudo mv -T --backup=numbered "$path" "$replaced$path" + moved+=("$path") + echo " $path -> $replaced$path" +done +echo + +if OMARCHY_UPDATE_RETRY=1 omarchy-update-system-pkgs; then + moved=() + exit 0 +fi +exit 1 diff --git a/test/shell.d/unowned-system-paths-test.sh b/test/shell.d/unowned-system-paths-test.sh new file mode 100755 index 00000000..f3bec23c --- /dev/null +++ b/test/shell.d/unowned-system-paths-test.sh @@ -0,0 +1,134 @@ +#!/bin/bash + +# A file Omarchy writes into /usr belongs to nobody, and the +# day a package starts shipping that same path, pacman refuses the upgrade for +# everyone who has the file. omarchy-update-system-pkgs-when-conflicted recovers from +# that, but the cheaper answer is to ship the file in the package instead. +# +# This flags a script writing such a path unless a PKGBUILD installs it, or it +# is recorded below with the reason it cannot be packaged. +# +# It is a net, not a proof: it reads the destination off the command, so a path +# assembled from variables passes through. The two known cases are recorded +# below, and a new one is caught only if it names the path where it writes it. + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" + +python3 - "$ROOT" <<'PYTHON' +import os, re, sys +from pathlib import Path + +root = Path(sys.argv[1]) + +# Paths Omarchy writes into /usr that no package owns, with the reason. +allowed = { + # Symlinks into another package's icon theme; owning them would mean owning + # paths inside Yaru. + "/usr/share/icons/Yaru/scalable/actions", + # Hardware-conditional sleep hooks, installed only on the machines that need + # them so the hook does not exist where it does not apply. + "/usr/lib/systemd/system-sleep", + # Written through a variable, so the scan below cannot see them at the point + # they are written. Both drop configuration into another project's tree rather + # than Omarchy's, which is why neither is a candidate for omarchy-settings. + "/usr/share/chromium/extensions", + "/usr/lib/firefox/distribution", + # Static content that belongs in omarchy-settings. It cannot move there in the + # same release that first ships omarchy-update-system-pkgs-when-conflicted: the + # upgrade carrying the handler is the one that would hit the conflict, and the + # handler only helps once it is on disk. Package it the release after. + "/usr/lib/chromium/initial_preferences", +} + +# One-time 3.x upgrade. It runs before this rule existed and cannot be made to +# retroactively matter for machines that already ran it. +skip_scripts = {"bin/omarchy-upgrade-to-quattro"} + +pkgs_candidates = [ + root.parent / "omarchy-pkgs/pkgbuilds", + root.parent.parent / "omarchy-pkgs/pkgbuilds", + root.parent / "omacom/omarchy-pkgs/pkgbuilds", + Path.home() / "Work/omacom/omarchy-pkgs/pkgbuilds", +] +override = os.environ.get("OMARCHY_PKGS_PATH") +if override: + pkgs_candidates = [Path(override) / "pkgbuilds", Path(override)] + pkgs_candidates +pkgs_root = next((p for p in pkgs_candidates if p.exists()), None) +if pkgs_root is None: + print("not ok - omarchy-pkgs checkout found for package ownership check", file=sys.stderr) + sys.exit(1) + +packaged = "\n".join(p.read_text() for p in pkgs_root.glob("*/PKGBUILD")) + +# Commands that put a file somewhere, as opposed to reading one. +# /etc is administrator territory that Omarchy legitimately edits. /usr is +# package territory, where writing anything is the thing worth catching. +writer = re.compile(r"\b(tee|cp|install|ln)\b|>\s*/usr/") +target = re.compile(r"/usr/[A-Za-z0-9._@/+-]+") + +problems = [] +for base in ("bin", "install", "migrations"): + for path in sorted((root / base).rglob("*")): + if not path.is_file(): + continue + rel = str(path.relative_to(root)) + if rel in skip_scripts: + continue + # Join continuations first, so a command split across lines is judged whole + # rather than as a source path on one line and a destination on the next. + joined, buf, start = [], "", 1 + for lineno, line in enumerate(path.read_text(errors="ignore").splitlines(), 1): + if not buf: + start = lineno + if line.rstrip().endswith("\\"): + buf += line.rstrip()[:-1] + " " + continue + joined.append((start, buf + line)) + buf = "" + if buf: + joined.append((start, buf)) + + for lineno, line in joined: + code = line.split("#", 1)[0] + if not writer.search(code): + continue + # A redirection names its destination directly. Otherwise cp/install/ln + # put the destination last, and anything earlier is a source being read. + redirect = re.search(r">\s*\"?(/usr/[^\s\"]+)", code) + if redirect: + hit = redirect.group(1).rstrip("/") + else: + tokens = re.sub(r"[12]?>\s*\S+", "", code).split() + if not tokens or not tokens[-1].startswith("/usr/"): + continue + hit = tokens[-1].rstrip("/") + # Omarchy's own tree and its binaries are covered elsewhere. + if hit.startswith(("/usr/share/omarchy", "/usr/bin")) or hit.count("/") < 3: + continue + # Directory or file form of a recorded path both count as recorded, but + # only on a path boundary: system-sleeping is not system-sleep. + def covers(a, b): + return a == b or b.startswith(a + "/") + if any(covers(a, hit) or covers(hit, a) for a in allowed): + continue + # Likewise in the PKGBUILD text, where the path is a destination rather + # than a prefix of a longer one. + if re.search(re.escape(hit) + r'(?=["\'\s]|$)', packaged, re.M): + continue + problems.append(f"{rel}:{lineno}: {hit}") + +if problems: + print("not ok - Omarchy writes paths under /usr that no package owns", file=sys.stderr) + for p in problems: + print(f" {p}", file=sys.stderr) + print( + "\nShip it from a PKGBUILD so pacman owns it, or record it in this test\n" + "with the reason it cannot be packaged.", + file=sys.stderr, + ) + sys.exit(1) +PYTHON + +pass "no Omarchy script writes a path under /usr that no package owns" diff --git a/test/shell.d/update-file-conflict-test.sh b/test/shell.d/update-file-conflict-test.sh new file mode 100755 index 00000000..5fa954ea --- /dev/null +++ b/test/shell.d/update-file-conflict-test.sh @@ -0,0 +1,276 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +stub_bin="$test_tmp/bin" +mkdir -p "$stub_bin" + +cat >"$stub_bin/sudo" <<'STUB' +#!/bin/bash +exec "$@" +STUB + +# Fails the first -Syu with the report under test, then succeeds unless the case +# asked for the retry to fail too. +cat >"$stub_bin/pacman" <<'STUB' +#!/bin/bash +if [[ $1 == -Qo ]]; then + # Anything in OWNED_PATHS has a package behind it; everything else is unowned. + [[ " $OWNED_PATHS " == *" $2 "* ]] + exit $? +fi + +attempt=$(($(cat "$PACMAN_ATTEMPTS") + 1)) +echo "$attempt" >"$PACMAN_ATTEMPTS" +if ((attempt == 1)); then + cat "$CONFLICT_REPORT" >&2 + exit 1 +fi +if [[ -n ${RETRY_FAILS:-} ]]; then + # Optionally commit the file first, as a partial transaction would. + [[ -n ${RETRY_INSTALLS:-} ]] && echo "packaged" >"$RETRY_INSTALLS" + echo "error: failed to retrieve some files" >&2 + exit 1 +fi +echo "upgrade complete" +STUB + +chmod +x "$stub_bin/sudo" "$stub_bin/pacman" + +replaced="$test_tmp/replaced" + +run_update() { + OMARCHY_REPLACED_DIR="$replaced" \ + RETRY_FAILS="${RETRY_FAILS:-}" \ + RETRY_INSTALLS="${RETRY_INSTALLS:-}" \ + PACMAN_ATTEMPTS="$test_tmp/attempts" \ + CONFLICT_REPORT="$test_tmp/report" \ + OWNED_PATHS="${OWNED_PATHS:-}" \ + PATH="$stub_bin:$ROOT/bin:$PATH" \ + bash "$ROOT/bin/omarchy-update-system-pkgs" +} + +# $1 blamed package, $2 path, $3 optional owning package. +write_report() { + echo 0 >"$test_tmp/attempts" + { + echo "error: failed to commit transaction (conflicting files)" + echo "$1: $2 exists in filesystem${3:+ (owned by $3)}" + } >"$test_tmp/report" +} + +# Raw conflict lines, for reports the recovery must refuse wholesale. +write_raw_report() { + echo 0 >"$test_tmp/attempts" + { + echo "error: failed to commit transaction (conflicting files)" + printf '%s\n' "$@" + } >"$test_tmp/report" +} + +work="$test_tmp/work" +fresh_work() { + rm -rf "$work" "$replaced" + mkdir -p "$work" +} + +# An unowned path one of the packages is taking over. +fresh_work +stray="$work/omarchy-fcitx5.service" +echo "stray content" >"$stray" +write_report omarchy-settings-dev "$stray" +run_update >"$test_tmp/out" 2>"$test_tmp/err" || + fail "an unowned file conflict is not resolved" +[[ ! -e $stray ]] || + fail "the file is left in pacman's way" +pass "a file pacman is taking over is moved out of its way" + +# Kept out of the directory it came from, where SDDM and systemd-sleep read +# every file and every executable respectively. +[[ -z $(ls -A "$work") ]] || + fail "something is left in the directory the replaced file came from" +grep -qx "stray content" "$replaced$stray" || + fail "the replaced file is destroyed rather than kept out of the way" +pass "the replaced file is quarantined outside the directory it came from" + +# A real fight between packages, not Omarchy's leftovers. pacman appends +# "(owned by ...)" here. +fresh_work +echo "theirs" >"$stray" +write_report omarchy-settings-dev "$stray" someone-else +if run_update >"$test_tmp/out" 2>"$test_tmp/err"; then + fail "a file owned by another package is silently taken" +fi +[[ -e $stray && ! -e $replaced$stray ]] || + fail "a file owned by another package is silently taken" +pass "a conflict owned by another package stops the upgrade instead of being taken" + +# Report reads unowned, database disagrees: the parse is never the only thing +# between a retry and another package's file. +fresh_work +echo "theirs" >"$stray" +write_report omarchy-settings-dev "$stray" +if OWNED_PATHS="$stray" run_update >"$test_tmp/out" 2>"$test_tmp/err"; then + fail "pacman -Qo is not consulted before moving a file" +fi +[[ -e $stray && ! -e $replaced$stray ]] || + fail "pacman -Qo is not consulted before moving a file" +pass "an owned path is left alone even when the report reads as unowned" + +# A name prefix is not a namespace; only the packages that own system paths. +fresh_work +echo "stray" >"$stray" +write_report omarchy-chromium-bin "$stray" +if run_update >"$test_tmp/out" 2>"$test_tmp/err"; then + fail "an optional omarchy-prefixed package gets its conflicts auto-resolved" +fi +pass "only the packages that own system paths get their conflicts resolved" + +# Not Omarchy's conflict to resolve. +fresh_work +echo "stray" >"$stray" +write_report some-other-pkg "$stray" +if run_update >"$test_tmp/out" 2>"$test_tmp/err"; then + fail "a conflict from an unrelated package is auto-resolved" +fi +pass "a conflict from a non-Omarchy package is left for a human" + +# The path is used literally, so glob characters in a name mean nothing. +fresh_work +globby="$work/omarchy-[1].conf" +echo "globby" >"$globby" +write_report omarchy-settings-dev "$globby" +run_update >"$test_tmp/out" 2>"$test_tmp/err" || + fail "a path whose name contains glob characters is not resolved" +[[ -f "$replaced$globby" && ! -e "$globby" ]] || + fail "a path whose name contains glob characters is treated as a pattern" +pass "a path whose name would act as a glob is moved literally" + +# A leftover directory is cleared the same way a file is. +fresh_work +conflict_dir="$work/omarchy-dir" +mkdir -p "$conflict_dir" +write_report omarchy-settings-dev "$conflict_dir" +run_update >"$test_tmp/out" 2>"$test_tmp/err" || + fail "a conflicting directory is not cleared out of pacman's way" +[[ -d "$replaced$conflict_dir" && ! -e $conflict_dir ]] || + fail "a conflicting directory is left in place" +pass "a conflicting directory is moved away" + +# A space is legal in a package path; the parse must not truncate it. +fresh_work +spaced="$work/omarchy theme.conf" +echo "spaced" >"$spaced" +write_report omarchy-settings-dev "$spaced" +run_update >"$test_tmp/out" 2>"$test_tmp/err" || + fail "a conflicting path containing a space is not resolved" +[[ -f "$replaced$spaced" && ! -e "$spaced" ]] || + fail "a conflicting path containing a space is truncated" +pass "a conflicting path containing a space is parsed whole" + +# An earlier quarantined copy is the more original one, and might not be ours. +fresh_work +echo "current" >"$stray" +mkdir -p "$replaced$work" +echo "from an earlier run" >"$replaced$stray" +write_report omarchy-settings-dev "$stray" +run_update >"$test_tmp/out" 2>"$test_tmp/err" || + fail "a conflict with an existing quarantined copy is not resolved" +grep -qrx "from an earlier run" "$replaced" || + fail "an existing quarantined copy is destroyed to make room for a new one" +pass "an existing quarantined copy survives a later run needing the same name" + +# mv without -T would move the source inside an existing destination directory. +fresh_work +echo "ours" >"$stray" +mkdir -p "$replaced$stray" +write_report omarchy-settings-dev "$stray" +run_update >"$test_tmp/out" 2>"$test_tmp/err" || + fail "a conflict whose destination is a directory is not resolved" +[[ -f "$replaced$stray" ]] || + fail "the leftover was moved inside the existing destination directory" +pass "an existing destination directory is replaced, not moved into" + +# One healable conflict beside one that is not. Moving only the first leaves the +# retry blocked by the second, and that config inactive for nothing. +fresh_work +echo "ours" >"$stray" +write_raw_report \ + "omarchy-settings-dev: $stray exists in filesystem" \ + "some-package: $work/theirs exists in filesystem (owned by other-package)" +if run_update >"$test_tmp/out" 2>"$test_tmp/err"; then + fail "an upgrade with an unhealable conflict reports success" +fi +[[ -e $stray && ! -e $replaced$stray ]] || + fail "a healable conflict is moved even though another conflict dooms the retry" +pass "nothing moves unless every reported conflict is healable" + +# The retry can still fail for an unrelated reason. Leave nothing inactive. +fresh_work +echo "ours" >"$stray" +write_report omarchy-settings-dev "$stray" +if RETRY_FAILS=1 run_update >"$test_tmp/out" 2>"$test_tmp/err"; then + fail "a failed retry reports success" +fi +[[ -f $stray ]] || + fail "a failed retry leaves the file moved away and inactive" +grep -qx "ours" "$stray" || + fail "the restored file is not the original content" +[[ $(cat "$test_tmp/attempts") == 2 ]] || + fail "the handler and the upgrade re-invoke each other instead of stopping" +pass "a failed retry puts the files back, without re-invoking the handler" + +# A retry that failed after committing the files has nothing to restore, and +# should not announce a restore it is not doing. +fresh_work +echo "ours" >"$stray" +write_report omarchy-settings-dev "$stray" +if RETRY_FAILS=1 RETRY_INSTALLS="$stray" run_update >"$test_tmp/out" 2>"$test_tmp/err"; then + fail "a failed retry reports success" +fi +grep -qi "restoring" "$test_tmp/out" "$test_tmp/err" && + fail "a restore is announced when no file is put back" +grep -qx "packaged" "$stray" || + fail "the file pacman installed was overwritten by the restore" +pass "no restore is announced when there is nothing to put back" + +# A dangling symlink reads as absent to -e, so a relative link that no longer +# resolves from inside the quarantine must still be recognised and put back. +fresh_work +ln -s ./neighbour "$stray" +write_report omarchy-settings-dev "$stray" +if RETRY_FAILS=1 run_update >"$test_tmp/out" 2>"$test_tmp/err"; then + fail "a failed retry reports success" +fi +[[ -L $stray ]] || + fail "a symlink that dangles from the quarantine is never restored" +pass "a dangling symlink is restored rather than stranded in the quarantine" + +# The handler acts on a pacman report; an old or hand-written one would move +# live files aside for an upgrade that is not happening. +fresh_work +echo "ours" >"$stray" +write_report omarchy-settings-dev "$stray" +if PATH="$stub_bin:$ROOT/bin:$PATH" OMARCHY_REPLACED_DIR="$replaced" \ + bash "$ROOT/bin/omarchy-update-system-pkgs-when-conflicted" "$test_tmp/report" \ + >"$test_tmp/out" 2>"$test_tmp/err"; then + fail "the handler acts on a report handed to it outside an update" +fi +[[ -f $stray ]] || + fail "the handler moved a live file when run outside an update" +pass "the handler refuses a report handed to it outside an update" + +# The happy path must not pay for any of this. +fresh_work +: >"$test_tmp/report" +echo 1 >"$test_tmp/attempts" +run_update >"$test_tmp/out" 2>"$test_tmp/err" || + fail "a clean upgrade fails" +[[ $(cat "$test_tmp/attempts") == 2 ]] || + fail "a clean upgrade runs more than one pacman transaction" +pass "a clean upgrade runs a single pacman transaction"