`sync` on a box with a live session on it was unsafe in two independent ways, found by preflighting the run rather than performing it. Refs #44. **`enable --now` would have armed a screen lock mid-session.** The unit loop started every `systemd-user/` unit whose `ExecStart` resolves. `swayidle-lock.service` on blankpad is `enabled`/`inactive` — nothing started it, no swayidle process exists, `autostart.kdl` does not launch one — so one `deploy.sh` would have begun blanking and locking the monitors while the operator was mid-sentence. `live-sync.service` is `linked`, driven by its timer, and `--now` forces a heartbeat commit at an arbitrary moment. A unit is now started only when systemd reports it as nothing-yet (`""`/`disabled`), which is precisely a fresh box; anything already installed is merely `enable`d. The state has to come from the **printed state**: `is-enabled` exits 1 for `linked` exactly as for `disabled`, so exit status cannot distinguish "installed, timer-driven" from "never installed" — the first version of this fix got that wrong and the test caught it. **`--check` output was being read as more than it can mean.** It prints `RELINK` for every path the `zz` tier carries, even when the live link already points at the override (`rules.kdl`, `keybinds.kdl`, `misc.kdl`, `autostart.kdl`, `.zshrc` all did on blankpad), because the walk visits the base package first and reports the intermediate owner change. The sentence committed in #43 — RELINK noise is "the sound of a stale override waiting" — is too strong and is corrected: the line proves a pending change only when `readlink` shows the live target is not already the `zz-$HOST` file, and `actions=0` is the honest summary. The stale-override hazard #43 removed was and is real; the signal for it is the target, not the line. **The dconf dump carried six of its 24 keys from another box**, none of them preferences: a Firefox file-chooser `last-folder-path` of `/home/dank/Downloads`, a nautilus `window-state` section, and `picture-uri`/`picture-uri-dark` pointing at `file:///home/dank/.config/background`. The last five showed no conflict because blankpad's live tree *already* held those `/home/dank` values — an earlier deploy wrote them here — and a foreign value that agrees with live is invisible to any diff while still being wrong. They are dropped; `picture-options='zoom'` stays, because it is a preference. The live wallpaper URIs are cleared on the box as part of landing this (niri draws the wallpaper, noctalia owns it, nothing reads a dead path). `tests/deploy.bats` grows by four, over a stub `systemctl` — the real one on this box is exactly what must not be invoked from a test. Red before: an already-`enabled` unit and a `linked` unit both get `--now`. Green after, including the two directions that keep the change honest: a `disabled` unit is still `enable --now`'d so a fresh box comes up, and a unit whose `ExecStart` is absent here is never touched at all. Verification: `bats tests/` 70/70, `kb-lint.sh omp-docs` 0, `kb-coverage.py` 0. The dump now reconciles against live state with **0 conflicts and no `/home/` values in 18 keys**, proven by key-by-key comparison rather than `diff` — `dconf load` writes only the keys present, so a diff overstates the damage one way and hides it the other.
151 lines
6.5 KiB
Bash
Executable File
151 lines
6.5 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# deploy.sh — stow-equivalent deployment of the dotfiles/ packages in this repo.
|
|
#
|
|
# Model: the repo checkout IS the deployment. Packages are home-relative trees
|
|
# under dotfiles/<pkg>/; deploying replaces live files with symlinks into the
|
|
# repo so `git pull` + `deploy.sh` is the whole propagation story.
|
|
#
|
|
# Modes:
|
|
# deploy.sh sync (default): adopt identicals, link new, quarantine-and-replace divergents
|
|
# deploy.sh --check dry-run report only; writes nothing
|
|
# deploy.sh --adopt like sync but divergents are reported and SKIPPED, not quarantined
|
|
#
|
|
# Host divergence: a package named zz-$(hostname) is stowed LAST with override
|
|
# semantics (its files replace symlinks from base packages).
|
|
#
|
|
# Special cases:
|
|
# - dconf package: dump.txt is loaded via `dconf load /` instead of symlinked.
|
|
# - systemd-user: after linking, daemon-reload + enable --now units whose
|
|
# ExecStart binary resolves on THIS machine (silent no-op elsewhere).
|
|
set -euo pipefail
|
|
|
|
REPO="$(git -C "$(dirname "$(realpath "$0")")" rev-parse --show-toplevel 2>/dev/null || true)"
|
|
REPO="${DEPLOY_REPO:-$REPO}"
|
|
PKGS_DIR="$REPO/dotfiles"
|
|
HOST="${DEPLOY_HOST:-$(hostname)}"
|
|
QUAR="$REPO/.quarantine"
|
|
|
|
MODE="sync"
|
|
case "${1:-}" in
|
|
--check) MODE="check" ;;
|
|
--adopt) MODE="adopt" ;;
|
|
""|sync) MODE="sync" ;;
|
|
*) echo "usage: deploy.sh [--check|--adopt|sync]" >&2; exit 2 ;;
|
|
esac
|
|
|
|
[ -d "$PKGS_DIR" ] || { echo "deploy.sh: no dotfiles/ under $REPO" >&2; exit 1; }
|
|
|
|
# packages = all dirs in dotfiles/, override package last
|
|
mapfile -t PKGS < <(find "$PKGS_DIR" -maxdepth 1 -mindepth 1 -type d ! -name "zz-*" | sort)
|
|
OVR="$PKGS_DIR/zz-$HOST"
|
|
[ -d "$OVR" ] && PKGS+=("$OVR")
|
|
|
|
actions=0; quarantined=0; skipped=0
|
|
|
|
deploy_file() { # $1 = repo file path, $2 = home-relative target path
|
|
local src="$1" tgt="$HOME/$2"
|
|
# Generated theme output is never deployment state: noctalia rewrites these files in
|
|
# place, so a tracked copy is a stale snapshot and syncing one reverts a live palette to
|
|
# the colours of the day it was committed. Classes are those the census measures by
|
|
# mtime — see omp-docs/inventory/home-config-census.md, "Generated palette expansion".
|
|
case "$2" in
|
|
*/themes/noctalia*|*/colors/noctalia*|*/noctalia.*|*/noctalia-*|*/Noctalia*)
|
|
echo "SKIP $2 (generated theme output)"; skipped=$((skipped+1)); return 0 ;;
|
|
esac
|
|
if [ -L "$tgt" ]; then
|
|
if [ "$(realpath "$tgt")" = "$(realpath "$src")" ]; then return 0; fi # already deployed
|
|
case "$MODE" in
|
|
check) echo "RELINK $2 (managed symlink changing owners)" ;;
|
|
adopt) echo "SKIP $2 (foreign symlink, --adopt does not overwrite)" ; skipped=$((skipped+1)) ;;
|
|
sync)
|
|
if [[ "$(realpath "$tgt")" == "$REPO/"* ]]; then
|
|
# managed file changing owners between packages: silent relink, nothing to quarantine
|
|
ln -sfn "$(realpath "$src")" "$tgt"; actions=$((actions+1))
|
|
else
|
|
ts="$(date +%Y%m%d-%H%M%S)-$$"
|
|
dest="$QUAR/$HOST/$ts/$2"
|
|
mkdir -p "$(dirname "$dest")"
|
|
mv -- "$tgt" "$dest"
|
|
ln -sfn "$(realpath "$src")" "$tgt"
|
|
actions=$((actions+1)); quarantined=$((quarantined+1))
|
|
echo "QUAR $2 -> .quarantine/$HOST/$ts/$2"
|
|
fi
|
|
;;
|
|
esac
|
|
return 0
|
|
fi
|
|
if [ -e "$tgt" ]; then
|
|
if cmp -s "$src" "$tgt"; then
|
|
if [ "$MODE" = "check" ]; then echo "ADOPT $2 (identical, will symlink)"; return 0; fi
|
|
ln -sfn "$(realpath "$src")" "$tgt"; actions=$((actions+1)); return 0
|
|
fi
|
|
case "$MODE" in
|
|
check) echo "DRIFT $2 (differs from repo; sync would quarantine)" ;;
|
|
adopt) echo "SKIP $2 (divergent, --adopt does not overwrite)" ; skipped=$((skipped+1)) ;;
|
|
sync)
|
|
ts="$(date +%Y%m%d-%H%M%S)-$$"
|
|
dest="$QUAR/$HOST/$ts/$2"
|
|
mkdir -p "$(dirname "$dest")"
|
|
mv -- "$tgt" "$dest"
|
|
ln -sfn "$(realpath "$src")" "$tgt"
|
|
actions=$((actions+1)); quarantined=$((quarantined+1))
|
|
echo "QUAR $2 -> .quarantine/$HOST/$ts/$2"
|
|
;;
|
|
esac
|
|
return 0
|
|
fi
|
|
if [ "$MODE" = "check" ]; then echo "LINK $2 (missing, will symlink)"; return 0; fi
|
|
mkdir -p "$(dirname "$tgt")"
|
|
ln -sfn "$(realpath "$src")" "$tgt"; actions=$((actions+1))
|
|
}
|
|
|
|
for pkg in "${PKGS[@]}"; do
|
|
pkgname="$(basename "$pkg")"
|
|
# dconf package: load dump instead of symlinking
|
|
if [ "$pkgname" = "dconf" ] && [ -f "$pkg/dump.txt" ]; then
|
|
if [ "$MODE" != "check" ]; then
|
|
if command -v dconf >/dev/null; then dconf load / < "$pkg/dump.txt" && echo "LOAD $pkgname/dump.txt -> dconf"; fi
|
|
else
|
|
echo "LOAD $pkgname/dump.txt (dconf load / would run on sync)"
|
|
fi
|
|
continue
|
|
fi
|
|
while IFS= read -r -d '' f; do
|
|
rel="${f#"$pkg"/}"
|
|
# override packages nest base-package dirs internally: strip that segment for targets
|
|
if [ "${pkgname#zz-}" != "$pkgname" ]; then case "$rel" in */*) [ -d "$PKGS_DIR/${rel%%/*}" ] && rel="${rel#*/}";; esac; fi
|
|
deploy_file "$f" "$rel"
|
|
done < <(find "$pkg" \( -type f -o -type l \) ! -name 'dump.txt' ! -path '*/.AGENTTRASH/*' -print0)
|
|
done
|
|
|
|
if [ "$MODE" != "check" ] && [ -d "$PKGS_DIR/systemd-user/.config/systemd/user" ]; then
|
|
systemctl --user daemon-reload || true
|
|
for unit in "$PKGS_DIR"/systemd-user/.config/systemd/user/*.service; do
|
|
[ -e "$unit" ] || continue
|
|
bin="$(sed -n 's/^ExecStart=\([^ ]*\).*/\1/p' "$unit" | head -1)"
|
|
if [ -n "$bin" ] && command -v "$bin" >/dev/null 2>&1; then
|
|
name="$(basename "$unit")"
|
|
# Read the state, not the exit status: `is-enabled` exits 1 for a `linked` unit exactly
|
|
# as for a `disabled` one. Starting something already installed is how a deploy locks a
|
|
# live session — swayidle-lock sits enabled/inactive on blankpad and `--now` arms it
|
|
# mid-conversation. Only a unit nothing has installed yet gets started. Refs #44.
|
|
state="$(systemctl --user is-enabled "$name" 2>/dev/null || true)"
|
|
case "$state" in
|
|
"" | disabled)
|
|
systemctl --user enable --now "$name" || true
|
|
echo "ENABLE $name (ExecStart resolves here; nothing running it yet)" ;;
|
|
*)
|
|
systemctl --user enable "$name" || true
|
|
echo "ENABLE $name (already $state; not started — a live session decides what runs)" ;;
|
|
esac
|
|
else
|
|
echo "SKIP $(basename "$unit") (ExecStart '$bin' absent on this host)"
|
|
fi
|
|
done
|
|
fi
|
|
|
|
# keep the secret gate armed on every machine that deploys
|
|
git -C "$REPO" config core.hooksPath .githooks
|
|
|
|
echo "deploy.sh [$MODE] host=$HOST repo=$REPO — actions=$actions quarantined=$quarantined skipped=$skipped"
|