VIDEO TUTORIAL - CPP DEMO · TESTBED VIDEO EXAMPLE
We wanted a modern build system for humans — one that is even easier than
Make, using the intuitive, purely declarative syntax of TOML, yet as powerful
as Bazel. And rather than make you assemble your own dependencies and
toolchains, we wanted it to rely on a single, standard, ready-made catalog of
them: nixpkgs, the largest collection of
packages in the Linux world. That is rigx.
Status: actively maintained. The core build and test workflow has been tested extensively in production by the hirosim.com project. Some of the more advanced features (such as capsules and testbeds) are still experimental, and the
rigx.tomlschema and CLI behavior may evolve. If you run into problems, please report issues.
rigx is a build system (think Make or Bazel) for C, C++, Go, Rust, Zig,
Nim, and Python — plus anything else you can script. It is designed to be
very easy to use while quietly enforcing good policies on your behalf.
Your build and test targets are defined in a single rigx.toml file with
a very simple syntax.
Your builds and tests do not depend on what you have (or don't have)
installed on your system. All the packages your targets depend on are
pulled in on demand, kept cached, and never pollute the host. Every build
and test runs in a sandbox; outputs are cached for speed and land in an
output/ folder so the rest of your tree stays clean.
This works through a system called Nix. If you've never used it: Nix is a
package manager and build system rolled into one — think apt/yum plus a
build sandbox, except every package is pinned to an exact version and every
build runs in isolation, so you get the same result on every machine. Its
package catalog is called nixpkgs — the equivalent of your distro's
repositories, but version-locked. rigx drives Nix for you, so although Nix is
mentioned throughout this document, you do not have to learn it. Install Nix
and rigx once and forget about them.
rigx also offers some advanced features: running tests concurrently,
running integration tests outside the sandbox, packaging artifacts as
capsules (a kind of lightweight container), and orchestrating tests on
testbeds that comprise multiple capsules and support fault injection.
While rigx itself is written in Python, the actual build system is not
Python — it's Nix. rigx works by parsing rigx.toml and generating a
file called flake.nix (a Nix build description — you will not need to
read or edit it). It tells Nix how to build each target from its sources
inside its own sandbox.
Why do I care about reproducibility? Because if you ship anything that runs on your machine, you expect it to run the same way everywhere else — your CI, your colleague's laptop, your customer's server. Most "works on my machine" bugs are really "I depended on something I didn't declare" bugs; rigx makes that class of bug structurally impossible.
Do I need to run NixOS to use rigx? No. rigx runs everywhere Nix runs — any Linux distribution (and macOS). Nix is a tool you install alongside your existing OS, not a replacement for it.
Why use rigx instead of Make? Make is a recipe runner: you write
the shell commands and manage every dependency — including the
toolchain — yourself. That makes "works on my machine" the default
failure mode, and Make has no idea whether your gcc matches your
colleague's. rigx is declarative (you describe what to build, not the
recipe), the toolchain comes from a pinned nixpkgs, builds run in a
sandbox that can't see whatever you happen to have installed, and every
output is cached under a hash of its inputs — so identical inputs reuse the
same build instead of recompiling, even across machines.
Why use rigx instead of Bazel? (Skip this if you don't use Bazel.)
Bazel is more powerful — fine-grained action caching, a scripting language
(Starlark) for custom rules, true remote build execution, designed for
10k-target monorepos at a large company. The cost is a steep learning curve,
BUILD / WORKSPACE files written in Starlark, and a heavyweight setup.
rigx gives you most of the same wins (reproducibility, sandboxing,
input-hashed caching, multi-language) through a single
declarative TOML file with no scripting layer. (Nix-level remote builders and
shared caches also work with rigx builds when you configure them in Nix, but
rigx doesn't provide or manage them itself.) For most projects that's enough; if you're running
a monorepo with thousands of fine-grained build actions and a team to
maintain it, reach for Bazel. Unlike Bazel, with Rigx you do not need
to maintain your own toolchains. Moreover, Rigx supports the unique
concepts of capsules and testbeds which allow you to orchestrate
and test distributed applications.
Why use rigx instead of Cargo? (Skip this if you don't use Rust.)
For a pure Rust project, you shouldn't — Cargo is the standard: crates.io, semver resolution,
incremental per-crate compilation, cargo test/bench/doc, and the
entire ecosystem assumes it. rigx's rustc-based kind = "executable"
is for single-source Rust binaries; it has no crate graph and no
crates.io integration. Where rigx complements Cargo is (1) polyglot
projects — if Rust is one component alongside C++, Go, Python, etc.,
Cargo only covers its slice while rigx describes the whole tree in one
rigx.toml; (2) system-level reproducibility — Cargo.lock pins crates
but not rustc, not openssl, not libpq, whereas rigx pins the
toolchain and every system dep through nixpkgs + flake.lock; and
(3) wrapping a Cargo workspace via kind = "custom", which gives you
the sandboxed, pinned-toolchain, content-addressed-cached envelope
around cargo build, plus integration into capsules and testbeds.
Rule of thumb: pure Rust → cargo; Rust plus anything else, or Rust
where the host environment varies → rigx wrapping cargo.
How well is it tested? rigx has more than 300 unit tests covering config parsing, Nix generation, capsule construction, the testbed proxy, and the CLI workflow.
# Makefile
CXX = g++
CXXFLAGS = -std=c++17 -O2
hello: src/main.cpp src/greet.cpp
$(CXX) $(CXXFLAGS) -o hello $^# BUILD.bazel
cc_binary(
name = "hello",
srcs = ["src/main.cpp", "src/greet.cpp"],
copts = ["-std=c++17", "-O2"],
)# rigx.toml
[project]
name = "myproject"
[targets.hello]
kind = "executable"
sources = ["src/main.cpp", "src/greet.cpp"]
cxxflags = ["-std=c++17", "-O2"]rigx build hello # builds in a sandbox
./output/hello/bin/hello # run it
What's different:
- You describe what to build, not the recipe — closer to Bazel's
cc_binarythan Make's$(CXX) ... $^block.kind = "executable"tells rigx how to compile a C++ program. - The compiler and any
deps.nixpkgs = ["fmt"]libraries come from a pinnednixpkgs— not your$PATH(Make) and not Bazel's host toolchain. First build pulls them; later builds use the Nix store cache. - Outputs live in
/nix/store/...andoutput/hellois a symlink to the current build.rigx cleanremoves the symlink; the store entry persists and gets reused next time. - No
.PHONY, nogenrule— for side-effecting tasks (publish, deploy, run a script) usekind = "script"andrigx run <name>. - Need a different language? Just drop
.go,.rs,.zig, or.nimfiles intosources— language is inferred from the extension, the toolchain comes from nixpkgs, and you get the samekind = "executable"shape. Usekind = "python_script"for Python;kind = "custom"for project-managed builds (Cargo workspaces,cmake, …). rigx.tomlis pure data — no Starlark, no Make macros. Sharing values across targets is[vars]; sharing across folders is[modules]or[dependencies.local.*](see below).- Remote and shared builds (Nix-level, not a rigx feature): rigx itself
has no remote-execution or cache machinery — it just runs
nix build. But because rigx builds are ordinary Nix builds, they transparently pick up whatever you configure in Nix: remote builders (so a build can be sent over SSH to another machine — handy for compiling ARM on an x86 box) and a binary cache (so your team and CI download prebuilt outputs instead of recompiling — like a package mirror for your build artifacts;cache.nixos.orgis the public one, or host your own with Cachix, attic, or plain S3). Configuring these is standard Nix setup (e.g.nix.conf), done independently of rigx. Note that Nix ships whole targets, not individual compiler invocations, so there's no per-action remote-execution scheduler like Bazel's — but with a shared cache for your team and CI, that's usually plenty.
Each example below is a self-contained rigx.toml snippet you can copy and
build. Dependencies such as fmt, sqlite, catch2, or pytest are named
as nixpkgs packages — nixpkgs is a large online catalog of ready-to-build
software that rigx pins to an exact version on your behalf. You never install
these on your machine: rigx fetches and caches each one the first time a
target needs it, and they never pollute your system. (If you've never heard
of Nix or nixpkgs, that's fine — you don't have to learn either to use rigx.)
Go usually gets its libraries from Go modules, but it can also call C
libraries through cgo. List the C library under deps.nixpkgs and rigx makes
its headers and linker available to the build automatically:
[project]
name = "go-sqlite"
[targets.kv]
kind = "executable"
sources = ["src/kv.go"] # cgo: `import "C"`, `#cgo LDFLAGS: -lsqlite3`,
# `#include <sqlite3.h>`
deps.nixpkgs = ["sqlite"] # sqlite headers + library, pinned from nixpkgsrigx build kv
./output/kv/bin/kv
Nim compiles through the C compiler, so the same idea works: pull the C
library from nixpkgs and point Nim's linker at it with nim_flags:
[project]
name = "nim-zlib"
[targets.squeeze]
kind = "executable"
sources = ["src/squeeze.nim"] # calls zlib's compress2() via `{.importc.}`
deps.nixpkgs = ["zlib"]
nim_flags = ["-d:release", "--passL:-lz"]rigx build squeeze
./output/squeeze/bin/squeeze
Zig brings its own compiler (also pinned from nixpkgs — nothing to install) and is an excellent cross-compiler, which the next example builds on:
[project]
name = "zig-hello"
[targets.hello_zig]
kind = "executable"
sources = ["src/hello.zig"]
zigflags = ["-O", "ReleaseFast"]rigx build hello_zig
./output/hello_zig/bin/hello_zig
Add target = "<triple>" and rigx routes the build through the matching
cross toolchain — no extra setup, nothing to install. The same source builds
for another CPU or operating system:
[project]
name = "cross"
# A C program built for 64-bit ARM Linux from any host.
[targets.hello_arm]
kind = "executable"
sources = ["src/hello.c"]
target = "aarch64-linux"
cflags = ["-O2"]
# Zig cross-compiles natively; here the same .zig is built for Windows.
[targets.hello_win]
kind = "executable"
sources = ["src/hello.zig"]
target = "x86_64-windows"rigx build hello_arm # an aarch64 Linux binary
file ./output/hello_arm/bin/hello_arm
rigx build hello_win # a Windows .exe
Built-in target aliases include aarch64-linux, armv7-linux,
x86_64-linux-musl, and x86_64-windows; see
Cross-compilation below for the full list and
per-language details.
A kind = "test" target is discovered and run by rigx test. Here a small
test binary is built against the Catch2 framework (pulled from nixpkgs,
header-only) and then run. By default tests run in an isolated, cached
environment, so an unchanged test passes instantly without re-running:
[project]
name = "cpp-test"
# The code under test.
[targets.calc]
kind = "static_library"
sources = ["src/calc.cpp"]
includes = ["include"]
public_headers = ["include"]
cxxflags = ["-std=c++17"]
# The test binary. tests/calc_test.cpp starts with:
# #define CATCH_CONFIG_MAIN
# #include <catch2/catch.hpp>
[targets.calc_test_bin]
kind = "executable"
sources = ["tests/calc_test.cpp"]
includes = ["include"]
cxxflags = ["-std=c++17"]
deps.internal = ["calc"]
deps.nixpkgs = ["catch2"] # header-only test framework from nixpkgs
# ${calc_test_bin} resolves to the built binary; the test just runs it.
[targets.calc_test]
kind = "test"
deps.internal = ["calc_test_bin"]
script = "${calc_test_bin}/bin/calc_test_bin"rigx test calc_test
Say this project is a local Python package — mypkg — and you want to run
its pytest suite. The package and its test dependency are described by the
project's pyproject.toml, and rigx drives uv (a fast Python package
manager, pulled from nixpkgs — nothing to install) to install the package
plus pytest into a managed venv and run the tests against it:
myproject/
├── rigx.toml
├── pyproject.toml # defines the `mypkg` package; pytest as a test dep
├── uv.lock # generated once by `rigx pkg uv -- lock`
├── src/mypkg/__init__.py # the code under test
└── tests/test_mypkg.py # `from mypkg import …`, then asserts
# pyproject.toml (sketch) — a standard local package with a test dependency:
#
# [project]
# name = "mypkg"
# version = "0.1.0"
# dependencies = [] # mypkg's own runtime deps go here
# [dependency-groups]
# dev = ["pytest"] # test-only deps
# [build-system]
# requires = ["setuptools"]
# build-backend = "setuptools.build_meta"
[project]
name = "mypkg"
# `uv run` installs the local `mypkg` package (from pyproject.toml) plus its
# dev/pytest dependency into a managed venv, then runs pytest — which imports
# `mypkg` and executes everything under tests/.
[targets.pytest_suite]
kind = "test"
sandbox = false # uv resolves packages over the network
deps.nixpkgs = ["uv"] # uv comes from the pinned nixpkgs
script = """
uv run --frozen pytest -q
"""rigx pkg uv -- lock # once, to create uv.lock
rigx test pytest_suite # installs mypkg + pytest, runs the suite
- TOML target declarations: inputs, outputs, internal and external deps.
- External deps via pinned
nixpkgsorgitflake inputs. - Lock file (
flake.lock) pins every input revision. - Sandboxed builds: compilation never touches the local filesystem; each derivation runs against the Nix store's layered filesystem.
- Parallel builds:
rigx build -j Nruns up to N targets concurrently (each via its ownnix build; the Nix daemon dedupes shared deps). Default is sequential — failures are reported per-target rather than cancelling the whole batch. - Outputs only appear under
output/as symlinks into the Nix store. - Parameterized targets via variants (e.g.
debug/release). - Multi-language, first-class: C, C++, Go, Rust, Zig, Nim, Python — pick
by extension or set
language = "..."explicitly. Anything else (Cargo workspaces,cmake, custom build pipelines) goes throughkind = "custom". - Cross-compilation built in:
target = "aarch64-linux"(orarmv7-linux,x86_64-windows, …) routes c/cxx throughpkgsCross.<x>, setsGOOS/GOARCHfor Go, passes-targetto Zig, auto-emits azigccshim for Nim. Combine with variants for one-source / multi-platform builds. - Multi-folder projects: split a project into subfolders via
[modules](merged into one flake) or[dependencies.local.*](each subfolder is its own flake, parent depends on built artifacts). - Code generation as ordinary builds: a
kind = "custom"target whoseinstall_scriptruns your generator (protoc, capnp, OpenAPI, …) and writes the result into$out. Downstream targets pick the files up by writing${gen}/foo.extintosources/includes/flags; the dep edge is auto-derived from the interpolation, so no restating required. Works in any language. - Host-provided binary inputs via
[external_inputs.*]: wire vendor SDKs / system libs into the sandbox by env-var, with optional content-hash pinning so builds break loudly when the host blob changes. - Sharable vars (
[vars]+extends) keep flag/source/dep lists DRY across targets and across files. - Built-in workflow tools:
rigx watch(rebuild on change),rigx test(discovers everykind = "test"target — sandboxed + cached by default, opt-out withsandbox = false),rigx new(scaffold a target + stub source),rigx fmt(canonical TOML),rigx graph(Mermaid dep graph),rigx build --json(CI-friendly output).
- Nix 2.4+ with flakes enabled (rigx passes
--extra-experimental-features "nix-command flakes"automatically). This is the only tool you must install on the host — everything else (toolchains,uv, language-specific interpreters, …) comes from nixpkgs on demand and is pinned inflake.lock.
Everything else — Python, uv, language toolchains — comes from nixpkgs, so
Nix is the only thing you install on the host. rigx ships as a Nix flake and is
itself installed straight from this repo (see below); its own Python runtime is
pulled from nixpkgs. For generating Python uv.lock files you can run
rigx pkg uv -- lock — rigx pulls uv (or any other binary) from the project's
pinned nixpkgs, no host install needed.
First install Nix (the only host prerequisite):
- macOS / Linux (official):
sh <(curl -L https://nixos.org/nix/install) --daemon - macOS / Linux (Determinate Systems):
curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install
Restart your shell (or source /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh).
rigx enables flakes per-invocation, but if your Nix predates flakes-by-default
add experimental-features = nix-command flakes to ~/.config/nix/nix.conf.
rigx installs directly from its flake — no PyPI, pip, or host uv required.
The stable tag tracks the latest release commit.
nix profile install (recommended — isolated, on your PATH):
nix profile install github:unofficialtools/rigx/stable
Upgrade with nix profile upgrade rigx; remove with nix profile remove rigx.
Pin a specific release instead of stable with a version tag, e.g.
github:unofficialtools/rigx/v0.8.3.
Ephemeral (run once without installing):
nix run github:unofficialtools/rigx/stable -- -C ./example-project build
Confirm the install: nix --version && rigx --help.
From a project directory containing rigx.toml:
rigx version # print the rigx version (also: `--version` / `-V`)
rigx list # list targets
rigx list --kind test # filter by kind (executable, test, run, …)
rigx lock # generate flake.nix and update flake.lock
rigx build # build every target (and variant)
rigx build hello # build one target
rigx build hello@release # build a specific variant
rigx build 'hello*' # glob over target names (variants expanded)
rigx build -j 8 # up to 8 targets concurrently (one nix-build each)
rigx build --json # machine-readable output for CI / scripts
rigx watch [target] # rebuild on source change (Ctrl-C to stop)
rigx test # discover & run all kind=test targets, sequentially
rigx test smoke perf # run only the named tests (literal names)
rigx test 'unit_*' # filters are fnmatch patterns — globs work too
rigx test -j 4 # up to 4 tests concurrently (exclusives still serial)
rigx graph hello # print a Mermaid dep graph for one target
rigx flake # print generated flake.nix (for debugging)
rigx ls-source hello # print the resolved `src` file list for a target (requires [project].sources)
rigx fmt [--write] # canonical-format rigx.toml (comments not preserved)
rigx new executable foo # scaffold a new target + stub source files
rigx clean # remove output/
rigx run publish # execute a script-kind target (publish/deploy/etc.)
rigx run deploy -- --dry-run prod # forward args after `--` as $1, $2, …
rigx pkg uv -- lock # run any nixpkgs binary (uv, jq, ripgrep, …) from pinned nixpkgs
If rigx isn't installed, invoke it as a module:
PYTHONPATH=/path/to/rigx python3 -m rigx -C /path/to/project build.
Every rigx.toml has a [project] section, an optional [nixpkgs] section,
an optional [vars] table, zero or more [dependencies.git.*] entries,
zero or more [dependencies.local.*] entries, an optional [modules] block,
and one or more [targets.*].
An optional list of other TOML files to splice into this one before parsing. Each
entry is a literal path or a glob (*, **, ?, […]) resolved
relative to the file declaring the include. Use this to keep large
projects manageable by splitting [targets.*], [vars.*], and
[dependencies.*] across files.
# rigx.toml — must come BEFORE any [section] header
include = [
"lib/extra.toml",
"targets/*.toml", # glob — sorted, empty match is OK
]
[project]
name = "myproject"Semantics:
- Inlined textually. Included files are merged into the parent's data
before any other parsing. Paths inside them (target sources,
[vars].extends, etc.) resolve against the rootrigx.toml's directory, not the include file's directory. If you want subtree- relative paths and a namespace, use[modules]instead. - No identity sections. Included files must not declare
[project]or[nixpkgs]— only the rootrigx.tomlowns project identity and the nixpkgs ref. - Duplicate names error. A target, var, or dependency name defined in two files (root + include, or two includes) is a config error.
- Recursion. An included file may itself declare
include = [...]; paths in that nested array resolve relative to that file's directory. Cycles are detected. - Position matters.
include = [...]must appear before any[section]header. After a header, TOML scopes the assignment into that section — rigx will print an explicit error if it findsproject.include.
[project]
name = "myproject" # required. Used as the generated flake's identity.
version = "0.1.0" # optional; default "0.0.0". Used as Nix derivation version.
description = "A short summary" # optional; defaults to "rigx build for <name>". Shown in `nix flake metadata`.
rigx_min_version = "0.5.0" # optional. Refuse to load with a clear error if the running rigx is older. Format X.Y.Z; editable dev installs skip the check.
# Optional source-filter (opt-in). When `sources` is set, every target's
# derivation `src` is narrowed to the include set instead of hashing the
# whole repo. Smaller store copies, fewer rebuilds when unrelated files
# change. Unset → today's whole-tree-with-basename-blacklist behavior.
sources = ["**/*.cpp", "**/*.h", "**/*.nim", "**/*.py"]
excludes = ["**/*_generated.nim", "**/__pycache__/**"]
respect_gitignore = true # default true; intersects with `git ls-files` when in a git checkoutSource-filter rules — when [project].sources is set:
- A target with no
sourcesfield of its own gets the full project baseline. - A target with
sources = [...]gets the union of: its listed sources, every project-baseline file under any directory listed inincludes/public_headers, and anynixos_modulespaths. Anything intarget.sourcesthat doesn't appear in the project baseline is a config error (typo / missing extension / forgotten include). - Glob syntax is path-aware:
*(any chars except/),?(single non-/),**(zero or more path components),[abc](character class). Globs match against POSIX paths relative to the project root. excludesapply after the include set; gitignore filtering applies on top of that whenrespect_gitignoreis true and the project root is a git checkout.- Use
rigx ls-source <target>to print the resolved file list and verify what's about to be hashed into a derivation.
For data files alongside code: list the data directory in the target's includes field (its contents are bundled into src automatically), or omit target.sources so the target inherits the full project baseline.
Add this table to expose a packages.default / apps.default in the generated
flake, built from the project's pyproject.toml. The project then installs
straight from its repo — no PyPI, pip, or host uv needed:
nix profile install github:you/yourproject # or .../<tag>
nix run github:you/yourproject -- --help
[project.install]
bin = "rigx" # console-script name from [project.scripts]; default = project name
build_system = ["setuptools"] # nixpkgs python package attrs for the PEP 517 backend; default ["setuptools"]
runtime_path = ["nix"] # nixpkgs attrs wrapped onto the installed binary's PATH (makeWrapperArgs)
python_packages = "python3Packages" # nixpkgs python set to build against; e.g. "python311Packages" to pin- Emits
packages.<system>.<bin>plus adefaultalias, and anapps.<system>.defaultwhose program is…/bin/<bin>.<bin>must not collide with a target name. - The package is a
buildPythonApplication(pyproject = true) whose source is the whole project tree, sopyproject.tomland your package directory are always present regardless of any[project].sourcesfiltering. runtime_pathis for tools the app shells out to at runtime. rigx itself setsruntime_path = ["nix"]so the pinnednixis always on the installed binary'sPATH.- nixpkgs 24.11 ships setuptools 75.x — use the table form
license = { text = "…" }inpyproject.toml(the SPDX-string form needs setuptools ≥ 77).
[nixpkgs]
ref = "nixos-24.11" # optional; default "nixos-24.11". Any nixpkgs branch, tag, or commit.The revision is resolved into flake.lock on rigx lock.
The default value for ref may change in future versions of rigx so we recommend
that you specify a value for it.
An optiona list of reusable values shared between targets. Each entry must be a list of
strings. Reference one inside any list field with "$vars.<name>" — it
expands inline (the entry is replaced by the var's contents).
[vars]
common_sources = ["src/util.cpp", "src/log.cpp"]
cxx_deps = ["fmt", "spdlog"]
opt_release = ["-O2", "-flto"]
[targets.app]
kind = "executable"
sources = ["$vars.common_sources", "src/main.cpp"]
deps.nixpkgs = ["$vars.cxx_deps"]
[targets.app.variants.release]
cxxflags = ["$vars.opt_release", "-DNDEBUG"]- Expansion is whole-element only:
"prefix/$vars.x"stays literal. - Vars cannot reference other vars (one-pass resolution, no cycles).
- An undefined
$vars.<name>is a config error. - Works in every list field of a target or variant:
sources,includes,public_headers,cxxflags,ldflags,nim_flags,args,outputs,native_build_inputs, and all threedeps.*lists.
Sharing vars across files — the reserved key extends pulls in [vars]
from another TOML file, useful when independent rigx projects (e.g. siblings
declared via [dependencies.local.*]) want a common toolchain config:
# shared.toml
[vars]
cxx_libs = ["fmt", "spdlog"]
opt = ["-O2", "-flto"]
# rigx.toml
[vars]
extends = ["../shared.toml"] # paths relative to this file
local = ["x"]Extended files are loaded recursively (with cycle detection); collisions
across extends chains and the local table are config errors.
Declare external flake inputs. Referenced from targets via deps.git = ["<name>"].
[dependencies.git.mylib]
url = "https://github.com/someone/mylib"
rev = "v1.0.0" # branch / tag / 40-char commit SHA
flake = true # must be a flake in this version (default true)
attr = "default" # attribute inside packages.${system} (default "default")A target then pulls the built package into its buildInputs:
[targets.app]
kind = "executable"
sources = ["src/main.c"]
deps.git = ["mylib"] # adds inputs.mylib.packages.${system}.<attr>
deps.nixpkgs = ["pkg-config"]rigx turns each entry into a git+ flake input. The shape of rev decides
how it is pinned:
rev value |
generated flake URL | meaning |
|---|---|---|
"v1.0.0" / "main" (default "HEAD") |
git+<url>?ref=v1.0.0 |
a git ref (tag/branch) |
| 40-char hex SHA | git+<url>?rev=<sha> |
an exact commit |
The detection is purely syntactic — a 40-character all-hex string is treated
as a commit SHA, anything else as a ref (see git_input_url in
rigx/nix/flake.py). A short SHA (abc1234) is not recognized as a
commit; it goes through as ref=abc1234, which git can usually still resolve
but is not a reproducible pin. Use the full 40-character SHA when you want an
immutable pin.
# Pin to an exact commit (most reproducible — recommended for releases)
[dependencies.git.libfmt]
url = "https://github.com/fmtlib/fmt"
rev = "e69e5f977d458f2650bb346dadf2ad30c5320281"
# Track a moving branch (re-pinned each time you run `rigx lock`)
[dependencies.git.devlib]
url = "https://github.com/example/devlib"
rev = "main"
# Consume a non-default package from the dependency's flake
[dependencies.git.toolkit]
url = "https://github.com/example/toolkit"
rev = "v2.3.0"
attr = "cli" # -> inputs.toolkit.packages.${system}.clirigx does not handle authentication itself — it emits a flake input and lets Nix (and the git it shells out to) authenticate using your host credentials. Use a transport git can already authenticate:
# SSH — uses your ssh-agent / ~/.ssh keys
[dependencies.git.internal]
url = "ssh://git@github.com/your-org/internal-lib"
rev = "v1.0.0"
# HTTPS — uses your git credential helper / a token in the credential store
[dependencies.git.internal]
url = "https://github.com/your-org/internal-lib"
rev = "v1.0.0"Tips:
- The
?ref=/?rev=qualifier is appended by rigx, so put only the repo URL inurl— do not add your own?query string. - For CI, configure a git credential helper or an
access-tokensentry innix.conf(e.g.access-tokens = github.com=ghp_…) so the fetch is non-interactive. rigx passes nothing through itself. - If
nixruns as the daemon, the daemon's credentials are what matter, not your user's — a common cause of "works withgit clone, fails underrigx build".
The generated flake URL is git+<url>?ref=…/?rev=… and nothing else. rigx
does not emit Nix's ?submodules=1 flag, and there is no toml field to
request it, so a dependency's git submodules are not fetched. If you
depend on a repo that needs its submodules, vendor them, switch to a flake
that fetches them itself, or open an issue — supporting a
submodules = true field is a small change to git_input_url.
A git dependency that is itself a flake brings its own inputs along.
Nix resolves that whole graph and records every node — direct and
transitive — in this project's flake.lock. You only declare the direct
edge; you don't list a dependency's dependencies.
Caveat: rigx does not generate inputs.<dep>.inputs.nixpkgs.follows = "nixpkgs" overrides (there is no follows field). So if a git dependency
pins its own nixpkgs, your build may evaluate two nixpkgs — yours and
theirs. That is correct but heavier (more to download, larger closure). For a
tightly-coupled set of repos, prefer [dependencies.local.*] or [modules],
which share the parent's pinned [nixpkgs].
Reserved. In this version a git dependency referenced via deps.git is
consumed as inputs.<name>.packages.${system}.<attr>, which only exists for
flake inputs — so leave flake = true (the default). A raw (non-flake)
source tree input has no packages output and cannot be consumed this way
yet.
rigx never fetches git dependencies during config parsing — it generates a
flake.nix whose inputs block lists every git dep, and Nix does the
fetching and pinning. Run:
rigx lock # regenerates flake.nix, then runs `nix flake lock`This resolves every ref/rev to a concrete revision and writes
flake.lock. Commit flake.lock — it is what makes a build reproducible
on another machine: subsequent builds reuse the locked revisions instead of
re-resolving main to whatever it points at today. Re-run rigx lock to
deliberately advance the pins.
Each locked input in flake.lock carries a narHash. A NAR is a "Nix
ARchive" — Nix's own deterministic, reproducible serialization of a file
tree (it normalizes timestamps, ordering, and permissions so the same content
always serializes identically). The narHash is a SHA-256 over that
serialization, i.e. a content hash of the fetched source tree. Nix verifies
it on every build: if a tag is force-pushed or a server returns different
bytes for the same revision, the hash mismatches and the build fails loudly
instead of silently using changed code. A locked entry looks roughly like:
"libfmt": {
"locked": {
"type": "git",
"url": "https://github.com/fmtlib/fmt",
"rev": "e69e5f977d458f2650bb346dadf2ad30c5320281",
"narHash": "sha256-Ab12…=="
},
"original": { "type": "git", "url": "https://github.com/fmtlib/fmt", "ref": "v1.0.0" }
}original is what you wrote in rigx.toml; locked (with rev + narHash)
is the pin Nix resolved it to.
Pull in a sibling rigx project as a path flake input. The sub-project
stays standalone (its own flake, its own output/, builds independently from
its directory) and the parent depends on its built outputs — never raw
sources.
[dependencies.local.frontend]
path = "./frontend" # required; relative to this rigx.toml
flake = true # default true; mirrors [dependencies.git.*]Reference targets across the boundary with the <localdep>.<target> form in
deps.internal, run, args, and shell scripts:
[targets.bundle]
kind = "custom"
deps.internal = ["frontend.app"] # adds the dep to buildInputs
install_script = "cp ${frontend.app}/bin/app $out/bin/" # ${X.Y} resolves cross-flakeCross-flake refs are opaque to the parent — it has no metadata about the
sub-project's targets, so the linker/include helpers used for same-project
deps don't fire. To consume a sibling static_library, write a custom
target that copies headers/archives explicitly, or use [modules] (below).
rigx build frontend.app from the parent re-exports and builds the
sub-project's output. rigx list shows everything reachable as
<localdep>.<target> forms. The parent's flake.lock pins each local-dep
as a path input.
Merge sibling rigx-style configs into the same flake. Use this when the
project really is a monorepo and you want cross-folder targets to share
sources, vars, and the parent's pinned [nixpkgs].
[modules]
include = ["frontend", "service"] # paths to sub-folders containing rigx.tomlEach module's rigx.toml:
- must not define
[project]or[nixpkgs](the parent owns identity). - may define
[targets.*],[vars],[dependencies.git.*],[dependencies.local.*], and its own[modules](recursive). - has its
[targets.*]automatically prefixed with the module's directory name:frontend/rigx.toml's[targets.app]becomesfrontend.appin the merged set. - has its source paths interpreted relative to the module's directory and rewritten to be parent-root-relative — so a module looks like a normal rigx project to its author.
Inside a module, deps.internal = ["greet"] (unqualified) auto-binds to the
same module's greet. To reference a different module, qualify it:
deps.internal = ["other.foo"].
[vars], [dependencies.git.*], and [dependencies.local.*] from each
module are flat-merged into the parent. Name collisions across modules (or
between a module and the parent) are config errors — keeps things explicit.
Picking between (A) [dependencies.local.*] and (B) [modules]:
| You want… | Use |
|---|---|
Subfolders that build independently (cd and go) |
(A) |
Subfolders with their own flake.lock / nixpkgs ref |
(A) |
Cross-folder static_library linking |
(B) |
Shared [vars] across folders |
(B) |
| One-flake monorepo with namespaced targets | (B) |
You can use both in the same parent — they share the dotted CLI surface
(frontend.app) but resolve through different mechanisms.
Wire a host-provided directory (vendor SDK, system library, prebuilt firmware, generated assets) into the build via env vars, without sacrificing sandboxing or reproducibility.
[external_inputs.vendor-sdk]
buckets = { include = "VENDOR_SDK_INCLUDE",
lib = "VENDOR_SDK_LIB" }
require_files = ["widget.h@include", "libwidget.so@lib"]
# Optional: pin the content hash so the build fails loudly when the host
# blob changes. Per-bucket form (since each bucket lives in a separate
# directory). Set as `sha256 = "…"` (scalar) when only one bucket is
# declared.
sha256 = { include = "sha256-aaaa…", lib = "sha256-bbbb…" }How it works:
buckets = { <bucket> = "<ENV_VAR>", … }— bucket names are user-defined (include,lib,bin,share,firmware,pkgconfig, …) and become the substitution key. Each env var must point at an existing directory.require_files = ["<filename>@<bucket>", …]— sanity-checked at config load. If a file is missing, rigx fails fast with a clear pointer at what's wrong, before the flake even tries to evaluate.- At eval time rigx copies each bucket's directory into the Nix store via
builtins.path { path = …; sha256 = …; }. The sandbox sees only the in-store copy; the per-host path lives in the eval-time layer. sha256is optional. Set it. When unset, rigx hashes whatever it finds and prints a WARN — reproducible across runs on the same host, but silently drifts if the host blob is updated.
Targets opt in via deps.external and reference the resolved paths with
${<name>.<bucket>}:
[targets.widget_demo]
kind = "executable"
language = "nim"
deps.external = ["vendor-sdk"]
sources = ["src/widget_demo.nim"]
nim_flags = [
"--threads:on", "-d:release",
"--passC:-I${vendor-sdk.include}",
"--passL:-L${vendor-sdk.lib} -l:libwidget.so",
]Use this for blobs you genuinely don't want in nixpkgs (closed-source vendor SDKs, on-the-fly QA artifacts, board-specific firmware) without giving up on sandboxed, hash-pinned builds.
Every target lives under [targets.<name>] and has a kind. Target and
variant names are taken verbatim — [targets.foo-bar] and
[targets.foo_bar] are different targets, just like [targets.Foo] and
[targets.foo] are.
Source globs. Entries in sources may use *, **, ?, and […]
patterns (Python Path.glob semantics). Globs are resolved against the
project root at config-load time, results are sorted for deterministic Nix
hashes, and a glob that matches no files is a config error. Literal entries
pass through unchanged, so you can mix them — useful when a kind treats
sources[0] as the entry point:
sources = ["src/main.cpp", "src/lib/**/*.cpp"] # main.cpp stays firstFields common to several kinds:
| Field | Type | Purpose |
|---|---|---|
kind |
string | One of the kinds listed below. Required. |
sources |
list[string] | Source files (paths or globs, relative to root). |
includes |
list[string] | Header / include search paths. |
language |
string | Override extension-based inference: c, cxx, go, rust, zig, nim. |
compiler |
string | Toolchain selector: stdenv variant (c/cxx) or nixpkgs attr (go/rust/zig/nim). |
target |
string | Cross-compilation triple (e.g. aarch64-linux). See Cross-compilation below. |
cflags |
list[string] | Compiler flags (C). |
cxxflags |
list[string] | Compiler flags (C++). |
goflags |
list[string] | Flags forwarded to go build (Go). |
rustflags |
list[string] | Flags forwarded to rustc (Rust). |
zigflags |
list[string] | Flags forwarded to zig build-exe (Zig). |
ldflags |
list[string] | Linker flags (C/C++ only). |
defines |
table | Preprocessor defines: { DEBUG = "1" }. |
deps.internal |
list[string] | Other targets in this rigx.toml. |
deps.nixpkgs |
list[string] | Nixpkgs attrs (e.g. fmt, uv, go). |
deps.git |
list[string] | Names from [dependencies.git.*]. |
deps.external |
list[string] | Names from [external_inputs.*]. |
Variants override/extend fields per configuration. Selected at the CLI as
target@variant.
[targets.hello.variants.debug]
cxxflags = ["-O0", "-g"]
defines = { DEBUG = "1" }
[targets.hello.variants.release]
cxxflags = ["-O2"]
defines = { NDEBUG = "1" }
# Toolchain-swap variants: `rigx build hello@gcc` and `rigx build hello@clang`
# produce two binaries from the same sources but different compilers.
[targets.hello.variants.clang]
compiler = "clang"
[targets.hello.variants.gcc13]
compiler = "gcc13"- Variant fields append to the target's base flag fields (
cxxflags,cflags,ldflags,nim_flags,goflags,rustflags,zigflags) and merge overdefines. - A variant's
compileroverrides the target'scompiler(and so picks a different stdenv variant for c/cxx, or a different toolchain attr for go/rust/zig). - Variants produce independent Nix derivations (
hello-debug,hello-release). rigx build hellobuilds all variants; the unqualified attribute aliases the alphabetically-first variant.
[targets.hello]
kind = "executable"
sources = ["src/main.cpp"] # extension picks the language
includes = ["include"]
cxxflags = ["-std=c++17", "-Wall"]
ldflags = ["-lfmt"] # linker flags (e.g. -lNAME for nixpkgs libs)
deps.internal = ["greet"] # static_library deps are linked in automatically
deps.nixpkgs = ["fmt"]- Language is inferred from source extensions:
.c→ C,.cpp/.cxx/.cc/.C→ C++,.go→ Go,.rs→ Rust,.zig→ Zig,.nim→ Nim. Mixed sources require an explicitlanguage = "cxx"(etc.) to disambiguate. - Compiler choice with
compiler = "...":- C/C++: names a stdenv variant —
"clang"→clangStdenv,"gcc13"→gcc13Stdenv, etc. Default ispkgs.stdenv(gcc on Linux, clang on macOS). - Go/Rust/Zig: names a nixpkgs attr providing the toolchain —
"go_1_21", a specific"rustc_1_75", or whatever is available. Default isgo,rustc,zig. - Per-variant override (
hello@gccvshello@clang) lets one target produce two binaries with different toolchains.
- C/C++: names a stdenv variant —
- Per-language flag fields:
cflags(C),cxxflags(C++),goflags(Go),rustflags(Rust),zigflags(Zig). Only the field matching the resolved language is used. - Output:
$out/bin/<name>in the Nix store, symlinked tooutput/<name>. - Linking (C/C++ only):
static_libraryinternal deps are added to the link line automatically as${dep}/lib/lib<dep>.a. For adeps.nixpkgslibrary, rigx puts its headers andlib/on the compiler's and linker's search paths for you (much likepkg-configwould) — you just add the usual-l<name>inldflagsto link it.
# Go (toolchain auto-pulled; no need to list it in deps.nixpkgs)
[targets.hello_go]
kind = "executable"
sources = ["src/hello.go"]
goflags = ["-trimpath"]
# Rust (single source compiled with rustc; for Cargo workspaces use `custom`)
[targets.hello_rust]
kind = "executable"
sources = ["src/hello.rs"]
rustflags = ["-Copt-level=2"]
# Zig (single source via `zig build-exe`; for `build.zig` projects use `custom`)
[targets.hello_zig]
kind = "executable"
sources = ["src/hello.zig"]
zigflags = ["-O", "ReleaseFast"]
# Pick a different C++ compiler per variant.
[targets.hello.variants.clang]
compiler = "clang"
[targets.hello.variants.gcc13]
compiler = "gcc13"[targets.greet]
kind = "static_library"
sources = ["src/greet.cpp"]
includes = ["include"]
public_headers = ["include"] # dirs whose contents are copied to $out/include
cxxflags = ["-std=c++17", "-Wall"]
deps.nixpkgs = ["fmt"]- Same language inference as
executable, but limited toc,cxx, andrust(Go/Zig static libraries are out of scope for v1 — usecustomif you need them). - Rust archives are built with
rustc --crate-type=staticlib; the result islib<name>.a(so it links naturally into a downstream C/C++ executable viadeps.internal). - Output:
$out/lib/lib<name>.aand$out/include/<public_headers…>. - Downstream targets that list this in
deps.internalautomatically get the include path and the archive on the link line.
Nim is now just another language for
kind = "executable"— drop a.nimfile insourcesand thenimtoolchain is auto-pulled from nixpkgs. Seeexecutableabove for the Nim example. The earliernim_executablekind has been retired.
[targets.mylib]
kind = "shared_library"
sources = ["src/mylib.cpp"]
public_headers = ["include"]
cxxflags = ["-std=c++17", "-Wall"]- Build line:
$CXX -shared -fPIC … -o lib<name>.so(analogous for C and Rust's--crate-type=cdylib). - Output:
$out/lib/lib<name>.soand$out/include/<public_headers…>. - Same language constraints as
static_library(c,cxx,rust); same per-language flag fields. - macOS produces
.sofor cross-platform parity. If you need.dylibconventions specifically,kind = "custom"is the right escape hatch.
# Default: sandbox = true. Runs as its own Nix derivation, fully
# hermetic, and the result is cached on input hash — unchanged inputs
# means an instant pass without re-running the script.
[targets.fmt_check]
kind = "test"
deps.internal = ["my_app"]
script = """
${my_app}/bin/my_app --self-test
diff -u expected.txt <(${my_app}/bin/my_app --print)
"""
# Opt out of the sandbox when a test needs the host: invoke an
# `output/`-symlinked binary, talk to a real database, fight for a
# port, etc. No caching; you own concurrency safety.
[targets.integ_db]
kind = "test"
sandbox = false
exclusive = true # see below
deps.nixpkgs = ["postgresql"]
script = """
pg_ctl -D $TMPDIR/db start
trap 'pg_ctl -D $TMPDIR/db stop' EXIT
./output/myapp/bin/myapp --integration
"""sandbox = true(default): the test becomes a Nix derivation. Same isolation guarantees as every other build kind — clean rootfs, fresh$HOME/$TMPDIR, no host filesystem, no network. Success means the build succeeds; rigx synthesizes a minimal$outfor Nix. Automatic caching: rigx never re-runs an unchanged sandboxed test.sandbox = false: the test runs host-side vianix shell …#deps --command bash -c <script>withcwd = project root. No caching; whatever's in your shell environment is in scope.exclusive = trueblocks parallelism for tests that touch shared host state (a fixed port, a temp dir, a daemon) — underrigx test -j N, exclusive tests always run alone in a serial phase before the pool spins up. Sandboxed tests don't needexclusive; the sandbox provides isolation.- Both flavors:
rigx testdiscovers them all; reach into dep$outs via${dep_name}interpolation; samedeps.internal/deps.nixpkgs/deps.gitsemantics. - Excluded from
rigx builddefault.rigx build <test>errors with a pointer torigx test.
Quick guide. Default to sandbox = true — it's safer (hermetic),
faster (cached), and parallel-safe out of the box. Reach for
sandbox = false when the test must reach into ./output/, hit a real
external service, or otherwise step outside Nix's reproducibility
guarantees.
[targets.greet_py]
kind = "python_script"
sources = ["src/greet.py"] # sources[0] is the entry; all sources are bundled
python_version = "3.12" # → pkgs.python312 from nixpkgs
python_project = "." # dir with pyproject.toml + uv.lock (relative to root)
python_venv_hash = "sha256-..." # optional; see workflow below
python_venv_extra = ["vendor", "wheels/*.whl"] # optional; vendored wheels / path-deps- Output:
$out/bin/<name>— a launcher that invokes the pinned Python interpreter with the venv'ssite-packagesprepended toPYTHONPATH, plus the entry script's directory. - Dependencies come from
pyproject.toml+uv.lock, not fromdeps.nixpkgs.uv sync --frozenruns inside a fixed-output derivation (FOD) — a build step that's allowed to reach the network (here, PyPI) precisely because its result is pinned in advance by a hash, so the build still can't smuggle in anything unexpected.
Vendored wheels / path-deps via python_venv_extra. By default the venv
FOD only sees pyproject.toml + uv.lock. That keeps the FOD hash a
function of the lockfile alone — fast, stable. To make additional files
visible to uv sync (typical case: an offline vendor/ of pinned wheels,
a find-links directory, or a path = "../local-pkg" dep), list them in
python_venv_extra:
python_venv_extra = ["vendor", "wheels/*.whl"]- Paths are relative to
python_project(the directory holdingpyproject.toml). They land in the FOD source at the same relative position, sopyproject.toml'stool.uv.find-links = ["vendor"]resolves naturally. - Both
$vars.Xsubstitution and globs (*,**,?,[…]) are supported, the same as insources. - Tradeoff: anything you list here re-runs
uv sync(and shifts the venv hash) when it changes. That's exactly what you want for vendored wheels (deterministic), and not what you want for stray sibling files — list only whatuv syncactually needs.
python_venv_hash workflow (optional but recommended):
- Write
pyproject.tomlwith your deps; runrigx pkg uv -- lock(oruv lockif you have uv installed locally) to produceuv.lock. - First
rigx build <target>fails with a hash-mismatch error:error: hash mismatch in fixed-output derivation ... specified: sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= got: sha256-<real-hash> - Paste the
got:hash intopython_venv_hashand rebuild. - When
uv.lockchanges, the hash changes — repeat.
If omitted, every build fails deterministically on hash mismatch.
# Invoke another target you built
[targets.greeting]
kind = "run"
run = "gen_greeting" # internal target name
args = ["--name", "Massimo", "--out", "greeting.txt"]
outputs = ["greeting.txt"] # files (or directories) captured to $out/
# Invoke a nixpkgs tool from PATH
[targets.headers_zip]
kind = "run"
run = "zip" # not an internal target → looked up on PATH
deps.nixpkgs = ["zip"] # supplies zip on PATH in the sandbox
args = ["-r", "headers.zip", "include"]
outputs = ["headers.zip"]
# Consume another run target's artifact via Nix interpolation
[targets.unpack_headers]
kind = "run"
run = "unzip"
deps.nixpkgs = ["unzip"]
deps.internal = ["headers_zip"] # declare the build-order dep
args = ["-d", "extracted", "${headers_zip}/headers.zip"]
outputs = ["extracted"] # directory; cp -r handles itrunresolves as an internal target first (${name}/bin/<name>), otherwise as a bare command looked up on PATH. Usedeps.nixpkgsto supply PATH tools.argsare shell-quoted by rigx.${other_target}inside an arg is a Nix interpolation that expands to the dependency's store path at flake evaluation time.outputsare captured withcp -r, so directories work.
Use custom when the first-class kinds aren't enough — e.g. a Cargo
workspace, a cmake project, a make-driven external build, generated
sources, or a post-build orchestration like packaging multiple targets
into a single artifact.
# Stitch already-built targets into a release tarball, using ${dep} to
# reach into each dep's $out and gnutar/gzip from nixpkgs.
[targets.release_bundle]
kind = "custom"
deps.internal = ["hello", "hello_go", "hello_rust"]
deps.nixpkgs = ["gnutar", "gzip"]
install_script = """
mkdir -p $out
staging=$TMPDIR/release
mkdir -p $staging
cp ${hello}/bin/hello $staging/
cp ${hello_go}/bin/hello_go $staging/
cp ${hello_rust}/bin/hello_rust $staging/
tar -C $TMPDIR -czf $out/release.tar.gz release
"""
# native_build_inputs = ["makeWrapper"] # optional; mapped to nativeBuildInputsinstall_scriptis required;build_scriptis optional.- Scripts run in a standard Nix stdenv sandbox with
srcalready unpacked and the cwd set to the source root.$out,$TMPDIR,$HOMEare available (stdenv's defaultHOME=/homeless-shelteris read-only — redirect it for tools like Go, Cargo, Nim that want a writable home). - All
deps.*entries end up onbuildInputs, so their binaries are on PATH and their libraries/headers are on the usual compile/link paths. - Literal
${inside a script must be written as''${(Nix indented-string escape) because${var}is interpreted by Nix.
Code-generation steps (protoc, capnp, OpenAPI, ROS message
generation, …) are just custom targets whose install_script writes the
generated files into $out. Downstream targets reference the produced
files via ${<gen-target>}/<file> in sources / includes / flags;
the dep edge is auto-derived from the interpolation (the producer's name
appears in the path), so no deps.internal restatement is needed.
# Generate C++ from a .proto with `protoc`, pulled straight from nixpkgs.
# The generated greeting.pb.cc / greeting.pb.h land in $out.
[targets.proto_gen]
kind = "custom"
deps.nixpkgs = ["protobuf"]
sources = ["proto/greeting.proto"]
install_script = """
mkdir -p $out
protoc --cpp_out=$out -I proto proto/greeting.proto
"""
# Downstream target picks the generated files up by name. The interpolation
# implicitly adds `proto_gen` to this target's deps.internal.
[targets.greeter_pb]
kind = "executable"
sources = ["src/main.cpp", "${proto_gen}/greeting.pb.cc"]
includes = ["${proto_gen}"]
cxxflags = ["-std=c++17"]
deps.nixpkgs = ["protobuf"]
ldflags = ["-lprotobuf"]Mechanics are exactly those of any other custom build — cacheable on
input hash, sandboxed (no host filesystem, no network), composable
across languages.
[targets.publish]
kind = "script"
deps.nixpkgs = ["uv"]
script = """
rm -rf dist
uv build
uv publish
"""Unlike every other kind, a script target runs on the host, not inside
a Nix build sandbox. It executes via nix shell nixpkgs/<pinned-ref>#<deps> -- command bash -eo pipefail -c "<script>" in the project root.
Invoke with rigx run, not rigx build:
rigx run publish
rigx run publish -- --dry-run prod # extra args become $1, $2, … in the script
Script targets produce no artifact and therefore aren't buildable. If you name
one in rigx build, you'll get an error pointing at rigx run.
Anything after -- is forwarded to the script as positional arguments — use
"$@" (or $1, $2, …) inside the script body to consume them. The target
name is $0. Without --, the script runs with no extra arguments.
- Intended for side-effecting tasks: publishing, deploying, pushing images, running end-to-end tests against real systems.
deps.nixpkgstools come from the project's pinned nixpkgs, so the environment is still reproducible even though the script is not sandboxed.- Excluded from
rigx buildentirely — they're listed byrigx listfor discoverability but only runnable viarigx run. - Produces no
output/<target>symlink — side effects happen in place. - Variants,
$out, and the Nix store are not available — the script runs as a plain bash-eo pipefailblock in your current shell environment (with tools on PATH,$HOME, etc.).
Credentials needed by the script (UV_PUBLISH_TOKEN, cloud CLI creds, SSH
keys, …) are read from your shell environment — set them before invoking
rigx run <target>.
[targets.start_demo]
kind = "testbed"
deps.nixpkgs = ["python3"]
deps.internal = [
"api_server", "web_frontend", "cache",
]
script = """
python3 testbeds/demo.py
"""A testbed target is a host-side scenario that stands up a set of
capsules and waits — typically printing a "open this URL" message
and reading from stdin until the user is done. It shares the runtime
contract with kind = "script" (host-side, nix shell for
deps.nixpkgs, no sandbox, runs via rigx run <name>), but the
distinct kind:
- discovers cleanly via
rigx list --kind testbedso users can find scenarios without sifting through every host-side helper; - documents intent — "this is a long-running interactive setup," not "this exits 0/1 like a test or a publish step;"
- composes with the testbed Python helpers (
rigx.testbed.Network,rigx.capsule.start) without forcing them through akind = "test"sandbox = falseworkaround that signals "asserts and exits."
Like kind = "script", testbeds aren't buildable — naming one in
rigx build redirects to rigx run. A typical body uses
rigx.testbed + rigx.capsule.start (see Advanced features below)
and ends with read -r _ or input(...) so the user can hold the
scenario open while they poke at it.
[targets.greeter]
kind = "capsule"
backend = "lite"
deps.internal = ["hello_go"]
deps.nixpkgs = ["coreutils"]
entrypoint = "${hello_go}/bin/hello_go"
ports = [5000]A capsule packages a rigx-built artifact as a container that mounts
the host's /nix/store at runtime — FROM scratch image, kilobytes
in size, no NixOS, no systemd. After rigx build greeter, run with
./output/greeter/bin/run-greeter (needs docker or podman).
The full schema, the rigx.capsule Python orchestrator, and the
rigx.testbed network simulator are documented under
Advanced features: capsules and testbeds.
Set target = "<triple>" on an executable or static_library and rigx
routes the build through the right cross toolchain. No kind = "custom",
no zigcc shim to maintain by hand. Works for c, cxx, go, zig, and nim.
[targets.hello_c_arm64]
kind = "executable"
sources = ["src/hello.c"]
target = "aarch64-linux" # → pkgs.pkgsCross.aarch64-multiplatform.stdenv
[targets.hello_nim_arm64]
kind = "executable"
sources = ["src/hello.nim"]
target = "aarch64-linux" # → auto-emit zigcc shim, set --cpu/--os
nim_flags = ["-d:release"]What each backend does with target:
| Language | Behavior |
|---|---|
c, cxx |
Routes through pkgs.pkgsCross.<x>.stdenv (or <compiler>Stdenv). $CC/$CXX point at the cross-gcc/cross-clang. |
go |
Sets GOOS=…, GOARCH=…, CGO_ENABLED=0 before go build. |
zig |
Adds -target <triple> to zig build-exe (Zig is a cross-compiler natively). |
nim |
Auto-emits a zigcc shim wrapping zig cc -target …, points Nim at it via --cc:clang --clang.exe:zigcc, sets --cpu / --os. Pulls pkgs.zig automatically. Recipe per the nim_zigcc guide. |
rust |
(not yet wired through target — fall back to kind = "custom" for cross-Rust until then). |
Built-in target aliases (resolve to the right pkgsCross.<x> / Zig triple
/ GOOS/GOARCH):
target = … |
Meaning |
|---|---|
aarch64-linux |
ARM64 Linux (musl on Zig/Nim, glibc on c/cxx) |
armv7-linux |
ARMv7 hard-float Linux |
x86_64-linux-musl |
x86_64 Linux, musl libc |
x86_64-windows |
x86_64 Windows (mingw-w64) |
Anything else passes through verbatim (you're responsible for the spelling matching whatever the underlying tool expects).
Use variants to produce both native and cross binaries from the same source:
[targets.hello]
kind = "executable"
sources = ["src/hello.c"]
[targets.hello.variants.arm64]
target = "aarch64-linux"
[targets.hello.variants.windows]
target = "x86_64-windows"Then rigx build hello@arm64, rigx build hello@windows, or just
rigx build hello for all of them.
rigx graph <target> prints a Mermaid graph TD
for the dep tree rooted at the named target. GitHub renders Mermaid code
blocks inline, so the simplest way to use it is:
rigx graph release_bundle > graph.md
# or paste into any Mermaid-aware viewer (GitHub PR, Obsidian, mermaid.live, …)
Running it on the release_bundle target from example-project/ yields:
graph TD
release_bundle["release_bundle [custom]"]
hello["hello [executable]"]
greet["greet [static_library]"]
pkg_fmt(["pkgs.fmt"])
hello_go["hello_go [executable]"]
hello_rust["hello_rust [executable]"]
hello_zig["hello_zig [executable]"]
pkg_gnutar(["pkgs.gnutar"])
pkg_gzip(["pkgs.gzip"])
release_bundle --> hello
hello --> greet
greet --> pkg_fmt
hello --> pkg_fmt
release_bundle --> hello_go
release_bundle --> hello_rust
release_bundle --> hello_zig
release_bundle --> pkg_gnutar
release_bundle --> pkg_gzip
classDef internal fill:#e1f5fe,stroke:#01579b
classDef nixpkgs fill:#f3e5f5,stroke:#4a148c
classDef git fill:#fff3e0,stroke:#e65100
classDef cross_flake fill:#e0f7fa,stroke:#006064,stroke-dasharray:4 2
class release_bundle,hello,greet,hello_go,hello_rust,hello_zig internal
class pkg_fmt,pkg_gnutar,pkg_gzip nixpkgs
Visual key:
- Rectangles — internal targets in this flake (your own and any
[modules]-merged ones). - Stadium shapes (rounded ends) — leaf dependencies:
pkgs.<name>(nixpkgs),git:<name>(git flake inputs), or<localdep>.<target>([dependencies.local.*], dashed cyan border). - Edges —
A --> Bmeans A depends on B (B is built first).
Notes:
target@variantworks as input but the variant suffix is stripped — rigx variants vary flags, not deps, so the graph is identical across variants.- A-style cross-flake refs render as opaque leaves. To see their graph,
cdinto the sibling project and runrigx graphthere — that flake has the metadata. - The
runfield on akind = "run"target is treated as an implicit dep edge to the named target.
A handful of small commands round out day-to-day use:
Appends a [targets.<name>] block to rigx.toml and writes a stub source
file (when applicable). Refuses to overwrite an existing target name or
existing files.
rigx new executable hello # cxx default; src/hello.cpp
rigx new executable tool --language go # src/tool.go
rigx new static_library mylib # src/mylib.cpp + include/mylib.h
rigx new test smoke # kind=test stub; run via `rigx test`
rigx new testbed demo # interactive scenario stub; `rigx run demo`
rigx new run gen --run my_tool # kind=run, invokes my_tool
Supported kinds: executable, static_library, python_script,
custom, script, run, test, testbed. Languages for the first
two: c, cxx, go, rust, zig, nim.
Polls the project tree (skipping output/, .git, flake.lock) every
0.5s and rebuilds the named targets whenever a file's mtime bumps. Cheap
implementation deliberately — no inotify / fsevents dependency, works
identically on Linux/macOS. Ctrl-C exits.
rigx watch # all targets
rigx watch hello # one target
rigx watch hello@arm64 # specific variant
Discovers every kind = "test" target — both sandboxed (default) and
host-side (sandbox = false). Tests are excluded from rigx build;
this is the canonical entry point. Reports a PASS/FAIL summary; exit
code is the worst test's exit code so CI can gate on it.
Filters accept literal names and fnmatch globs (*, ?, […]): a
target runs if it matches any filter. No filter = * = all.
rigx test # run all test targets, sequentially
rigx test smoke # literal name
rigx test 'unit_*' # all tests starting with `unit_`
rigx test smoke 'integ_*' # mix literal + glob
rigx test '*' # explicit "all" (same as no args)
rigx test -j 4 # up to 4 concurrently (per phase, see below)
Phases under -j N:
- Sandboxed tests (
sandbox = true, default) run first, in a thread pool of size N. Each invokesnix buildagainst the test's derivation; sandbox isolation makes parallelism always safe. Output captured. - Host tests with
exclusive = truerun sequentially, streaming. - Other host tests (
sandbox = false, not exclusive) run in a thread pool of size N. Output captured per-test and printed on completion.
Sequential mode (no -j or -j 1) streams every target's output live
in declaration order — sandboxed first, then host.
Quote globs in your shell so the shell doesn't expand them against filesystem paths first.
Re-emits rigx.toml in a stable shape: top-level sections in fixed
order, schema-aware field ordering within each table, = aligned per
section. Useful for code review and to settle nit-pick disagreements.
rigx fmt # print canonical to stdout
rigx fmt --write # overwrite rigx.toml in place
Caveat: comments are not preserved. Python's stdlib
tomllibstrips them on parse and re-emitting them faithfully needs a comment-preserving parser. Pipe through stdout first if you have comments you care about.
Emits a JSON array of {attr, output} instead of the human-readable
list, for CI/scripts that want to consume rigx's output.
rigx build --json | jq '.[] | select(.attr == "hello") | .output'
rigx writes flake.nix and flake.lock next to your rigx.toml. When
their content changes inside a git work-tree, rigx prints a one-line
hint to stderr:
[rigx] regenerated flake.nix — commit when stable so future runs reuse the same lock.
That's all it does — rigx never touches the git index or commits anything on your behalf. Commit both files once they've stabilized; future invocations will reuse the same lock and stop printing the reminder until something changes again.
The hint is suppressed outside a git work-tree (no actionable advice).
Status: experimental. Linux-only. Capsule runners need
/nix/storeand the Nix daemon socket mountable (the standard nix multi-user setup), anddockerorpodmanon PATH.
A capsule packages a rigx-built artifact as a container that mounts the
host's /nix/store and Nix daemon socket at runtime. The image itself
is FROM scratch and ships only /etc/{nix.conf,passwd,group} plus
mount-anchor stub directories — every binary in the container,
including bash and the user's entrypoint, is reachable through the
shared host store. This is the unofficialtools/nix-docker
shape: kilobyte-sized images, no NixOS, no systemd, no duplication
between host and container.
[targets.greeter]
kind = "capsule"
backend = "lite" # `lite` (container) or `qemu` (NixOS VM)
deps.internal = ["hello_go"] # rigx-built deps reachable as ${name}
deps.nixpkgs = ["coreutils"] # PATH inside the container; opt-in
entrypoint = "${hello_go}/bin/hello_go --port $PORT"
ports = [5000]
hostname = "greeter" # default: target name
env = { PORT = "5000", LOG_LEVEL = "info" }deps.nixpkgs controls the container's PATH — strict opt-in. Each
listed nixpkgs attr's /bin is colon-joined and exported as PATH
inside the container. Empty deps.nixpkgs means an empty PATH; the
entrypoint still works because Nix interpolates the rigx-built deps'
absolute store paths into the image's Cmd at flake-eval time.
External tools (ls, grep, curl) need to be listed explicitly —
no auto-coreutils.
Output layout (output/<capsule>/):
bin/run-<name> # docker/podman wrapper: mounts host /nix/store + daemon
bin/shell-<name> # same mounts but `--entrypoint <bash>` for poking
image/image.tar.gz # the loadable scratch OCI tarball (kilobytes)
manifest.json # contract: name, backend, ports, image locator, env
The runner inherits a few env knobs that orchestrators set:
| env var | purpose |
|---|---|
RIGX_NAME |
stable container name (default: random uuid) |
RIGX_DETACH |
1 = docker run -d (default: foreground) |
RIGX_NETWORK |
join an existing docker network |
RIGX_PUBLISH |
host:cont,host:cont,… port forwards |
RIGX_ENV |
K=V,K=V,… extra env vars |
RIGX_VOLUMES |
host:cont[:mode],… extra bind-mounts (mode: rw / ro, default rw). Relative host paths resolve against RIGX_PROJECT_ROOT. Appended to any volumes declared in rigx.toml. |
RIGX_PROJECT_ROOT |
absolute project root used to resolve relative host paths in RIGX_VOLUMES and TOML-declared volumes. Default: walk up from $PWD to find rigx.toml. |
RIGX_USER |
override the TOML user (<user>[:<group>]); empty/unset keeps the TOML default. |
Capsules can declare bind-mounts so they can read/write files on the host — useful for staging data between cooperating capsules (telemetry-receiver writes a column store, telemetry-visualizer reads it back), persisting state across runs, or surfacing a config tree the entrypoint expects to find:
[targets.telemetry_receiver]
kind = "capsule"
backend = "lite"
entrypoint = "${rx_bin}/bin/rx --out /shared"
volumes = [
# relative paths resolve against the project root at runtime
{ host = "testbed-data", container = "/shared", mode = "rw" },
# absolute paths and `~` pass through verbatim
{ host = "/var/log/myapp", container = "/var/log/myapp", mode = "ro" },
]volumes is supported on backend = "lite" and backend = "nixos"
in v1; qemu capsules reject it (the equivalent
virtualisation.sharedDirectories plumbing is deferred). TOML-declared
mounts are baked into the runner; orchestrators can add more at runtime
via RIGX_VOLUMES.
By default a docker container runs as root, which means files the
capsule writes into a bind-mounted volume end up root-owned on the
host — annoying when you want to inspect them after the run. Set
user = "$UID:$GID" on the capsule and the runner will pass
--user to docker, expanding $UID/$GID against the host
shell at runner-launch time:
[targets.telemetry_receiver]
kind = "capsule"
backend = "lite"
entrypoint = "${rx_bin}/bin/rx --out /shared"
user = "$UID:$GID" # canonical: container files are yours
volumes = [{ host = "testbed-data", container = "/shared" }]Numeric ids ("1000:1000") and bare names ("nobody",
"myuser:mygroup") also work; only a single $VAR per side is
allowed (no ${VAR}, no command substitution). Lite-only — nixos's
systemd needs to start as uid 0, and qemu has no --user knob.
RIGX_USER overrides the TOML default per invocation.
A nixos capsule runs a real NixOS userspace inside a docker/podman
container with systemd as PID 1 — so declarative services from
nixos_modules actually run, units start in dependency order, journald
collects logs. Same FROM-scratch-ish image and host-store mount as
lite, but the user's entrypoint runs as a systemd one-shot service
rather than the container's Cmd.
[targets.web]
kind = "capsule"
backend = "nixos"
deps.internal = ["web_bin"]
deps.nixpkgs = ["coreutils", "curl"]
entrypoint = "${web_bin}/bin/web --port 8080"
ports = [8080]
hostname = "webcap"
nixos_modules = ["vm/openssh.nix"] # standard NixOS modulesWhen to pick it (vs the other two):
| Backend | PID 1 | NixOS services? | Boot time | Runner needs |
|---|---|---|---|---|
lite |
your entrypoint | no | seconds | docker/podman |
nixos |
systemd | yes | a few seconds (systemd) | docker/podman + --privileged (handled by runner) |
qemu |
systemd in NixOS VM | yes | tens of seconds (kernel + init) | qemu (+ KVM on Linux) |
Output layout (output/<capsule>/):
bin/run-<name> # docker/podman runner (--privileged, mounts host /nix/store)
image/image.tar.gz # OCI tarball whose Cmd is `${toplevel}/init`
manifest.json # contract: name, backend, ports, image locator
The runner takes the same RIGX_* env-var contract as lite. The
--privileged flag is required so systemd can manage cgroups and write
to /run, /tmp, /var/log (the runner mounts those as tmpfs). On
hosts where you can't grant --privileged, you'd need to pin specific
caps and mounts manually — that's not in v1.
nixos_modules works exactly like the qemu backend (see below) — list
NixOS module files to splice into the system evaluation.
A qemu capsule boots a real NixOS VM under qemu. The user's entrypoint
runs as a systemd one-shot service inside the VM, deps.nixpkgs land in
environment.systemPackages, declared ports are forwarded host→guest
via qemu user-mode networking. Use this when you need a real kernel,
real init, or a different CPU architecture from the host.
[targets.hello_arm_capsule]
kind = "capsule"
backend = "qemu"
target = "aarch64-linux" # ARM VM on an x86_64 host
deps.internal = ["hello_nim_arm64"]
entrypoint = "${hello_nim_arm64}/bin/hello_nim Massimo && systemctl poweroff"
hostname = "armcap"
ports = [22] # forwarded to host via qemu hostfwdtarget = "<triple>" (same alias map used for cross-compilation) shifts
the VM's NixOS evaluation to a different system, so the same capsule
declaration produces an ARM VM regardless of the host arch. Building it
needs aarch64 builders or boot.binfmt.emulatedSystems = [ "aarch64-linux" ]
on the host; the declaration is portable.
Output layout (output/<capsule>/):
bin/run-<name> # bash wrapper that execs the underlying NixOS vm script
system/vm-script # symlink into the NixOS-VM derivation (kernel, initrd, ...)
manifest.json # contract: name, backend, ports, image locator
v1 uses NixOS's system.build.vm, which 9p-mounts the host's
/nix/store rather than baking a standalone qcow2 — fast iteration,
host-store-tied, requires KVM on Linux. A self-contained qcow2 layout
is reserved for follow-on work when mixed-backend labs need the VM to
run on a different host. The Python rigx.capsule.start() orchestrator
and rigx.testbed integration are also deferred — for v1, qemu
capsules work via rigx build + output/<name>/bin/run-<name>.
The qemu and nixos backends share the same configuration model: the
"image" isn't a separate base image — it's a NixOS system evaluated
from your project's pinned nixpkgs ref, plus a small rigx-generated
module (your entrypoint, ports, deps.nixpkgs, hostname). To go
beyond that — enable services, declare extra users, mount volumes, swap
kernel packages, anything you'd put in a configuration.nix — list
user NixOS module files via nixos_modules:
[targets.web_capsule]
kind = "capsule"
backend = "qemu" # or "nixos" — same nixos_modules contract
deps.internal = ["web_bin"]
entrypoint = "${web_bin}/bin/web --port 8080"
ports = [8080]
nixos_modules = [
"vm/openssh.nix", # literal paths, project-relative
"vm/extras/*.nix", # globs are expanded at config-load time
]Each entry is an ordinary NixOS module file:
# vm/openssh.nix
{ ... }: {
services.openssh.enable = true;
services.openssh.settings.PermitRootLogin = "yes";
users.users.root.password = "rigx";
}The modules are spliced into eval-config.nix alongside the
rigx-generated module. They can reference pkgs, lib, config the
usual way — same as a regular NixOS configuration. Available on
backend = "qemu" and backend = "nixos"; using nixos_modules on a
lite capsule is a config error (lite is a FROM-scratch container
with no NixOS to configure).
For tests and orchestration, rigx ships a small Python helper:
from rigx.capsule import start
with start("greeter", publish={5000: 5050}) as cap:
cap.wait_for_port(5000, timeout=10)
# ... talk to 127.0.0.1:5050
print(cap.logs())start(name) finds output/<name>/, invokes the runner in detached
mode, and returns a Capsule handle. wait_for_port polls the host-
mapped port until something accepts. cap.exec([…]) runs docker exec
inside the container. Context exit calls docker stop on the
container.
Supported backends:
liteandnixos— docker/podman-shaped.start(),stop(),exec(),logs(),wait_for_port(), and the testbed integration all work transparently.qemu— supported with caveats.start()runs the runner viaPopen(qemu is a child process of the test);stop()terminates it;logs()reads the captured console;wait_for_port()works through the forwarded host ports.exec()is not available — the VM has no docker-shell-out equivalent. To run a command inside a qemu capsule, enable SSH vianixos_modulesand connect through a forwarded port.
For testbed use specifically, RIGX_PUBLISH is translated by each
backend's runner into the right port-forward primitive: docker -p
flags for lite/nixos, QEMU_NET_OPTS=hostfwd=… for qemu. The testbed
sees the same bindings() interface across all three.
Real integration tests want multiple capsules talking to each other,
plus the ability to inject faults (drop, delay, corrupt, partition).
rigx.testbed.Network is a userspace TCP/UDP proxy with a fault-rule
chain that sits between capsules:
from rigx.capsule import start
from rigx.testbed import Network
with Network() as net:
net.declare("sim", listens_on=[5000])
net.declare("fc", listens_on=[5001])
net.link("sim", "fc", to_port=5001) # sim → fc:5001
with start("simulator", **net.bindings("sim")) as sim, \
start("flight_computer", **net.bindings("fc")) as fc:
sim.wait_for_port(5000)
fc.wait_for_port(5001)
# Happy path
# ...
# 50% drop on sim→fc for the duration of the block
with net.fault("sim", "fc", drop_rate=0.5):
# ...
# 100ms latency with ±20ms jitter
with net.fault("sim", "fc", delay_ms=100, jitter_ms=20):
# ...
# Bidirectional partition
with net.partition(["sim"], ["fc"]):
# ...bindings("sim") returns kwargs ready for start():
publishmaps each declared listening port to a testbed-allocated host endpoint. By default each value is a(addr, port)tuple; ifdeclare(expose=…)adds external publishes, the value becomes a list andstart()emits one-pflag per entry.publish_udp: the same shape, for UDP listening ports.envexposes peer endpoints as<DST>_<PORT>_ADDRenv vars (e.g.FC_5001_ADDR=127.0.0.1:5023). The capsule's entrypoint reads these to know where to connect.volumesresolves anyshared_volume()handles attached to the capsule into a host-path → container-path mapping. Empty if no shared volumes were declared.
When two or more capsules need to read/write the same files (a
telemetry pipeline staging columns, a simulator picking up a config
tree dropped by a setup script), allocate a shared_volume on the
testbed and attach it to each capsule with a container-side path:
from rigx.capsule import start
from rigx.testbed import Network
with Network() as net:
data = net.shared_volume("data") # tempdir, auto-cleaned
net.declare("rx", listens_on=[8001], volumes={data: "/shared"})
net.declare("vis", listens_on=[8000],
volumes={data: ("/shared", "ro")}) # read-only mount
with start("telemetry_receiver", **net.bindings("rx")) as rx, \
start("telemetry_visualizer", **net.bindings("vis")) as vis:
rx.wait_for_port(8001)
vis.wait_for_port(8000)
# rx writes to /shared inside its container; vis sees the
# same files at /shared (read-only).The handle owns a host tempdir for the lifetime of the testbed's
with block. bindings("…") includes a resolved volumes={host: container} entry that start() wires up as RIGX_VOLUMES. Volumes
attached this way only apply to capsules with backends that accept
volumes (lite, nixos); attempting to attach one to a qemu
capsule fails fast at start() with a clear error.
The proxy is a great fit for inter-capsule traffic with faults, but
the wrong shape for "open this in a browser" — the loopback alias the
testbed allocates isn't reachable from outside the host. expose=
adds an additional (addr, port) publish on top of the normal
loopback alias, so a port stays reachable both ways:
with Network(subnet="127.0.10.0/24") as net:
net.declare("vis", listens_on=[8000],
expose=[("0.0.0.0", 8000)])
# vis is reachable to other capsules on its 127.0.10.X alias
# (proxied, fault-injectable) AND to a browser on 0.0.0.0:8000
# (direct, no faults — the proxy never sees it).
with start("telemetry_visualizer", **net.bindings("vis")) as vis:
vis.wait_for_port(8000)
print("open http://localhost:8000 in your browser")
input("press Enter to tear down…")Each exposed port must already appear in listens_on (or
udp_listens_on for udp_expose=). Faults declared with
net.fault(...) apply only to traffic that goes through the proxy
— exposed-endpoint traffic bypasses the rule chain.
The testbed is a userspace L4 proxy — it never talks to the capsule
directly, only to host-side ports. So all three capsule backends
(lite, nixos, qemu) plug into the same Network() and the
same testbed can mix them: e.g. a fast lite capsule running a
test driver next to a nixos capsule running real systemd services
next to a qemu capsule running an aarch64 firmware. The proxy, fault
chain, and distinct-loopback addressing apply uniformly.
What each backend supports through rigx.capsule.start():
| Backend | start / stop / wait_for_port | logs | exec | Boot time | Network transport |
|---|---|---|---|---|---|
lite |
✅ | docker logs |
docker exec |
seconds | docker -p flags |
nixos |
✅ | docker logs |
docker exec |
a few seconds (systemd) | docker -p flags |
qemu |
✅ | captured tempfile | ❌ NotImplementedError | tens of seconds (kernel + init) | QEMU_NET_OPTS=hostfwd=… |
The testbed renders bindings() into the same RIGX_PUBLISH env-var
shape regardless of backend — each backend's runner translates that
to the right primitive (docker port-forward flags or qemu hostfwd
rules). The RIGX_PUBLISH contract is the seam.
Caveats specific to qemu in a testbed:
- Slow
wait_for_port— bumptimeout=to 60-120s; the VM has to finish booting before its services bind. lite/nixos peers can use the default 10s. - No
cap.exec()— there's no docker-shell-out equivalent. If your test needs to poke inside the VM (drop a file, trigger a signal, read/proc), enable SSH vianixos_modules, forward a port for it, and connect from the test through the forwarded port. - Cross-arch (
target = "aarch64-linux") needs host setup —boot.binfmt.emulatedSystemson NixOS, or qemu-user-static + nixextra-platformselsewhere. rigx prints a distro-agnostic hint when the build fails for this reason. networkparameter is ignored — qemu uses its own user-mode networking. The testbed doesn't passnetwork, so this only matters for hand-rolledstart(network="…")calls.
By default every capsule shares 127.0.0.1 and is distinguished by
port. To give each capsule a real distinct IP — useful when capsule
code asserts on source IPs, hardcodes addresses, or needs subnet
semantics — pass a subnet (must be inside 127.0.0.0/8) to
Network:
with Network(subnet="127.0.10.0/24") as net:
net.declare("sim", listens_on=[5000]) # auto: 127.0.10.2
net.declare("fc", listens_on=[5001]) # auto: 127.0.10.3
# Or pin explicitly: declare("sim", address="127.0.10.10", …)
net.link("sim", "fc", to_port=5001)
# ...The testbed binds each capsule's listener on its own loopback alias
and routes the proxy's upstream socket through the source's address
before connect(), so the destination sees the real source IP — not
the proxy's. Linux routes all of 127.0.0.0/8 to lo automatically.
On macOS, run sudo ifconfig lo0 alias 127.0.X.Y once per address
the testbed will use.
Capsules that talk over UDP — telemetry buses, NTP-style protocols,
DNS — declare UDP listening ports separately and link with
proto="udp":
with Network(subnet="127.0.10.0/24") as net:
net.declare("sim", listens_on=[5000]) # TCP
net.declare("fc", udp_listens_on=[5001]) # UDP
net.link("sim", "fc", to_port=5001, proto="udp")
with start("simulator", **net.bindings("sim")) as sim, \
start("flight_computer", **net.bindings("fc")) as fc:
# Capsules may speak both protocols; declare both lists and
# link both protos. TCP and UDP port spaces are independent
# — same port number works for both.
...The UDP forwarder maintains a per-(src_addr, src_port) session
table so reply datagrams route back to the right sender. Source-IP
visibility on the forward path works exactly like TCP — the
upstream socket binds on the source's address before sendto. Reply
datagrams come back from the proxy's listener address (capsule code
that uses connect() with strict source-checking sees the proxy,
not the destination — symmetric reply-path src-IP visibility is
deferred).
Fault rules apply to UDP datagrams the same way they do to TCP byte
chunks: drop_rate discards datagrams, delay_ms holds them up,
corrupt_rate flips a byte, partition() blocks the link.
net.fault(src, dst, proto="udp", ...) scopes a fault to the UDP
link if both protocols exist between the same pair.
Outbound UDP env vars get a _UDP suffix so they don't collide
with TCP equivalents: FC_5001_ADDR is TCP, FC_5001_UDP_ADDR is
UDP.
A single test that drives a control-plane (TCP) link and a telemetry (UDP) link between the same pair of capsules, exercising fault injection on both protocols:
# tests/flight_loop.py
from rigx.capsule import start
from rigx.testbed import Network
with Network(subnet="127.0.10.0/24") as net:
# sim sends control commands over TCP/5001 and pushes telemetry
# over UDP/6000. fc accepts both.
net.declare("sim", listens_on=[5000], udp_listens_on=[6000])
net.declare("fc", listens_on=[5001], udp_listens_on=[6000])
net.link("sim", "fc", to_port=5001) # TCP control
net.link("sim", "fc", to_port=6000, proto="udp") # UDP telemetry
with start("simulator", **net.bindings("sim")) as sim, \
start("flight_computer", **net.bindings("fc")) as fc:
sim.wait_for_port(5000)
fc.wait_for_port(5001)
# Baseline: both links healthy.
assert_healthy(sim, fc)
# 30% packet loss on the UDP telemetry link.
with net.fault("sim", "fc", proto="udp", drop_rate=0.3):
assert_telemetry_gaps_are_tolerated(fc)
# 200ms latency + 50ms jitter on TCP control; UDP stays clean.
with net.fault("sim", "fc", proto="tcp",
delay_ms=200, jitter_ms=50):
assert_command_acks_still_arrive(fc, deadline_ms=2000)
# Corrupt 1% of UDP datagrams — app-level CRC must reject them.
with net.fault("sim", "fc", proto="udp", corrupt_rate=0.01):
assert_no_corrupt_telemetry_accepted(fc)
# Stack faults: lossy UDP and slow TCP at the same time.
with net.fault("sim", "fc", proto="udp", drop_rate=0.1), \
net.fault("sim", "fc", proto="tcp", delay_ms=100):
assert_degraded_but_live(fc)
# Hard partition blocks both protocols in both directions.
with net.partition(["sim"], ["fc"]):
assert_fc_enters_safe_mode(fc, within_ms=500)
# Recovery once the partition lifts.
assert_fc_recovers(fc, within_ms=500)Wire it into rigx as a sandbox-disabled test target so it can drive
docker:
[targets.flight_loop]
kind = "test"
sandbox = false
deps.internal = ["simulator", "flight_computer"]
deps.nixpkgs = ["python3"]
script = """
python3 tests/flight_loop.py
"""Then rigx test flight_loop runs the suite.
- Linux only. Capsule runners depend on
/nix/storeand (for lite/nixos) the Nix daemon socket being mountable — the standard nix-multi-user setup on Linux. qemu additionally needs KVM for acceptable performance. dockerorpodmanmust be on PATH forlite/nixoscapsules at runtime.qemucapsules need qemu in the closure (rigx pulls it in automatically) but no docker.- L2 simulation deferred. The testbed operates at L4 (TCP byte
streams / UDP datagrams) — no Ethernet frames, no ARP, no MTU
effects, no link-layer drops. Bus protocols (CAN, MIL-STD-1553, …)
belong in their own simulator process running as another capsule,
not in
rigx.testbed. Real L2/L3 simulation needs Linux netns + veth pairs (option 3 in TODO). - UDP
delay_msis head-of-line on the listener thread — a delayed datagram blocks subsequent ones. Acceptable for moderate delays; a heap-based scheduler would eliminate this. - Mixed-backend topologies are supported in principle (the testbed
is L4-only and doesn't care what's behind a port) but mixing has
not been heavily exercised in practice — file an issue if you hit
edge cases with
lite+qemuornixos+qemutopologies. - See TODO.md for further deferred items — s6 multi-service capsules, time control, qcow2-baked qemu disks for cross-host portability.
# Bolierplate #####################################################################
[project]
name = "hello-example"
version = "0.1.0"
[nixpkgs]
ref = "nixos-24.11"
# Simple Examples #################################################################
[targets.greet]
kind = "static_library"
sources = ["src/greet.cpp"]
includes = ["include"]
public_headers = ["include"]
cxxflags = ["-std=c++17", "-Wall"]
deps.nixpkgs = ["fmt"]
[targets.hello]
kind = "executable"
sources = ["src/main.cpp"]
includes = ["include"]
cxxflags = ["-std=c++17", "-Wall"]
ldflags = ["-lfmt"]
deps.internal = ["greet"]
deps.nixpkgs = ["fmt"]
# Examples of Variants ############################################################
[targets.hello.variants.debug]
cxxflags = ["-O0", "-g"]
defines = { DEBUG = "1" }
[targets.hello.variants.release]
cxxflags = ["-O2"]
defines = { NDEBUG = "1" }
# Toolchain-swap variant: `rigx build hello@clang` reuses the same sources
# but routes the build through nixpkgs `clangStdenv` instead of the default.
[targets.hello.variants.clang]
compiler = "clang"
cxxflags = ["-O2"]
# Examples in Dependencies and Artifact Output ####################################
[targets.gen_greeting]
kind = "executable"
sources = ["src/gen_greeting.cpp"]
cxxflags = ["-std=c++17"]
[targets.greeting]
kind = "run"
run = "gen_greeting"
args = ["--name", "Massimo", "--out", "greeting.txt"]
outputs = ["greeting.txt"]
# A run target that invokes a nixpkgs tool (`zip`) to bundle project files.
[targets.headers_zip]
kind = "run"
run = "zip" # name resolved via PATH (no such internal target)
deps.nixpkgs = ["zip"] # provides `zip` on PATH in the sandbox
args = ["-r", "headers.zip", "include"]
outputs = ["headers.zip"]
# A run target that consumes the artifact of another run target.
# ${headers_zip} Nix-interpolates to the store path of that derivation.
[targets.unpack_headers]
kind = "run"
run = "unzip"
deps.nixpkgs = ["unzip"]
deps.internal = ["headers_zip"] # declares the build-order dependency
args = ["-d", "extracted", "${headers_zip}/headers.zip"]
outputs = ["extracted"] # a directory; cp -r handles it
# Examples in Other Languages #####################################################
# Nim: language inferred from `.nim` extension; nim toolchain auto-pulled.
[targets.hello_nim]
kind = "executable"
sources = ["src/hello.nim"]
nim_flags = ["-d:release", "--opt:speed"]
[targets.hello_nim.variants.debug]
nim_flags = ["-d:debug", "--debugger:native"]
[targets.hello_nim.variants.release]
nim_flags = ["-d:release", "--opt:speed"]
[targets.greet_py]
kind = "python_script"
sources = ["src/greet.py"]
python_version = "3.12"
python_project = "." # dir containing pyproject.toml + uv.lock
# Pinned uv-venv FOD hash; bump whenever pyproject.toml or uv.lock changes
# (rigx prints the new hash in the build error so you can paste it back).
python_venv_hash = "sha256-a1eSPty02qsWzhZCEJ+XpTdSNIXsNg6vw+LsZD/kaIo="
# Go: language inferred from `.go` extension; toolchain auto-pulled from nixpkgs.
[targets.hello_go]
kind = "executable"
sources = ["src/hello.go"]
# Rust: same idea — `.rs` → language=rust, rustc auto-pulled.
[targets.hello_rust]
kind = "executable"
sources = ["src/hello.rs"]
rustflags = ["-Copt-level=2"]
# Zig: `.zig` → language=zig, zig auto-pulled.
[targets.hello_zig]
kind = "executable"
sources = ["src/hello.zig"]
zigflags = ["-O", "ReleaseFast"]
# Example of Cross Compilation #####################################################
# ─── Cross-compilation, first-class via `target = …` ────────────────────────
# `target = "aarch64-linux"` routes c/cxx through `pkgsCross.aarch64-multi…
# .stdenv`, sets `GOOS/GOARCH` for go, passes `-target` to zig, and emits a
# zigcc shim for nim. Same target shape, different language, different
# platform — no custom build_script needed.
[targets.hello_c_arm64]
kind = "executable"
sources = ["src/hello.c"]
target = "aarch64-linux"
cflags = ["-O2"]
[targets.hello_nim_arm64]
kind = "executable"
sources = ["src/hello.nim"]
target = "aarch64-linux"
nim_flags = ["-d:release"]
# Example of Capsules and Testbeds #################################################
# Capsule: package an existing rigx-built binary as a runnable container
# in the unofficialtools/nix-docker shape — kilobyte-sized FROM-scratch
# image that mounts the host's /nix/store at runtime so the binary is
# reachable. `${hello_go}` interpolates to the rigx-built target's store
# path. After `rigx build greeter`, drop into the image with
# `./output/greeter/bin/shell-greeter` (needs docker/podman on PATH).
#
# Heavier first build than the rest of this project — nix builds the
# scratch image and the daemon pulls dockerTools dependencies. Skip
# from `rigx build` (no args) by listing other targets explicitly if
# this is a problem.
[targets.greeter]
kind = "capsule"
backend = "lite"
deps.nixpkgs = ["coreutils", "helix"]
deps.internal = ["hello_go"]
entrypoint = "${hello_go}/bin/hello_go"
# Qemu capsule running a native (host-arch) Go binary inside a NixOS VM.
# No `target` field means the VM is built for whatever system you run
# rigx under — works out of the box on any Linux host with KVM. Use
# this to verify your host's qemu setup before tackling the cross-arch
# variant below.
[targets.hello_x86_capsule]
kind = "capsule"
backend = "qemu"
deps.internal = ["hello_go"]
entrypoint = "${hello_go}/bin/hello_go Massimo && systemctl poweroff"
hostname = "x86cap"
[targets.test_hello_x86]
kind = "test"
sandbox = false
deps.internal = ["hello_x86_capsule"]
deps.nixpkgs = ["coreutils", "gnugrep"]
script = """
set -euo pipefail
echo "[test_hello_x86] booting x86_64 qemu capsule (this can take a minute)"
out=$(timeout 180 ./output/hello_x86_capsule/bin/run-hello_x86_capsule 2>&1 || true)
echo "$out" | tail -20
echo "$out" | grep -q "Hello, Massimo!"
echo "[test_hello_x86] saw expected greeting"
"""
# Qemu capsule running an ARM binary inside an aarch64 NixOS VM.
# `target = "aarch64-linux"` shifts the VM evaluation to aarch64 so the
# same capsule declaration produces an ARM VM on an x86_64 host. The
# rigx-built ARM binary (cross-compiled via zigcc above) is referenced
# through `${hello_nim_arm64}` and runs natively in the VM's aarch64
# kernel. Requires aarch64 builders or `boot.binfmt.emulatedSystems` on
# the host to actually build; the declaration itself is portable.
[targets.hello_arm_capsule]
kind = "capsule"
backend = "qemu"
target = "aarch64-linux"
deps.internal = ["hello_nim_arm64"]
entrypoint = "${hello_nim_arm64}/bin/hello_nim Massimo && systemctl poweroff"
hostname = "armcap"
# Host-side test that boots the ARM capsule, captures its console output,
# and asserts on the greeting. The entrypoint shuts the VM down once
# hello_nim exits so the run terminates without a timeout. `sandbox =
# false` is required — the test launches qemu, which doesn't run inside
# Nix's build sandbox.
[targets.test_hello_arm]
kind = "test"
sandbox = false
deps.internal = ["hello_arm_capsule"]
deps.nixpkgs = ["coreutils", "gnugrep"]
script = """
set -euo pipefail
echo "[test_hello_arm] booting ARM qemu capsule (this can take a minute)"
out=$(timeout 180 ./output/hello_arm_capsule/bin/run-hello_arm_capsule 2>&1 || true)
echo "$out" | tail -20
echo "$out" | grep -q "Hello, Massimo!"
echo "[test_hello_arm] saw expected greeting"
"""
# `custom` is the escape hatch for non-trivial workflows the first-class
# kinds don't cover. Here it stitches together previously-built targets
# into a release tarball — using ${dep} interpolation to reach into each
# dep's $out, gnutar/gzip from nixpkgs, and producing a single artifact.
[targets.release_bundle]
kind = "custom"
deps.internal = ["hello", "hello_go", "hello_rust", "hello_zig"]
deps.nixpkgs = ["gnutar", "gzip"]
install_script = """
mkdir -p $out
staging=$TMPDIR/release
mkdir -p $staging
cp ${hello}/bin/hello $staging/
cp ${hello_go}/bin/hello_go $staging/
cp ${hello_rust}/bin/hello_rust $staging/
cp ${hello_zig}/bin/hello_zig $staging/
tar -C $TMPDIR -czf $out/release.tar.gz release
"""The repo's own rigx.toml declares two test targets, both discovered by
rigx test from the repo root:
| Target | Kind | What it covers |
|---|---|---|
unittests |
kind = "test" (sandboxed, default) |
Pure-Python unit suite: TOML parser, validator, flake-text generator, builder attribute resolution. No nix or network access required at runtime — runs hermetically in a Nix derivation, cached on input hash. |
example_project_build |
kind = "test", sandbox = false, exclusive = true |
End-to-end integration: shells out to nix build against every target in example-project/ to catch regressions in flake generation, parallel dispatch, and per-target failure isolation that unit tests can't see. |
rigx test # both, sequential
rigx test -j 4 unittests # just the Python suite, parallel-ready
rigx test 'example_*' # just the end-to-end smoke test
You can also run the unit suite directly (no rigx, no nix):
python3 -m unittest discover tests -v
See example-project/ for a working version of the above.
cd example-project
rigx build hello@release
./output/hello-release/bin/hello "friend"
rigx is a developer build tool, not a sandbox.
- Builds (
rigx build,rigx test --sandboxed) run under Nix's build sandbox. Reproducibility comes from there — content-addressed inputs, pinnednixpkgs, locked git / local-flake deps. - Script, testbed, and capsule runners (
rigx run,rigx.testbed,output/<name>/bin/run-<name>) are developer tools. They are not security boundaries. - Capsules mount the host's
/nix/storeand the Nix daemon socket into the container so the user's rigx-built deps are reachable via the same store paths the flake baked in.backend = "nixos"runs the container with--privilegedbecause systemd needs it. Neither capsule backend is hardened against a malicious entrypoint, dep, or rigx-built artifact. rigx.tomlcan ship arbitrary shell viakind = "script",kind = "custom"build/install scripts,kind = "test"scripts,kind = "capsule"entrypoints, andkind = "run"arguments. Treat it like any other source file in the repo.
Do not run rigx build, rigx run, rigx test, or any
output/.../bin/run-<name> against untrusted projects without
reviewing the rigx.toml, capsule entrypoints, custom scripts, and the
git / local-flake inputs first.
BSD 3-Clause License. See LICENSE.md for the full text.
Created by Massimo Di Pierro <massimo.dipierro@gmail.com> in collaboration with Claude and ChatGPT (author's own accounts), in his own free time, with his own resources, for the greater good.
