Files
Nomarchy/flake.nix
Bernardo Magri 14558ad296
All checks were successful
Check / eval (push) Successful in 2m57s
test+fix: close #14, V2 #57 live install entry, #41 float slice
- #14: first scheduled lock bump confirmed (8fded63 nomarchy-bump Mon
  05:20 UTC 2026-07-06); remove queue item.
- #57 V2: checks.live-install-entry builds nomarchy-live HM gen and
  asserts Install Nomarchy.desktop + installer bin + menu gate.
- #41: float/center hyprpolkitagent + pinentry-qt from package app-ids;
  capture-float-classes.nix harness (softGL empty); residual portal
  class stays NEXT [blocked:hw]. HARDWARE-QUEUE confirm steps.

Verified: live-install-entry + windowrule-syntax green.
2026-07-10 09:20:15 +01:00

952 lines
48 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-detect-hw = final.callPackage ./pkgs/nomarchy-detect-hw { };
};
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-control-center = pkgs.nomarchy-control-center;
nomarchy-battery-notify = pkgs.nomarchy-battery-notify;
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
'';
# 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
'';
# 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"]:
assert probe in healthy, f"doctor hardware section missing a self-gated '{probe}' row:\n{healthy}"
'';
};
# 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.
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;
};
};
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")
'';
};
# 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";
# 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"
)
'';
};
# 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")
'';
};
# 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";
})
]
);
}
];
};
};
}