A flake framework for a tree published as one or many repos
Nix 100%

README.md

Nix Workspace #

Developed here. It is mirrored to github.com/overby-me/nix-workspace, which is read-only; open issues and pull requests on this repo.

It was extracted from the overby.me monorepo, whose history it keeps, and which is now one of its consumers rather than its home.

A flake framework for a tree published as one or many repos.

It grew out of the overby.me monorepo, where every published project is one filtered directory that has to build on its own. They all need the same three things, so those live here rather than in forty copies:

  • a plain-nixpkgs build of the crate, read from its own Cargo.toml
  • a devshell carrying the pre-commit hooks the monorepo holds it to
  • a formatter

This flake is callable, through the functor option, so a consuming flake is the call and whatever is different about that project:

{
  description = "A GNU sed-compatible stream editor written in Rust";

  inputs.workspace.url = "git+https://tangled.org/overby.me/nix-workspace";

  outputs = inputs:
    inputs.workspace {
      name = "oxidized-sed";
      description = "A GNU sed-compatible stream editor written in Rust";
    };
}

That is the whole file. The inputs the call closes over are this flake's own, so nixpkgs and the hooks are the same in every consuming repo without any of them naming a revision, and each of their locks has one direct input.

The module is also exported as workspaceModules.default, for a flake that needs to compose it with modules of its own.

Workspaces #

A tree finds its own projects: every directory holding a default.nix is one and is imported, so a monorepo's root flake stops carrying a list that drifts.

outputs = inputs:
  inputs.workspace {
    inherit inputs;
    systems = ["x86_64-linux"];
    outputDirs = [./platform/nix];
    imports = [./platform/nix/workspace-modules/lib.nix];  # what is not a project
    workspaces.exclude = ["platform/nix"];
  };

There is no root argument, and it cannot come from self: self is outputs plus sourceInfo, so forcing it needs the output set's shape, and the root is what decides that shape - nix reports the attempt as infinite recursion (NixOS/nix#8300, closed as inherent; NixOS/nix#4090, the lazy-attribute-names change that would dissolve it, open since 2020). The root comes from where the call is written: nix records a source position per attribute, unsafeGetAttrPos reads one without touching self, and the position's directory is the flake's own. The set must therefore be written literally in the flake's own file - in a helper file it would name the helper's directory, which is the same misplacement that silently misroots a ./., and is an error here because the derived directory must hold a flake.nix.

The same call builds a single project, which is the same thing with one project at its root:

outputs = inputs:
  inputs.workspace {
    name = "oxidized-sed";
    description = "A GNU sed-compatible stream editor written in Rust";
  };

The two are told apart by inputs. A tree hands its own over so that the modules it takes can be found in them; a single project has nothing to find, and gets nixpkgs and the hooks from this flake's inputs instead.

workspaces.exclude takes repo-relative paths and workspaces.depth bounds the walk (default 4, enough for area/group/project).

<outputDir>/workspace-modules is the other half of not listing things: every .nix file directly inside it is a module of the tree. One level deep, and no default.nix rule, because these are not projects - naming the directory is the point, and a subdirectory of it is a library the modules share rather than another module.

A tree's nixpkgs comes from its own inputs rather than from this flake, so a monorepo pins what its other modules were written against. A single project is the other way round, and that is what makes nixpkgs identical across every repo built this way without any of them naming a revision.

workspace is also exported by name, for a tree that would rather import it by path than take this flake as an input.

When it replaced a hand-written list of 56 directories here, the evaluated output surface was identical except for one project that had been missing from the list for as long as it had existed.

Projects that name themselves from where they are #

A project may provide workspace.nix instead of default.nix. The workspace applies it to its own identity, so the file states what it builds and never where the names come from:

{
  packages = {
    default = ...;   # -> wclip
    dev = ...;       # -> wclip-dev
  };
  checks = {
    test-version = ...;   # -> wclip-test-version
  };
}

That is the whole file: an ordinary module, declaring what it builds in local names. There is nothing to wrap it in, because the workspace already holds both the file and the identity and applies one to the other. Every block of names is qualified; imports, options and config are structure rather than names and pass through.

A module that needs to know where it is takes workspace among its arguments:

{lib, project, ...}: {
  packages.default = { ... meta.homepage = ".../${project.path}"; ... };
}

which works because the workspace applies the function itself. Handing it to the module system could not do this: _module.args is evaluation-wide, so every module would see the same project.

default is the project itself, which is Bazel's //foo/bar meaning //foo/bar:bar; every other key hangs off it.

A name beginning with / is absolute and passes through unqualified, the way a leading // means the root rather than here. That is how a project keeps something it did not name after itself:

nixosConfigurations = {
  default = ...;        # -> oxidized-nixos
  "/nixos-nix" = ...;   # -> nixos-nix, the baseline it is measured against
};

workspace.qualify does one name where you need it inside a value rather than around one: workspace.qualify "dev" is "wclip-dev".

