Two lies in the unit loop, both reported as success: - `systemctl --user enable live-sync.service` fails on every deploy — the file has no [Install] section, because live-sync.timer is what arms it. The result was swallowed by `|| true` and the tool printed ENABLE anyway, so the line an operator reads after a rebuild was false on every box, on the repo's own heartbeat. The [Install] section is readable from the file being deployed, so the tool now checks it and prints INERT with the reason instead of making a call that cannot succeed; a real enable failure prints INERT too. - The loop walked *.service only. live-sync.timer was never enabled by anything, so a rebuilt box got the heartbeat and no schedule for it. Timers are walked now; a timer has no ExecStart, so the "does this unit belong on this box" test runs against the service it activates. The systemctl stub models systemd's refusal (no [Install] section -> non-zero), which is what makes the new tests red against the old loop; both new tests were run red first, and the five existing unit tests still pin #44's semantics. Refs #60
279 lines
17 KiB
Markdown
279 lines
17 KiB
Markdown
---
|
|
type: reference
|
|
title: Dotfiles deployment tier
|
|
description: How dotfiles/ packages become live configuration — deploy.sh modes, host override packages, and the tier's hard-won laws, from media and generated theme output to systemd enable/start semantics, dconf dumps, and idle-lock watcher triage.
|
|
tags:
|
|
- dotfiles
|
|
- deployment
|
|
- systemd
|
|
- noctalia
|
|
timestamp: 2026-10-20
|
|
verified: 2026-10-21
|
|
---
|
|
|
|
# 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 /`. The
|
|
`systemd-user` tier gets `daemon-reload`, then **every `*.service` and `*.timer` whose start
|
|
command resolves on this box** is enabled — a timer is the schedule that makes a oneshot service
|
|
run at all, and it carries no `ExecStart` of its own, so the belonging test is applied to the
|
|
service it activates. A unit is **started** (`--now`) only when systemd reports it as nothing-yet
|
|
(`""`/`disabled`), and never when its binary is absent here, so a unit belonging to another box
|
|
stays inert instead of crash-looping. Three outcomes are printed, and each says what actually
|
|
happened: `ENABLE` only when `systemctl enable` returned success, `INERT` when it could not or did
|
|
not, `SKIP` when the unit is foreign. Refs #44, #60.
|
|
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 — but `https://wallhaven.cc/w/<id>` answers `text/html`, the web page, so
|
|
resolve the direct CDN URL through the API and download that:
|
|
|
|
```bash
|
|
id=73g8xo # the wallhaven_<id> stem of any file in the set
|
|
url=$(curl -s "https://wallhaven.cc/api/v1/w/$id" | grep -o '"path":"[^"]*' | cut -d'"' -f4)
|
|
curl -L -o ~/.local/share/noctalia/wallpapers/"${url##*/}" "$url"
|
|
```
|
|
|
|
`data.path` already ends in `wallhaven_<id>.<ext>`, so the filename keeps itself. 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.
|
|
|
|
**A third state exists, and it is not an error.** A unit file with no `[Install]` section cannot be
|
|
enabled at all — `systemctl --user enable` answers with a non-zero exit and a paragraph about
|
|
missing `WantedBy`. `live-sync.service` is one on purpose: `live-sync.timer` pulls it in, which is
|
|
also why systemd reports it as `linked`. Until 2026-10-21 the deploy swallowed that failure and
|
|
printed `ENABLE` anyway, so the line an operator reads to learn what a rebuild armed was false on
|
|
every box, on the repo's own heartbeat. The section is readable from the file being deployed, so
|
|
the tool checks it and prints `INERT …(no [Install] section — another unit arms it, enable cannot)`
|
|
without making the call. Refs #60.
|
|
|
|
## 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.
|
|
|
|
## Law: a watcher must restart on a clean exit too
|
|
|
|
`swayidle-lock.service` was `enabled`, present in `default.target.wants/`, and **dead for eight
|
|
days**: `Result=success`, `NRestarts=0`, gone since 2026-10-12 16:22 after 2d 21h of uptime.
|
|
`Restart=on-failure` cannot see a clean exit, and `swayidle -w` exits 0 on SIGTERM. The trigger is
|
|
attributable to eight seconds: **`niri` and `noctalia` both started `Mon Oct 12 16:22:31 2026`**
|
|
(`ps -o lstart=`) while swayidle's last state change was `16:22:23` — the compositor session
|
|
restarted, the watcher went with it, and because it was wanted by `default.target` rather than tied
|
|
to the session target, nothing brought it back. The user journal keeps no entries for that window,
|
|
so the attribution rests on process ages rather than logs, which is its own lesson: diagnose a
|
|
lapse within the retention you actually have.
|
|
|
|
**The severity claim in this page's first draft was wrong, and is corrected here.** It said closing
|
|
the lid suspended the machine unlocked, because the `before-sleep` hook lived in the dead unit. But
|
|
`systemd-inhibit --list` shows `noctalia … sleep "Lock before sleep" delay` — the shell holds its
|
|
own sleep inhibitor and locks on suspend independently of swayidle, so the suspend path was covered
|
|
the whole time. What was actually dead for eight days is the **idle** lock: five minutes of
|
|
inactivity locked nothing, and `after-resume` never fired. `pgrep -c -u blankpad
|
|
'swayidle|swaylock|hyprlock|gtklock'` = 0 remains true; it simply did not mean *no lock path at
|
|
all*, because a locker can be a hook inside a running shell rather than a name on the process list.
|
|
Read the inhibitor table, not only `pgrep`.
|
|
|
|
Three settings, each with a job: `Restart=always`, because a supervisor must not assume it can
|
|
only die badly; `StartLimitIntervalSec=0`, because a fast loop must never wedge it into `failed` —
|
|
a lock that keeps trying beats one that stopped trying; and `PartOf=graphical-session.target`,
|
|
because the unit is wanted by `default.target`, so the session coming back did not bring *it*
|
|
back.
|
|
|
|
Verify by behaviour rather than by reading the file — the point is surviving the event that killed
|
|
it in October:
|
|
|
|
```bash
|
|
systemctl --user start swayidle-lock.service
|
|
kill -TERM "$(systemctl --user show swayidle-lock.service -p MainPID --value)"
|
|
sleep 8 && systemctl --user show swayidle-lock.service -p NRestarts -p MainPID -p ActiveState
|
|
```
|
|
|
|
An idle inhibitor also disarms the timeout outright, which surprises people: while an `omp` agent
|
|
session is running, swayidle logs `Not enabling timeouts: idle inhibitor found` and arms no idle
|
|
timeout at all — `systemd-inhibit --list` shows `omp … idle "omp agent session" block`. A box with
|
|
an agent working therefore does not lock on inactivity, by harness design, while `before-sleep` and
|
|
`after-resume` keep working. Anyone asking "why did the screen never lock" should read the inhibitor
|
|
table before suspecting the unit.
|
|
|
|
The general shape: `deploy.sh` only ever *enables*. Nothing on any box re-checks that a unit the
|
|
system depends on is still running, so a security posture can lapse silently and stay lapsed for a
|
|
week. A liveness check for this tier belongs in the heartbeat, not in a human's memory. Refs #47.
|