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"];
};
The root is passed rather than taken from self, because it cannot be:
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.
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.
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.