The argument is named for what it is. It carries a label, among a path, the renderings and the helpers, so calling it label would name one attribute of it - and label.label gives that away. It is not the workspace either: that is the tree this project sits in, and the function the root flake calls.

This is the dendritic idea and path-derived naming at once, which look opposed until the file receives its identity rather than typing it: one uniform kind of file, discovered by walking the tree, and the tree decides the names. The reason it needs applying rather than a module argument is that _module.args is evaluation-wide - the module system cannot hand one module a different argument from another - and the reason applying is unambiguous is that every file found this way is the same kind of thing.

A project written in a local vocabulary also cannot spell a name outside its own namespace, which is otherwise something checks.namespace-ownership has to go looking for after the fact.

default.nix keeps working as an ordinary module, so a tree migrates one project at a time.

Worked examples #

A crate, its dev build and its tests #

dev/wclip/workspace.nix. Every name is local; the label supplies the rest.

{lib, ...}: {
  packages = {
    default = {lib, ...}: lib.buildCargoProject { pname = "rust-wclip"; ... };
    dev     = {lib, ...}:
      lib.buildCargoProject {pname = "rust-wclip-dev"; release = false; ...};
  };

  checks = {
    test-version = pkgs:
      import ./testsuite.nix {inherit pkgs; name = "version";};
  };
}
packages.wclip          packages.wclip-dev          checks.wclip-test-version

pname stays the crate's own name. A label names a target; a crate is resolved against Cargo.lock and keeps its identity.

A project that contributes what an output directory contributes #

safety/oxidized/nixos/workspace.nix produces a devshell, two NixOS configurations and six checks - output types that would otherwise mean four directories named after them, in a tree far from the project:

label: {
  devShells = workspace.qualify { default = pkgs: { packages = [pkgs.just]; }; };

  nixosConfigurations = workspace.qualify {
    default = _: {
      system = "x86_64-linux";
      modules = [./base.nix ./systemd.nix];
    };
  } // {
    # Named for what it is rather than for the project that keeps it.
    nixos-nix = _: { system = "x86_64-linux"; modules = [./base.nix]; };
  };

  checks = workspace.qualify {
    boot           = pkgs: import ./nixos-test.nix {inherit pkgs;};
    rung1-tmpfiles = pkgs: import ./rung1-tmpfiles-test.nix {inherit pkgs;};
  };
}
devShells.oxidized-nixos              nixosConfigurations.oxidized-nixos
checks.oxidized-nixos-boot            nixosConfigurations.nixos-nix
checks.oxidized-nixos-rung1-tmpfiles

Before this the same file wrote oxidized-nixos into nine names by hand. It now appears nowhere in it.

Output directories #

outputDirs is the other half of the tree, for everything that belongs to no project: a directory names the output it feeds, a file names the entry.

outputDirs = [./platform/nix];
platform/nix/packages/datui.nix       -> packages.datui
platform/nix/nixos-modules/core/      -> nixosModules.core
platform/nix/with-overlays/rust.nix   -> an entry of withOverlays

Directory names are this tree's kebab-case, and the option they feed is derived from them. A <output>.nix file, or a <output>/default.nix, feeds the whole option at once; otherwise each entry is imported and keyed by its filename, with a leading _ stripped so a file can order itself in a listing without that reaching the flake. Whether the entries arrive as a set or a list is decided by the option's own type, which is what lets a list-valued output like withOverlays work the same way.

This began as a replacement for flakelight's nixDir, which it was checked against: all fourteen outputs evaluated to the same names either way. Doing it here means the whole convention lives in one place rather than half of it being an alias table, and it is what let flakelight be dropped entirely.

A module that wants a directory for its own reasons should name it directly rather than reach for the scanner: secrets.nix scans ../secrets for .age files, and lib.nix resolves ../lib, because neither is importing modules keyed by filename.

A workspace that is one output #

A workspace whose whole content is a single output would otherwise hold a directory repeating its own name (packages/packages/). In the tree call, a path-valued key names an output and the directory that feeds it - path-valued is the whole test, so there is no reserved-name list:

outputs = inputs: inputs.workspace {
  inherit inputs;
  packages = ./.;
};
packages-flake/datui.nix              -> packages.datui
packages-flake/tree-sitter-mojo/      -> packages.tree-sitter-mojo

The root then counts as an output directory: discovery skips it, its workspace-modules/ load, and its other conventional directories (with-overlays/, ...) keep working. An option read from a shared directory skips flake.nix and every sibling whose name feeds a different option, both derived from the option set rather than listed. The flake gets real outputs and still exports workspaceModule for trees that fold it in.

Which of the two to use #

They are the same idea reached from opposite ends, and both are in use.

An output directory puts the output type in the directory and the entry name in the file: platform/nix/packages/datui.nix is packages.datui, and platform/nix/nixos-modules/services/openssh.nix is a nixosModules entry. Nothing inside those files says what they are or what they are called. That is already path-derived naming, which is why converting them to self-declaring modules would be a step backwards, and why they stay.

