nix-cache-pin
A flake-parts module that automatically pins your nixpkgs flake inputs to revisions where all your specified packages have binary cache hits.
Stop waiting for builds. If Hydra (or your binary cache) has already built it,
nix-cache-pin finds the right revision for you.
Problem
Nixpkgs-unstable moves fast. When you update your flake lock, some packages
(especially from overlay-heavy attribute sets like pkgsRocm or pkgsCuda)
may not yet have cache hits, forcing expensive local builds.
Solution
Declare which packages you need cached, and nix-cache-pin will:
- Query Hydra for recent nixpkgs evaluations
- Verify via narinfo that packages are actually in the binary cache
- Fall back to scanning GitHub commits if packages aren't on Hydra
- Update your
flake.nixinput pin to the most recent fully-cached revision - Watch optional
wishPackagesand stop when one is ready to promote - Merge pin requirements that target the same flake input into one search
- Gate revisions on exact required outputs from the consuming flake
Quick start
Add nix-cache-pin as a flake input and import the module:
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
flake-parts.url = "github:hercules-ci/flake-parts";
nix-cache-pin.url = "github:caniko/nix-cache-pin";
# Inputs that will be pinned to cached revisions
nixpkgs-rocm.url = "github:NixOS/nixpkgs/<some-rev>";
nixpkgs-cuda.url = "github:NixOS/nixpkgs/<some-rev>";
};
outputs = inputs:
inputs.flake-parts.lib.mkFlake {inherit inputs;} {
imports = [inputs.nix-cache-pin.flakeModules.default];
systems = ["x86_64-linux"];
cache-pin = {
nixpkgs = inputs.nixpkgs.legacyPackages.x86_64-linux;
pins.rocm = {
packages = ["torchWithRocm" "torchvision"];
wishPackages = ["obs-studio-plugins.obs-backgroundremoval"];
inputName = "nixpkgs-rocm";
attrPrefix = "pkgsRocm";
pythonPackages = "python313Packages";
};
pins.cuda = {
packages = ["cudatoolkit" "cudnn"];
inputName = "nixpkgs-cuda";
attrPrefix = "cudaPackages";
pythonPackages = null;
};
};
};
}
After setting this up, run the pin updater to find cached revisions:
Plan schema v1 reports revision policy, package and wish evidence, provenance, typed diagnostics, and aggregate fail-before-write failures. Blocked plans exit non-zero and never update repository files.
Standalone CLI tools
The project also provides standalone CLI tools that can be used independently:
# Check if store paths are in the binary cache
# Query Hydra for build status
# Evaluate nix store paths
Presets
Ready-to-use configurations for common use cases:
cache-pin.pins.rocm = inputs.nix-cache-pin.presets.rocm // {
inputName = "nixpkgs-rocm";
packages = ["torchWithRocm" "torchvision" "torchaudio"];
};
cache-pin.pins.cachyos = inputs.nix-cache-pin.presets.cachyos-kernel // {
inputName = "nix-cachyos-kernel";
packages = ["linux-cachyos-latest-lto-zen4"];
};
Available presets: rocm, cuda, cachyos-kernel.
Embedding in your own CLI
For downstream CLIs that wrap nix-cache-pin, the module exposes a pure-data
flake output enumerating the configured pin set:
# {"schemaVersion": 4, "pins": {"rocm": {"inputName": "nixpkgs-rocm", ...}, ...}}
Reading cachePinMeta does not trigger validation, so you can list pins even
when validation would otherwise throw. Use it to:
- Build per-pin subcommands or completions dynamically (no hardcoded names).
- Render help text that reflects whatever pins the user configured.
- Drive repo-doctor / CI freshness checks.
schemaVersion is the public contract — bumps signal a breaking change.
Pin options
| Option | Type | Default | Description |
|---|---|---|---|
packages |
[str] |
required | Package attr paths relative to attrPrefix |
wishPackages |
[str] |
[] |
Packages to watch and promote to packages once built |
consumerFlakeRef |
str? |
null |
Flake whose consumer-specific targets should be evaluated |
consumerTargets |
{ name = target; } |
{} |
Map package names to derivation paths in consumerFlakeRef |
requiredConsumerTargets |
{ label = target; } |
{} |
Exact consuming-flake derivations required in addition to packages; excluded from Hydra discovery |
inputName |
str |
required | Flake input name to update in flake.nix |
attrPrefix |
str |
required | Top-level nixpkgs attr set (e.g. pkgsRocm) |
pythonPackages |
str? |
"pythonPackages" |
Python package set; set to null for non-Python packages |
caches |
[str] |
["https://cache.nixos.org"] |
Binary cache URLs to check |
hydraJobset |
str |
"nixpkgs/trunk" |
Hydra jobset to query |
hydraUrl |
str |
"https://hydra.nixos.org" |
Hydra instance URL |
hydraJobPattern |
str |
"{jobset}/{pkg}.{arch}" |
URL template for Hydra job lookups |
hydraRevInput |
str |
"nixpkgs" |
How to extract rev from Hydra evals |
flakeRef |
str |
"github:NixOS/nixpkgs" |
Flake reference (without revision) |
flakeOutput |
str |
"legacyPackages" |
Flake output attribute for eval |
arch |
str? |
current system | System architecture |
depth |
int |
15 |
Number of commits/evals to scan |
branch |
str |
"nixpkgs-unstable" |
Git branch for narinfo fallback |
branchFallbacks |
[str] |
[] |
Branches tried in order when the primary branch has no complete cache hit |
skipValidation |
bool |
false |
Skip nixpkgs attr path validation |
failFast |
bool |
false |
Exit on first cache miss |
lockOnly |
bool |
false |
Update only flake.lock, transactionally, leaving the source URL unchanged |
verifyClosure |
bool |
false |
Require every referenced store path to be available from the configured caches |
versionConstraints |
{ attr = { target?, taints?, versionAttr?; }; } |
{} |
Per-package version gates |
Updates are fail-before-write and transactional across flake.nix,
flake.lock, and the derived cache-pin.lock.json manifest. The lock file is
the source of truth; the manifest records the selected revision and the
individual requirements that were merged for each input. It is safe to delete
and regenerate the manifest by running cache-pin-update again.
Pins with different inputName values are never merged, even when they point
at the same nixpkgs source (for example, independent ROCm and CUDA package
sets). Pins with the same input are merged only when their evaluator settings
are compatible; package, wish-package, consumer-target, required-target, and
version requirements are combined, while conflicting same-label targets or
settings fail with an actionable error.
Wish packages
Use wishPackages for package attributes you want to require eventually but
which are not currently built by Hydra or present in your configured caches:
cache-pin.pins.rocm = {
packages = ["blender" "obs-studio"];
wishPackages = ["obs-studio-plugins.obs-backgroundremoval"];
inputName = "nixpkgs-rocm";
attrPrefix = "pkgsRocm";
pythonPackages = null;
};
Every update applies a promotion gate before changing the pin:
- Query Hydra's cheap
latest-finishedendpoint for every wish in parallel. - If none are on Hydra, find the normal target revision.
- Check every wish at that revision against all configured binary caches.
- Abort with the package names if any wish is built.
Move the reported names from wishPackages to packages and rerun. Required
and wish lists must be disjoint, and both receive the same attribute-path
validation.
Consumer-aware targets
When a package is selected through a consuming flake's host configuration, evaluate that exact derivation instead of a bare nixpkgs attribute:
cache-pin.pins.aarch64 = {
packages = ["rauthy" "kanidm"];
consumerFlakeRef = ".";
consumerTargets = {
rauthy = "nixosConfigurations.thething-crossbow.config.services.rauthy.package";
kanidm = "nixosConfigurations.thething-crossbow.config.services.kanidm.package";
};
requiredConsumerTargets.system =
"nixosConfigurations.thething-crossbow.config.system.build.toplevel";
inputName = "nixpkgs";
attrPrefix = "pkgs";
arch = "aarch64-linux";
lockOnly = true;
};
Each candidate is checked with nix eval --override-input, so follows,
overlays, and host-specific package selection are included. lockOnly then
merges the candidate lock graph transactionally and leaves the branch URL in
flake.nix untouched.
requiredConsumerTargets adds exact required gates without treating their
labels as Hydra jobs or source package attributes. Its labels must be disjoint
from packages, wishPackages, and consumerTargets, and it requires
consumerFlakeRef.
Current pin checks
cache-pin --check-current reads each grouped input revision from flake.lock
and verifies packages plus requiredConsumerTargets without searching or
writing. Consumer targets are evaluated directly from the currently locked
consuming flake, without --override-input, so forked or followed lock pins are
preserved.
Human output lists every label, availability, and store path. --json emits
schema version 1 with pin, input, revision, allCached, and per-result
package, target, storePath, availability, cache, and error fields.
Multiple input groups produce an array of these reports. Missing and unknown
availability both return a nonzero status.
Version gates
versionConstraints lets a pin reject or target revisions by package version
after cache hits are checked:
cache-pin.pins.cachyos-zen4 = inputs.nix-cache-pin.presets.cachyos-kernel // {
inputName = "nix-cachyos-kernel-zen4";
packages = ["linux-cachyos-latest-lto-zen4"];
versionConstraints."linux-cachyos-latest-lto-zen4" = {
target = "< 7.0.8";
taints = [">= 7.0.8"];
versionAttr = "version";
};
};
targetaccepts only versions matching the constraint.taintsrejects versions matching any listed constraint.versionAttrdefaults toversion.- Supported operators: exact,
=,!=,<,<=,>,>=,~,^, and comma-separated ranges.
Requirements
Runtime dependencies (provided automatically via the generated apps):
nix,git,gh(GitHub CLI)
CI
Woodpecker CI on Codeberg runs nix flake check on every push and pull request to verify module evaluation and the test suite.
License
MIT
Concurrent mutation safety
Mutating cache-pin operations use the rust-nix nix crate's nonblocking
flock guard. The persistent nix-cache-pin.mutation.lock anchor lives in
the Git common directory (or the supplied directory for non-Git inputs).
Contention is an error; releasing the guard never deletes the anchor.
Transactional pin application holds one guard across staging and promotion. Nested lock/manifest writers receive that guard rather than reacquiring it. Standalone declaration, lock, manifest, and source-sidecar writers participate in the same coordination. Baseline bytes are checked immediately before replacement to detect non-cooperating edits; conflicts preserve the live file. An advisory lock cannot exclude editors that ignore it, so this is not a filesystem compare-and-swap guarantee.
The nix crate supplies Unix locking, not Nix package-manager evaluation.
Nix CLI subprocesses remain in use for flake and cache operations.