Day 45 — stdenv phases
Day 45 — stdenv phases
Stage V · ~4h (lab-heavy)
Goal: Package a small C or shell tool with stdenv.mkDerivation, name every default phase, and know when to override hooks vs rewrite the whole build.
Stage V starts after you have run a real host. Packaging now is craft, not theory before you felt closure size.
Why this day exists
Most “how do I package X?” questions collapse into one skill: drive stdenv phases deliberately. Language helpers (buildGoModule, buildPythonPackage, …) are thin wrappers on the same lifecycle. If you cannot read a failing phase log, every later day in Stage V will feel random.
Theory 1 — What stdenv.mkDerivation actually is
A derivation is a pure build plan: inputs → sandbox → outputs under /nix/store.
stdenv.mkDerivation fills that plan with a bash driver (genericBuild) and a default toolchain (compiler, coreutils, patch, make, …).
{ stdenv, fetchurl }:
stdenv.mkDerivation rec {
pname = "hello";
version = "2.12.1";
src = fetchurl {
url = "mirror://gnu/hello/hello-${version}.tar.gz";
hash = "sha256-…"; # replace with real FOD hash after first fail
};
meta = {
description = "GNU Hello prints a friendly greeting";
license = lib.licenses.gpl3Plus;
};
}| Concept | Meaning |
|---|---|
| pname / version | Human labels; feed name as ${pname}-${version} by default |
| src | Input path (usually a fixed-output fetcher) |
| buildInputs | Host libraries linked into the result (runtime + link time on native) |
| nativeBuildInputs | Tools that run on the build machine (compilers, cmake, pkg-config) |
| propagated* | Rare; pollutes dependents—prefer explicit deps |
buildInputs vs nativeBuildInputs (cross-aware)
| Attr | Role | Cross example |
|---|---|---|
nativeBuildInputs |
Execute during build | pkg-config, cmake, host gcc when cross-compiling |
buildInputs |
Link / use as target libraries | openssl, zlib for the target system |
depsBuildBuild |
Advanced bootstrap | Tools for the build→build hop |
For native-only packaging you can get away with sloppy placement; cross and static punish it. Prefer correct classification from day one.
Theory 2 — Default phase order
genericBuild runs roughly:
unpackPhase
patchPhase
updateAutotoolsGnuConfigScriptsPhase # often invisible
configurePhase
buildPhase
checkPhase # if doCheck = true
installPhase
fixupPhase
installCheckPhase # if doInstallCheck = true
distPhase # rare for binary packages
Hooks fire before and after many phases (preConfigure, postInstall, …). Prefer hooks over replacing an entire phase when you only need a few lines.
Phase responsibilities
| Phase | Default intent | Common overrides |
|---|---|---|
| unpack | Extract src into $sourceRoot |
sourceRoot, setSourceRoot, custom unpackPhase for flat files |
| patch | Apply patches list |
prePatch to fix line endings |
| configure | ./configure --prefix=$out if script exists |
cmakeConfigurePhase, meson, or dontConfigure |
| build | make |
buildPhase = "go build …" for tiny tools (prefer language helpers) |
| check | make check |
Gate with doCheck = true |
| install | make install |
Copy binaries into $out/bin |
| fixup | Strip, compress man, move docs, patch shebangs | dontStrip, separateDebugInfo |
Theory 3 — Important environment variables in the sandbox
During the build, the driver exports paths you should treat as API:
# always available in phases
echo "$out" # primary output
echo "$src" # unpacked-from input
echo "$NIX_BUILD_TOP"
echo "$sourceRoot"Multiple outputs (Day 50) add $dev, $man, $doc, …. For today, stick to $out.
Shebang and RPATH fixups happen in fixupPhase / patchShebangs. If a script still points at /usr/bin/env incorrectly, you usually forgot nativeBuildInputs for the interpreter or skipped fixup.
Theory 4 — Minimal patterns you will reuse forever
Shell-only “package” (no compile)
{ stdenv, lib, bash }:
stdenv.mkDerivation {
pname = "greet";
version = "0.1.0";
dontUnpack = true;
nativeBuildInputs = [ ];
installPhase = ''
runHook preInstall
mkdir -p $out/bin
cat > $out/bin/greet <<'EOF'
#!${bash}/bin/bash
echo "hello from Stage V"
EOF
chmod +x $out/bin/greet
runHook postInstall
'';
meta = {
description = "Trivial greet script packaged with stdenv";
license = lib.licenses.mit;
mainProgram = "greet";
};
}Autotools / simple C
{ stdenv, fetchurl, lib }:
stdenv.mkDerivation rec {
pname = "hello";
version = "2.12.1";
src = fetchurl {
url = "mirror://gnu/hello/hello-${version}.tar.gz";
hash = "sha256-jZkUKv2SV28wsM18tCqNxoCZmLxdYH2Idh9RLibH2yA=";
};
doCheck = true;
meta = {
homepage = "https://www.gnu.org/software/hello/";
license = lib.licenses.gpl3Plus;
mainProgram = "hello";
};
}Single C file without autotools
{ stdenv, lib }:
stdenv.mkDerivation {
pname = "addone";
version = "0.1.0";
src = ./src; # directory with addone.c
buildPhase = ''
runHook preBuild
$CC addone.c -O2 -o addone
runHook postBuild
'';
installPhase = ''
runHook preInstall
install -Dm755 addone $out/bin/addone
runHook postInstall
'';
meta = {
description = "Adds one to an integer argument";
license = lib.licenses.mit;
mainProgram = "addone";
};
}Always call runHook pre* / runHook post* when you override a phase so preInstall hooks from dependencies still run.
Worked example — flake that exports the package
# flake.nix
{
description = "Day 45 — stdenv packaging lab";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
outputs = { self, nixpkgs }:
let
systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" "x86_64-darwin" ];
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f (
import nixpkgs { inherit system; }
));
in {
packages = forAllSystems (pkgs: {
default = pkgs.callPackage ./pkgs/greet.nix { };
greet = pkgs.callPackage ./pkgs/greet.nix { };
hello = pkgs.hello; # control sample
});
checks = forAllSystems (pkgs: {
greet-runs = pkgs.runCommand "greet-runs" {
buildInputs = [ self.packages.${pkgs.system}.greet ];
} ''
greet | grep -q "Stage V"
touch $out
'';
});
};
}nix build .#greet
./result/bin/greet
nix log ./result # if build failed earlier
nix flake checkLab — multi-step
Suggested workspace: ~/lab/90daysofx/02-nixos/day45
Step 1 — Scaffold
mkdir -p ~/lab/90daysofx/02-nixos/day45/{pkgs,src}
cd ~/lab/90daysofx/02-nixos/day45Create src/addone.c:
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char **argv) {
if (argc != 2) { fprintf(stderr, "usage: %s N\n", argv[0]); return 1; }
printf("%d\n", atoi(argv[1]) + 1);
return 0;
}Step 2 — Write three packages
pkgs/greet.nix— shell script package
pkgs/addone.nix— single-file C
- Optional: package
helloyourself withfetchurl(compare topkgs.hello)
Step 3 — Break phases on purpose
In addone.nix, set temporarily:
installPhase = "false"; # fail installBuild, read the log, note which phase failed:
nix build .#addone --print-build-logs || true
# restore installPhase with runHook wrappersStep 4 — Flip input kinds
Move a tool between buildInputs and nativeBuildInputs. On native Linux both may “work”; write one sentence on why native is still correct for compilers.
Step 5 — Phase introspection
nix develop .#default # if you add a shell with the package
# or
nix build .#greet -L
nix path-info -r ./result | headDocument: unpack → build → install → fixup for each of your packages (what actually ran).
Step 6 — Host consumption (optional but good)
Wire environment.systemPackages = [ inputs.self.packages.${system}.greet ]; on a lab host or run via nix shell .#greet.
Common gotchas
| Symptom / mistake | What to do |
|---|---|
builder for '…' failed with exit code 1 with no clue |
Rebuild with -L / --print-build-logs; jump to last phase name in log |
install: missing destination |
Create $out/bin before install; use install -Dm755 |
| Binary runs in build sandbox but not after install | Forgot install into $out; or RPATH/interpreter wrong—check ldd / file |
patchShebangs left bad paths |
Ensure interpreter package is a build input; call patchShebangs $out |
Overrode buildPhase without runHook |
Add runHook preBuild / postBuild |
Used buildInputs for cmake/pkg-config |
Prefer nativeBuildInputs |
src is a single file, unpack fails |
unpackPhase = "cp $src ./foo.c"; sourceRoot = "."; or dontUnpack + copy in install |
| Name collisions / impure timestamps in C | Avoid embedding timestamps; prefer -D defines from version only |
Checkpoint
- Can list default phase order from memory
greetandaddonebuild withnix build
- At least one intentional phase failure diagnosed from logs
nativeBuildInputsvsbuildInputsexplained in your notes
- Phases documented for each package
Commit
git add .
git commit -m "day45: stdenv phases — greet + addone packages"Write three personal gotchas before continuing.
Tomorrow
Day 46 — fetchers & FODs: hashes, fetchFromGitHub, and the mismatch workflow that keeps the store honest.