Files
Nomarchy/flake.nix
Bernardo Magri 9e37e11915
Some checks failed
Check / eval (push) Failing after 3m11s
feat(lifecycle): auto-commit sweep of the machine flake before pull/rebuild/home
With settings.autoCommit on, only menu/theme mutations were committed
(theme-sync pathspec-limits to theme-state.json by design), so hand
edits to system.nix/home.nix and lock bumps stayed forever-dirty.

New internal nomarchy-autocommit (nomarchy-lifecycle): live-reads the
same flag, commits everything dirty as "nomarchy: auto-commit before
<pull|rebuild|home switch>" with the swept file list in the body,
fallback identity, never fatal (|| true at call sites). Called at the
top of all three lifecycle commands; before the ff-only pull it also
un-dirties the tree. Exposed via passthru.autocommit + package export;
guarded by checks.lifecycle-autocommit (sandbox repos: sweep, no-op on
clean/off/non-repo). Sync sweep: README, RECOVERY, ROADMAP, rofi
toggle, control-center prompt, theme-sync docstring, JOURNAL.

Verified: V1+ — flake check --no-build green; the new check builds;
end-to-end run of the built nomarchy-rebuild (stubbed sudo/rebuild)
against a copy of a real dirty ~/.nomarchy: 4 files → one commit,
clean tree. No VM boot — no session-visible surface.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 12:52:05 +01:00

