The first real `deploy.sh` run on blankpad printed:
SKIP live-sync.service (ExecStart '%h/.omp/scripts/live-sync.sh' absent on this host)
while `/home/blank/.omp/scripts/live-sync.sh` sits right there. `%h` is expanded by systemd,
never by the shell, so the literal string was handed to `command -v`, failed, and the unit was
declared to belong to another machine — through the same code path whose whole purpose is to
keep a *foreign* unit inert, and silently. The victim was this repo's own heartbeat, and the
verdict was the same on every box that ever deployed it, because `%h` is exactly where the
checkout lives.
Nothing broke tonight only by luck of install state: `live-sync.service` is `linked`, driven
by its timer, so the heartbeat kept running while the deploy lied about it. A unit whose only
install route was this loop would simply never have been enabled anywhere.
`%h`, `%u` and `%U` are now expanded before resolution; an unrecognised specifier is left
alone so it fails loudly rather than being guessed at. Pinned by a test that writes a
`%h/.local/bin/helper` unit into the scratch home — red before (`SKIP`), green after
(`already linked; not started`), which also keeps the #45 rule intact: resolving a unit is not
licence to start it. Refs #44.
Verification: `bats tests/` 71/71, `kb-lint.sh omp-docs` 0 violations, `kb-coverage.py` 0
issues, `bash -n` clean.
208 lines
12 KiB
Markdown
208 lines
12 KiB
Markdown
---
|
|
type: reference
|
|
title: Dotfiles deployment tier
|
|
description: How dotfiles/ packages become live configuration — stow-equivalent symlinks, host override packages, and the two laws about theme media and systemd enable symlinks.
|
|
tags:
|
|
- dotfiles
|
|
- deployment
|
|
- systemd
|
|
- noctalia
|
|
timestamp: 2026-10-20
|
|
verified: 2026-10-20
|
|
---
|
|
|
|
# Dotfiles deployment tier
|
|
|
|
`dotfiles/` holds home-relative trees and `scripts/deploy.sh` is a stow-equivalent: the
|
|
repo checkout **is** the deployment, and deploying replaces live files with symlinks into
|
|
the repo, so `git pull` + `deploy.sh` is the whole propagation story. One directory per
|
|
tool (`niri`, `ghostty`, `gtk-3.0`, `systemd-user`, …); a package named `zz-$(hostname)`
|
|
is stowed last and its files replace symlinks coming from base packages.
|
|
|
|
| Mode | Effect |
|
|
|---|---|
|
|
| `deploy.sh --check` | dry run — adopts identicals, reports what would be linked, lists divergents, writes nothing. Run this first, always |
|
|
| `deploy.sh` (sync) | adopt identicals, link new, quarantine divergents into `.quarantine/` and replace them |
|
|
| `deploy.sh --adopt` | like sync, but divergents are reported and skipped rather than quarantined |
|
|
|
|
Two package shapes are not symlinks. `dconf/dump.txt` is loaded with `dconf load /`, and
|
|
`systemd-user` units get `daemon-reload` plus `enable`; a unit is **started** only when
|
|
systemd reports it as nothing-yet (`""`/`disabled`), and never when its `ExecStart` binary
|
|
is absent here, so a unit belonging to another box stays inert instead of crash-looping.
|
|
Starting something already installed is what makes `deploy.sh` unsafe to run beside a live
|
|
session — see *Never start a unit a session may already be running*.
|
|
|
|
**The tracked unit is the file, not the directory.** A live directory can be a real
|
|
directory and still be fully tracked, because every file inside it is a symlink into the
|
|
repo (`niri` is 8 tracked / 2 box-only; `gtk-3.0` is 3/0). Any audit that classifies by
|
|
directory mislabels the tree — classify per file with `readlink -f`. The live census and
|
|
its classifier command are in [home-config-census](/inventory/home-config-census.md).
|
|
|
|
## Law: media the theme pipeline consumes is content, not config
|
|
|
|
Wallpapers live at `dotfiles/noctalia/.local/share/noctalia/wallpapers/` and are
|
|
gitignored. They were tracked until 2026-10-20 — 23 files, 80 MiB — which meant every
|
|
clone of a *configuration* repo carried a media library no diff can review, and the
|
|
payload only ever grows because deleting a tracked blob from the tree does not delete it
|
|
from history. The topology is not what one would guess: [Noctalia](noctalia-shell.md)
|
|
keeps its own library at `~/.local/share/noctalia/wallpapers/` as a **real directory of
|
|
real files**, so the tracked tree was a duplicate of it and never its source — measured
|
|
2026-10-20, when the merge of #41 deleted the repo copies and the desktop did not
|
|
flicker. Two consequences to know before untracking media: the copy that survives belongs
|
|
to the application, not to the repo; and untracking deletes the file in every other
|
|
checkout, `git rm --cached` on the branch notwithstanding.
|
|
|
|
Restore on a fresh box: the library lives in `~/.local/share/noctalia/wallpapers/`, a path
|
|
`deploy.sh` does not manage. Every filename is `wallhaven_<id>.<ext>` and the ID is the
|
|
whole address — `curl -L -o <name> "https://wallhaven.cc/w/<id>"` into that directory puts
|
|
the set back. Losing it is an acceptable outcome; 80 MB per clone forever is not.
|
|
|
|
## Law: never `systemctl --user enable` a unit from a worktree
|
|
|
|
`enable` writes `~/.config/systemd/user/default.target.wants/<unit>` as a symlink to the
|
|
unit path it was handed, and that path is absolute. The heartbeat reaps `wt/*` worktrees
|
|
older than seven days ([live-sync](live-sync.md)), so a unit enabled from a worktree turns
|
|
into a dangling symlink while `systemctl --user is-enabled` still answers `enabled` — the
|
|
unit is dead and the status lies about it. Found on 2026-10-20:
|
|
`blankpad-desk-autoswitch.service` pointed into the reaped `wt/de-redesign-20261009`, so
|
|
desk auto-switching had been silently off since the reap.
|
|
|
|
Check the link target before the unit file:
|
|
|
|
```bash
|
|
ls -l ~/.config/systemd/user/default.target.wants/
|
|
# every target must resolve; a dangling one is a reaped worktree, not a config bug
|
|
```
|
|
|
|
Fix is to relink into the deployment checkout (`~/.omp/dotfiles/…`), `systemctl --user
|
|
daemon-reload`, then confirm with `is-active` — `is-enabled` proves nothing.
|
|
|
|
## Law: a generated file must never be a required include
|
|
|
|
`~/.config/niri/config.kdl` ends with `include "noctalia.kdl"` — no `optional=true`.
|
|
That file is noctalia's palette output (`#ff8c00`, `#f43841`, one re-stamp
|
|
`2026-10-18 17:36:27`), so it is machine state and correctly untracked. But on a fresh
|
|
box `deploy.sh` links `config.kdl` first, and niri then refuses to start until noctalia
|
|
has run once and written the file — the compositor is bricked by the order of operations,
|
|
not by a bad config. Close it at the include, which survives someone skipping the theme
|
|
pipeline: mark generated includes `optional=true`, as the `display.kdl` and
|
|
`switch-events.kdl` lines in the same file already are.
|
|
|
|
## Desk choice is live state, never config
|
|
|
|
`~/.config/niri/cfg/display.kdl` is a relative symlink to whichever profile is active
|
|
(`desk-1-home.kdl`, `desk-2-portable.kdl`), written by `desk.sh`. Both profiles are
|
|
tracked in `dotfiles/zz-blankpad/`; the pointer is not, because which desk the box sits on
|
|
is not a fact about the repo. A fresh box therefore starts with no output configuration —
|
|
`config.kdl` includes `display.kdl` with `optional=true`, so niri comes up usable but with
|
|
default monitors until `desk.sh` is run once.
|
|
|
|
## Law: generated theme output is never deployed
|
|
|
|
noctalia rewrites palette output in place — `noctalia.*`, `themes/noctalia*`,
|
|
`colors/noctalia*`, `noctalia-*` (the classes the [census](/inventory/home-config-census.md)
|
|
proves by same-millisecond mtime). A tracked copy of one is a stale snapshot of a colour
|
|
scheme, and the failure is quiet: `dotfiles/zz-blankpad/niri/.config/niri/noctalia.kdl`
|
|
(commit `b3d3df4`, 2026-10-18) sat byte-identical to the live file for two days, harmless,
|
|
while `--check` cheerfully proposed `ADOPT … noctalia.kdl`. The moment the palette changed,
|
|
the next sync would have quarantined the live file and restored 2026-10-18's colours —
|
|
a tool silently reverting a user-visible theme choice.
|
|
|
|
`deploy.sh` now refuses the class (`SKIP … (generated theme output)`), `.gitignore` carries
|
|
the matching patterns so it cannot be re-added, and `tests/deploy.bats` pins both
|
|
directions: generated output is never adopted, and an ordinary identical config still is.
|
|
Untracked in #43, Refs #42.
|
|
|
|
## Law: a `zz` override older than the file it overrides is a time bomb
|
|
|
|
Stow order means `zz-$HOST` wins, and when both packages carry the same path, sync relinks
|
|
it *silently* — the script's own comment: "managed file changing owners between packages:
|
|
silent relink, nothing to quarantine". `dotfiles/zz-blankpad/niri/.config/niri/cfg/layout.kdl`
|
|
held focus ring `width 2` / `active-color "#e01b24"` from `7b7dbf5` (2026-10-17) while the
|
|
base package absorbed `b3d3df4` (2026-10-18): `width 4`, `"#ff8c00"`. Live was linked to
|
|
base, the newer intent, so `--check` printed `RELINK .config/niri/cfg/layout.kdl` on every
|
|
run and one unattended `deploy.sh` would have reverted the amber focus ring with no
|
|
quarantine and no warning. The duplicate is deleted in #43.
|
|
|
|
Rule: a `zz` file exists only to differ. When base absorbs the change, delete the `zz` copy
|
|
in the same commit.
|
|
|
|
How to read `--check`, measured on blankpad: 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` and `.zshrc` all did — because the walk
|
|
processes the base package first and reports the intermediate owner change. A `RELINK` line
|
|
therefore proves a pending change only when `readlink` shows the live target is *not*
|
|
already the `zz-$HOST` file. `actions=0` is the only honest summary.
|
|
## Law: never start a unit a session may already be running
|
|
|
|
`sync` used to end with `systemctl --user enable --now` for every unit in `systemd-user/`
|
|
whose `ExecStart` resolves. Measured on blankpad before one such run:
|
|
|
|
|unit|`UnitFileState`|`ActiveState`|
|
|
|---|---|---|
|
|
|`live-sync.service`|`linked`|`inactive`|
|
|
|`shelly-notifications.service`|`enabled`|`active`|
|
|
|`swayidle-lock.service`|`enabled`|`inactive`|
|
|
|
|
`swayidle-lock.service` was installed and **not running** — nothing started it, no swayidle
|
|
process existed (`pgrep -c swayidle` = 0), and `autostart.kdl` does not launch one. A
|
|
`deploy.sh` run would have armed it: monitors blanking and locking on the idle timer while
|
|
the operator was mid-sentence. `live-sync.service` is `linked`, driven by its timer;
|
|
`--now` forces a heartbeat commit at an arbitrary moment.
|
|
|
|
Two rules follow. Install state must be read from the **printed state**, not the exit
|
|
status — `is-enabled` exits 1 for `linked` exactly as for `disabled`, so exit status cannot
|
|
tell "installed, driven by a timer" apart from "never installed". And a unit is started only
|
|
in the latter case: a machine with a session on it has already decided what runs, while a
|
|
fresh box — where everything reports `disabled` — still gets its units up. Both directions
|
|
are pinned in `tests/deploy.bats` over a stub `systemctl`, because the real one on this box
|
|
is exactly what must not be invoked from a test. Refs #44.
|
|
|
|
## Law: expand systemd specifiers before calling a unit foreign
|
|
|
|
`ExecStart=%h/.omp/scripts/live-sync.sh` is valid systemd and meaningless to a shell. The
|
|
resolution check handed that literal string to `command -v`, which fails everywhere, so the
|
|
deployment concluded the unit belonged to another machine and skipped it — silently, through
|
|
the same code path that is supposed to keep a *foreign* unit inert. The unit it kept skipping
|
|
was this repo's own heartbeat, and `%h` is precisely where this checkout lives, so the false
|
|
verdict was the same on every box that deployed it. Measured on blankpad: the first real
|
|
`deploy.sh` run printed `SKIP live-sync.service (ExecStart '%h/.omp/scripts/live-sync.sh'
|
|
absent on this host)` while `/home/blank/.omp/scripts/live-sync.sh` sits right there.
|
|
|
|
Expand the specifiers a user unit can carry — `%h`, `%u`, `%U` — before resolving, and let an
|
|
unknown specifier fail rather than guess at it. The probe needs no privileges and writes
|
|
nothing:
|
|
|
|
```bash
|
|
bin=$(sed -n 's/^ExecStart=\([^ ]*\).*/\1/p' \
|
|
~/.omp/dotfiles/systemd-user/.config/systemd/user/live-sync.service | head -1)
|
|
command -v "${bin//%h/$HOME}" # resolves → the unit is this box's; the SKIP was the lie
|
|
```
|
|
|
|
## Law: the dconf dump holds preferences, not per-box state
|
|
|
|
`dconf load /` writes every key in `dotfiles/dconf/dump.txt`, so the dump carries whatever
|
|
box it was taken from. Comparing key by key against live state on blankpad found **six of
|
|
24** keys that were not this box's, and not one of them was a preference:
|
|
|
|
[org/gnome/portal/filechooser/firefox] last-folder-path
|
|
repo: '/home/dank/Downloads' live: '/home/blank/Downloads'
|
|
[org/gnome/nautilus/window-state] initial-size, initial-size-file-chooser, maximized
|
|
[org/gnome/desktop/background] picture-uri, picture-uri-dark
|
|
both: 'file:///home/dank/.config/background'
|
|
|
|
A file-chooser memory, a window geometry, a wallpaper URI: application state that arrived
|
|
with the dotfiles import. The last five did not appear as conflicts at all, because
|
|
blankpad's **live** tree already held the same `/home/dank` values — an earlier deploy had
|
|
written them here. A foreign value that agrees with live is invisible to any diff and still
|
|
wrong. All six come out in #44; the two wallpaper URIs are also cleared on this box
|
|
(`gsettings set org.gnome.desktop.background picture-uri ''`, and its `-dark` twin), because
|
|
niri draws the wallpaper, noctalia owns it, and nothing reads a dead path.
|
|
|
|
Compare **key by key**, not with a plain `diff`: `dconf load` writes only the keys present,
|
|
so live-only keys survive and a diff overstates the damage in one direction and understates
|
|
it in the other. Re-dump on the box whose settings are canonical (`dconf dump / >
|
|
dotfiles/dconf/dump.txt`), then delete the state sections before committing — the census's
|
|
*Never track: app state* class applies inside a tracked file, not just to whole directories.
|
|
Refs #44.
|