259 lines
9.9 KiB
Bash
Executable File
259 lines
9.9 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
# `panama update`: the routine command, and the promises that make it routine.
|
|
#
|
|
# An update that asks questions is an update nobody runs, and a machine nobody
|
|
# updates drifts until the next reinstall. So the properties below are the
|
|
# whole point of the command rather than details of it:
|
|
#
|
|
# 1. --upgrade NEVER runs the interview. This is the regression that started
|
|
# the redesign: ./install asked the full first-install questionnaire every
|
|
# time, including on a machine whose answers could not have changed.
|
|
# 2. --upgrade runs exactly the stages that need no answer, and never
|
|
# setup-identity or install-hardware. Both act only on interview answers.
|
|
# 3. install-packages is gated on a content hash of the package lists, and
|
|
# the hash is NOT recorded when the stage fails -- the same rule
|
|
# panama-migrate applies to its markers, for the same reason.
|
|
# 4. A conflicted `git stash pop` leaves no conflict markers in the tree.
|
|
# Every dotfile here is a symlink into the checkout, so a half-merged file
|
|
# is not something to fix later: it is live in ~/.config immediately, and
|
|
# a broken .qml costs the running shell.
|
|
#
|
|
# Driven against fixture stages in a throwaway PANAMA_PATH, with sudo and
|
|
# gsettings shimmed, so this never touches the machine running it.
|
|
|
|
set -uo pipefail
|
|
|
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
installer="$repo_dir/install"
|
|
panama="$repo_dir/bin/panama"
|
|
|
|
findings=()
|
|
note() { findings+=("$1"); }
|
|
|
|
[[ -x "$installer" ]] || { printf 'update command contract: no installer at %s\n' "$installer" >&2; exit 1; }
|
|
|
|
tmp="$(mktemp -d -t panama-update-contract.XXXXXX)"
|
|
trap 'rm -rf "$tmp"' EXIT
|
|
|
|
STAGE_NAMES=(install-packages link-dotfiles link-skills link-user change-settings
|
|
link-vicinae-scripts setup-identity install-hardware)
|
|
|
|
# A PANAMA_PATH that looks enough like the real one for install to run, and
|
|
# records what it was asked to do instead of doing it.
|
|
build_fixture() {
|
|
local root="$1" packages_rc="${2:-0}"
|
|
rm -rf "$root"
|
|
mkdir -p "$root/bin" "$root/setup/scripts" "$root/setup/packages" \
|
|
"$root/config/dot/quickshell/scripts"
|
|
|
|
cp "$installer" "$root/install"
|
|
: >"$root/bin/ascii"
|
|
printf 'base-package\n' >"$root/setup/packages/base"
|
|
|
|
local stage
|
|
for stage in "${STAGE_NAMES[@]}"; do
|
|
cat >"$root/setup/scripts/$stage" <<EOF
|
|
#!/usr/bin/env bash
|
|
printf '%s\n' "$stage" >>"\$PANAMA_RAN"
|
|
EOF
|
|
chmod +x "$root/setup/scripts/$stage"
|
|
done
|
|
# The one stage whose exit code the caller wants to control.
|
|
cat >"$root/setup/scripts/install-packages" <<EOF
|
|
#!/usr/bin/env bash
|
|
printf 'install-packages\n' >>"\$PANAMA_RAN"
|
|
exit $packages_rc
|
|
EOF
|
|
chmod +x "$root/setup/scripts/install-packages"
|
|
|
|
cat >"$root/setup/scripts/interview" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
printf 'interview\n' >>"$PANAMA_RAN"
|
|
: >"$PANAMA_ANSWERS"
|
|
EOF
|
|
chmod +x "$root/setup/scripts/interview"
|
|
|
|
cat >"$root/bin/panama-migrate" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
printf 'migrate %s\n' "${1:-run}" >>"$PANAMA_RAN"
|
|
EOF
|
|
chmod +x "$root/bin/panama-migrate"
|
|
|
|
cat >"$root/config/dot/quickshell/scripts/panama-doctor" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
printf 'doctor\n' >>"$PANAMA_RAN"
|
|
EOF
|
|
chmod +x "$root/config/dot/quickshell/scripts/panama-doctor"
|
|
|
|
# Nothing that reaches the real machine. sudo would prompt in a test run,
|
|
# and gsettings would genuinely change the tester's screensaver.
|
|
mkdir -p "$root/shim"
|
|
cat >"$root/shim/sudo" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
[[ "${1:-}" == -v || "${1:-}" == -n ]] && exit 0
|
|
exit 0
|
|
EOF
|
|
cat >"$root/shim/gsettings" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
exit 0
|
|
EOF
|
|
cat >"$root/shim/hostnamectl" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
exit 0
|
|
EOF
|
|
chmod +x "$root/shim"/*
|
|
}
|
|
|
|
# Run the fixture installer and echo what ran, one stage per line.
|
|
run_install() {
|
|
local root="$1"; shift
|
|
: >"$root/ran"
|
|
PATH="$root/shim:$PATH" \
|
|
PANAMA_PATH="$root" \
|
|
PANAMA_RAN="$root/ran" \
|
|
XDG_STATE_HOME="$root/state" \
|
|
bash "$root/install" "$@" >"$root/out" 2>&1
|
|
printf '%s' "$?" >"$root/rc"
|
|
cat "$root/ran"
|
|
}
|
|
|
|
# ── 1. The interview never runs on an upgrade ────────────────────────────────
|
|
|
|
build_fixture "$tmp/a"
|
|
ran="$(run_install "$tmp/a" --upgrade)"
|
|
|
|
if grep -qx 'interview' <<<"$ran"; then
|
|
note 'install --upgrade ran the interview, which is the whole regression this prevents'
|
|
fi
|
|
|
|
# And the control: a real install must still ask.
|
|
build_fixture "$tmp/b"
|
|
ran_install="$(run_install "$tmp/b")"
|
|
if ! grep -qx 'interview' <<<"$ran_install"; then
|
|
note 'a plain ./install no longer asks the interview, so a new machine is never configured'
|
|
fi
|
|
|
|
# ── 2. Exactly the answer-free stages ────────────────────────────────────────
|
|
|
|
for stage in install-packages link-dotfiles link-skills link-user change-settings link-vicinae-scripts; do
|
|
grep -qx "$stage" <<<"$ran" || note "install --upgrade did not run $stage"
|
|
done
|
|
for stage in setup-identity install-hardware; do
|
|
grep -qx "$stage" <<<"$ran" \
|
|
&& note "install --upgrade ran $stage, which exists only to act on interview answers"
|
|
done
|
|
|
|
# A stage added to STAGES without a decision about which path owns it shows up
|
|
# here, because this list is written down twice on purpose.
|
|
mapfile -t declared < <(python3 - "$installer" <<'PY'
|
|
import re, sys
|
|
line = next(l for l in open(sys.argv[1], encoding="utf-8") if l.startswith("STAGES="))
|
|
print("\n".join(re.findall(r"[\w-]+", line)[1:]))
|
|
PY
|
|
)
|
|
for stage in "${declared[@]}"; do
|
|
printf '%s\n' "${STAGE_NAMES[@]}" | grep -qx "$stage" \
|
|
|| note "install declares a stage this contract has never heard of: $stage"
|
|
done
|
|
|
|
# ── 3. The packages hash gates the stage, and a failure does not record it ───
|
|
|
|
# Second run, nothing changed: the stage must be skipped.
|
|
ran_again="$(run_install "$tmp/a" --upgrade)"
|
|
grep -qx 'install-packages' <<<"$ran_again" \
|
|
&& note 'install-packages ran again with the package lists unchanged'
|
|
|
|
# --packages overrides the hash.
|
|
ran_forced="$(run_install "$tmp/a" --upgrade --packages)"
|
|
grep -qx 'install-packages' <<<"$ran_forced" \
|
|
|| note '--packages did not force install-packages to run'
|
|
|
|
# A changed list brings the stage back.
|
|
printf 'another-package\n' >>"$tmp/a/setup/packages/base"
|
|
ran_changed="$(run_install "$tmp/a" --upgrade)"
|
|
grep -qx 'install-packages' <<<"$ran_changed" \
|
|
|| note 'a changed package list did not bring install-packages back'
|
|
|
|
# A failing stage must not record the hash, or the failure is hidden forever.
|
|
build_fixture "$tmp/c" 1
|
|
run_install "$tmp/c" --upgrade >/dev/null
|
|
if [[ -r "$tmp/c/state/panama/packages-hash" ]]; then
|
|
note 'install-packages failed but its hash was recorded, so it will never be retried'
|
|
fi
|
|
|
|
# A full install always runs the stage, whatever any recorded hash says.
|
|
build_fixture "$tmp/d"
|
|
run_install "$tmp/d" --upgrade >/dev/null
|
|
ran_full="$(run_install "$tmp/d")"
|
|
grep -qx 'install-packages' <<<"$ran_full" \
|
|
|| note 'a full ./install skipped install-packages because of a recorded hash'
|
|
|
|
# ── 4. A conflicted pop never leaves markers in a live config ────────────────
|
|
#
|
|
# Two halves. The first checks that git still behaves the way the design
|
|
# depends on; the second checks that panama acts on it. Neither is worth much
|
|
# without the other.
|
|
|
|
conflict="$tmp/conflict"
|
|
mkdir -p "$conflict"
|
|
(
|
|
set -e
|
|
cd "$conflict"
|
|
git init -q up && cd up
|
|
git config user.email contract@panama && git config user.name contract
|
|
printf 'one\n' >f; git add -A; git commit -qm one
|
|
cd "$conflict"; git clone -q up work; cd work
|
|
git config user.email contract@panama && git config user.name contract
|
|
cd "$conflict/up"; printf 'upstream\n' >f; git commit -qam two
|
|
cd "$conflict/work"; printf 'local\n' >f
|
|
git stash push --include-untracked -m contract >/dev/null
|
|
git pull -q --ff-only
|
|
git stash pop >/dev/null 2>&1 && exit 3 # a conflict was the point
|
|
git reset -q --hard HEAD
|
|
[[ -n "$(git stash list)" ]] || exit 4 # the stash must survive
|
|
grep -q '<<<<<<<' f && exit 5 # and no markers may remain
|
|
exit 0
|
|
) >/dev/null 2>&1
|
|
case $? in
|
|
0) ;;
|
|
3) note 'the conflict fixture did not conflict, so this check proves nothing' ;;
|
|
4) note 'git no longer keeps the stash after a conflicted pop; panama update would lose work' ;;
|
|
5) note 'git reset --hard left conflict markers behind' ;;
|
|
*) note 'the stash conflict fixture could not be built' ;;
|
|
esac
|
|
|
|
# panama update must act on that: reset the tree rather than leave the markers.
|
|
body="$(sed -n '/^cmd_update()/,/^}/p' "$panama")"
|
|
if [[ -z "$body" ]]; then
|
|
note 'bin/panama has no cmd_update to check'
|
|
else
|
|
grep -q 'git stash push' <<<"$body" \
|
|
|| note 'cmd_update does not stash local changes, so a pull can fail on a dirty tree'
|
|
grep -q 'git reset --hard' <<<"$body" \
|
|
|| note 'cmd_update does not reset after a failed pop, so conflict markers reach ~/.config'
|
|
grep -q 'git pull --ff-only' <<<"$body" \
|
|
|| note 'cmd_update does not pull with --ff-only'
|
|
grep -q -- '--upgrade' <<<"$body" \
|
|
|| note 'cmd_update does not hand off to install --upgrade, so it would ask the interview'
|
|
fi
|
|
|
|
# The two verbs stay separate: sync must never run the installer.
|
|
sync_body="$(sed -n '/^cmd_sync()/,/^}/p' "$panama")"
|
|
if [[ -z "$sync_body" ]]; then
|
|
note 'bin/panama has no cmd_sync, so the git workflow lost its home'
|
|
else
|
|
grep -q 'install' <<<"$sync_body" \
|
|
&& note 'cmd_sync runs the installer; committing and updating are separate jobs'
|
|
grep -q 'git stash' <<<"$sync_body" \
|
|
&& note 'cmd_sync stashes, which the commit-before-pull order exists to avoid'
|
|
fi
|
|
|
|
if (( ${#findings[@]} > 0 )); then
|
|
printf 'update command contract: %d finding(s)\n' "${#findings[@]}" >&2
|
|
printf ' - %s\n' "${findings[@]}" >&2
|
|
exit 1
|
|
fi
|
|
|
|
printf 'update command contract: PASS\n'
|