Skip to content

Recipes

A recipe is a named build script. PCL points at one with recipe = "std.NAME"; the realiser dispatches to the matching module under src/punix/realise/recipes_lib/std/.

module Curl {
  pname = "curl"
  version = "8.5.0"
  recipe = "std.autotools"
  recipeArgs = { configureFlags = ["--without-ssl"] }
  source = { … }
}

Shipped recipes

Recipe What it does Required recipeArgs
std.shell Run an arbitrary POSIX shell command with $OUTPUT set command: Str
std.autotools ./configure && make && make install (with --prefix=$OUTPUT) (optional) configureFlags, makeFlags
std.make make && make install (no autoconf) (optional) makeFlags, installCommand
std.cmake cmake .. && cmake --build . && cmake --install . (optional) cmakeFlags
std.go go build + install (optional) package, subdir
std.python_wheel Build a wheel; install via pip (optional) extras
std.binary Unpack a pre-built archive into $OUTPUT (no build) none — source supplies the bytes

Each recipe lives in src/punix/realise/recipes_lib/std/<name>.py. Recipe code is content-addressed via the canonical derivation — edit a recipe's .py (or its sibling _postinstall.sh, _dep_env.py) ⇒ the hash changes ⇒ every package using that recipe gets a new store path. The conformance suite has a dedicated test for this against a recipe-hook edit.

The recipe field in the canonical derivation

recipe = "std.autotools" is not just a string lookup. The canonical derivation includes the recipe's identity:

"recipe": {
  "name": "std.autotools",
  "source_hash": "<sha256 of the recipe's .py + sibling files>"
}

That means:

  • Two packages with recipe = "std.autotools" share the recipe's source_hash ⇒ if the recipe is edited, both rebuild.
  • A user-defined recipe with the same name but different bytes (impossible today, but conceptually) would have a different source_hash ⇒ distinct paths.

This is the cache-staleness fix that v2 makes structural. Old: "did I make clean after editing _postinstall.sh?" New: edit the file ⇒ all dependents already have a different hash ⇒ they'll be rebuilt next punix build.

Recipe environment

Every recipe runs in a sandbox with:

  • $OUTPUT — the temp path that becomes the store path on success.
  • $SOURCE — the source dir (post prepare_source).
  • $BUILD — the build dir (under $OUTPUT for most recipes).
  • $NIX_BUILD_CORES — the recommended make -j value.
  • $MAKEFLAGS-j$NIX_BUILD_CORES.
  • $PUNIX_ARCH — the target system (x86_64-linux etc.).
  • $FINAL_STORE_PATH — the predicted final path. Important: this is the path the artifact will live at after the build. Languages that bake the prefix in (Ruby's --prefix, ELF's RPATH) need this value at build time, before the artifact has moved.
  • $PATH — each dep's <store>/bin prepended (in dep order). Nothing else — no system PATH leakage.

Writing a recipe

A recipe is class StdRecipe(Recipe): (or a function — see existing recipes for the pattern). The run(req) method receives a RealiseRequest and writes to $OUTPUT:

class Shell(Recipe):
    def run(self, req: RealiseRequest, sandbox: Sandbox) -> None:
        command = req.recipe_args["command"]
        script = f"#!/bin/sh\nset -eu\n{command}\n"
        sandbox.run(script=script, )

You can ship custom recipes in your repo — they participate in the canonical derivation like any other (the recipe's source-hash field makes them addressable). Stage 7+ corpus migration may upstream patterns into std.

Fixed-output recipes

A source = { type = "fixed_output", command = "…", hash = "…" } is special:

  • command runs in the sandbox (with network permitted — fixed-output IS the network seam).
  • The resulting tree is hashed (compute_tree_hash).
  • If the hash matches the declared one ⇒ success. If not ⇒ [E13].

This is how Punix model arbitrary fetches (git clones, custom protocols, etc.) — the recipe says "this is what I'll produce", the realiser verifies. A [E13] conformance test pins this.

CLI: punix build for the build flow. → Reference: error codes for E10–E13.