Skip to content

unstable-cherry-pick-overlay

Overlays

Cherry-pick a few fast-moving packages from nixpkgs-unstable onto an otherwise stable nixpkgs, using a plain overlay — and survive the small build fixes those packages recurrently need to compile in the Nix sandbox.

The problem

You want your system pinned to a stable nixpkgs channel for reproducibility and cache hits, but a handful of packages (editors, AI/CLI tools, dev utilities) move fast enough that the stable version is uselessly old. You don't want to flip the whole system to unstable just for them.

An overlay solves the pinning half in one line:

inherit (unstable) atuin neovim;

The part that bites you is the other half: fast-moving packages regularly fail to build under the Nix sandbox, and you have to patch around it right here in the overlay.

The traps (why this file exists)

The Nix build sandbox has no network, no /dev/ptmx, and tight fd limits. Two failure shapes recur:

  1. Sandbox-incompatible tests. A test assumes network access, a pty, or loose fd limits, so it fails in the sandbox even though the package itself is fine. This is not a real regression — disable the offending tests, don't pin backwards.
  2. Surgical: disabledTests = old.disabledTests ++ [ "test_foo" ] when only a few tests are the problem (the aider-chat example).
  3. Wholesale: doCheck = false when the whole suite is unsafe — e.g. a test suite that aborts the build on OpenptyFailed because there's no /dev/ptmx (the ghostty example).

  4. A pinned build backend that lags. A Python package may pin its PEP-517 build backend (uv_build, setuptools, hatchling, …) to a narrow version range that the unstable interpreter set no longer provides — so it fails to build, not test. Relax the pin and feed the backend in from unstable (the marimo example):

  5. filter out any nixpkgs patch that re-pins the backend,
  6. substituteInPlace pyproject.toml to widen the version bound (use --replace-quiet so it's a harmless no-op once upstream relaxes it),
  7. add the backend (and any newly-required deps) from unstable.python3Packages.

Keeping these fixes in the overlay means the day a package needs the workaround, you add three lines; the day upstream fixes it, you delete them — without ever leaving your stable base.

Usage

Add an unstable nixpkgs input alongside your stable one:

inputs.nixpkgs.url          = "github:NixOS/nixpkgs/nixos-25.05";
inputs.nixpkgs-unstable.url = "github:NixOS/nixpkgs/nixos-unstable";

Instantiate the unstable set for the same system/config as your base, then apply the overlay:

let
  unstable = import inputs.nixpkgs-unstable {
    inherit system;
    config.allowUnfree = true;   # match your base config
  };
in
import inputs.nixpkgs {
  inherit system;
  overlays = [
    (import ./overlays/unstable-cherry-pick-overlay { inherit unstable; })
  ];
}

Now pkgs.neovim, pkgs.ghostty, etc. resolve to the unstable builds while the rest of your system stays on the stable channel.

Arguments

Arg Required What it is
unstable yes A fully-instantiated nixpkgs-unstable package set (import nixpkgs-unstable { ... }), not the raw flake input. Instantiate it for the same system/config as your base.
zig no Optional example of pulling a package from a third source — an external flake input exposing packages.<system>.*. Omit it (and the zig attr) if you don't need it.

Caveats

  • Cache hits. Cherry-picking from unstable means those packages build against the unstable channel; expect them to (re)build from source unless the unstable binary cache covers your system. Overriding attrs (doCheck, patches) also perturbs the derivation hash, guaranteeing a local rebuild — fine for a few tools, painful if you do it to something huge like a compiler toolchain. Prefer leaving cache-hitting packages untouched.
  • Config drift. If your base sets config.allowUnfree, cudaSupport, etc., set the same on the instantiated unstable set or the cherry-picked packages may evaluate differently (or refuse to evaluate).
  • Disable the narrowest thing that works. Reach for disabledTests before doCheck = false; you keep the rest of the suite as a real signal.
  • Interpreter skew is the usual root cause. Build-backend and dependency breakage almost always traces back to unstable's Python version having moved ahead of what the package pinned. Relaxing the pin is the fix; bumping the package version sometimes removes the need entirely.

Source

overlays/unstable-cherry-pick-overlay/default.nix
# unstable-cherry-pick-overlay
#
# Cherry-pick a handful of fast-moving packages from nixpkgs-unstable onto an
# otherwise-stable nixpkgs, via a plain overlay. The rest of your system keeps
# tracking the stable channel; only the packages named here jump ahead.
#
# The whole reason this file is more than a one-line `inherit (unstable) ...`
# is that fast-moving packages regularly need small build fixes to succeed in
# the Nix sandbox. Two shapes recur, so read the traps before copy-pasting:
#
#   1. Sandbox-incompatible tests. The build sandbox has no network, no
#      /dev/ptmx, and tight fd limits. Tests that assume any of those fail in
#      the sandbox even though the package is fine — disable them (per-test with
#      `disabledTests`, or wholesale with `doCheck = false`) rather than
#      carrying a real regression.
#
#   2. Pinned build-backend lag. A package can pin its PEP-517 build backend to
#      a narrow version range that the nixpkgs-unstable interpreter set no
#      longer provides, so it fails to *build* (not test). Relax the pin in
#      `pyproject.toml` and supply the backend from the unstable python set.
#
# ── How to wire this up ──────────────────────────────────────────────────────
#
# In your flake, add a second nixpkgs input tracking unstable:
#
#     inputs.nixpkgs.url        = "github:NixOS/nixpkgs/nixos-25.05";
#     inputs.nixpkgs-unstable.url = "github:NixOS/nixpkgs/nixos-unstable";
#
# Then apply this overlay when you instantiate your stable `pkgs`, passing the
# unstable package set in. `unstable` here is a fully-instantiated package set
# (`import nixpkgs-unstable { inherit system; config = ...; }`), NOT the raw
# flake input — instantiate it for the same `system`/`config` as your base so
# the cherry-picked packages match your platform:
#
#     let
#       unstable = import inputs.nixpkgs-unstable {
#         inherit system;
#         config.allowUnfree = true;   # match your base config
#       };
#     in
#     import inputs.nixpkgs {
#       inherit system;
#       overlays = [
#         (import ./overlays/unstable-cherry-pick-overlay { inherit unstable; })
#       ];
#     };
#
# `zig` is an OPTIONAL example of pulling a package from a *third* source — an
# external flake input that publishes its own `packages.<system>.*` (here, the
# Zig toolchain's `master`). Drop the arg and the `zig = ...` line if you don't
# need it; it only illustrates that overlay inputs can come from anywhere, not
# just `unstable`. If you keep it, add the input to your flake:
#
#     inputs.zig.url = "github:mitchellh/zig-overlay";
#
{
  # Fully-instantiated nixpkgs-unstable package set (see header).
  unstable,

  # OPTIONAL external flake input exposing packages.<system>.<name>.
  # Remove this arg (and the `zig` attr below) if unused.
  zig ? null,
}:
final: prev:
{
  # ── Trap 1a: disable specific network-bound tests ──────────────────────────
  # These three tests reach out to the network / touch state the sandbox
  # forbids. Everything else in the test suite still runs. Prefer this
  # surgical form over `doCheck = false` when only a few tests are the problem.
  aider-chat = unstable.aider-chat.overridePythonAttrs (old: {
    disabledTests = (old.disabledTests or [ ]) ++ [
      "test_max_context_tokens"
      "test_cmd_read_only_with_image_file"
      "test_cmd_tokens_output"
    ];
  });

  # ── Trap 1b: disable the whole check phase ─────────────────────────────────
  # This package's test suite aborts the *build* in the sandbox: its pty tests
  # hit OpenptyFailed (no /dev/ptmx) and a hostname check fails. These are
  # sandbox incompatibilities, not regressions, so drop checks wholesale.
  ghostty = unstable.ghostty.overrideAttrs (_: {
    doCheck = false;
  });

  # ── Straight cherry-picks (no fix needed) ──────────────────────────────────
  # The common case: just take the unstable build as-is. Trim / extend this
  # list to whatever you actually want ahead of the stable channel.
  inherit (unstable)
    atuin
    neovim
    ;

  # ── Trap 2: relax a lagging pinned build backend ───────────────────────────
  # This package pins `uv_build` to a narrow range (`>=0.8.3,<0.12.0`) that the
  # unstable interpreter set no longer satisfies, so it fails to build. The fix:
  #   - drop nixpkgs' own patch that hard-pins the backend (filtered by name),
  #   - relax the upper bound in pyproject.toml,
  #   - supply the build backend + any newly-required deps from `unstable`.
  # `--replace-quiet` is used so the substitution is a no-op (not an error) if a
  # future version already relaxed the pin upstream.
  marimo = unstable.marimo.overridePythonAttrs (old: {
    patches = builtins.filter (
      p: !(p ? name && p.name == "uv-build.patch")
    ) (old.patches or [ ]);
    build-system = (old.build-system or [ ]) ++ [ unstable.python3Packages.uv-build ];
    dependencies = (old.dependencies or [ ]) ++ [ unstable.python3Packages.msgspec ];
    pythonRelaxDeps = (old.pythonRelaxDeps or [ ]) ++ [ "jedi" ];
    postPatch = (old.postPatch or "") + ''
      substituteInPlace pyproject.toml \
        --replace-quiet 'uv_build>=0.8.3,<0.12.0' 'uv_build>=0.8.3'
    '';
  });
}
# ── Optional: pull from a third source (an external flake input) ─────────────
# Merged in only when `zig` is provided. Demonstrates that an overlay's inputs
# need not all come from `unstable`.
// prev.lib.optionalAttrs (zig != null) {
  zig = zig.packages.${prev.stdenv.hostPlatform.system}.master;
}