cache-pin 0.1.4

Pin Nix flake inputs to revisions with full binary-cache coverage
# nix-cache-pin

<!-- simit:badges:start -->

[![CI](https://img.shields.io/badge/CI-managed-2088ff)](.github/workflows/ci.yaml) [![Nix](https://img.shields.io/badge/Nix-drift-5277c3)](flake.nix) [![crates.io](https://img.shields.io/badge/crates.io-ready-f46623)](https://crates.io/crates/cache-pin)

<!-- simit:badges:end -->

A [flake-parts](https://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:

1. **Query Hydra** for recent nixpkgs evaluations
2. **Verify via narinfo** that packages are actually in the binary cache
3. **Fall back to scanning** GitHub commits if packages aren't on Hydra
4. Update your `flake.nix` input pin to the most recent fully-cached revision
5. Watch optional `wishPackages` and stop when one is ready to promote
6. Merge pin requirements that target the same flake input into one search
7. Gate revisions on exact required outputs from the consuming flake

## Quick start

Add `nix-cache-pin` as a flake input and import the module:

```nix
{
  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:

```sh
nix run .#cache-pin         # update all pins
nix run .#cache-pin-rocm    # update a specific pin
nix run .#cache-pin-cuda    # update a specific pin
nix run .#cache-pin-update  # search and apply all pins as one transaction
nix run .#cache-pin -- --check-current         # verify the locked revisions
nix run .#cache-pin -- --check-current --json  # machine-readable verification
nix run .#cache-pin -- --plan                  # read-only JSON update plan
```

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:

```sh
# Check if store paths are in the binary cache
narinfo-check /nix/store/abc...-hello --cache https://cache.nixos.org
narinfo-check --config pin.json --rev <rev> --json

# Query Hydra for build status
hydra-query --config pin.json
hydra-query --hydra-url https://hydra.nixos.org --job nixpkgs/trunk/blender.x86_64-linux --json

# Evaluate nix store paths
nix-eval-store-path --flake-ref github:NixOS/nixpkgs --rev <rev> --attr blender
nix-eval-store-path --config pin.json --rev <rev> --json
```

## Presets

Ready-to-use configurations for common use cases:

```nix
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:

```sh
nix eval --json .#cachePinMeta
# {"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:

```nix
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:

1. Query Hydra's cheap `latest-finished` endpoint for every wish in parallel.
2. If none are on Hydra, find the normal target revision.
3. Check every wish at that revision against all configured binary caches.
4. 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:

```nix
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:

```nix
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";
  };
};
```

- `target` accepts only versions matching the constraint.
- `taints` rejects versions matching any listed constraint.
- `versionAttr` defaults to `version`.
- 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.