Recipes in Nickel
Writing a request by hand is tedious: a real target pulls in hundreds of packages — each a source plus a build recipe — all wired together by node id. Recipes are instead written in Nickel, a typed configuration language, and lowered to a request. The bobr-recipes repository is a worked example: a Nickel program that builds a whole Linux root filesystem from source, starting from an OCI base image, bootstrapping a self-hosted toolchain, and composing the result into root filesystems and disk images.
bobr itself never sees Nickel — it only receives the JSON request the Nickel
layer produces. This chapter describes how that layer works in bobr-recipes.
The package set
bobrpkgs.ncl exports a function from an ordered list of overlays to the
recipe set; (import "bobrpkgs.ncl") [] builds the default set. It folds
together the package recipes — one module per package, listed in pkgs/pkgs.ncl
and living beside it in pkgs/ — with the modules that assemble images, tests,
and bundles. Each module is a function of the finished set, so recipes refer to
one another by name — pkgs.gcc, pkgs.glibc_libs,
pkgs.base_filesystem. The set is a fixed point: every package sees the
fully assembled pkgs, which is what lets recipes depend on each other and lets
overlays override anything.
A package module returns named recipes:
fun pkgs =>
let libffi_src = {
version | default = "3.6.0",
name = "libffi-src-%{version}",
tag = "Source",
object_hash | default = "…",
origin = { tag = "Http", url = "https://github.com/libffi/libffi/releases/download/v%{version}/libffi-%{version}.tar.gz" },
} in
let libffi = {
name = "libffi-%{inputs.source.version}",
tag = "Autotools",
deps = { build = [], runtime = [pkgs.glibc_libs] },
config = { configure_args = ["--disable-static"] },
inputs = { source = pkgs.libffi_src },
} in
{ include [libffi_src, libffi] }
request.ncl ties it together: given the paths of one run, the final package
set, and an ordered non-empty array of recipe goals, it lowers their shared DAG
to a full request. Callers rarely invoke it directly —
bin/bobr-build.sh does, reading everything but the run’s own identity from a
build profile (bobr.ncl, shaped by
profiles/contracts.ncl). A normal profile imports the maintained
profiles/bobr-user.ncl preset instead of copying a template.
The profile is itself the Nickel program that constructs the package set and selects its goals:
let bobrpkgs = import "bobr-recipes/bobrpkgs.ncl" in
let pkgs = bobrpkgs [] in
(import "bobr-recipes/profiles/bobr-user.ncl") & {
include pkgs,
goals = [pkgs.world],
}
include pkgs adds the existing local pkgs value as a record field. Multiple
goals share one lowered DAG and retain their array order. Repeated --target NAME options replace the profile goals for one invocation; each name is looked
up in the profile’s final pkgs.
Profile goals are recipe values, not attribute names. They can also contain a
recipe imported from outside pkgs, for example:
goals = [(import "./hello.ncl") pkgs],
Here hello.ncl is a function returning a recipe record. Such a recipe needs no
overlay to be built, but cannot be selected by --target unless added to pkgs.
See Compile a local program.
A build profile can attach ordered local and remote backends to the Realizer. Capabilities are explicit and independent; enabling both on one profile entry is shorthand for two low-level request entries backed by the same source:
secondaries.providers = [
{
name = "previous",
mappings = true,
content = true,
local = {
store = "../bobr-store.previous",
transfer = "hardlink",
},
},
{
name = "public",
content = true,
remote = {
master_url = "https://repo.example/bobr/master",
trusted_keys = ["./repository-key.pem"],
},
},
]
mappings allows authoritative BuildKey and ReuseKey answers; content
allows acquisition for an already-known hash. Neither implies the other. Local
content requires transfer: hardlink requires the relevant store directories
to share filesystems, while copy deliberately creates independent inodes and
works across filesystems. Local store paths, remote key paths, an optional CA
bundle, and secondaries.repository_cache are resolved relative to the
top-level profile. An empty cache setting selects
<store>/repository-cache. See
Request for the normalized wire contract.
An optional output_repository section enables a post-build publication stage in
bobr-build.sh. After a successful non-dry-run build, the driver uploads missing
content and prepares an unsigned candidate master when the repository state
changes; signing and publishing that master are separate administrator actions.
This section is not included in the JSON build request. See
Repository CLI for preparation and publication.
Local sources
Local files and directories are Source recipes with a declared content hash.
Inside bobr-recipes, RecipePath names a path relative to the recipes checkout;
request lowering converts it to Path with an absolute host path. For example,
a recipe in pkgs/foo/ can refer to a sibling patch:
{
name = "foo-fix",
tag = "Source",
object_hash = std.string.trim (import "./fix.patch.fsobj-hash" as 'Text),
origin = {
tag = "RecipePath",
path = "pkgs/foo/fix.patch",
},
}
The hash import is relative to the Nickel file; RecipePath.path is relative to
the checkout root. A plain Path origin instead requires an absolute host path,
including for sources outside the checkout.
The adjacent .fsobj-hash file pins the source content. After editing the patch,
refresh it from the recipes checkout:
bin/bobr-update-fsobj-hashes.sh pkgs/foo/fix.patch
bobr-build.sh checks existing hash locks inside the recipes checkout before
lowering; it does not update them or check sources outside that checkout. For
external sources, update the declared hash yourself after changes. An object
already cached under the declared hash can be reused without rereading its
origin. See Compile a local program
for an external-source example.
Overlays
An overlay is a function fun final => fun prev => patch, where prev is
the package set built so far and final is the finished set. Overlays are
applied in order, and each one’s fields replace the matching packages — the same
idea as Nix overlays. For example, to pin a different shadow version:
[
fun final => fun prev => {
shadow_src = prev.shadow_src & {
version = "4.18.0",
object_hash = "add4604d3bc410344433122a819ee4154b79dd8316a56298c60417e637c07608",
},
},
]
The version and source archive hash must match. For an override to propagate,
consumers of a public recipe must refer to it through pkgs (or final in an
overlay), not a module-local binding that still names the original recipe.
Important: Recipe names are also the current lowering layer’s deduplication keys. Multiple consumers may share the same recipe value and name, but different reachable recipe definitions must have different names. Lowering does not currently diagnose a conflict: if two different recipes have the same name, it keeps one definition and makes both references point to it.
This is a limitation of the Nickel recipe layer. Recipe names do not
participate in Bobr’s BuildKey.
Replacing a public Source such as shadow_src above leaves only one shared
definition, so its version-derived name is sufficient. If an overlay instead
replaces the Source input of only one consumer while the original Source remains
reachable, the replacement needs an explicitly distinct name:
fun _final => fun prev => {
gzip = prev.gzip & {
inputs.source | force = prev.gzip_src & {
name | force = "gzip-src-1.14-local",
object_hash = "…",
},
},
}
Changing only object_hash does not change a name derived from version.
Names need to be unique only among different recipes reachable in the same
lowered graph.
Recipes are data, not functions
Values in the recipe set are plain data records, not functions. Modules and
factories may be functions returning records; their evaluated results are the
recipes. An overlay replaces selected fields of the set and can explicitly merge
changes into prev.foo with &. A function value cannot be patched that way.
Keeping recipes as records makes their versions, configure flags, and inputs
accessible to overlays.
Synthetic recipes
The recipe above uses the tag Autotools, which is not a bobr
builder — bobr has no such builder. It is a synthetic recipe: a
high-level, Nickel-only tag that stands for a common build pattern and is
lowered (expanded) into real builder nodes before the request reaches bobr.
Lowering happens in recipe-lib.ncl’s to_request_goals, which walks the recipe
graph and looks each node’s tag up in synthetic/registry.ncl. A node whose tag
is already a real builder — Tree, TreeMerge, Sandbox, SandboxInstall,
Source, and so on — passes through unchanged. A node with a synthetic tag is
expanded into real builder nodes: ordinary build families use SandboxInstall
with a build rootfs assembled by TreeMerge; staging variants also use
TreeMove. Expansion runs on the
already-overlaid package set, so overlays always patch the high-level recipe and
expansion sees the result; lowering then assigns node ids and emits the JSON
nodes map.
HostBundle is different: it is a real bobr builder, used directly from a
Nickel recipe rather than expanded by the synthetic registry. Its inputs and
typed configuration are documented in HostBundle.
Build and runtime dependencies
A dependency-aware synthetic recipe carries deps = { build = [...], runtime = [...] }:
build— packages needed to build this one. The lowering layer assembles the build environment as aTreeMergeofbase_filesystemwith the runtime closure of a default toolchain plus these build deps, and injects it as the build sandbox’s_rootfsinput.runtime— packages this one needs at run time. They form the package’s own runtime closure, which is followed transitively wherever the package appears in another recipe’sbuildorruntimelist.
So a recipe declares what a package needs, and the root filesystem to build it in is assembled automatically from the runtime closures of its build dependencies.
Split outputs
The unsuffixed attribute (pkgs.glibc) is the full install tree. You can also
add recipes for split outputs — suffixed attributes carrying a subset of
that tree, so a dependent pulls in only what it needs. A split output is just a
TreeSubset over an fs-tree, so it is very cheap: it references
the same shared fs-files as the full tree, with nothing copied. The built-in
helper recipe.split.libs produces a _libs output from the shared libraries,
loader, and SONAME symlinks — pkgs.glibc_libs from pkgs.glibc. Attributes
are snake_case; the builder name keeps the version in kebab-case
(glibc-libs-2.42).
Depend on the narrowest output that has the files you need — deps.runtime on
_libs for a library dependency (pkgs.glibc_libs, not pkgs.glibc), the full
attribute only when the whole tree is required.
Other suffixes are reserved for the same idea and added by hand as a
TreeSubset when a package needs them; today only _libs has a helper (plus one
hand-written systemd_dev):
_dev— headers, pkg-config/CMake metadata, and dev symlinks_tools— executable utilities_static— static libraries_doc,_locale,_data— documentation, locale data, other architecture-independent runtime data
Avoid _runtime (already the deps.runtime role) and _closure (used by
RootfsClosure). _src denotes a public upstream Source recipe, while _gen1
marks a bootstrap stage.
Synthetic builders
A synthetic builder expands to a sandboxed build (see Request)
that unpacks the source and runs it through a build pipeline. The build families
here lower to the additive SandboxInstall builder: their result is a fs-tree
layer of the files the build installs, destined for the target filesystem and
merged into a rootfs like any other package. A build whose result is instead a
self-contained artifact — a disk image, an archive — rather than files for the
target root lowers to the plain Sandbox builder, which captures a @{out}
staging directory as a plain object (as recipe.erofs_image does).
Most build families come in two flavors:
- the dependency-aware ones (
Autotools,Makefile,Meson,Cmake,PerlModule,PythonModule,Cargo, plus the custom-stepsSandboxInstallDepsandSandboxStageDeps) take adepsrecord and build their own rootfs fromdeps.build; scripted families requiresource,Cargoadditionally requirescrates, and custom-step recipes declare only the inputs their steps need; - the explicit-rootfs variants (
AutotoolsStageRootfs,MakefileStageRootfs,MesonRootfs,CargoRootfs,SandboxStageRootfs) take an explicit_rootfsinput instead of deriving it fromdeps.build; they may still declaredeps.runtimefor consumers of their output.
New, non-bootstrap recipes should use the plain dependency-aware builders; the
*Rootfs and *Stage* variants are bootstrap-chain machinery and are rarely the
right choice otherwise.
Cmake, PerlModule, and PythonModule have no explicit-rootfs variant.
The common native toolchain referenced by the defaults below is
linux_headers, glibc, binutils, gcc, bash, make, coreutils,
gawk, sed, grep, tar, gzip, bzip2, xz, patch, findutils, and
diffutils.
How a synthetic build runs
Except for the custom-steps Sandbox* builders, the builders share one pipeline,
run as sandbox steps against the build rootfs:
- prepare — unpack the source and apply patches (below);
- any
pre_configure(or, forMakefile,pre_build) hook steps; - configure — the tool’s configure step (
Makefilehas none); - build — the tool’s build step, as
build-user, parallel (-j= CPU count); - install — as
rootinto the build’s live overlay root (--prefix=/usr, noDESTDIR); the additiveSandboxInstallcapture takes the files the install added as an fs-tree layer, which is the recipe’s result. The*StageRootfsandSandboxStageDepsvariants instead install into a virgin/stage, re-rooted/stage → /byTreeMove, for bootstrap recipes where writing into/would touch lower-layer files; - any
post_installhook steps.
Source and patches. The source input is unpacked into @{build}/source;
if it is a single tarball whose contents sit under one wrapping directory, that
wrapper is stripped. Any input whose name begins with patch is then applied
from the source root with patch -p1, in sorted input-name order — a .patch
file directly, and a directory by applying every *.patch it contains. So
patches are added as extra inputs:
inputs = {
source = foo_src,
patch0 = foo_fix_configure, # a .patch Source
patch1 = foo_extra_patches, # or a Source directory of *.patch files
}
Config. A recipe’s config fields reach the build script through
script_config (materialized at @{config}): configure_args, make_args,
setup_args, and perl_args become the arguments of the matching command;
env entries are loaded by each synthetic build-script phase; source_subdir
builds a subdirectory of the source; and in_tree (Autotools) builds in the
source tree instead of a separate build directory. Hooks are independent
Sandbox steps, so a hook that needs environment variables must declare its
own env.
Hooks. pre_configure / pre_build / post_install are extra Sandbox
steps injected into the pipeline — a single step or an array. Each is a normal
step (name, run_as, argv, optional env, and cwd, which defaults to the
source directory for pre-hooks and the install root for post_install: / for
ordinary installs or /stage for staging variants), so a recipe can run
arbitrary commands before configuring or after installing without leaving the
synthetic builder.
Autotools
- Steps:
./configure --prefix=/usr <configure_args>→make -j <make_args>→make <make_args> install. - Config (all optional):
configure_args,make_args,env,in_tree,source_subdir,pre_configure,post_install. - Default build tools: the common native toolchain plus
autoconf,m4, andperl.
Makefile
- Steps: no configure;
make -j <make_args>→make <make_args> install(skipped whenskip_installis set). - Config (all optional):
make_args,env,source_subdir,pre_build,post_install,skip_install. - Default build tools: the common native toolchain.
Meson
- Steps:
meson setup <build-dir> <source> --prefix=/usr <setup_args>→meson compile -j→meson install --destdir /. - Config (all optional):
setup_args,env,source_subdir,pre_configure,post_install. - Default build tools: the common native toolchain plus
pkgconf,python,meson,ninja,python_setuptools, andpython_packaging.
Cmake
- Steps: in-source
cmake -S . -B .(Unix Makefiles generator;-DCMAKE_INSTALL_PREFIX=/usrand-DCMAKE_BUILD_TYPE=Releasebaked in) with<cmake_args>→make -j→make install. - Config (all optional):
cmake_args,make_args,env,source_subdir,pre_configure,post_install. - Default build tools: the common native toolchain plus
cmake.
PerlModule
- Steps:
perl Makefile.PL <perl_args>→make -j <make_args>→make <make_args> install. - Config (all optional):
perl_args,make_args,env,source_subdir,pre_configure,post_install. - Default build tools: the common native toolchain plus
perl.
PythonModule
- Steps: build a wheel with
pip wheel --no-build-isolation --no-deps, thenpip install --prefix /usr(no--root) into the overlay root. - Config (all optional):
env,source_subdir,pre_configure,post_install. - Default build tools: the common native toolchain plus
python.
Cargo
- Steps: vendor the crate dependencies offline (below), then run
cargo install --path <source> --offline --lockedto build and install the selected crate[[bin]]targets into the overlay root. - Inputs: required
sourceandcrates.cratesis normally a publicBundlerecipe generated from the upstreamCargo.lockwithcargo.vendor_inputsfromcargo.ncl. - Config (all optional):
features,bins,no_default_features,env,source_subdir,post_install. - Default build tools: the common native toolchain plus
rust_bin.
One typical public dependency bundle is:
let cargo = import "../../cargo.ncl" in
let foo_crates = {
version | default = "1.2.3",
cargo_lock | default = import "Cargo.lock" as 'Toml,
name = "foo-%{version}-crates",
tag = "Bundle",
config = {},
inputs = cargo.vendor_inputs cargo_lock,
} in
# ... inputs = { source = pkgs.foo_src, crates = pkgs.foo_crates }
Vendoring. The sandbox has no network, so a Rust build cannot pull crates
from crates.io; its dependencies are provided by the explicit crates input.
cargo.vendor_inputs turns a parsed lockfile (import "Cargo.lock" as 'Toml)
into one .crate HTTP Source per crates.io entry. Each Source’s
object_hash is exactly the checksum recorded in Cargo.lock: the
fsobj-hash of a non-executable file equals its SHA-256, and crates.io serves
each version as an immutable .crate, so the lockfile content-addresses and
verifies every vendored crates.io dependency. Git, path, and
alternative-registry dependencies are not fetched by this helper. A built-in
step unpacks the bundle into an offline Cargo vendor/ tree before the build.
The source archive and its crates bundle can be separate, overridable recipes
and must describe the same release. Recipes exposing such a pair should check
their declared versions. Cargo then uses the Cargo.lock shipped by the
unpacked source with --locked --offline. A missing dependency, incompatible
lockfile, or checksum mismatch therefore fails the build instead of changing
the lockfile or accessing the network. Custom Meson or Autotools recipes that
invoke Cargo should run cargo.verify_lock_step after preparing the same
vendor directory.
CargoRootfs has the same explicit source and crates inputs as Cargo and
additionally requires _rootfs.
SandboxInstallDeps, SandboxStageDeps, SandboxStageRootfs
Run your own Sandbox step plan instead of the pipeline above — the same config
as the low-level SandboxInstall builder (steps, script_config; see
Request). Source unpacking and patching are not automatic here;
do them in your own steps. All three capture their result additively:
SandboxInstallDeps— build rootfs fromdeps; the steps install into the live overlay root (/);SandboxStageDeps— build rootfs fromdeps; the steps must install into a virgin/stage, re-rooted/stage → /byTreeMove;SandboxStageRootfs— the/stagemodel ofSandboxStageDeps, but with an explicit_rootfsinput rather than one derived fromdeps.build.
For a plain-object result — a @{out} staging directory rather than an fs-tree,
e.g. a disk image — use the low-level Sandbox builder with an explicit
_rootfs directly (as recipe.erofs_image does).
- Default build tools (the
Depsvariants):bash,tar,gzip,bzip2,xz, andpatch.
RootfsClosure
Not a build: assembles a root filesystem as a TreeMerge of base_filesystem
with the runtime closure of its inputs — the standard way to turn a set of
packages into a usable rootfs.
- Inputs: one or more packages (the roots of the closure).
- Config: none.
Filesystem images
Filesystem images are produced by Sandbox recipes that run a packing tool
over a materialized _tree input, in a rootfs assembled from the tool’s runtime
closure. The result is an ordinary object containing the image file.
EROFS
To pack a filesystem tree into a reproducible EROFS image, use the
recipe.erofs_image helper — it lowers to a low-level
Sandbox (with an explicit _rootfs: a RootfsClosure of pkgs.erofs_utils
and pkgs.bash) that runs mkfs.erofs over the materialized tree:
let qemu_image = recipe.erofs_image pkgs { name = "qemu-image", tree = pkgs.qemu_rootfs }
The reproducibility flags (--sort=path, a fixed -T) and the constant label
(bobr-rootfs) live in the os/mk-erofs.sh build script, which also derives the
image UUID (a version-8 UUID) from BOBR_BUILD_SEED — the per-build seed the
sandbox exports (see Request). Same inputs give the same seed, so
the image stays reproducible while distinct rootfs contents get distinct,
non-null UUIDs. Being a plain-object Sandbox, the result is a plain-object
directory holding a single erofs-rootfs.erofs file, since the image blob has no
per-file ownership of its own.
initramfs
The initrd is packed the same way, in linux/initrd.ncl: a Sandbox over the
merged initrd tree that runs linux/mk-initramfs.sh and writes a single
initramfs.img, so the boot artifact is <object>/initramfs.img.
The archive is written by the kernel’s own gen_init_cpio (built from the kernel
source as pkgs.gen_init_cpio), driven by a spec file the script generates from
the tree — the two-stage scheme the kernel uses for CONFIG_INITRAMFS_SOURCE.
That split is what makes the image reproducible: the spec carries only path,
type, permissions, and ownership, and gen_init_cpio synthesizes the rest
(inode numbers from a constant, fixed link counts, one SOURCE_DATE_EPOCH
timestamp). Running cpio(1) over the tree instead would copy the on-disk inode
numbers, link counts, and mtimes — and a materialized fs-tree hardlinks its files
from the store’s shared fs-files, so those vary with what else the store holds.