Files
command-center/scripts/deploy.sh
Frank 3022277167 Stop the deploy tier from reverting live theme state
Three findings from verifying the #41 merge against the live box with
`deploy.sh --check`. One commit because they share the deploy tier, its
documentation, and the first test file that tier ever had.

**1. Generated palette output was tracked, and sync would have restored it over a
live theme.** `dotfiles/zz-blankpad/niri/.config/niri/noctalia.kdl` (`b3d3df4`,
2026-10-18) sat byte-identical to the live file — harmless while the palette held, and
`--check` was already proposing `ADOPT … noctalia.kdl`. The moment the theme changed, the
next sync would have quarantined the live file and reinstated 2026-10-18's colours.
`deploy.sh` now refuses the class outright (`SKIP … (generated theme output)`), `.gitignore`
carries the matching patterns so it cannot be re-added, and the file is untracked.

**2. A `zz` override older than the file it overrides.** `dotfiles/zz-blankpad/niri/…/
layout.kdl` held focus ring `width 2` / `#e01b24` (`7b7dbf5`, 2026-10-17) while base
absorbed `b3d3df4` (2026-10-18): `width 4`, `#ff8c00`. Stow order makes `zz-*` win, and a
cross-package owner change relinks *silently* — no quarantine, no warning. Live was linked
to base, the newer intent, so `--check` printed `RELINK … layout.kdl` every run and one
unattended `deploy.sh` would have reverted the amber focus ring. The duplicate is deleted;
base owns the file.

**3. Two topologies I had asserted and not measured.** The media law and the `.gitignore`
comment both said `~/.local/share/noctalia/wallpapers` symlinks into the repo, so deleting
the tracked copies would blank the desktop. Measured: it is a real directory of real files
that Noctalia owns; the repo held a duplicate, and the #41 merge deleted that duplicate
without the desktop flickering. The correct general law replaces the false one — untracking
a path deletes the file in every other checkout, `git rm --cached` on the branch
notwithstanding — and the restore procedure now points at the app's directory, which
`deploy.sh` does not manage.

The guard is `tests/deploy.bats`, black-box over exit status, stdout, the symlink shape
under `$HOME`, and the quarantine directory. Before the change: test 1 and test 2 fail,
test 2 because the live palette really is quarantined and reverted. After: 4/4, including
the control that an ordinary identical config is still adopted — an exclusion nobody can
turn off is just a broken tool.

Verification: `bats tests/` 66/66, `kb-lint.sh omp-docs` 0 violations, `kb-coverage.py` 0
issues, and live `deploy.sh --check` on blankpad reports `SKIP … (generated theme output)`
with `actions=0` instead of `ADOPT … noctalia.kdl`. The real `deploy.sh` run that clears the
remaining `RELINK` lines is deliberately left until this is in main — running it from a
worktree would repoint live symlinks into a checkout the heartbeat reaps.

Refs #42
2026-10-20 17:08:06 -07:00

139 lines
5.8 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
systemctl --user enable --now "$(basename "$unit")" || true
echo "ENABLE $(basename "$unit") (ExecStart resolves here)"
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"