Managing Dependencies Across Projects

Updated

September 12, 2026

Managing Dependencies Across Projects

Five repos, five copies of desk-checker, five versions. The boring default is: one tooling flake on nixpkgs 26.05 exporting packages and overlays.default; consumers follows that nixpkgs and pin the tooling input.

This is the same idea as the platform-engineering IDP chapter, at package scale: a linter, not a whole OS.

Mental model

desk-tooling (flake)
    packages.*.desk-checker
    overlays.default
         │
         ▼
billing-service  inputs.desk-tooling
                 inputs.nixpkgs.follows = "desk-tooling/nixpkgs"
                 # or both follow a platform flake

nix flake update desk-tooling is the upgrade. Not curl | sh. The consumer’s flake.lock records the tooling rev. CI must fetch that git URL, not path:../tooling.

Worked examples

Case 1: Tooling flake

Save as tooling/flake.nix:

# flake.nix
{
  description = "Shared desk tooling";

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

  outputs = { self, nixpkgs }:
    let
      system = "x86_64-linux";
      pkgs = nixpkgs.legacyPackages.${system};
      desk-checker = pkgs.writeShellApplication {
        name = "desk-checker";
        text = ''
          echo "Validating desk organizational standards: OK"
        '';
      };
    in
    {
      packages.${system}.desk-checker = desk-checker;
      packages.${system}.default = desk-checker;
      overlays.default = final: prev: {
        desk-checker = self.packages.${prev.stdenv.hostPlatform.system}.desk-checker;
      };
    };
}

(If hostPlatform.system is awkward in an overlay, export only packages and reference desk-tooling.packages.${system}.desk-checker from the consumer — often clearer. That is Case 3, and the boring default.)

Case 2: Consumer with follows

Save as project/flake.nix:

# flake.nix
{
  description = "Billing service";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
    desk-tooling.url = "path:../tooling";
    desk-tooling.inputs.nixpkgs.follows = "nixpkgs";
  };

  outputs = { self, nixpkgs, desk-tooling }:
    let
      system = "x86_64-linux";
      pkgs = import nixpkgs {
        inherit system;
        overlays = [ desk-tooling.overlays.default ];
      };
    in
    {
      devShells.${system}.default = pkgs.mkShell {
        packages = [ pkgs.desk-checker ];
      };
    };
}
cd project
nix develop --command desk-checker

Output:

Validating desk organizational standards: OK

Case 3: Prefer packages over overlay

devShells.${system}.default = pkgs.mkShell {
  packages = [ desk-tooling.packages.${system}.desk-checker ];
};

No overlay, no pkgs.desk-checker magic. Overlay when many packages must look like nixpkgs (callPackage trees, override, overrideAttrs). A single checker does not earn an overlay. Overlays that replace stdenv or python3 in a consumer shell are how you get two glibcs and a six-hour rebuild.

Case 4: Update the pin

nix flake update desk-tooling
nix flake check
git diff flake.lock

CI on the consumer fails if the tooling flake broke desk-checker. That is the contract. Review the lock diff: one input should move (desk-tooling), and nixpkgs should not move unless you meant nix flake update (everything). follows keeps them on the same nixpkgs; a lock that shows two nixpkgs revs means someone dropped follows.

A scheduled workflow that only runs nix flake update desk-tooling && nix flake check and opens a PR is the boring bump. Humans still merge it.

Case 5: CI uses git, not path:

# flake.nix fragment — CI and humans
{
  inputs.desk-tooling.url = "git+ssh://git@git.desk.internal/desk-tooling";
  # or: github:desk/desk-tooling
}

path:../tooling is for this chapter’s laptop. CI checkouts one repo; path: to a sibling that is not in the workspace is a missing input. Lock the git rev in flake.lock. nix flake metadata must show a git rev, not a dirty path, on the runner.

Private git: runner access-tokens / SSH deploy key (CI secrets chapter), not a token in flake.nix.

The trap

The trap is copying desk-checker.sh into every repo. The fifth copy diverges. One flake.

The other trap is not follows. Tooling on 26.05, app on another nixpkgs, two glibcs in one shell.

A third is overlays for one binary. A fourth is path: in CI, or nix flake update (all inputs) when you meant update desk-tooling.

The boring rule

  • One tooling flake. 26.05. Lockfile.
  • Export packages; overlay only if many attrs must look like nixpkgs. Never overlay stdenv “for convenience.”
  • Consumers follows nixpkgs. One glibc in the shell.
  • nix flake update desk-tooling is the bump. Review flake.lock.
  • git URL in CI; path: only locally. Tokens stay in runner config.

Try this

  1. Two directories as in Cases 1–2; nix develop --command desk-checker.
  2. Change the echo text in tooling, update the consumer, run again.
  3. nix flake metadata in the consumer; one nixpkgs.
  4. Replace the overlay with a direct packages reference (Case 3).
  5. nix flake metadata in CI logs: desk-tooling is a git rev. If it says path:, the workflow checked out the wrong tree.