nixpkgs-multiverse

The NixOS, nix-darwin, and home-manager module

nixosModules.default, darwinModules.default, and homeManagerModules.default share every option below:

{
  # Use darwinModules.default on nix-darwin or homeManagerModules.default in
  # home-manager.
  imports = [ inputs.multiverse.nixosModules.default ];

  multiverse = {
    enable = true;
    config.allowUnfree = true;

    # Attributes pinned to an exact version, each resolved against whichever
    # revision last shipped it.
    pins = {
      vscode = "1.107.0";
      ripgrep = "13.0.0";
    };

    # The same idea, maintained by `mvs lock` instead of by hand: a set of
    # commits, each moved on its own by `mvs lock update <attr>`.
    lock = ./multiverse.lock;
  };
}

Pins are also available as derivations, for options that take a package rather than installing one:

programs.vscode.package = config.multiverse.pinned.vscode;
# and, for a lock file, config.multiverse.locked.vscode

An attribute claimed by more than one of pins, lock and cooldown.packages is a configuration error rather than a file collision out of buildEnv: each side would resolve to a different derivation of the same package.

Note: Only indexed attributes work โ€” every top-level one, plus the children of the few package sets nix/nested-sets.nix lists, named by their path: jetbrains.idea. The large sets are deliberately not in the index, so python3Packages.* and nodePackages.* cannot be used. See building the index.

How many nixpkgs your pins cost

Cost is per revision touched, not per package: ten pins landing on ten revisions is ten nixpkgs fetches and ten evaluations. multiverse.minimize is on by default and resolves the whole set through the fewest revisions that can serve it, which is often one or two.

The versions installed are identical either way โ€” minimising decides which revision serves a version, never which version is served, and a pin can never leave the run of the version it names. What it does change is which build of that version you get: a pin can land on an older revision inside its run, and so carries that revision's closure. Set minimize = false to put every pin back on the newest revision shipping it, one fetch each.

multiverse.plan reports the division, and is computed from the index without fetching anything โ€” so it is available to an assertion:

{
  multiverse.pins = {
    python3 = "3.8.9";
    nodejs = "14.17.3";
  };

  # These two must come out of the same nixpkgs, or fail the build.
  assertions = [
    {
      assertion = config.multiverse.plan.revisions == 1;
      message = config.multiverse.plan.why;
    }
  ];
}

plan.groups says which pin lands where and how much older that leaves it, in days and in revisions. plan.certificate names the pins that make a smaller plan impossible. It is the same structure mvs solve --json prints โ€” see Minimising.

Cooldown

A soak period, as a module option. days behind anchor, along nixos-unstable, using the same machinery as daysBehind:

multiverse = {
  enable = true;
  cooldown = {
    enable = true;
    days = 7;
    # any selector `at` takes
    anchor = "tip";
    packages = [ "ripgrep" "fd" ];
  };
};

This soaks the packages you name, not the system. The whole soaked revision is available as a package set for anything the list cannot express:

programs.neovim.package = config.multiverse.cooldown.pkgs.neovim;

Soaking an entire NixOS configuration is a different operation and has to happen at the flake level, where nixosSystem is called, see flakeAt.

An attribute claimed by both pins and cooldown.packages fails the configuration rather than colliding at build time.

Reaching the rest of the API

config.multiverse.instance is a full multiverse carrying the module's config and overlays, for everything the options do not cover:

environment.systemPackages = [
  (config.multiverse.instance.at "24.11").ghc
];

Rewriting pkgs.<attr> instead

The module installs derivations; it never touches nixpkgs.overlays. That is deliberate since home-manager discards every nixpkgs.* definition when home-manager.useGlobalPkgs = true, so a module that set overlays would silently do nothing in the most common home-manager deployment.

If you want a pin to be visible to every other module, apply the overlay yourself, at the layer that honours it:

nixpkgs.overlays = [
  (inputs.multiverse.lib.pinOverlay {
    pins.vscode = "1.107.0";
    config.allowUnfree = true;
  })
];

Now pkgs.vscode is 1.107.0 everywhere, and anything reading it โ€” including other modules' package defaults โ€” picks it up.

pinOverlay minimises its pins the same way and by the same default as multiverse.minimize, and takes minimize = false to turn it off, so moving a set of pins from the module to the overlay does not quietly change what it costs.

Without the module

mkMultiverse in an overlay works too, if you would rather have the whole API hanging off pkgs:

nixpkgs.overlays = [
  (final: prev: {
    mv = inputs.multiverse.lib.mkMultiverse {
      system = final.stdenv.hostPlatform.system;
      config.allowUnfree = true;
      overlays = [
        # whatever overlays you want to apply to every revision
      ];
    };
  })
];

# ...then, in any module
environment.systemPackages = [ pkgs.mv.versions.vscode."1.107.0" ];

Same caveat as above: this sets nixpkgs.overlays, so it is a NixOS-level or standalone-home-manager pattern, not one to reach for under useGlobalPkgs.

Edit this page on GitHub