{ 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 { }; # pull / rebuild / home (+ legacy aliases) — also on HM so a home # switch can refresh a broken system-package nomarchy-pull. 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-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 ''; # 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 `:` # 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") ''; }; # 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" ) ''; }; # 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 ` 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"; }) ] ); } ]; }; }; }