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. What they share lives here rather than in forty copies: a tree walks
itself, a directory names what its default.nix declares, and outputs are
merged through one module system.
It knows about directories, modules and outputs, and about no language. A
build is a module an input carries, and declaring that input is the whole of
taking it - modules/rust is the Rust one, a directory of this
repo that is its own flake.
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";
rust = {
url = "git+https://tangled.org/overby.me/nix-workspace?dir=modules/rust";
inputs.workspace.follows = "workspace";
};
};
outputs = inputs:
inputs.workspace {
inherit inputs;
};
}
That is the whole file. The crate's Cargo.toml states its name. rust.* are options that module declares, not ours;
without the input there is no rust option and nothing Rust-shaped is
carried. nixpkgs and the hooks come from this flake, so they are the same in
every consuming repo without any of them naming a revision.
Workspaces #
A tree walks itself: every directory holding a default.nix 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.
There is one call form. A single project is the same thing with one directory
at its root, and what builds it is its own default.nix plus whichever build
module its inputs carry.
Every call passes inputs, because that attribute is where the root comes
from: a flake cannot ask itself where it is, so the root is read off the
source position of something the call was written with, and an attribute that
may be absent is one the root silently depends on being written.
A tree's own inputs are also where the modules it takes are found: any input
exporting a workspaceModule (or a workspaceModules.default) is imported,
so declaring it is the whole of taking it and there is no list beside the
declarations to forget a line in.
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 - 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 when it declares one, so a
monorepo pins what its other modules were written against; a repo that
declares none falls through to this flake's, which 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 directory that had been missing from the list for as long as it had existed.
A directory names what it declares #
A discovered default.nix is an ordinary module. What makes it more than one
package is that the directory it sits in supplies the names, so the file
states what it builds and never where those names come from:
{
packages = {
default = ...; # -> wclip
dev = ...; # -> wclip-dev
};
checks = {
test-version = ...; # -> wclip-test-version
};
}
That is the whole file. There is nothing to wrap it in, because the workspace
holds both the file and the path and applies one to the other. Every block of
names is qualified; imports, options and config are structure rather than
names and pass through.
default is the directory 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 directory
keeps something it did not name after itself:
nixosConfigurations = {
default = ...; # -> oxidized-nixos
"/nixos-nix" = ...; # -> nixos-nix, the baseline it is measured against
};
This is the dendritic idea and path-derived naming at once, which look opposed until the file receives its names rather than typing them: one uniform kind of file, discovered by walking the tree, and the tree decides the names.
A file written in a local vocabulary also cannot spell a name outside its own namespace, which is otherwise something a check has to go looking for after the fact.
Worked examples #
A crate, its dev build and its tests #
dev/wclip/default.nix. Every name is local; the path 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 directory that contributes what an output directory contributes #
safety/oxidized/nixos/default.nix produces a devshell, two NixOS
configurations and six checks - output types that would otherwise mean four
directories named after them, far from the thing they belong to:
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
nowhere in particular: 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.
Doing it here means the whole convention lives in one place rather than half of it being an alias table.
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 discovered default.nix puts the output type in the file and the name
in the path: safety/oxidized/nixos/default.nix says nixosConfigurations
and checks, and the label says oxidized-nixos.
An output directory is the better fit when the thing belongs nowhere in
particular - a package wrapping upstream software, a NixOS module about
services.flatpak. A discovered default.nix is the better fit when it does,
because it keeps the outputs where the thing is 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 three upstreams: nix-darwin,
home-manager and system-manager. Each adds outputs the flake has no
builtin for - darwinConfigurations, homeConfigurations, systemConfigs -
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.followsto the upstream you declare, or accept that it builds against one of its own. -
system-managerfits 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>"; }; -
secretspecis not an input at all. Its NixOS, home and system modules ship unconditionally, and the CLI they call ispkgs.secretspecfrom your own package set - so there is no pin here, and nothing to override to switch it on. Settingsecretspec.enableis the whole of enabling it.The modules pass
--reason, which secretspec added in 0.19, and a tree whose nixpkgs is older fails an assertion naming the version it found. Point the option at a newer package set when that happens:secretspec.package = pkgs.pkgsUnstable.secretspec;
Options #
| option | default | for |
|---|---|---|
inputs |
— | required; the flake's own inputs, and where the root comes from |
systems |
three | the systems every per-system output is built for |
outputDirs |
[] |
directories whose names are output types |
workspaces.exclude |
[] |
repo-relative paths the walk skips |
workspaces.depth |
4 |
how deep the walk goes |
imports |
[] |
modules that are not projects |
withOverlays |
[] |
overlays the package set is built with |
formatter |
— | nix fmt, and a checks.formatting that runs it |
Everything else is an option some module declares, and the modules a tree
takes are its inputs. A build is one of those: rust.* below belongs to
modules/rust, not here.
rust.* option |
default | for |
|---|---|---|
pname |
the crate's own name | what to call the derivation; a Cargo workspace root has no [package] to take it from |
description |
the flake's own | the package's meta.description |
subdir |
"" |
the crate is one level down |
nativeBuildInputs |
[] |
nixpkgs attribute names of build-time tools |
buildInputs |
[] |
nixpkgs attribute names of libraries linked against |
env |
_: {} |
build-time environment, as a function of pkgs |
toolchain |
a rust-toolchain.toml is present |
take rustc from that file |
aliases |
{} |
extra names for a binary the crate builds |
setupHook |
null |
a file installed as nix-support/setup-hook |
hooks |
seven | the pre-commit hooks nix develop installs |
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.