Skip to content

Your first build

Goal: get a single PCL module to type-check, lower to IR, and realise into the content-addressed store. ~5 minutes.

A minimal package

Create a directory:

mkdir -p ~/punix-tutorial
cd ~/punix-tutorial
mkdir src        # the package's source dir (any contents work)

Write hello.pcl:

hello.pcl
module Hello {
  pname = "hello"
  version = "1.0"
  recipe = "std.shell"
  recipeArgs = {
    command = "mkdir -p $OUTPUT/bin && echo '#!/bin/sh\necho hi from punix' > $OUTPUT/bin/hi && chmod +x $OUTPUT/bin/hi"
  }
  source = { type = "local" path = "./src" }
}

What each field does:

  • pname + version: the human-readable name/version. They appear in the store path but do not participate in the hash directly (the canonical derivation does).
  • recipe = "std.shell": the recipe to use. std.shell runs the recipeArgs.command as a POSIX shell script in a sandbox, with $OUTPUT set to a temp dir that becomes the store path on success.
  • source: where the build's input comes from. local is the simplest; other kinds are url, git, url_per_arch, fixed_output.

Type-check first

Always: check before build. The type-checker catches every config error with file:line:col locations before any byte hits the store.

uv run punix check hello.pcl

Expected:

ok

If you mistype a field — e.g. recpie = "std.shell" instead of recipe — you get a located [E#]:

hello.pcl:4:3: error: [E1] unknown field 'recpie' on module 'Hello'
  hint: did you mean 'recipe'?

Realise

uv run punix build hello.pcl

Expected:

→ building 1 package(s) into /Users/you/.punix/store
  per-package build logs: /Users/you/.punix/punix-build-logs/<module>.log
[1/1] Hello
  [1] realise Hello
ok  Hello   /Users/you/.punix/store/a1b2c3d4…-hello-1.0
1 packages: 1 ok, 0 failed

The store path is content-addressed: same recipe + same inputs ⇒ same hash ⇒ same path. Run build again:

uv run punix build hello.pcl

The realise reporter logs cache hit instead of realise. The store path is identical because nothing about the derivation changed.

Inspect the store

uv run punix store path hello.pcl Hello

Prints the predicted store path without building (uses RealiseDryRun). Lets you wire scripts that need the path without paying the build cost.

ls /Users/you/.punix/store/*-hello-1.0/
# bin  built  derivation.json  provenance.json
  • bin/hi: what the recipe wrote.
  • built: a marker that says "this store entry is complete." Realise writes it last; if it's missing, the entry is invalid (caught by Store.is_valid).
  • derivation.json: the canonical derivation — every input that fed the hash. The "why is this path THIS hash" answer offline.
  • provenance.json: per-resolved-binding, the chain of contributions that produced its value. Reads as a "blame" trace for the PCL composition — useful for "why is this build pulling that version of OpenSSL?"-class questions.

Try it:

cat /Users/you/.punix/store/*-hello-1.0/bin/hi
# #!/bin/sh
# echo hi from punix

/Users/you/.punix/store/*-hello-1.0/bin/hi
# hi from punix

Mutate something — see the cache work

Edit hello.pcl and change version = "1.0" to version = "1.1". Re-run:

uv run punix build hello.pcl

A new store path appears (different hash). The old one is still there — content-addressed paths are immutable; "updating" really means "writing a new entry alongside."

Edit recipeArgs.command instead (change hi from punix to hello from punix). Same thing: new hash, new path. This is the recipe-identity hash property — the canonical derivation includes the recipe's identity (name + args + hook bodies), so editing a build script invalidates the cache for that package.

Why does that matter?

Concepts: Content addressing walks through why this property is load-bearing for everything else (GC, multi- arch coexistence, secret-hash-exclusion, atomic rollback).

→ Or move on to your first deploy, where you take this package and turn it into a running service.