1561 lines
79 KiB
Nix
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
description = "Nomarchy the rock-solid reproducibility of NixOS, the polish of an opinionated desktop";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
home-manager = {
url = "github:nix-community/home-manager/release-26.05";
inputs.nixpkgs.follows = "nixpkgs";
};
stylix = {
# Track the release branch matching nixpkgs/HM — master is built
# against unstable and warns about the version skew on every
# activation. (No home-manager input to follow anymore: stylix
# vendors its HM integration.)
url = "github:nix-community/stylix/release-26.05";
inputs.nixpkgs.follows = "nixpkgs";
};
# Pinned by Nomarchy so distro + hardware quirks are tested together
# (consumed by lib.mkFlake's hardwareProfile). Its nixpkgs input is
# only used by its own CI — follow ours to keep locks/ISO lean.
nixos-hardware = {
url = "github:NixOS/nixos-hardware/master";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, home-manager, stylix, nixos-hardware, ... }:
let
system = "x86_64-linux";
username = "nomarchy"; # reference host only; downstream picks its own
pkgs = import nixpkgs {
inherit system;
overlays = [ self.overlays.default ];
# Matches nixpkgs.config.allowUnfree in modules/nixos — the
# standalone HM generation must come from like-configured pkgs.
config.allowUnfree = true;
};
# The guided installer — live-ISO only (not in the overlay): it bakes
# in the downstream template, this checkout's flake.lock, and the
# source identity of this very revision so installs work offline.
gitUrl = "https://git.bemagri.xyz/bernardo/nomarchy.git";
nomarchyInstall = pkgs.callPackage ./pkgs/nomarchy-install {
templateDir = ./templates/downstream;
nomarchyLock = ./flake.lock;
hardwareModuleNames = pkgs.writeText "nixos-hardware-modules.txt"
(nixpkgs.lib.concatStringsSep "\n"
(builtins.attrNames nixos-hardware.nixosModules));
nixpkgsPath = nixpkgs.outPath;
# Branch model: `main` is the development default; `v1` is the
# release pointer — the stable line for the NixOS 26.05 rewrite,
# advanced to main once a batch is tested (rolling, no tags). A
# future major bump would become `v2`. Installed machines track
# `?ref=v1` on `nix flake update`, not the forge's default branch.
flakeUrl = "git+${gitUrl}?ref=v1";
# Future updates re-resolve from the forge (original); the install
# itself resolves from the ISO store (locked = path) — fully
# offline, even from a dirty-tree ISO.
originalNode = { type = "git"; url = gitUrl; ref = "v1"; };
lockedNode = {
type = "path";
path = self.outPath;
narHash = self.narHash;
lastModified = self.lastModified or 0;
};
rev = self.rev or null;
};
in
{
# ─── Downstream API ────────────────────────────────────────────
# A user's machine flake imports these and layers its own
# system.nix / home.nix on top (see templates/downstream).
# Their theme-state.json lives in THEIR flake and is wired up via
# the `nomarchy.stateFile` option — reading it stays pure.
overlays.default = final: prev: {
nomarchy-theme-sync = final.callPackage ./pkgs/nomarchy-theme-sync {
# Presets baked into the package, so `nomarchy-theme-sync list`
# works on machines that don't check out this repo.
themesDir = ./themes;
};
nomarchy-doctor = final.callPackage ./pkgs/nomarchy-doctor { };
nomarchy-control-center = final.callPackage ./pkgs/nomarchy-control-center { };
nomarchy-battery-notify = final.callPackage ./pkgs/nomarchy-battery-notify { };
nomarchy-first-boot = final.callPackage ./pkgs/nomarchy-first-boot { };
nomarchy-what-changed = final.callPackage ./pkgs/nomarchy-what-changed { };
nomarchy-detect-hw = final.callPackage ./pkgs/nomarchy-detect-hw { };
# pull / rebuild / home (+ legacy aliases) — also on HM so a home
# switch can refresh a broken system-package nomarchy-pull.
# Includes nomarchy-what-changed (generation readability, #82).
nomarchy-lifecycle = final.callPackage ./pkgs/nomarchy-lifecycle { };
};
nixosModules.nomarchy = {
imports = [ ./modules/nixos ];
nixpkgs.overlays = [ self.overlays.default ];
};
homeModules.nomarchy = {
# stylix's home module is injected here because it needs the
# flake input; modules/home/stylix.nix only configures it.
imports = [ stylix.homeModules.stylix ./modules/home ];
};
# One-call downstream wrapper — see templates/downstream/flake.nix.
# The generated flake is never hand-edited; machines live in
# system.nix / home.nix.
lib = import ./lib.nix {
inherit nixpkgs home-manager nixos-hardware;
nomarchy = self;
};
templates.default = {
path = ./templates/downstream;
description = "Nomarchy machine configuration (downstream flake)";
};
packages.${system} = {
nomarchy-theme-sync = pkgs.nomarchy-theme-sync;
nomarchy-install = nomarchyInstall;
# Overlay tools exported for `nix build .#nomarchy-doctor` etc.
# (maintainer/CI convenience — modules install them via the overlay).
nomarchy-doctor = pkgs.nomarchy-doctor;
nomarchy-lifecycle = pkgs.nomarchy-lifecycle;
nomarchy-control-center = pkgs.nomarchy-control-center;
nomarchy-battery-notify = pkgs.nomarchy-battery-notify;
nomarchy-first-boot = pkgs.nomarchy-first-boot;
nomarchy-what-changed = pkgs.nomarchy-what-changed;
nomarchy-detect-hw = pkgs.nomarchy-detect-hw;
default = pkgs.nomarchy-theme-sync;
};
# ─── Checks ────────────────────────────────────────────────────
# `nix flake check` never evaluates templates/, so exercise the
# downstream template through mkFlake directly (this bypasses the
# template's own three-line flake.nix, which only resolves once
# instantiated — everything else is covered here, including the
# hardwareProfile → nixos-hardware mapping).
checks.${system} =
let
downstream = self.lib.mkFlake {
src = ./templates/downstream;
username = "me";
hardwareProfile = "framework-13-7040-amd"; # exercises the mapping
};
in
{
downstream-template-system =
downstream.nixosConfigurations.default.config.system.build.toplevel;
downstream-template-home =
downstream.homeConfigurations.me.activationPackage;
# Every hex-on-hex text pairing the generated swaync CSS uses
# must contrast in EVERY palette — summer-day's body text was
# invisible on hardware (item 25: subtext==base there). Cheap
# (no VM); extend the script's PAIRINGS as more generated
# surfaces move to palette-safe roles (item 27: waybar/rofi).
theme-contrast = pkgs.runCommand "nomarchy-theme-contrast"
{ nativeBuildInputs = [ pkgs.python3 ]; }
''
python3 ${./tools/check-theme-contrast.py} ${./themes}
touch $out
'';
# Whole-swap waybar.css is read raw (no palette prepend) — every
# @color it references must be @define-color'd in the same file
# (neon-glass shipped zero defines; BACKLOG #62). Also gate
# preview.png + non-empty backgrounds/ per preset.
theme-wholeswap = pkgs.runCommand "nomarchy-theme-wholeswap"
{ nativeBuildInputs = [ pkgs.python3 ]; }
''
python3 ${./tools/check-theme-wholeswap.py} ${./themes}
touch $out
'';
# The README option tables must track the live `nomarchy.*`
# surface. The surface is walked at eval time from the four
# option-declaring modules (their lazy `config` halves stay
# unevaluated), so the live side of the diff can't go stale;
# the script wants a table row per option (inline `.leaf`
# mentions ok for non-enable sub-knobs) and flags stale rows.
option-docs =
let
lib = nixpkgs.lib;
names = prefix: opts: lib.concatLists (lib.mapAttrsToList
(n: v:
if lib.isOption v
then lib.optional (!(v.readOnly or false)) "${prefix}${n}"
else names "${prefix}${n}." v)
opts);
surface = lib.concatLists [
(names "nomarchy."
((import ./modules/home/options.nix { inherit lib pkgs; config = { }; }).options.nomarchy))
(names "nomarchy.system."
((import ./modules/nixos/options.nix { inherit lib; config = { }; }).options.nomarchy.system))
(names "nomarchy.hardware."
((import ./modules/nixos/hardware.nix { inherit lib pkgs; config = { }; }).options.nomarchy.hardware))
(names "nomarchy.services."
((import ./modules/nixos/services.nix { inherit lib pkgs; config = { }; }).options.nomarchy.services))
];
in
pkgs.runCommand "nomarchy-option-docs"
{ nativeBuildInputs = [ pkgs.python3 ]; }
''
python3 ${./tools/check-option-docs.py} \
${pkgs.writeText "option-surface.txt"
(lib.concatStringsSep "\n" surface + "\n")} \
${./README.md}
touch $out
'';
# The state-file friendly-errors contract (item 11): validate
# reports broken fixtures by field with a fix, good state
# passes, and an invalid `set` is refused BEFORE the write —
# the on-disk file must be byte-identical after the attempt.
theme-sync-validate = pkgs.runCommand "nomarchy-theme-sync-validate"
{ nativeBuildInputs = [ pkgs.python3 pkgs.jq ]; }
''
set -eu
tool=${./pkgs/nomarchy-theme-sync/nomarchy-theme-sync.py}
export NOMARCHY_DEFAULT_THEMES=${./themes}
mkdir good && cp ${./templates/downstream/theme-state.json} good/theme-state.json
chmod +w good/theme-state.json
NOMARCHY_PATH=$PWD/good python3 "$tool" validate
mkdir syntax && printf '{ "slug": "x", }' > syntax/theme-state.json
if NOMARCHY_PATH=$PWD/syntax python3 "$tool" validate 2>err; then
echo "FAIL: syntax fixture accepted"; exit 1; fi
grep -q "syntax error at line" err
mkdir badhex && jq '.colors.accent = "7aa2f7"' \
good/theme-state.json > badhex/theme-state.json
if NOMARCHY_PATH=$PWD/badhex python3 "$tool" validate 2>err; then
echo "FAIL: bad hex accepted"; exit 1; fi
grep -q "colors.accent" err && grep -q "fix:" err
mkdir badtype && jq '.ui.gapsOut = "12"' \
good/theme-state.json > badtype/theme-state.json
if NOMARCHY_PATH=$PWD/badtype python3 "$tool" validate 2>err; then
echo "FAIL: bad type accepted"; exit 1; fi
grep -q "ui.gapsOut" err
mkdir unknown && jq '.extraKey = 1' \
good/theme-state.json > unknown/theme-state.json
NOMARCHY_PATH=$PWD/unknown python3 "$tool" validate \
| grep -q "unknown top-level key"
cp good/theme-state.json before.json
if NOMARCHY_PATH=$PWD/good python3 "$tool" --quiet \
set ui.gapsOut '"12"' --no-switch 2>err; then
echo "FAIL: invalid set was written"; exit 1; fi
grep -q "refusing to write" err
cmp good/theme-state.json before.json
touch $out
'';
# The lifecycle "sweep before switch": with settings.autoCommit
# on, nomarchy-pull/-rebuild/-home first commit every dirty file
# in the machine flake (theme-sync's own auto_commit stays
# pathspec-limited to theme-state.json). Exercises the real
# helper binary against sandbox repos: flag on → one commit,
# clean tree, fallback identity, file list in the body; clean
# tree / flag off / no repo → untouched, never an error.
lifecycle-autocommit = pkgs.runCommand "nomarchy-lifecycle-autocommit"
{ nativeBuildInputs = [ pkgs.git ]; }
''
set -eu
tool=${pkgs.nomarchy-lifecycle.autocommit}/bin/nomarchy-autocommit
export HOME=$PWD # no global git config fallback identity
mkdir flake && git -C flake init -q
printf '{ "settings": { "autoCommit": true } }' > flake/theme-state.json
echo base > flake/system.nix
git -C flake add -A
git -C flake -c user.name=T -c user.email=t@t commit -qm init
echo edit >> flake/system.nix
echo lock > flake/flake.lock
NOMARCHY_PATH=$PWD/flake $tool rebuild
[ -z "$(git -C flake status --porcelain)" ] \
|| { echo "FAIL: tree still dirty after sweep"; exit 1; }
git -C flake log -1 --format=%s \
| grep -qx 'nomarchy: auto-commit before rebuild'
git -C flake log -1 --format=%ae | grep -qx 'nomarchy@localhost'
git -C flake log -1 --format=%b | grep -q 'flake.lock'
# Clean tree no new commit
NOMARCHY_PATH=$PWD/flake $tool rebuild
[ "$(git -C flake rev-list --count HEAD)" = 2 ]
# Flag off dirty stays dirty, no commit
printf '{ "settings": { "autoCommit": false } }' > flake/theme-state.json
echo more >> flake/system.nix
NOMARCHY_PATH=$PWD/flake $tool rebuild
[ "$(git -C flake rev-list --count HEAD)" = 2 ]
[ -n "$(git -C flake status --porcelain)" ]
# Not a git repo silent no-op, exit 0
mkdir plain
printf '{ "settings": { "autoCommit": true } }' > plain/theme-state.json
NOMARCHY_PATH=$PWD/plain $tool rebuild
touch $out
'';
# Installer hardware DB ↔ nixos-hardware (item 49): every module
# name the DB table and the CPU/GPU/chassis autodetect reference
# must exist in the PINNED nixos-hardware.nixosModules — a lock
# bump can rename a module upstream (the X1 Carbon → x1-Nth-gen
# churn) and break profiled installs on just the matching DMI,
# invisible to any VM. The available-set is generated here from
# attrNames — the same value fed to nomarchy-install — so it
# tracks the lock automatically.
hardware-db-modules = pkgs.runCommand "nomarchy-hardware-db-modules"
{ nativeBuildInputs = [ pkgs.python3 ]; }
''
python3 ${./tools/check-hardware-db.py} \
${./pkgs/nomarchy-install/hardware-db.sh} \
${pkgs.writeText "nixos-hardware-modules.txt"
(nixpkgs.lib.concatStringsSep "\n"
(builtins.attrNames nixos-hardware.nixosModules))}
touch $out
'';
# compose-lock.py offline-composer contract (item 49): the lock
# the installer writes on the target with no network must root at
# nomarchy, carry every upstream node one level down with its
# `follows` paths rebased, and fail closed on a missing narHash or
# a pre-existing nomarchy node. Pure — runs the script on fixtures.
installer-compose-lock = pkgs.runCommand "nomarchy-installer-compose-lock"
{ nativeBuildInputs = [ pkgs.python3 ]; }
''
python3 ${./tools/check-compose-lock.py} \
${./pkgs/nomarchy-install/compose-lock.py}
touch $out
'';
# Installer safety guards (item 54): offline compose-lock fail-closed
# (no network flake lock), disk-signature warn regex, pre-activate
# recovery hint path, password floors. Pure source+regex contract.
installer-safety = pkgs.runCommand "nomarchy-installer-safety"
{ nativeBuildInputs = [ pkgs.python3 ]; }
''
python3 ${./tools/check-install-safety.py} \
${./pkgs/nomarchy-install/nomarchy-install.sh}
touch $out
'';
# Installer share ↔ templates/downstream SoT (item 72): the four
# files default.nix copies into share/.../template/ must stay
# byte-identical to the checkout template (single machine-seed
# SoT; no thinner second catalog). Cheap package build + cmp.
template-sot = pkgs.runCommand "nomarchy-template-sot"
{ nativeBuildInputs = [ pkgs.python3 ]; }
''
python3 ${./tools/check-template-sot.py} \
${nomarchyInstall}/share/nomarchy-install/template \
${./templates/downstream}
touch $out
'';
# The installer's disko arg contract (item 49): swapSize "0"/"0G"
# (bare "0" is what the swap=0 install passes) must yield NO @swap
# subvolume, a sized value a correctly-sized one, and withLuks must
# gate the LUKS wrapper around the BTRFS root. Pure eval of
# disko-config.nix (a plain function) — no disko, no VM.
installer-disko =
let
diskoFor = args: import ./pkgs/nomarchy-install/disko-config.nix
({ mainDrive = "/dev/vda"; } // args);
rootContent = args:
(diskoFor args).disko.devices.disk.main.content.partitions.root.content;
btrfsOf = c: if c.type == "luks" then c.content else c;
subvols = args: (btrfsOf (rootContent args)).subvolumes;
hasSwap = args: builtins.hasAttr "@swap" (subvols args);
expect = cond: msg: nixpkgs.lib.assertMsg cond "installer-disko: ${msg}";
ok =
expect (! hasSwap { swapSize = "0"; })
''swapSize "0" produced an @swap subvol''
&& expect (! hasSwap { swapSize = "0G"; })
''swapSize "0G" produced an @swap subvol''
&& expect (hasSwap { swapSize = "2G"; })
''swapSize "2G" produced no @swap subvol''
&& expect ((subvols { swapSize = "2G"; })."@swap".swap.swapfile.size == "2G")
''the @swap swapfile size is not "2G"''
&& expect ((rootContent { withLuks = true; }).type == "luks")
"withLuks=true did not wrap root in luks"
&& expect ((rootContent { withLuks = true; }).name == "crypted")
"the luks device is not named 'crypted'"
&& expect ((rootContent { withLuks = false; }).type == "btrfs")
"withLuks=false did not put btrfs directly on root"
&& expect (builtins.hasAttr "@" (subvols { withLuks = false; }))
"the no-luks root lost its @ subvolume";
in
assert ok; pkgs.runCommand "nomarchy-installer-disko" { } "touch $out";
# Hyprland windowrule dead-syntax guard (item 49): build the
# GENERATED hyprland.conf (the ISO's HM output) and fail on a
# regression to the pre-0.53 grammar — the `windowrulev2` keyword,
# rule-first order (`float, class:^…$`), or a bare `<prop>:`
# matcher. That syntax shipped a red config-error banner on every
# boot (fixed in ed7fd93); this locks the correct form. Building
# one config file is a pure derivation, not a VM.
windowrule-syntax =
let
hyprconf = self.nixosConfigurations.nomarchy-live.config
.home-manager.users.${username}.xdg.configFile."hypr/hyprland.conf".source;
in
pkgs.runCommand "nomarchy-windowrule-syntax"
{ nativeBuildInputs = [ pkgs.python3 ]; }
''
python3 ${./tools/check-windowrule-syntax.py} ${hyprconf}
touch $out
'';
# nomarchy-doctor's contract: an induced failed unit flips the
# sheet to ✖/exit-1 and names the unit; with the failure
# cleared it reports healthy/exit-0. Minimal node (just the
# package) — the disk/flake/snapper checks self-skip in a VM.
# Live-ISO install affordance (BACKLOG #57): desktop entry baked
# into the live host's HM generation + installer on PATH + Tools
# menu gate in nomarchy-menu. Builds the real nomarchy-live HM
# package (not a full ISO) — V2 file-level smoke of the same
# artifacts the ISO ships.
live-install-entry =
let
liveHm =
self.nixosConfigurations.nomarchy-live.config.home-manager.users.${username};
gen = liveHm.home.activationPackage;
in
pkgs.runCommand "nomarchy-live-install-entry"
{
nativeBuildInputs = [ pkgs.gnugrep pkgs.findutils ];
passAsFile = [ ];
}
''
set -euo pipefail
desk=$(find ${gen}/home-path/share/applications \
-name nomarchy-install.desktop | head -1)
test -n "$desk"
grep -q '^Name=Install Nomarchy$' "$desk"
grep -q '^Terminal=true$' "$desk"
grep -q '^Exec=nomarchy-install$' "$desk"
test -x ${nomarchyInstall}/bin/nomarchy-install
# Tools Install Nomarchy self-gate lives in nomarchy-menu
# (rofi.nix), which is on the live HM path.
menu=$(find ${gen}/home-path/bin -name nomarchy-menu | head -1)
test -n "$menu"
grep -q 'Install Nomarchy' "$menu"
echo "live-install-entry: ok desk=$desk menu=$menu"
touch $out
'';
doctor = pkgs.testers.runNixOSTest {
name = "nomarchy-doctor";
nodes.machine = { ... }: {
environment.systemPackages = [ pkgs.nomarchy-doctor ];
systemd.services.doomed = {
description = "deliberately failing unit (doctor test)";
serviceConfig = {
Type = "oneshot";
ExecStart = "${pkgs.coreutils}/bin/false";
};
wantedBy = [ "multi-user.target" ];
};
};
testScript = ''
machine.wait_for_unit("multi-user.target")
machine.wait_until_succeeds("systemctl is-failed doomed.service")
out = machine.fail("nomarchy-doctor 2>&1")
assert "doomed.service" in out, f"doctor did not name the failed unit:\n{out}"
assert "journalctl" in out, f"doctor did not print a fix:\n{out}"
machine.succeed("systemctl reset-failed")
# The healthy run also exercises the #44 hardware section: in a
# bare VM every probe (nmcli/wpctl/vainfo/fprintd/fwupd) is
# absent, so each check must self-gate to a skip row and the
# sheet must still exit 0 i.e. the section runs without
# erroring under `set -euo pipefail`.
healthy = machine.succeed("nomarchy-doctor 2>&1")
for probe in ["NetworkManager", "fingerprint", "fwupd", "VA-API", "hibernate", "battery health"]:
assert probe in healthy, f"doctor hardware section missing a self-gated '{probe}' row:\n{healthy}"
# #80 battery health: faked sysfs (test_power has no cycle/design).
machine.succeed(
"mkdir -p /tmp/fake-ps/BAT0 && "
"echo Battery > /tmp/fake-ps/BAT0/type && "
"echo System > /tmp/fake-ps/BAT0/scope && "
"echo 150 > /tmp/fake-ps/BAT0/cycle_count && "
"echo 4000000 > /tmp/fake-ps/BAT0/charge_full && "
"echo 5000000 > /tmp/fake-ps/BAT0/charge_full_design"
)
bat = machine.succeed(
"NOMARCHY_POWER_SUPPLY_ROOT=/tmp/fake-ps nomarchy-doctor 2>&1"
)
assert "BAT0 health" in bat, f"doctor missing battery health line:\n{bat}"
assert "150 cycles" in bat, f"doctor missing cycle_count:\n{bat}"
assert "80% of design capacity" in bat, f"doctor missing design %:\n{bat}"
# Peripheral Device-scope batteries must not produce a health line.
machine.succeed(
"mkdir -p /tmp/fake-ps/MOUSE0 && "
"echo Battery > /tmp/fake-ps/MOUSE0/type && "
"echo Device > /tmp/fake-ps/MOUSE0/scope && "
"echo 9 > /tmp/fake-ps/MOUSE0/cycle_count"
)
bat2 = machine.succeed(
"NOMARCHY_POWER_SUPPLY_ROOT=/tmp/fake-ps nomarchy-doctor 2>&1"
)
assert "MOUSE0" not in bat2, f"doctor reported Device-scope battery:\n{bat2}"
# #83 first-boot pre-activate fail flag: log present, no HM gen .
machine.succeed(
"mkdir -p /var/log && "
"echo 'error: activation failed' > /var/log/nomarchy-hm-preactivate.log"
)
# Bare test VM has no per-user HM profile for root.
pre = machine.fail("nomarchy-doctor 2>&1")
assert "pre-activat" in pre.lower() or "Home Manager generation" in pre, (
f"doctor missing pre-activate fail line:\n{pre}"
)
assert "home-manager switch" in pre, f"doctor missing recovery one-liner:\n{pre}"
# Once a generation exists, the flag clears (recovered).
machine.succeed(
"mkdir -p /nix/var/nix/profiles/per-user/root && "
"ln -sfn /run/current-system "
"/nix/var/nix/profiles/per-user/root/home-manager"
)
pre_ok = machine.succeed("nomarchy-doctor 2>&1")
assert "desktop generation is present" in pre_ok, (
f"doctor should ok when HM gen exists:\n{pre_ok}"
)
'';
};
# Config-level VM assertions for the nomarchy.hardware.* toggles —
# what they SET (kernel cmdline, fprintd, PAM). Real hardware
# behaviour (firmware/driver/device) needs bare metal and is out of
# scope here. Imports only hardware.nix, so the VM stays minimal.
# Two nodes: pam=true must wire pam_fprintd; pam=false must NOT
# (NixOS defaults fprintAuth from fprintd.enable — we force off).
hardware-toggles = pkgs.testers.runNixOSTest {
name = "nomarchy-hardware-toggles";
nodes = {
machine = { ... }: {
imports = [ ./modules/nixos/hardware.nix ];
nomarchy.hardware = {
intel.enable = true;
amd.enable = true;
fingerprint = { enable = true; pam = true; };
npu.enable = true;
};
};
nopam = { ... }: {
imports = [ ./modules/nixos/hardware.nix ];
nomarchy.hardware.fingerprint = { enable = true; pam = false; };
};
};
testScript = ''
machine.wait_for_unit("multi-user.target")
cmdline = machine.succeed("cat /proc/cmdline")
assert "amd_pstate=active" in cmdline, cmdline
assert "i915.enable_guc=3" in cmdline, cmdline
machine.succeed("systemctl cat fprintd.service")
machine.succeed("grep -q pam_fprintd /etc/pam.d/sudo")
nopam.wait_for_unit("multi-user.target")
nopam.succeed("systemctl cat fprintd.service")
nopam.fail("grep -q pam_fprintd /etc/pam.d/sudo")
nopam.fail("grep -q pam_fprintd /etc/pam.d/login")
'';
};
# Dock audio sink priority (#87): WirePlumber rules prefer
# HDMI/DP/USB sinks over built-in analog. Pure unit test of the
# rule set + a minimal VM that the conf file is installed where
# WirePlumber reads it (XDG_DATA_DIRS / share/wireplumber).
dock-audio =
let
inherit (nixpkgs) lib;
rules = import ./modules/nixos/dock-audio-rules.nix;
alsa = rules."monitor.alsa.rules" or [ ];
expect = cond: msg: lib.assertMsg cond "dock-audio: ${msg}";
json = builtins.toJSON alsa;
pureOk =
expect (builtins.isList alsa && builtins.length alsa >= 2)
"expected 2 alsa rule groups"
&& expect (lib.hasInfix "hdmi" json)
"HDMI match missing from rules JSON"
&& expect (lib.hasInfix "usb-" json || lib.hasInfix "usb" json)
"USB match missing from rules JSON"
&& expect (lib.hasInfix "priority.session" json)
"priority.session missing"
&& expect (lib.hasInfix "1100" json)
"HDMI session priority 1100 missing";
in
assert pureOk;
pkgs.testers.runNixOSTest {
name = "nomarchy-dock-audio";
nodes.machine = { ... }: {
services.pipewire = {
enable = true;
alsa.enable = true;
pulse.enable = true;
wireplumber.enable = true;
wireplumber.extraConfig."90-nomarchy-dock-audio" =
import ./modules/nixos/dock-audio-rules.nix;
};
# WirePlumber is a user service; enable linger-less check via
# the system data dir the package puts conf into.
environment.systemPackages = [ pkgs.wireplumber ];
};
testScript = ''
machine.wait_for_unit("multi-user.target")
conf = machine.succeed(
"find /nix/store -path "
"'*wireplumber.conf.d/90-nomarchy-dock-audio.conf' "
"2>/dev/null | head -1"
).strip()
assert conf, "90-nomarchy-dock-audio.conf not in the store"
body = machine.succeed(f"cat '{conf}'")
assert "monitor.alsa.rules" in body, body
assert "priority.session" in body, body
assert "hdmi" in body.lower() or "HDMI" in body, body
assert "usb" in body.lower(), body
'';
};
# Clamshell / dock lid policy (#86): logind must ignore lid close
# when docked (external display present). Real multi-monitor lid
# events stay V3; this asserts the baked logind.conf + that
# logind is alive to consume it. Imports options + power only.
clamshell-logind = pkgs.testers.runNixOSTest {
name = "nomarchy-clamshell-logind";
nodes.machine = { ... }: {
imports = [ ./modules/nixos/options.nix ./modules/nixos/power.nix ];
nomarchy.system.power = {
enable = true;
laptop = true;
};
};
testScript = ''
machine.wait_for_unit("multi-user.target")
machine.wait_for_unit("systemd-logind.service")
conf = machine.succeed("cat /etc/systemd/logind.conf")
assert "HandleLidSwitchDocked=ignore" in conf.replace(" ", ""), (
f"clamshell: logind.conf missing HandleLidSwitchDocked=ignore:\n{conf}"
)
# logind must be running (policy is dead if the unit is down).
machine.succeed("systemctl is-active systemd-logind.service")
# busctl property: Docked lid policy is not always exported;
# re-read conf after logind start is enough for the contract.
'';
};
# Runtime VM check for the battery-charge-limit re-apply: the udev
# rule must restart the oneshot on an AC state change (firmware can
# clear the threshold on unplug). The `test_power` module fakes a
# Mains adapter we can toggle to emit a real power_supply uevent —
# so this exercises the trigger, not just the config wiring. (The
# sysfs *write* needs a real charge_control_end_threshold and stays
# an on-hardware check.) Imports options.nix (where the power
# options live) + power.nix, so the VM stays minimal.
battery-charge-limit = pkgs.testers.runNixOSTest {
name = "nomarchy-battery-charge-limit";
nodes.machine = { ... }: {
imports = [ ./modules/nixos/options.nix ./modules/nixos/power.nix ];
boot.kernelModules = [ "test_power" ]; # fake Mains + battery
nomarchy.system.power = {
enable = true;
laptop = true;
batteryChargeLimit = 80;
};
};
testScript = ''
machine.wait_for_unit("multi-user.target")
# The oneshot exists and the fake Mains adapter is present.
machine.succeed("systemctl cat nomarchy-battery-charge-limit.service")
machine.succeed("udevadm settle")
machine.succeed("grep -lx Mains /sys/class/power_supply/*/type")
# Let any add-event restart settle, then sample the invocation.
machine.succeed("sleep 2")
before = machine.succeed(
"systemctl show -p InvocationID --value "
"nomarchy-battery-charge-limit.service"
).strip()
# Simulate an AC unplug 'change' uevent on the Mains device.
machine.succeed("echo off > /sys/module/test_power/parameters/ac_online")
machine.succeed("udevadm settle")
# The udev rule must have restarted the oneshot (new InvocationID).
machine.wait_until_succeeds(
'test "$(systemctl show -p InvocationID --value '
'nomarchy-battery-charge-limit.service)" != "' + before + '"',
timeout=30,
)
'';
};
# Unit test of the monitor-rule overlay (modules/home/
# monitor-rules.nix — pure, imported directly, no HM eval): the
# active profile must replace base entries whole by name, leave
# unnamed base outputs alone, and the disable-guard must stop a
# stale menu resolution pick (settings.monitors) resurrecting a
# panel the profile disables. Junk in settings.displayProfile
# degrades to base config, never an eval error.
display-profiles =
let
inherit (nixpkgs) lib;
monitorLib = import ./modules/home/monitor-rules.nix { inherit lib; };
entry = a: monitorLib.defaults // a;
fixture = {
base = [
(entry { name = "eDP-1"; position = "0x0"; })
(entry { name = "HDMI-A-1"; position = "auto-right"; })
];
profiles.docked = [
(entry { name = "eDP-1"; resolution = "disable"; })
(entry { name = "DP-3"; position = "0x0"; })
];
resOverrides."eDP-1" = "1920x1080@60"; # stale pick — must stay dead
};
rulesFor = active: map monitorLib.rule
(monitorLib.resolve (fixture // { inherit active; }));
docked = rulesFor "docked";
junk = rulesFor 42; # hand-edited state (validator only warns)
expect = cond: msg: lib.assertMsg cond
"display-profiles: ${msg} (rendered: ${builtins.toJSON docked})";
ok =
expect (lib.elem "eDP-1, disable" docked)
"the profile did not disable eDP-1"
&& expect (! lib.any (lib.hasInfix "1920x1080") docked)
"a stale resolution pick resurrected the disabled panel"
&& expect (lib.elem "DP-3, preferred, 0x0, 1" docked)
"the profile-only output's rule is missing"
&& expect (lib.elem "HDMI-A-1, preferred, auto-right, 1" docked)
"an unnamed base output lost its rule"
&& expect (lib.elem "eDP-1, 1920x1080@60, 0x0, 1" junk)
"junk active profile must degrade to base config (with the pick applied)"
&& expect (monitorLib.workspaceRule "1" "DP-3" == "1, monitor:DP-3")
"workspace pin did not render as a Hyprland workspace rule";
in
assert ok; pkgs.runCommand "nomarchy-display-profiles" { } "touch $out";
# Night-light geo mode: with BOTH lat/long set the backing unit
# flips hyprsunset → wlsunset (location-computed sunrise/sunset)
# and the live-state ExecCondition gate survives the swap (the
# toggle script's unit= comes from the same let-binding, so the
# gate landing on wlsunset covers the selection logic). Pure
# eval over the rendered HM units — nothing is built. The
# visible warm shift itself stays hardware-tier (HARDWARE-QUEUE),
# exactly like the original night-light full cycle.
nightlight-geo =
let
inherit (nixpkgs) lib;
hm = home-manager.lib.homeManagerConfiguration {
inherit pkgs;
modules = [
self.homeModules.nomarchy
{
nomarchy.stateFile = ./templates/downstream/theme-state.json;
nomarchy.nightlight = {
enable = true;
latitude = "52.52";
longitude = "13.40";
};
home = { username = "geo"; homeDirectory = "/home/geo"; };
}
];
};
units = hm.config.systemd.user.services;
exec = toString units.wlsunset.Service.ExecStart;
cond = toString units.wlsunset.Service.ExecCondition;
expect = c: msg: lib.assertMsg c "nightlight-geo: ${msg}";
ok =
expect (lib.hasInfix "52.52" exec && lib.hasInfix "13.40" exec)
"wlsunset ExecStart lacks the configured coordinates: ${exec}"
&& expect (lib.hasInfix "4000" exec)
"wlsunset ExecStart lacks the night temperature: ${exec}"
&& expect (lib.hasInfix "should-start" cond)
"wlsunset lost the live-state ExecCondition gate"
&& expect (!(hm.config.services.hyprsunset.enable or false))
"geo mode must not also run hyprsunset";
in
assert ok; pkgs.runCommand "nomarchy-nightlight-geo" { } "touch $out";
# The low-battery watcher's crossing logic: one toast per downward
# crossing (normal at 25%, critical at 10%), re-armed by charging.
# test_power fakes a battery whose capacity/status we script;
# notify-send is shimmed to a log via PATH (the package resolves
# it from PATH for exactly this reason). What stays on-hardware:
# the real toast rendering through swaync in a session.
battery-notify = pkgs.testers.runNixOSTest {
name = "nomarchy-battery-notify";
nodes.machine = { ... }: {
boot.kernelModules = [ "test_power" ]; # fake battery + Mains
environment.systemPackages = [ pkgs.nomarchy-battery-notify ];
};
testScript = ''
machine.wait_for_unit("multi-user.target")
# notify-send shim: log the calls instead of needing a session bus.
machine.succeed(
"mkdir -p /shim && printf '#!/bin/sh\necho \"$*\" >> /tmp/notifications\n'"
" > /shim/notify-send && chmod +x /shim/notify-send"
)
# Self-gate: with the fake battery gone, the watcher exits 0 at once.
machine.succeed("modprobe -r test_power")
machine.succeed("timeout 5 nomarchy-battery-notify 1")
machine.succeed("modprobe test_power")
# Healthy level while discharging: silence.
machine.succeed("echo discharging > /sys/module/test_power/parameters/battery_status")
machine.succeed("echo 80 > /sys/module/test_power/parameters/battery_capacity")
machine.succeed(
"systemd-run --unit=batwatch --setenv=PATH=/shim:/run/current-system/sw/bin"
" /run/current-system/sw/bin/nomarchy-battery-notify 1"
)
machine.sleep(3)
machine.fail("test -s /tmp/notifications")
# Crossing 25% fires the low toast exactly once, not per poll.
machine.succeed("echo 20 > /sys/module/test_power/parameters/battery_capacity")
machine.wait_until_succeeds("grep -q 'Battery low' /tmp/notifications")
machine.succeed("echo 15 > /sys/module/test_power/parameters/battery_capacity")
machine.sleep(3)
assert machine.succeed("grep -c 'Battery low' /tmp/notifications").strip() == "1"
# Crossing 10% escalates to a critical-urgency toast, once.
machine.succeed("echo 5 > /sys/module/test_power/parameters/battery_capacity")
machine.wait_until_succeeds("grep -q 'Battery critical' /tmp/notifications")
assert "critical" in machine.succeed("grep 'Battery critical' /tmp/notifications")
machine.sleep(3)
assert machine.succeed("grep -c 'Battery critical' /tmp/notifications").strip() == "1"
# Back on the charger re-armed: the next drain notifies again.
machine.succeed("echo charging > /sys/module/test_power/parameters/battery_status")
machine.succeed("echo 80 > /sys/module/test_power/parameters/battery_capacity")
machine.sleep(3)
machine.succeed("echo discharging > /sys/module/test_power/parameters/battery_status")
machine.succeed("echo 20 > /sys/module/test_power/parameters/battery_capacity")
machine.wait_until_succeeds(
"test \"$(grep -c 'Battery low' /tmp/notifications)\" = 2"
)
'';
};
# First-session welcome toast gate (#81): fires once when
# settings.firstBootShown is unset, writes the marker via
# theme-sync, silent on re-run; skips hostname nomarchy-live.
# notify-send + theme-sync shimmed on PATH (same pattern as
# battery-notify). Real toast rendering stays session/V3.
first-boot = pkgs.testers.runNixOSTest {
name = "nomarchy-first-boot";
nodes.machine = { ... }: {
environment.systemPackages = [
pkgs.nomarchy-first-boot
pkgs.nomarchy-theme-sync
pkgs.jq
];
};
testScript = ''
machine.wait_for_unit("multi-user.target")
# Writable flake checkout with a valid theme-state (template).
machine.succeed(
"mkdir -p /tmp/fake-nomarchy && "
"cp ${./templates/downstream/theme-state.json} "
"/tmp/fake-nomarchy/theme-state.json"
)
machine.succeed(
"mkdir -p /shim && "
"printf '#!/bin/sh\\necho \"$*\" >> /tmp/notifications\\n' > /shim/notify-send && "
"chmod +x /shim/notify-send"
)
env = (
"PATH=/shim:/run/current-system/sw/bin "
"NOMARCHY_PATH=/tmp/fake-nomarchy "
"HOME=/root"
)
# First run: toast + marker.
machine.succeed(f"{env} nomarchy-first-boot")
out = machine.succeed("cat /tmp/notifications")
assert "You're set" in out, f"welcome toast missing:\n{out}"
assert "SUPER+M" in out and "SUPER+T" in out, f"key pointers missing:\n{out}"
assert "Network" in out or "Wi" in out, f"network pointer missing:\n{out}"
shown = machine.succeed(
f"{env} nomarchy-theme-sync get settings.firstBootShown"
).strip()
assert shown == "true", f"marker not written: {shown!r}"
# Second run: silent (no second toast).
machine.succeed("rm -f /tmp/notifications")
machine.succeed(f"{env} nomarchy-first-boot")
machine.fail("test -s /tmp/notifications")
# Live ISO hostname: skip even with marker cleared.
machine.succeed(
f"{env} nomarchy-theme-sync --quiet set settings.firstBootShown false --no-switch"
)
machine.succeed("rm -f /tmp/notifications")
machine.succeed(f"{env} hostname nomarchy-live || true")
# uname -n is the kernel nodename set via hostnamectl/hostname.
machine.succeed("hostname nomarchy-live")
machine.succeed(f"{env} nomarchy-first-boot")
machine.fail("test -s /tmp/notifications")
'';
};
# Generation readability (#82): nomarchy-what-changed turns an
# nvd report into plain-language counts. Inject a fake nvd via
# NOMARCHY_NVD so the VM stays minimal (no real store closures).
what-changed = pkgs.testers.runNixOSTest {
name = "nomarchy-what-changed";
nodes.machine = { ... }: {
environment.systemPackages = [ pkgs.nomarchy-what-changed ];
};
testScript = ''
machine.wait_for_unit("multi-user.target")
# Fake nvd: args are `nvd --color never diff A B` ignore paths.
machine.succeed(
"mkdir -p /shim && cat > /shim/fake-nvd <<'EOF'\n"
"#!/bin/sh\n"
"cat <<'R'\n"
"<<< /nix/store/old\n"
">>> /nix/store/new\n"
"Added packages:\n"
"[A.] #1 hello 2.12\n"
"[A.] #2 cowsay 3.04\n"
"Removed packages:\n"
"[R.] #1 fortune 1.0\n"
"Version changes:\n"
"[C.] #1 bash: 5.2 -> 5.3\n"
"Closure size: 100 -> 105 (10 paths added, 5 paths removed, delta +5).\n"
"R\n"
"EOF\n"
"chmod +x /shim/fake-nvd"
)
# Dummy before/after paths (script only needs them to exist and differ).
machine.succeed("mkdir -p /tmp/gen-a /tmp/gen-b && touch /tmp/gen-a/x /tmp/gen-b/y")
out = machine.succeed(
"NOMARCHY_NVD=/shim/fake-nvd nomarchy-what-changed --summary "
"--diff /tmp/gen-a /tmp/gen-b"
)
assert "2 added" in out, f"missing added count:\n{out}"
assert "1 removed" in out, f"missing removed count:\n{out}"
assert "1 updated" in out, f"missing updated count:\n{out}"
# Identical paths no package changes (no nvd call needed).
out2 = machine.succeed(
"NOMARCHY_NVD=/shim/fake-nvd nomarchy-what-changed --summary "
"--diff /tmp/gen-a /tmp/gen-a"
)
assert "no package changes" in out2, f"identical paths should short-circuit:\n{out2}"
# Empty-ish nvd report.
machine.succeed(
"cat > /shim/fake-nvd-empty <<'EOF'\n"
"#!/bin/sh\n"
"echo 'No version or selection state changes.'\n"
"echo 'Closure size: 1 -> 1 (0 paths added, 0 paths removed, delta +0).'\n"
"EOF\n"
"chmod +x /shim/fake-nvd-empty"
)
out3 = machine.succeed(
"NOMARCHY_NVD=/shim/fake-nvd-empty nomarchy-what-changed --summary "
"--diff /tmp/gen-a /tmp/gen-b"
)
assert "no package changes" in out3, f"empty report:\n{out3}"
'';
};
# Memory-pressure protection (oom.nix): earlyoom must kill a
# runaway allocator BEFORE the kernel thrash point — and must not
# take anything else with it. A bystander unit survives the kill,
# proving the process-level granularity that motivated earlyoom
# over cgroup-level systemd-oomd (which would kill the whole
# Hyprland session scope). Imports only oom.nix so the VM stays
# minimal.
oom-protection = pkgs.testers.runNixOSTest {
name = "nomarchy-oom-protection";
nodes.machine = { ... }: {
imports = [ ./modules/nixos/oom.nix ];
environment.systemPackages = [ pkgs.python3 ];
virtualisation.memorySize = 1024;
};
testScript = ''
machine.wait_for_unit("earlyoom.service")
# One owner: oomd (nixpkgs default-on, inert) is disabled.
machine.fail("systemctl is-active --quiet systemd-oomd.service")
# The session avoid-list made it into the daemon invocation.
machine.succeed("systemctl cat earlyoom.service | grep -q -- --avoid")
# A bystander that must survive (stand-in for the session).
machine.succeed("systemd-run --unit=bystander sleep infinity")
# The hog: allocate in 20 MB chunks with a short sleep, so
# earlyoom's poll wins the race against the kernel OOM killer.
machine.succeed(
"systemd-run --unit=hog python3 -c 'import time; "
"exec(\"a=[]\\nwhile True:\\n a.append(bytearray(20*1024*1024)); time.sleep(0.05)\")'"
)
# earlyoom (not the kernel) pulled the trigger
machine.wait_until_succeeds(
"journalctl -u earlyoom.service | grep -Eiq 'sending SIG(TERM|KILL)'",
timeout=120,
)
# the hog is gone
machine.wait_until_succeeds(
'test "$(systemctl show -p ActiveState --value hog.service)" != "active"',
timeout=60,
)
# and the bystander is untouched.
machine.succeed("systemctl is-active --quiet bystander.service")
'';
};
# zram compressed-RAM swap (oom.nix, BACKLOG #76 slice): enabled
# by default as the memory-pressure layer *before* earlyoom. Prove
# the device comes up as an active swap, uses zstd, and carries the
# high priority that keeps day-to-day paging off any disk swapfile
# (which #76 reserves for the hibernate image). Imports oom.nix
# directly so the check tracks the module, not a hand-copy.
zram-swap = pkgs.testers.runNixOSTest {
name = "nomarchy-zram-swap";
nodes.machine = { ... }: {
imports = [ ./modules/nixos/oom.nix ];
virtualisation.memorySize = 1024;
};
testScript = ''
# zram-generator brings the device up as swap shortly after boot.
machine.wait_until_succeeds("grep -q /dev/zram0 /proc/swaps", timeout=60)
# Configured compression algorithm is the selected one ([zstd]).
machine.succeed("grep -q '\\[zstd\\]' /sys/block/zram0/comp_algorithm")
# High priority (100) so the kernel fills zram before any disk
# swap the ordering #76's hibernate image depends on.
machine.succeed(
"awk '$1==\"/dev/zram0\" && $5==100 {ok=1} END{exit !ok}' /proc/swaps"
)
'';
};
# Hibernate → resume (BACKLOG #76): the load-bearing risk the
# hibernation default introduces is coexistence with the zram
# swap that oom.nix now enables by default. Prove, in the VM
# harness, that (a) a machine hibernates and resumes for real —
# a volatile ramfs marker survives resume but NOT a fresh boot —
# and (b) zram (priority 100) does not swallow the hibernate
# image: the disk swap (lower priority, explicit resumeDevice)
# carries it. Modeled on nixpkgs' hibernate.nix (partition swap +
# systemd-initrd resume). What QEMU genuinely can't do is feed a
# boot-time `resume_offset` for a btrfs *swapfile* (the offset
# only exists after the file is allocated on first boot) or drive
# a LUKS-initrd unlock end-to-end — so the encrypted @swap
# swapfile + offset path stays a V3 laptop check (HARDWARE-QUEUE),
# which already runs that exact layout. This test imports oom.nix
# so it tracks the real zram default, not a hand-copy.
hibernate = pkgs.testers.runNixOSTest {
name = "nomarchy-hibernate";
nodes.machine = { config, lib, pkgs, ... }: {
imports = [ ./modules/nixos/oom.nix ]; # zram default-on (#76)
virtualisation.memorySize = 2048;
# Store + EFI must persist across the hibernate/resume/crash
# boots; a disk swap ≥ 2×RAM holds the image.
virtualisation.useNixStoreImage = true;
virtualisation.useEFIBoot = true;
virtualisation.emptyDiskImages = [ (2 * config.virtualisation.memorySize) ];
boot.initrd.systemd.enable = true; # systemd-initrd, like Nomarchy
# Disk swap at a LOWER priority than zram (100); explicit
# resumeDevice — exactly how the installer wires hibernation —
# so the image goes to disk regardless of zram's priority.
swapDevices = lib.mkOverride 0 [
{ device = "/dev/vdc"; options = [ "x-systemd.makefs" ]; priority = 1; }
];
boot.resumeDevice = "/dev/vdc";
# Let the driver's shell reconnect after resume.
systemd.services.backdoor.conflicts = [ "sleep.target" ];
powerManagement.resumeCommands = "systemctl --no-block restart backdoor.service";
};
testScript = ''
machine.wait_for_unit("default.target")
# zram (oom.nix) up at priority 100; disk swap present and lower
# so the hibernate image lands on disk, not volatile zram.
machine.wait_until_succeeds("grep -q /dev/zram0 /proc/swaps", timeout=60)
machine.succeed(
"awk '$1==\"/dev/zram0\"&&$5==100{z=1} "
"$1==\"/dev/vdc\"&&$5<100{d=1} END{exit !(z&&d)}' /proc/swaps"
)
# Marker in volatile RAM: survives a resume, never a fresh boot.
machine.succeed(
"mkdir /run/test",
"mount -t ramfs -o size=1m ramfs /run/test",
"echo not persisted to disk > /run/test/suspended",
)
# Hibernate, wait for real power-off, then resume.
machine.execute("systemctl hibernate >&2 &", check_return=False)
machine.wait_for_shutdown()
machine.start()
machine.succeed("grep 'not persisted to disk' /run/test/suspended")
# Prove it was a genuine resume: a crash-boot must lose the marker.
machine.crash()
machine.wait_for_unit("default.target")
machine.fail("grep 'not persisted to disk' /run/test/suspended")
'';
};
# The installer's BTRFS @swap SWAPFILE setup (#76) — the parts the
# partition hibernate test (checks.hibernate) can't reach. A full
# hibernate→resume from a *runtime* swapfile isn't reachable
# headlessly: systemd reports CanHibernate=no unless the swap is
# declared in the boot config, and feeding a runtime-computed
# resume_offset into the boot cmdline is a genuine chicken-and-egg.
# So the power-cycle stays a real-hardware check (the dev box runs
# exactly this layout — /proc/cmdline verified). What IS verified
# here, bounded and deterministic, is that the swapfile is built
# correctly and its resume offset is valid: NOCOW `mkswapfile`, a
# good `map-swapfile -r` offset the kernel accepts, swapon as a file,
# and zram (prio 100) above it so the image lands on disk, not zram.
hibernate-swapfile = pkgs.testers.runNixOSTest {
name = "nomarchy-hibernate-swapfile";
nodes.machine = { config, lib, pkgs, ... }: {
imports = [ ./modules/nixos/oom.nix ]; # zram default-on (#76)
virtualisation.memorySize = 2048;
# A dedicated disk for the btrfs @swap subvolume.
virtualisation.emptyDiskImages = [ (4 * config.virtualisation.memorySize) ];
environment.systemPackages = [ pkgs.btrfs-progs ];
};
testScript = ''
machine.wait_for_unit("default.target")
machine.wait_until_succeeds("grep -q /dev/zram0 /proc/swaps", timeout=60)
# Reproduce the installer layout: btrfs, @swap subvolume, then a
# NOCOW swapfile via `btrfs filesystem mkswapfile`.
# The lone empty data disk (root is vda; no separate store disk
# here since useNixStoreImage is off).
machine.succeed("mkfs.btrfs -f /dev/vdb")
machine.succeed(
"mkdir -p /mnt-btrfs && mount /dev/vdb /mnt-btrfs "
"&& btrfs subvolume create /mnt-btrfs/@swap && umount /mnt-btrfs"
)
machine.succeed("mkdir -p /swap && mount -o subvol=@swap /dev/vdb /swap")
machine.succeed("btrfs filesystem mkswapfile --size 3g --uuid clear /swap/swapfile")
# NOCOW is mandatory a copy-on-write swapfile corrupts on btrfs.
machine.succeed("lsattr /swap/swapfile | grep -q C")
machine.succeed("swapon /swap/swapfile")
machine.succeed(
"awk '$1==\"/swap/swapfile\" && $2==\"file\"{f=1} END{exit !f}' /proc/swaps"
)
# The Nomarchy-specific offset math (patch-template bakes this
# into resume_offset): a valid physical offset the kernel accepts.
offset = machine.succeed(
"btrfs inspect-internal map-swapfile -r /swap/swapfile"
).strip()
assert offset.isdigit() and int(offset) > 0, f"bad resume offset: {offset!r}"
machine.succeed(f"echo {offset} > /sys/power/resume_offset")
# zram (prio 100) must sit above the disk swapfile, so a hibernate
# image would land on disk, not volatile zram.
machine.succeed(
"awk '$1==\"/dev/zram0\"&&$5==100{z=1} "
"$1==\"/swap/swapfile\"&&$5<100{d=1} END{exit !(z&&d)}' /proc/swaps"
)
'';
};
# Auto time-of-day theme (#79, VISION § D): simulate crossing
# sunset and sunrise by moving the VM clock, and assert
# `nomarchy-theme-sync auto` switches the active theme accordingly
# — day stays day (no needless rebuild), sunset flips to night,
# a later tick is idempotent, sunrise flips back. The actual
# `home-manager switch` is stubbed via NOMARCHY_REBUILD (that
# generic apply path is exercised by every manual theme change);
# this isolates the auto decision + apply + rebuild-trigger.
auto-theme = pkgs.testers.runNixOSTest {
name = "nomarchy-auto-theme";
nodes.machine = { pkgs, ... }: {
environment.systemPackages = [ pkgs.nomarchy-theme-sync ];
time.timeZone = "UTC"; # naive HH:MM == the clock we set
};
testScript = ''
machine.wait_for_unit("multi-user.target")
machine.succeed("timedatectl set-ntp false || true")
# Writable flake state (start on the day theme) + a rebuild stub
# that records each invocation instead of running home-manager.
machine.succeed("mkdir -p /root/.nomarchy")
machine.succeed(
"echo '{\"slug\":\"summer-day\"}' > /root/.nomarchy/theme-state.json"
)
machine.succeed(
"printf '#!/bin/sh\\necho switch >> /root/rebuilds\\n' > /root/rebuild"
" && chmod +x /root/rebuild"
)
env = ("NOMARCHY_PATH=/root/.nomarchy "
"NOMARCHY_DEFAULT_THEMES=${./themes} "
"NOMARCHY_REBUILD=/root/rebuild ")
# Pair + schedule (setup writes only, no rebuild).
for kv in ["settings.autoTheme.enable true",
"settings.autoTheme.day summer-day",
"settings.autoTheme.night summer-night",
"settings.autoTheme.sunrise 07:00",
"settings.autoTheme.sunset 20:00"]:
machine.succeed(env + "nomarchy-theme-sync --quiet set " + kv + " --no-switch")
def slug():
return machine.succeed(env + "nomarchy-theme-sync get slug").strip()
def rebuilds():
return int(machine.succeed("wc -l < /root/rebuilds 2>/dev/null || echo 0").strip())
# Before sunset: already the day theme no switch, no rebuild.
machine.succeed("date -s '2026-01-02 19:55:00'")
machine.succeed(env + "nomarchy-theme-sync auto")
assert slug() == "summer-day", f"pre-sunset should stay day, got {slug()}"
assert rebuilds() == 0, f"no needless rebuild, got {rebuilds()}"
# Cross sunset night theme, exactly one rebuild triggered.
machine.succeed("date -s '2026-01-02 20:05:00'")
machine.succeed(env + "nomarchy-theme-sync auto")
assert slug() == "summer-night", f"post-sunset should be night, got {slug()}"
assert rebuilds() == 1, f"sunset should trigger one rebuild, got {rebuilds()}"
# Another tick still after sunset idempotent, no extra rebuild.
machine.succeed("date -s '2026-01-02 20:06:00'")
machine.succeed(env + "nomarchy-theme-sync auto")
assert slug() == "summer-night"
assert rebuilds() == 1, f"idempotent tick must not rebuild, got {rebuilds()}"
# Cross sunrise the next morning back to day, second rebuild.
machine.succeed("date -s '2026-01-03 07:30:00'")
machine.succeed(env + "nomarchy-theme-sync auto")
assert slug() == "summer-day", f"post-sunrise should be day, got {slug()}"
assert rebuilds() == 2, f"sunrise should trigger a rebuild, got {rebuilds()}"
'';
};
# Snapshot GUI canary: btrfs-assistant crashes in libbtrfsutil's
# UNPRIVILEGED subvolume iteration (btrfs-progs 6.17.1, fixed
# upstream after 6.17.1) but runs fine as root — which is how the
# menu launches it (pkexec launcher). Guard the root path on a
# real btrfs volume so a lock bump that breaks it fails here, not
# on a user's machine. The GUI event loop is exercised offscreen
# (survives a 5s timeout = no startup crash past the Btrfs scan).
snapshot-gui = pkgs.testers.runNixOSTest {
name = "nomarchy-snapshot-gui";
nodes.machine = { pkgs, ... }: {
environment.systemPackages = [ pkgs.btrfs-assistant pkgs.btrfs-progs ];
virtualisation.emptyDiskImages = [ 512 ];
};
testScript = ''
machine.wait_for_unit("multi-user.target")
machine.succeed(
"mkfs.btrfs /dev/vdb && mkdir -p /mnt && mount /dev/vdb /mnt "
"&& btrfs subvolume create /mnt/sub1"
)
# Root (the launcher's effective context): must work.
machine.succeed("QT_QPA_PLATFORM=offscreen btrfs-assistant-bin --version")
# The GUI proper survives startup (timeout kill = alive 124).
machine.succeed(
"set +e; timeout 5 env QT_QPA_PLATFORM=offscreen btrfs-assistant-bin; "
"[ $? -eq 124 ]"
)
'';
};
# The distroId question (roadmap "Distro branding"): set
# distroId = "nomarchy" so /etc/os-release is honest (ID=nomarchy,
# ID_LIKE=nixos). The risk is switch-to-configuration's "is this
# NixOS?" guard — but it builds the check from the *configured*
# distroId (and /etc/NIXOS still exists), so a rebuild must still
# work. This boots such a system and runs `switch-to-configuration
# dry-activate` to prove the guard passes. Inline (not the full
# module) so the VM stays minimal — the real wiring in default.nix
# is covered by the downstream-template-system build.
distro-id = pkgs.testers.runNixOSTest {
name = "nomarchy-distro-id";
nodes.machine = { ... }: {
system.nixos.distroName = "Nomarchy";
system.nixos.distroId = "nomarchy";
# Test base omits switch-to-configuration by default; we need it
# to exercise the "is this NixOS?" guard.
system.switch.enable = true;
};
testScript = { nodes, ... }: ''
machine.wait_for_unit("multi-user.target")
# os-release reflects the rebrand, with the nixos lineage marker.
machine.succeed("grep -q '^ID=nomarchy$' /etc/os-release")
machine.succeed("grep -q '^ID_LIKE=nixos$' /etc/os-release")
machine.succeed("grep -q '^NAME=Nomarchy$' /etc/os-release")
# The rebuild path must still recognise this as a NixOS install
# (the guard is built from the configured distroId, not "nixos").
machine.succeed(
"${nodes.machine.system.build.toplevel}/bin/switch-to-configuration dry-activate"
)
'';
};
};
# ─── Reference host ────────────────────────────────────────────
# Manually wired rather than via lib.mkFlake: the repo-root
# theme-state.json is load-bearing (live ISO + in-repo CLI dev),
# which doesn't match mkFlake's src-layout convention.
# Deliberately split rebuild paths:
# system → sudo nixos-rebuild switch --flake .#nomarchy (rare)
# desktop → home-manager switch --flake .#nomarchy (no sudo;
# this is what `nomarchy-theme-sync apply` runs)
# Theme changes never touch the system layer, so they never need
# root and never rebuild the world.
nixosConfigurations.nomarchy = nixpkgs.lib.nixosSystem {
inherit system;
specialArgs = { inherit username; };
modules = [
self.nixosModules.nomarchy
{ nomarchy.system.stateFile = ./theme-state.json; }
./hosts/default/configuration.nix
];
};
homeConfigurations.${username} = home-manager.lib.homeManagerConfiguration {
inherit pkgs;
modules = [
self.homeModules.nomarchy
{
# The single source of truth — pure read: the file is part
# of this flake's source.
nomarchy.stateFile = ./theme-state.json;
home = {
inherit username;
homeDirectory = "/home/${username}";
};
}
];
};
# ─── Live ISO ──────────────────────────────────────────────────
# Full desktop on a bootable stick, no installation:
# nix build .#nixosConfigurations.nomarchy-live.config.system.build.isoImage
# Test in QEMU with tools/test-live-iso.sh. See docs/TESTING.md.
nixosConfigurations.nomarchy-live = nixpkgs.lib.nixosSystem {
inherit system;
specialArgs = { inherit username; nomarchySrc = self; };
modules = [
"${nixpkgs}/nixos/modules/installer/cd-dvd/installation-cd-minimal.nix"
self.nixosModules.nomarchy
./hosts/live.nix
# The ISO is a sealed artifact, so the desktop is baked in via
# the HM NixOS module (the installed-system split into
# nixos-rebuild vs home-manager switch doesn't apply here —
# though the seeded ~/.nomarchy flake still allows standalone
# `home-manager switch` for theme testing).
home-manager.nixosModules.home-manager
{
home-manager = {
useGlobalPkgs = true;
useUserPackages = true;
users.${username} = {
imports = [ self.homeModules.nomarchy ];
nomarchy.stateFile = ./theme-state.json;
};
};
# The standalone CLI that `nomarchy-theme-sync apply` runs for
# theme switching, from the same pinned input — plus the
# guided installer.
environment.systemPackages = [
home-manager.packages.${system}.home-manager
nomarchyInstall
];
# Keep the locked flake inputs in the ISO store so theme
# switching on the live system works without a network: the
# lockfile's narHashes resolve against these paths instead
# of fetching from GitHub. Must be *transitive* — evaluating
# the seeded flake also fetches stylix's own inputs.
system.extraDependencies =
let
collectInputs = input:
[ input.outPath ] ++ builtins.concatMap collectInputs
(builtins.attrValues (input.inputs or { }));
in
collectInputs self ++ (
# Pre-build a system shaped like what nomarchy-install
# GENERATES (snapper, auto-login, LUKS initrd, swapfile +
# resume — not the bare template!) plus the template
# desktop, so the offline install never builds packages
# from source: a custom username/hostname then changes
# only a handful of derivations. No hardware profile —
# matches a VM install; profiled installs share most of it.
let
template = self.lib.mkFlake {
src = ./templates/downstream;
username = username;
};
representativeInstall = nixpkgs.lib.nixosSystem {
inherit system;
specialArgs = { inherit username; };
modules = [
self.nixosModules.nomarchy
{ environment.systemPackages = [ home-manager.packages.${system}.home-manager ]; }
# The installer's autodetection emits these on real
# machines (and the intel CPU module pulls the whole
# iGPU stack) — without them an offline install on
# common hardware builds graphics drivers from
# source. Model-specific profiles may still need
# network; the common ground is covered.
nixos-hardware.nixosModules.common-cpu-intel
nixos-hardware.nixosModules.common-cpu-amd
nixos-hardware.nixosModules.common-gpu-amd
nixos-hardware.nixosModules.common-pc-laptop
nixos-hardware.nixosModules.common-pc-ssd
nixos-hardware.nixosModules.common-pc
(./templates/downstream + "/hardware-configuration.nix")
(./templates/downstream + "/system.nix")
({ lib, ... }: {
nomarchy.system.snapper.enable = true;
nomarchy.system.greeter.autoLogin = username;
# nixos-generate-config keys microcode off
# enableRedistributableFirmware (now on): the
# real machine enables exactly one vendor —
# pin both, or the offline install builds
# amd-ucode/intel-ucode from source (found the
# hard way: the 2700s regression hang).
hardware.cpu.amd.updateMicrocode = true;
hardware.cpu.intel.updateMicrocode = true;
swapDevices = [{ device = "/swap/swapfile"; }];
boot.resumeDevice = "/dev/disk/by-uuid/00000000-0000-0000-0000-000000000000";
boot.kernelParams = [ "resume_offset=1" ];
boot.initrd.luks.devices.crypted.device = "/dev/disk/by-partlabel/disk-main-root";
# The real install is BTRFS with a vfat ESP — the
# placeholder's bare ext4 would leave fs-gated bits
# out of this pin (snapper via btrfs; mtools and
# dosfstools enter systemPackages via vfat!).
fileSystems."/" = lib.mkForce {
device = "/dev/mapper/crypted";
fsType = "btrfs";
options = [ "subvol=@" "compress=zstd" "noatime" ];
};
fileSystems."/boot" = {
device = "/dev/disk/by-uuid/0000-0000";
fsType = "vfat";
options = [ "umask=0077" ];
};
})
];
};
in [
representativeInstall.config.system.build.toplevel
template.homeConfigurations.${username}.activationPackage
# The live session's `home-manager switch` builds the
# REPO's standalone generation (the seeded ~/.nomarchy
# flake) — not the template's, and not the NixOS-module
# one baked into the ISO. It has drvs the other two
# variants don't (user-dbus-services et al.), so pin it
# and its direct build inputs too.
self.homeConfigurations.${username}.activationPackage
self.homeConfigurations.${username}.activationPackage.inputDerivation
# home-files is rebuilt per theme and COPIES some of its
# inputs (user-dbus-services) instead of referencing
# them — the activation closure doesn't carry those, so
# pin home-files' direct build inputs separately.
self.homeConfigurations.${username}.config.home-files.inputDerivation
# A real install rebuilds the drvs that embed the
# user's hostname/username/UUIDs (etc, initrd, units,
# toplevel, HM activation). inputDerivation pins each
# one's direct build inputs…
representativeInstall.config.system.build.toplevel.inputDerivation
representativeInstall.config.system.build.etc.inputDerivation
representativeInstall.config.system.build.initialRamdisk.inputDerivation
template.homeConfigurations.${username}.activationPackage.inputDerivation
# home-files COPIES user-dbus-services instead of
# referencing it (same story as the self pin above) —
# a custom-username install rebuilds home-files and
# needs its direct inputs present.
template.homeConfigurations.${username}.config.home-files.inputDerivation
] ++ [
# A theme switch re-renders stylix's base16 templates
# (gtk.css, kvantum, …) on the machine: the renderer and
# the stdenv its trivial drvs build with must be on
# board, or an offline `apply <theme>` cascades into
# building mustache-go (and eventually stdenv) from
# source. Found with tools/vm/gap-analysis.py.
pkgs.mustache-go
pkgs.stdenv
] ++ [
# …and the second-level build tools those rebuilds need
# (their parents are themselves rebuilt, so parent
# inputDerivations don't reach them): kernel-module
# shrinking, unit-dir assembly, dbus config, /etc.
pkgs.kmod.dev
pkgs.nukeReferences
pkgs.xorg.lndir
pkgs.libxslt.bin
pkgs.python3Minimal
# system-path is rebuilt on EVERY machine: buildEnv's
# package-list order follows module-graph order, and any
# hardware profile/import reshuffles it. Its builder
# wants these.
pkgs.texinfo
pkgs.getconf
# greetd.toml embeds the auto-login user and is generated
# by remarshal — without this pin an offline install
# tries to BUILD remarshal, whose test closure pulls
# matplotlib/ffmpeg/x265 (the ~1900-drv cascade).
pkgs.remarshal
# system-path/initrd-bin-env are buildEnvs rebuilt per
# machine; their shared builder.pl is build-time-only
# (never in any runtime closure) — an empty env's
# inputDerivation retains it.
(pkgs.buildEnv { name = "nomarchy-pin-buildenv"; paths = [ ]; }).inputDerivation
# /etc/dbus-1 reassembly wants this setup hook.
pkgs.findXMLCatalogs
# nixos-generate-config keys microcode off
# enableRedistributableFirmware (now on): exactly one
# vendor per real machine, so pin both ucode cpios.
pkgs.microcode-amd
pkgs.microcode-intel
# …and one representative disko script: its tool closure
# (file, which, wrapper hooks, …) isn't otherwise on the
# ISO, and offline nix would try to build it from source.
# The user's actual args produce a different script drv,
# but with identical inputs — built offline in seconds.
(import "${pkgs.disko}/share/disko/cli.nix" {
inherit pkgs;
mode = "destroy,format,mount";
diskoFile = ./pkgs/nomarchy-install/disko-config.nix;
mainDrive = "/dev/vda";
withLuks = true;
swapSize = "2G";
})
]
);
}
];
};
};
}