A project module puts the output type in the file and the name in the path: safety/oxidized/nixos/workspace.nix says nixosConfigurations and checks, and the label says oxidized-nixos.

An output directory is the better fit when the thing belongs to no project - a package wrapping upstream software, a NixOS module about services.flatpak. The project module is the better fit when it does, because it keeps a project's outputs with the project and stops the name being repeated in each one. The rule is the same either way: the path is the address.

Integrations #

The framework carries wiring for four upstreams: nix-darwin, home-manager, system-manager and secretspec. Each adds outputs the flake has no builtin for - darwinConfigurations, homeConfigurations, systemConfigs, and a secretspec package with the NixOS, home and system modules that deliver secrets as files - and each is off until you ask for it.

Asking is overriding the input:

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

  nix-darwin = {
    url = "github:nix-darwin/nix-darwin/nix-darwin-25.11";
    inputs.nixpkgs.follows = "nixpkgs";
  };

  workspace = {
    url = "git+https://tangled.org/overby.me/nix-workspace";
    inputs = {
      nixpkgs.follows = "nixpkgs";
      nix-darwin.follows = "nix-darwin";
    };
  };
};

follows onto a pin you already have, or a url written inline - either is an override, and an override is the whole of enabling one. Declare nothing and the integration stays off, its code inert and its upstream never fetched.

The inputs default to a stub directory inside this repo, stubs/absent, which resolves without a fetch because it arrives with the framework. That is what makes them optional: flakes cannot declare an optional input (NixOS/nix#7205), and an input the framework does not declare is one you cannot override, so the declaration is ours and the choice stays yours. An unused integration costs a line in your lock file and nothing else.

Setting a configuration without overriding its input is an error naming the input and the option, rather than an output that quietly fails to appear.

A word on each:

  • nixpkgs is yours to align. An override replaces the whole declaration, so nothing here can point an upstream at your nixpkgs for you. Add inputs.nixpkgs.follows to the upstream you declare, or accept that it builds against one of its own.

  • system-manager fits a narrow nixpkgs window rather than a channel, so pin the nested nixpkgs to the revision its own lock names:

    system-manager = {
      url = "github:numtide/system-manager";
      inputs.nixpkgs.url = "github:NixOS/nixpkgs/<upstream-locked-rev>";
    };
    
  • secretspec has no flake upstream. It is a plain Cargo repo, and flake = false cannot be added by an override, so this repo ships the flake it lacks: upstreams/secretspec, which carries the package definition and has no inputs of its own - the version is a fixed-output fetch inside the derivation, and it is built with your nixpkgs rather than one it brings. Point the input at it, and enabling it looks like the other three:

    inputs.workspace = {
      url = "git+https://tangled.org/overby.me/nix-workspace";
      inputs.secretspec.url =
        "git+https://tangled.org/overby.me/nix-workspace?dir=upstreams/secretspec";
    };
    

    Because that is this same repo fetched once, the wrapper cannot lock to a revision the framework disagrees with. Pin your own version by pointing the input at any flake exporting a packageDef: a callPackage-style function, which the integration calls with your package set.

Options #

option default for
name — fallback package name; a workspace root has no [package]
description "" the package's meta.description
root the call's first argument the repo root, so the module can read its Cargo.toml
subdir "" the crate is one level down (see below)
nativeBuildInputs [] nixpkgs attribute names of build-time tools
buildInputs [] nixpkgs attribute names of libraries linked against
doCheck true run the crate's tests during the build
cargoTestFlags [] extra test arguments, e.g. --skip
env _: {} build-time environment, as a function of pkgs
toolchain false take rustc from the repo's rust-toolchain.toml
hooks seven the pre-commit hooks nix develop installs

subdir exists because a crate whose Cargo.toml has a path dependency on a sibling is published as several directories, so that path = "../pcre2" still resolves. Its own crate is then one level down, and the build needs both cargoRoot and buildAndTestSubdir, which do different jobs.

toolchain exists for compiler plugins: they link against rustc's internals, whose API differs between releases, so they need the exact nightly named in their own rust-toolchain.toml rather than whatever rustc nixpkgs ships. The consuming flake supplies the rust-overlay overlay for it, through withOverlays, which the call passes through:

inputs = {
  workspace.url = "git+https://tangled.org/overby.me/nix-workspace";
  rust-overlay.url = "github:oxalica/rust-overlay";
};

outputs = inputs:
  inputs.workspace {
    name = "fe-c";
    toolchain = true;
    withOverlays = [inputs.rust-overlay.overlays.default];
  };

An overlay is its own flake, so carrying it here would make every published repo fetch it for the one project that pins a toolchain.

Contributing #

Development happens here. Issues and pull requests are welcome on this repo; github.com/overby-me/nix-workspace is a read-only mirror of it.

This was extracted from the overby.me monorepo, which built it, published it as a mirror and imported the copy in its own tree. It keeps that history, and the monorepo is now one of its consumers - which is the point: the two bugs found on the way out were both ones only a consumer taking this as an input could hit.