Skip to content

What works

This page covers the engine, store, profile, services, and deploy machinery. For the package corpus (what you can actually punix install <name>), see Corpus status — that's the page that lists every recipe by topic and what builds today.

1224 tests green across Python 3.13 + 3.14 on x86_64 + aarch64 Linux. Every major property below has a dedicated test under tests/c_e2e/, and any regression is a CI release-blocker.

Package corpus at a glance

  • 365 recipes ship green today, organised by topic in Corpus → working. Every one builds from source under make dogfood against the Punix store — no Homebrew bridge under the hood, no prebuilt binary toolchain, no DYLD shims at runtime.
  • Coverage against Homebrew's top-500 install-on-request analytics: 62 % (310 / 500 formulae fully resolvable through the corpus; the rest are tracked in Corpus → blockers with explicit failure classes).
  • The corpus is regenerated weekly. A single command (uv run python tools/status-report.py) refreshes the docs from packages/official/.

This page is a stage-by-stage rollup of what shipped at the engine level, in plain language. For the error-code numbers and design-decision identifiers, see Reference: error codes, Reference: conformance, and Reference: decisions.

Stage 0 — Foundation ✅

  • Repo bootstrap: uv package manager, ruff lint + format, ty / pyrefly / mypy typecheckers, nox matrix across Python 3.13 / 3.14.
  • The IR core (a small inheritance-calculus evaluator) is pulled in as a pinned git source — no vendored copy in the repo.
  • Five foundational design decisions frozen: where the store lives, no subtyping / first-class type members in the language, no import keyword (whole-tree composition), and recipe identity in the canonical hash.

Stage 1 — Typed configuration language ✅

  • punix check FILE runs the type checker over a .pcl file or directory. Every error comes back with file:line:col locations + a short code. The pass is single-shot and provably terminating.
  • Hand-written parser; structural type checker. ~30 unit tests.
  • All six type-system error categories shipped with minimal-program examples that produce each error.

CLI: punix check · Reference: error codes

Stage 2 — Order-independent evaluation + provenance ✅

  • IR lowering: every secret reference flows through the IR as a reference string, never a value — the property that makes secrets stay out of the build hash.
  • Canonical derivation: keys sorted, recipe identity included.
  • Evaluator handles conflicting contributions with explicit priority arbitration; same-priority conflicts surface as a located error.
  • punix why FILE Module.field: prints a resolved value along with every contribution that produced it, with locations.
  • punix store path FILE Module: predicts a build's content- addressed store path without doing the build.
  • Conformance tests prove: permuting source ⇒ byte-identical store paths; a pinned version change propagates exactly to its dependents; a secret value never affects a store path.

Concepts: content addressing · CLI: punix why

