Pinning NixOS with npins

I have a small improvement that I found while wrangling nixos-rebuild-ng ignoring the NIX_PATH I gave it.

In my rebuild script I have the rebuild step with these 2 lines (written in fish):

set -l nixpkgs_path (nix-instantiate --json --eval npins/default.nix -A nixpkgs.outPath | jq -r .)
sudo nixos-rebuild switch -I nixos-config=/home/$USER/nixos-config/configuration.nix -I nixpkgs=$nixpkgs_path 2>&1 | tee rebuild.log

and in my config I have a pinning.nix module

{
  config,
  pkgs,
  ...
}: let
  sources = import ./npins;
in {
  # kill channels
  nix = {
    channel.enable = false;
    nixPath = [
      "nixpkgs=/etc/nixos/nixpkgs"
      "nixos-config=/home/USERNAME/nixos-config/configuration.nix"
    ];
  };
  environment.etc = {
    "nixos/nixpkgs".source = builtins.storePath pkgs.path;
  };

  # pinning
  nixpkgs = {
    config = {
      packageOverrides = pkgs: {
        unstable = import sources.nixpkgs-unstable {config = config.nixpkgs.config;};
        vpncpin = import sources.nixpkgs-vpncpin {config = config.nixpkgs.config;};
      };
    };
  };
}

overlays are useful for readability when I use them elsewhere.

For anyone reading the 2 blog posts mentioned originally, this and the discussion here It basically boils down to:

  • No flake features, eval for nixos-rebuild is done using Jade’s one liner with nix-instantiate
  • pin using npins
  • give nixos-rebuild the latest nixpkgs by passing -I which bypasses all other includes
  • make <nixpkgs> be consistent across the system (see piegames) blog
  • I also use overrides for the imported pins, but feel free to integrate however you like

see my full config here

EDIT:
save yourself the next headache and point the command-not-found to the correct database

programs.command-not-found.dbPath = "/etc/nixos/nixpkgs/programs.sqlite";

now, why is this hardcoded to point to the root nixos channel is beyond me, but yeah that fixes the

DBI connect('dbname=/nix/var/nix/profiles/per-user/root/channels/nixos/programs.sqlite','',...) failed: unable to open database file at /run/current-system/sw/bin/command-not-found line 13.
cannot open database `/nix/var/nix/profiles/per-user/root/channels/nixos/programs.sqlite' at /run/current-system/sw/bin/command-not-found line 13.

there seems to be some activity on the module in nixpkgs (including explicit fish integration :DD), but I don’t care enough to override it so I’ll just wait until 25.11. This works well enough

5 Likes

For overlays, is there a reason not to

  1. Create an overlayed.nix that evaluates to the result of import sources.nixpkgs { overlays = …; }
  2. Point the nixpkgs path to that?

Yes, because it breaks stuff like `lib = import <nixpkgs/lib> {}` (not sure if I got the details right about how to import it, it’s been a while). But generally `` does not point to an expression, but to a path, and while most uses simply end up importing it (where your solution would work fine), this is not always the case

Ah, that makes sense. Maybe something like this, then, if I really want to avoid patching:

let
  pins = import ./npins;
  pkgs = import pins.nixos {};
  files = builtins.mapAttrs (name: _: "${pins.nixos}/${name}") (builtins.readDir pins.nixos);
  wrapper = pkgs.writeText "default.nix" ''
    { ... } @ args:
    import ./default.orig.nix (args // {
      overlays = [ (final: prev: { path = ./.; }) ]
          ++ (import ${./overlays.nix})
          ++ (args.overlays or []);
    })
  '';
  links = pkgs.lib.mapAttrsToList (name: path: {inherit name path;})
    (files // { "default.nix" = wrapper; "default.orig.nix" = files."default.nix"; });
  nixosWrapped = pkgs.linkFarm "nixos-wrapped" links;
in
{ nixos = nixosWrapped; nixpkgs = nixosWrapped; }

Or this to avoid a chance of the overlays getting lost due to symlink expansion, though I’d not sure it’s too much different than your patch at that point:

let
  pins = import ./npins;
  pkgs = import pins.nixos {};
  wrapper = pkgs.writeText "default.nix" ''
    { ... } @ args:
    import ./default.orig.nix (args // {
      overlays = (import ${./overlays.nix}) ++ (args.overlays or []);
    })
  '';
  nixosWrapped = pkgs.runCommand "nixos-wrapped" {} ''
    cp -a ${pins.nixos} $out
    chmod u+w $out
    mv $out/default.nix $out/default.orig.nix
    cp ${wrapper} $out/default.nix
  '';
in
{ nixos = nixosWrapped; nixpkgs = nixosWrapped; }

Rather than using your direnv approach, I ended up writing this script to execute a command with a set of sources loaded into NIX_PATH. That way I can do

with-nix-sources /etc/nixos/sources.nix -- nixos-rebuild build

to test building with updated pins (sources.nix is the wrapper from my previous post), and then do

with-nix-sources /etc/nixos/sources.nix ~/.config/home-manager/npins -- home-manager build

to test build my Home Manager config with the updated npins before switching.

set -euo pipefail

usage() {
  echo "Usage: $0 FILE [FILE...] -- COMMAND [ARGS...]" >&2
  exit "${1:-1}"
}

files=()
while [[ $# -gt 0 ]]; do
  case $1 in
    -h|--help) usage 0 ;;
    --) shift; break ;;
    *) files+=("$1"); shift ;;
  esac
done

[[ ${#files[@]} -gt 0 && $# -gt 0 ]] || usage

args=()
count=${#files[@]}
width=${#count}
for i in "${!files[@]}"; do
  args+=(--argstr "$(printf "file%0${width}d" "$i")" "$(realpath -- "${files[$i]}")")
done

expr='{ ... }@args:
  let
    pkgs = import <nixpkgs> {};
    sources = builtins.concatLists
      (pkgs.lib.mapAttrsToList
        (_: file: pkgs.lib.mapAttrsToList
          (name: path: "${name}=${path}")
          (import file))
        args);
  in
    pkgs.writeText "NIX_PATH" (pkgs.lib.concatStringsSep ":" sources)'

# Using nix-build to realize a file containing the NIX_PATH value
# ensures that the constituent paths are also realized.
nix_path=$(< "$(nix-build --no-out-link -E "$expr" "${args[@]}")")
if [[ -n $nix_path ]]; then
  export NIX_PATH="$nix_path${NIX_PATH:+:$NIX_PATH}"
fi
exec "$@"