From cf238b0f39537baa607791547a27d6225833df18 Mon Sep 17 00:00:00 2001 From: forkwright Date: Wed, 12 Aug 2026 09:29:15 -0500 Subject: [PATCH 1/7] chore(nix): commit flake.lock and add a nix flake check CI job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Locks the flake inputs (nixpkgs, rust-overlay, crane, flake-utils, systems) so builds are reproducible, and adds a path-filtered nix flake check workflow — the verification surface for the module boot fixes from PR #619. Main always runs it; PRs run it only when the flake surface changes. Refs #610 --- .github/workflows/nix.yml | 35 ++++++++++++ flake.lock | 112 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 .github/workflows/nix.yml create mode 100644 flake.lock diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml new file mode 100644 index 00000000..aa10fa26 --- /dev/null +++ b/.github/workflows/nix.yml @@ -0,0 +1,35 @@ +name: Nix + +on: + pull_request: + paths: + - "flake.nix" + - "flake.lock" + - "nix/**" + - ".github/workflows/nix.yml" + push: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +# WHY path-filtered on PRs: `nix flake check` builds the full workspace plus +# two NixOS VM module tests, which is far too heavy for every PR. Main always +# runs it — the flake is a release surface (#610). +jobs: + flake-check: + name: nix flake check + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22 + - uses: DeterminateSystems/magic-nix-cache-action@908b263ff629f4cc17666315b7fd3ec127c6244d # v14 + - name: nix flake check + run: nix flake check --print-build-logs diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..d2a87638 --- /dev/null +++ b/flake.lock @@ -0,0 +1,112 @@ +{ + "nodes": { + "crane": { + "locked": { + "lastModified": 1785782307, + "narHash": "sha256-MPaRdVkf6zZP5fCPxYCi8Dr4pZzgmXzg8T9nVEbp3Mw=", + "owner": "ipetkov", + "repo": "crane", + "rev": "2c71e194474d13de031d729b729c968ddbe3507f", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "repo": "crane", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1786384358, + "narHash": "sha256-RzPPiWeUtuvymnpuEWsdtzli5w4kjZs49FqEs3/1u+I=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "2fcb964de67fcf60b43471c55d5d99e61a9ccb5a", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1744536153, + "narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "crane": "crane", + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": "nixpkgs_2" + }, + "locked": { + "lastModified": 1786507911, + "narHash": "sha256-w5aZRLbiu7H6TqsYXVMdRKg0S4DRaJpxyqxx86AwxVk=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "39db48099ad16834af7e27485a4babf9c28b3897", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} From 46aa6a6a76c0fee00ae47ae2111e2ba0b55b8698 Mon Sep 17 00:00:00 2001 From: forkwright Date: Wed, 12 Aug 2026 09:39:57 -0500 Subject: [PATCH 2/7] fix(ci): drop magic-nix-cache from the nix workflow The action now requires FlakeHub authentication and fails closed without it; the repo carries no such credential. The job runs uncached until a cache account is a deliberate operator decision. Refs #610 --- .github/workflows/nix.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index aa10fa26..1c6e4cf8 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -19,7 +19,8 @@ permissions: # WHY path-filtered on PRs: `nix flake check` builds the full workspace plus # two NixOS VM module tests, which is far too heavy for every PR. Main always -# runs it — the flake is a release surface (#610). +# runs it — the flake is a release surface (#610). No store cache action: +# magic-nix-cache requires FlakeHub credentials the repo does not carry. jobs: flake-check: name: nix flake check @@ -30,6 +31,5 @@ jobs: with: persist-credentials: false - uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22 - - uses: DeterminateSystems/magic-nix-cache-action@908b263ff629f4cc17666315b7fd3ec127c6244d # v14 - name: nix flake check run: nix flake check --print-build-logs From 6c1359fd9dac6e20be0ec2a47a541c4baa13755c Mon Sep 17 00:00:00 2001 From: forkwright Date: Wed, 12 Aug 2026 09:47:31 -0500 Subject: [PATCH 3/7] fix(nix): testers.nixosTest for the module tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The locked nixpkgs removed the pkgs.nixosTest alias; the module tests now use the current testers.nixosTest path. Caught by the first nix flake check run — the CI job proving itself on its first day. Refs #610 --- nix/tests/module-test.nix | 2 +- nix/tests/render-module-test.nix | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nix/tests/module-test.nix b/nix/tests/module-test.nix index 59fab9a2..ee760d96 100644 --- a/nix/tests/module-test.nix +++ b/nix/tests/module-test.nix @@ -1,6 +1,6 @@ { pkgs }: -pkgs.nixosTest { +pkgs.testers.nixosTest { name = "harmonia-basic"; nodes.server = { ... }: { diff --git a/nix/tests/render-module-test.nix b/nix/tests/render-module-test.nix index eabd8937..0c569d50 100644 --- a/nix/tests/render-module-test.nix +++ b/nix/tests/render-module-test.nix @@ -34,7 +34,7 @@ let } )).config; -in pkgs.nixosTest { +in pkgs.testers.nixosTest { name = "harmonia-render-module"; nodes.machine = { ... }: { From 1a3139f952248d98fd263855c05d87fcf95e3658 Mon Sep 17 00:00:00 2001 From: forkwright Date: Wed, 12 Aug 2026 10:26:20 -0500 Subject: [PATCH 4/7] fix(nix): port the modules to current-nixpkgs APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The locked nixpkgs exposed three bit-rotted surfaces the never-locked flake had been hiding: - hardware.raspberry-pi."4" is gone: the DAC overlay moves to hardware.deviceTree.overlays (dtbo from the rpi kernel's dtbs), and onboard-audio-off becomes a snd_bcm2835 module blacklist. The iqaudio-dacpro auto-parameter is inexpressible via deviceTree overlays — driven by the base overlay now, detection to be verified on hardware. - xdg.desktopEntries is Home Manager, not NixOS: the launcher entry becomes a makeDesktopItem package in environment.systemPackages. - builtins.log never existed: the renderer volume option now takes levelDb directly instead of an uncomputable linear-to-dB conversion. - services.harmonia collides with upstream nixpkgs's unrelated binary-cache module: disabledModules pins ours as the one. All six flake checks now evaluate locally (drvPath for each). Refs #610 --- nix/module.nix | 51 ++++++++++++++++++++------------- nix/modules/harmonia-dac.nix | 37 +++++++++++++++--------- nix/modules/harmonia-render.nix | 8 +++--- 3 files changed, 59 insertions(+), 37 deletions(-) diff --git a/nix/module.nix b/nix/module.nix index d322f9d8..b9fa7c7f 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -18,6 +18,12 @@ let downloadDir = cfg.settings.ergasia.download_dir or "/data/downloads"; podcastDir = cfg.settings.komide.podcast_dir or "/data/podcasts"; in { + # WHY: current nixpkgs ships an unrelated binary-cache server under the + # same services.harmonia namespace (nixos/modules/services/networking/ + # harmonia.nix); importing both declares the option path twice. This flake's + # module is the media platform — the upstream one is disabled here. + disabledModules = [ "services/networking/harmonia.nix" ]; + options.programs.harmonia-desktop = { enable = lib.mkEnableOption "Harmonia desktop application"; @@ -168,26 +174,31 @@ in { networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ (cfg.settings.paroche.port or 8096) ]; } // lib.mkIf desktopCfg.enable { - # Desktop application launcher entry. - xdg.desktopEntries.harmonia = { - name = "Harmonia"; - genericName = "Music Player"; - comment = "Self-hosted music, podcasts, and audiobooks"; - exec = "${desktopCfg.package}/bin/harmonia %U"; - icon = "harmonia"; - categories = [ "Audio" "Music" "Player" "AudioVideo" ]; - startupNotify = true; - mimeType = lib.optionals desktopCfg.installMimeTypes [ - "audio/flac" - "audio/mpeg" - "audio/mp4" - "audio/ogg" - "audio/opus" - "audio/wav" - "audio/aac" - "x-scheme-handler/harmonia" - ]; - }; + # Desktop application launcher entry. NixOS has no xdg.desktopEntries + # option (that namespace is Home Manager); a system-wide launcher is a + # package carrying share/applications/*.desktop. + environment.systemPackages = [ + (pkgs.makeDesktopItem { + name = "harmonia"; + desktopName = "Harmonia"; + genericName = "Music Player"; + comment = "Self-hosted music, podcasts, and audiobooks"; + exec = "${desktopCfg.package}/bin/harmonia %U"; + icon = "harmonia"; + categories = [ "Audio" "Music" "Player" "AudioVideo" ]; + startupNotify = true; + mimeTypes = lib.optionals desktopCfg.installMimeTypes [ + "audio/flac" + "audio/mpeg" + "audio/mp4" + "audio/ogg" + "audio/opus" + "audio/wav" + "audio/aac" + "x-scheme-handler/harmonia" + ]; + }) + ]; # D-Bus service file — allows the desktop environment to activate # Harmonia for MPRIS without it already running. diff --git a/nix/modules/harmonia-dac.nix b/nix/modules/harmonia-dac.nix index f89bfe9a..56d1eeca 100644 --- a/nix/modules/harmonia-dac.nix +++ b/nix/modules/harmonia-dac.nix @@ -12,13 +12,16 @@ let # Sources: HiFiBerry docs, IQaudio docs, Raspberry Pi overlay index. overlayMap = { # HiFiBerry DAC+ (standard, non-HD) - hifiberry-dacplus = { overlay = "hifiberry-dacplus"; alsaCard = "sndrpihifiberry"; }; + hifiberry-dacplus = { overlayFile = "hifiberry-dacplus"; alsaCard = "sndrpihifiberry"; }; # HiFiBerry DAC2 HD uses the "adcpro" overlay (same silicon, extra ADC channels) - hifiberry-dac2hd = { overlay = "hifiberry-dacplusadcpro"; alsaCard = "sndrpihifiberry"; }; + hifiberry-dac2hd = { overlayFile = "hifiberry-dacplusadcpro"; alsaCard = "sndrpihifiberry"; }; # IQaudio DAC+ (standard) - iqaudio-dacplus = { overlay = "iqaudio-dacplus"; alsaCard = "IQaudIODAC"; }; - # IQaudio DAC Pro uses the same overlay with auto-detection flag - iqaudio-dacpro = { overlay = "iqaudio-dacplus,auto"; alsaCard = "IQaudIODAC"; }; + iqaudio-dacplus = { overlayFile = "iqaudio-dacplus"; alsaCard = "IQaudIODAC"; }; + # IQaudio DAC Pro — same base overlay. Upstream recommends the ",auto" + # parameter for Pro auto-detection; hardware.deviceTree.overlays cannot + # express overlay parameters, so the Pro is driven by the base overlay + # and detection should be verified on the hardware. + iqaudio-dacpro = { overlayFile = "iqaudio-dacplus"; alsaCard = "IQaudIODAC"; }; }; selected = overlayMap.${dacCfg.model}; @@ -43,16 +46,24 @@ in { }; config = lib.mkIf (cfg.enable && dacCfg.enable) { - # Enable the DAC overlay and disable onboard audio on Pi 4. - # Pi 5 uses a compatible mechanism — see nix/README.md for Pi 5 notes. - hardware.raspberry-pi."4" = { - dt-overlays.${selected.overlay}.enable = true; - - # WHY: Onboard BCM audio and the DAC HAT share the I2S bus; - # both cannot run simultaneously. - audio.enable = false; + # Current-nixpkgs Pi device-tree configuration: the + # hardware.raspberry-pi."4" namespace was removed upstream, so the DAC + # overlay is applied through the generic hardware.deviceTree mechanism + # (the dtbo ships in the rpi kernel's dtbs/overlays directory). + hardware.deviceTree = { + enable = true; + overlays = [{ + name = selected.overlayFile; + dtboFile = "${config.boot.kernelPackages.kernel}/dtbs/overlays/${selected.overlayFile}.dtbo"; + }]; }; + # WHY: onboard BCM audio and the DAC HAT share the I2S bus and cannot + # run simultaneously; the removed hardware.raspberry-pi audio switch + # did this by withholding the overlay, and the current-API equivalent + # is to keep the onboard audio driver from loading. + boot.blacklistedKernelModules = [ "snd_bcm2835" ]; + # Set the DAC as the ALSA default card system-wide. environment.etc."asound.conf".text = '' pcm.!default { diff --git a/nix/modules/harmonia-render.nix b/nix/modules/harmonia-render.nix index e99e6f65..730cc090 100644 --- a/nix/modules/harmonia-render.nix +++ b/nix/modules/harmonia-render.nix @@ -13,7 +13,7 @@ let }; dsp = { - volume.level_db = 20.0 * (builtins.log cfg.dsp.volume / builtins.log 10.0); + volume.level_db = cfg.dsp.levelDb; replaygain = { enabled = cfg.dsp.replayGain != "off"; mode = if cfg.dsp.replayGain == "album" then "album" else "track"; @@ -101,10 +101,10 @@ in { }; dsp = { - volume = lib.mkOption { + levelDb = lib.mkOption { type = lib.types.float; - default = 1.0; - description = "Linear volume multiplier (1.0 = 0 dBFS, 0.5 = -6 dBFS)."; + default = 0.0; + description = "Output level in dBFS (0.0 = full scale, -6.0 = half amplitude). Rendered directly as the engine's volume.level_db."; }; replayGain = lib.mkOption { From 3ae5692d977844d65f98a9615ae41ebc82e2e5ca Mon Sep 17 00:00:00 2001 From: forkwright Date: Tue, 18 Aug 2026 15:55:34 -0500 Subject: [PATCH 5/7] fix(nix): compose module blocks with mkMerge, not // (the server block was shadowed) The plain // composition made the right-hand lib.mkIf magic attrset shadow the entire server block, so systemd.services.harmonia was never declared and the harmonia-basic NixOS VM test failed with an inactive unit. The bug predates this branch; the new CI job correctly caught it. mkMerge composes both conditional blocks without shadowing. --- nix/module.nix | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/nix/module.nix b/nix/module.nix index b9fa7c7f..44a6e6a0 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -100,7 +100,8 @@ in { }; }; - config = lib.mkIf cfg.enable { + config = lib.mkMerge [ + (lib.mkIf cfg.enable { users.users.${cfg.user} = { isSystemUser = true; group = cfg.group; @@ -173,7 +174,8 @@ in { networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ (cfg.settings.paroche.port or 8096) ]; - } // lib.mkIf desktopCfg.enable { + }) + (lib.mkIf desktopCfg.enable { # Desktop application launcher entry. NixOS has no xdg.desktopEntries # option (that namespace is Home Manager); a system-wide launcher is a # package carrying share/applications/*.desktop. @@ -214,5 +216,6 @@ in { "audio/wav" = "harmonia.desktop"; "audio/aac" = "harmonia.desktop"; }; - }; + }) + ]; } From 3ab160036b4dc6b2542af728ce7a1c18b14cb6f6 Mon Sep 17 00:00:00 2001 From: forkwright Date: Tue, 18 Aug 2026 18:47:18 -0500 Subject: [PATCH 6/7] fix(nix): include deny.toml in the flake source so cargoDeny reads it The src fileset carried only Cargo.toml/Cargo.lock/crates, so craneLib.cargoDeny ran with no config and rejected every license (MIT, Apache-2.0, BSD-3-Clause, the workspace LicenseRef-PolyForm- Noncommercial-1.0.0). With the repo deny.toml present the same 'cargo deny check bans licenses sources' passes locally. --- flake.nix | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/flake.nix b/flake.nix index 62c53cc7..36ed9b81 100644 --- a/flake.nix +++ b/flake.nix @@ -39,16 +39,18 @@ craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain; - # Restrict source to Rust workspace files only. The monorepo also - # contains mouseion/ (C#), akouo/ (Kotlin/TS), docs/, and legacy/ — - # none of which are part of this Cargo workspace and must not invalidate - # the Nix build cache on every documentation change. + # Restrict source to Rust workspace files only (plus deny.toml, which + # the cargoDeny check below reads for the license allow list). The + # monorepo also contains mouseion/ (C#), akouo/ (Kotlin/TS), docs/, and + # legacy/ — none of which are part of this Cargo workspace and must not + # invalidate the Nix build cache on every documentation change. src = lib.fileset.toSource { root = ./.; fileset = lib.fileset.unions [ ./Cargo.toml ./Cargo.lock ./crates + ./deny.toml ]; }; From 7168e9aaf1da34f73aab5d3fd9fccff18b28ed2f Mon Sep 17 00:00:00 2001 From: forkwright Date: Tue, 18 Aug 2026 20:34:00 -0500 Subject: [PATCH 7/7] fix(nix): include clippy.toml in the flake source so cargoClippy uses repo thresholds Same class of bug as the deny.toml fix: the src fileset stripped clippy.toml, so the nix clippy ran with default thresholds (7 args, type-complexity 250) instead of the repo's (6 args, 300). The two eksetasis #[expect(too_many_arguments)] sites (7 args each) became unfulfilled-lint-expectations errors, and komide's cache_validators field tripped type_complexity. Local 'cargo clippy -p eksetasis/-p komide --all-targets -- -D warnings' is clean with the repo config; the expects stay, the nix check now reads the same config. --- flake.nix | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 36ed9b81..17452764 100644 --- a/flake.nix +++ b/flake.nix @@ -40,7 +40,8 @@ craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain; # Restrict source to Rust workspace files only (plus deny.toml, which - # the cargoDeny check below reads for the license allow list). The + # the cargoDeny check below reads for the license allow list, and + # clippy.toml, which cargoClippy reads for the lint thresholds). The # monorepo also contains mouseion/ (C#), akouo/ (Kotlin/TS), docs/, and # legacy/ — none of which are part of this Cargo workspace and must not # invalidate the Nix build cache on every documentation change. @@ -51,6 +52,7 @@ ./Cargo.lock ./crates ./deny.toml + ./clippy.toml ]; };