diff --git a/setup/scripts/install-packages b/setup/scripts/install-packages index 925f842..5d655bf 100755 --- a/setup/scripts/install-packages +++ b/setup/scripts/install-packages @@ -6,6 +6,22 @@ set -euo pipefail log() { echo -e "\033[1;34m[INFO]\033[0m $*"; } exists() { command -v "$1" >/dev/null 2>&1; } +# The package names in a list, without the comments that explain them. +# +# The lists are annotated -- which package exists for which settings page, why +# an exception was made -- and those annotations are for whoever reads the file +# next. dnf is not so forgiving: it does not ignore an argument it cannot +# match, it reports "No match for argument: #" and exits 1, and with `set -e` +# above that ends this stage on the first annotated list it reaches. +# +# It could not be seen from here. On a machine that already has everything, a +# re-run matches every real name and fails only on the comments; and every +# contract that reads these lists strips comments before comparing, so the +# tests were reading a file this script was not. +packages_in() { + sed 's/#.*//' "$1" | tr "\n" " " +} + # --- Defined Paths --- PANAMA_PATH="$HOME/.local/share/Panama" @@ -46,7 +62,7 @@ sudo dnf install -y gstreamer1-plugins-{bad-\*,good-\*,base} \ # --- Install all initial packages --- PACKAGES_FILE="$PANAMA_PATH/setup/packages/initial-packages" if [[ -f "$PACKAGES_FILE" ]]; then - INITIAL_PACKAGES=$(tr "\n" " " <"$PACKAGES_FILE") + INITIAL_PACKAGES=$(packages_in "$PACKAGES_FILE") log "Installing Initial Packages" echo -e "Includes the following packages:" echo -e "$(<"$PACKAGES_FILE")" @@ -59,7 +75,7 @@ fi # --- Install Desktop Packages --- DESKTOP_FILE="$PANAMA_PATH/setup/packages/desktop-packages" if [[ -f "$DESKTOP_FILE" ]]; then - DESKTOP_PACKAGES=$(tr "\n" " " <"$DESKTOP_FILE") + DESKTOP_PACKAGES=$(packages_in "$DESKTOP_FILE") log "Installing Desktop Packages" echo -e "Includes the following packages:" echo -e "$(<"$DESKTOP_FILE")" @@ -72,7 +88,7 @@ fi # --- Install Development Packages needed for Neovim --- DEV_FILE="$PANAMA_PATH/setup/packages/development-packages" if [[ -f "$DEV_FILE" ]]; then - DEV_PACKAGES=$(tr "\n" " " <"$DEV_FILE") + DEV_PACKAGES=$(packages_in "$DEV_FILE") log "Installing Development Packages. Mostly for Neovim." echo -e "Includes the following packages:" echo -e "$(<"$DEV_FILE")" @@ -88,7 +104,7 @@ HYPR_FILE="$PANAMA_PATH/setup/packages/hyprland-packages" if [[ -f "$HYPR_FILE" ]]; then log "Enabling Hyprland COPR" sudo dnf copr enable -y lionheartp/Hyprland > /dev/null - HYPR_PACKAGES=$(tr "\n" " " <"$HYPR_FILE") + HYPR_PACKAGES=$(packages_in "$HYPR_FILE") log "Installing Hyprland desktop packages" echo -e "Includes the following packages:" echo -e "$(<"$HYPR_FILE")" @@ -153,7 +169,7 @@ fi # --- Install Flatpak Packages --- FLATPAK_FILE="$PANAMA_PATH/setup/packages/flatpak-packages" if [[ -f "$FLATPAK_FILE" ]]; then - FLATPAK_PACKAGES=$(tr "\n" " " <"$FLATPAK_FILE") + FLATPAK_PACKAGES=$(packages_in "$FLATPAK_FILE") log "Adding Flathub remote" sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo > /dev/null log "Installing Flatpak Packages" diff --git a/tests/setup/package-lists-contract b/tests/setup/package-lists-contract new file mode 100755 index 0000000..4e453dc --- /dev/null +++ b/tests/setup/package-lists-contract @@ -0,0 +1,108 @@ +#!/usr/bin/env bash + +# The comments in a package list are for the reader, not for dnf. +# +# Every list in setup/packages/ is annotated -- which package exists for which +# settings page, why an exception was made -- and install-packages passed the +# whole file to dnf, comment lines included. dnf does not ignore an argument it +# cannot match; it reports "No match for argument: #" and exits 1. With +# `set -euo pipefail` at the top of that script, the first annotated list ends +# the stage, and a fresh machine gets almost no packages. +# +# It could not show up here. This machine has everything already, so a re-run +# matches every real name and only fails on the comments; and every contract +# that reads these lists strips comments with sed before comparing, so the tests +# were reading a file the installer was not. That is the exact shape of bug this +# repository's fresh-install work exists to find: invisible on the machine it +# was written on, fatal on the next one. +# +# The check runs the installer's own extraction rather than describing it, so +# renaming or deleting the filter fails here instead of silently passing. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +installer="$repo_dir/setup/scripts/install-packages" +lists_dir="$repo_dir/setup/packages" + +findings=() +note() { findings+=("$1"); } + +# ── The installer's own filter, lifted out and run ─────────────────────────── + +filter="$(sed -n '/^packages_in()/,/^}/p' "$installer")" +if [[ -z "$filter" ]]; then + printf 'package lists contract: install-packages defines no packages_in filter, so it reads lists raw\n' >&2 + exit 1 +fi +eval "$filter" + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +fixture="$work/list" +cat >"$fixture" <<'LIST' +# A whole-line comment, which is what broke the install. +alpha +bravo + +#Indented and unspaced forms of the same thing. + # spaced +charlie +LIST + +read -ra extracted <<<"$(packages_in "$fixture")" + +expected=(alpha bravo charlie) +if [[ "${extracted[*]}" != "${expected[*]}" ]]; then + note "a commented list extracts as '${extracted[*]}' rather than '${expected[*]}'" +fi + +for token in "${extracted[@]}"; do + [[ "$token" == *"#"* ]] && note "the extracted list still carries a comment marker: $token" +done + +# ── Every real list survives it ────────────────────────────────────────────── +# +# Stripping comments must not also strip packages. Each name that is not a +# comment has to come out the other side, or this fix trades a loud failure for +# a quiet one. + +shopt -s nullglob +for list in "$lists_dir"/*; do + [[ -f "$list" ]] || continue + name="$(basename "$list")" + + # What a reader would say the file declares. + mapfile -t declared < <(sed 's/#.*//' "$list" | tr -d ' \t' | grep -v '^$' | sort) + read -ra passed <<<"$(packages_in "$list")" + mapfile -t passed_sorted < <(printf '%s\n' "${passed[@]}" | sort) + + if [[ "${declared[*]}" != "${passed_sorted[*]}" ]]; then + note "$name declares ${#declared[@]} packages but the installer would pass ${#passed_sorted[@]}" + fi + + for token in "${passed[@]}"; do + [[ "$token" == *"#"* ]] && note "$name would pass a comment marker to the package manager" + done +done + +# ── No list is read raw any more ───────────────────────────────────────────── +# +# The filter existing is not the same as it being used. A single missed call +# site is a stage that still dies on the list it forgot. + +if grep -nE 'tr "\\n" " " *<' "$installer" >/dev/null; then + note 'a package list is still read with tr rather than through packages_in' +fi + +# ── Report ─────────────────────────────────────────────────────────────────── + +if (( ${#findings[@]} > 0 )); then + mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u) + printf 'package lists contract: %d finding(s)\n' "${#findings[@]}" >&2 + printf ' - %s\n' "${findings[@]}" >&2 + exit 1 +fi + +printf 'package lists contract: PASS\n'