Stage 3 — Real builds ✅

  • Realise adapter: sandboxed builds with the seven shipped recipes (std.shell, std.autotools, std.cmake, std.make, std.go, std.python_wheel, std.binary).
  • Source kinds: local, url, git, mirror://, fixed_output (network-permitted with hash verification), url_per_arch (multi- arch binary sources).
  • Pre-pass error class for build-closure problems (cycles, missing recipe on a dep'd module) — surfaces before any sandbox runs.
  • Source-hash mismatch, fixed-output mismatch, and missing-per-arch errors all surface as located, coded errors.
  • The cache-staleness bug is gone: editing a recipe's .py (or a sibling _postinstall.sh) actually invalidates the cache for every package using that recipe.
  • Conformance tests prove: a real autotools package builds and re-builds as a store hit; two versions of Ncurses coexist with distinct paths; editing a recipe hook body changes the store path.

Language: recipes · CLI: punix build

Stage 4 — Atomic deploys + multi-backend services ✅

  • A Transport abstraction with two implementations: LocalTransport(root) for local deploys (and hermetic tests by pointing root at a sandbox dir), and SshTransport(host, user, port, key) for remote deploys (wired but only exercised by argv-construction tests in this stage).
  • Numbered generation manifests on disk: every deploy writes a gen-NNN.json containing every store path pinned, every config file's hash, the source bytes' hash, and the backend.
  • Atomic flip: the live state changes in exactly one rename syscall on the current symlink. Crash earlier ⇒ previous generation stays wholly live.
  • Garbage collection respects every generation's pins: a path referenced by any live or rollback-reachable deploy never gets collected.
  • compose_stack: read a stack PCL module into a ServiceStack the backend generator can render.
  • systemd backend: one .service unit per service + any stack- declared config files.
  • punix service deploy STACK --file PCL [--dry-run] and punix service rollback STACK shipped.
  • --transport-root PATH rebases the entire filesystem surface under a directory — turns the same code into a hermetic test fixture (no subprocess mocks).
  • Conformance tests prove the five sub-properties of atomic updates: kill mid-deploy ⇒ previous gen lives; rollback restores the previous gen exactly; GC respects pins; rollback after GC works; pinned-path-missing surfaces a clear error.

Concepts: generations and rollback · CLI: punix service

Stage 5 — SSH transport live ✅

  • --target ssh://user@host[:port] + --key + --known-hosts on service deploy and service rollback.
  • Closure push step: before flipping current, walks every store path in the stack and rsyncs anything the target doesn't already have. Content-addressed = bandwidth-free: existence on the target IS correctness (same hash-name ⇒ same bytes by construction). Summary line reports N pushed/M cached.
  • A pytest fixture spins up a real sshd on a free localhost port with a fresh keypair and a pre-populated known_hosts file — so SSH tests exercise the actual ssh/rsync code path and stay hermetic. Auto-skips when sshd / ssh-keygen aren't available.
  • Strict host-key checking stays on through tests via the fixture's known_hosts — no pollution of the developer's ~/.ssh/known_hosts.
  • Conformance tests prove the same atomic-update invariants hold over SSH (different syscall path, same property).
  • A real portability bug was caught at this stage via the live fixture: the GNU-only mv -T flag fails on macOS BSD mv. Replaced with bare mv (same rename(2) atomicity guarantee on POSIX).

Deploy over SSH · Concepts: transport and backends

Stage 6 — Hermeticity gap closed ✅

The release-evidence prerequisite. Three independent tracks:

Secrets at deploy. A service's environment can mix plain strings with {from_env="NAME"} or {from_file="path"} references. At deploy time the resolver:

  • reads the actual values from the deploy host's environment or filesystem;
  • writes them into the rendered systemd Environment= lines (the one place the value reaches);
  • keeps the references (not the values) in the generation manifest;
  • collects every missing reference into one clear error message rather than failing on the first.

Conformance tests prove that two deploys differing only in a secret value produce byte-identical source_hash + store_paths + services records; that three unset references all surface together; and that the secret value never appears in the generation manifest.

Fixed-output verification. Recipes that produce content via a network-permitted command (e.g. fetching a tarball whose URL doesn't directly verify) declare an expected hash; mismatch surfaces a clear error with both the expected and observed hashes in the build log.

Multi-arch CI. The matrix runs on ubuntu-latest AND ubuntu-24.04-arm, both Python 3.13 + 3.14. Conformance tests prove same recipe on x86_64 vs aarch64 yields distinct store paths, and that a binary source missing per-arch entries surfaces a clear error naming both the missing target and the available ones.

Concepts: secrets · Deploy: secrets at deploy

punix install + per-user profile ✅

A top-level command lands binaries from any successful build onto a per-user $PATH, with generation-backed rollback:

$ punix install just                # → gen-001 (just on PATH; FILE defaults to ./packages/)
$ punix install atuin               # → gen-002 (just + atuin)
$ punix uninstall just              # → gen-003 (atuin only)
$ punix profile list
   gen-001  2026-05-27T…  just
   gen-002  2026-05-27T…  atuin, just
 * gen-003  2026-05-27T…  atuin
active: gen-003

$ punix profile switch 2            # atomic flip back
switched to gen-002
$ just --version
just 1.51.0

Each generation lives at ~/.punix/profiles/gen-NNN/ (a directory with bin/<symlinks> + a profile.json Generation record). The active profile is a single symlink at ~/.punix/current → profiles/gen-N. Every install/uninstall writes a new generation and atomically flips that symlink via os.replace — the same D9 primitive used by punix service rollback. Crash before the flip returns: old generation stays active. After: new generation is active. No partial state.

Setup is one line in your shell rc: export PATH="$HOME/.punix/current/bin:$PATH".

CLI: punix install · uninstall · profile list/switch/diffConcepts: Generations and rollback

Atomic-flip + generation history applies across both service-stack deploys AND user-profile management — the infrastructure cost is amortized.

Phase 5 — daily-driver UX ✅

Four read-only commands that turn the typed PCL tree into a brew-comparable inspection surface:

  • punix list — what's installed in the active profile (parses pname/version from the store path; supports --format json).
  • punix search REGEX [FILE] — case-insensitive regex over pname + meta.description across every package module in the tree. ~150 ms over 100+ packages; no on-disk index. FILE is optional — Punix discovers the tree from $PUNIX_PACKAGES or ./packages/.
  • punix info MODULE [FILE] — recipe metadata (pname, version, recipe, deps, every meta.* field) + predicted store path (RealiseDryRun) + installed: yes — gen-NNN if it's in the active profile.
  • punix upgrade --check-all [FILE] — per-package upstream-drift report. For every module with meta.upstream = { type, repo }, queries GitHub via the gh CLI and reports <pname>: <current> → <latest>. Resolver errors are captured per-candidate (one flaky repo doesn't sink the report). v-prefix normalized in the compare. Read-only; PCL rewrite is deferred.

The brew translator auto-emits meta.upstream = { type = "github-release" repo = "X/Y" } when the source URL matches github.com/X/Y/releases/download/... (or github-tag for /archive/... URLs), so most translated recipes pick up upgrade-tracking for free.

CLI: punix list · search · info · upgrade

Test count over time

Stage Tests at end
1 ~30
2 ~65
3 ~120
4 248
5 272
6 302
6 + punix install v1 368
6 + punix install + profile v2 378
6 + Phase 5 (list/search/info/upgrade) 400
6 + punix help + ergonomics + translator-improvements + corpus-restructure 451
6 + translator patterns (npm/meson/cargo/go) + hard-reset audit 473
6 + recipe-class expansion (std.ruby_gem, std.swift, std.python_venv) + git-tag sources + corpus scale-up 555
6 + Stage 7 corpus arc (365 recipes, 62 % top-500 coverage) 583
+ deploy pillar: generic engine, tangled/NLnet reproductions, hermetic builds, live demos, cross-host artifacts, path confinement 1224

What you can do with v0.6

  • Write typed configuration; type-check it; ship it with no surprises at runtime.
  • Build any single-arch package or multi-package corpus into a content-addressed store. Edit recipes; rebuild correctly.
  • Compose a service stack; deploy it locally (with root for the /etc/systemd/system/ writes) or to any SSH-reachable remote.
  • Roll back a deploy in one command, constant time.
  • Use {from_env} / {from_file} secrets that flow into the running service's environment and nowhere else — provably.
  • punix install <module> / punix uninstall <module> / punix profile list / switch N / diff N M — manage a per-user profile of installed binaries on your $PATH with brew-comparable UX. Every operation writes a new generation and atomically flips the active symlink (same D9 primitive as service-stack rollback). Constant-time rollback to any prior generation.
  • punix list / search / info / upgrade --check-all — daily-driver inspection: enumerate the active profile, regex-search the PCL tree, inspect one module's metadata + predicted store path, or report per-package upstream drift via the gh CLI.
  • Run the test suite (make test) and see it green on x86_64 AND aarch64 Linux.

What's deliberately not in v0.6

Known limitations — the explicit gap list. → Roadmap — what's planned next.